create-brainerce-store 1.85.0 → 1.86.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.
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
2
+ <rect width="32" height="32" rx="7" fill="#71717a" />
3
+ <path
4
+ d="M11 13V10.5a5 5 0 0 1 10 0V13M8.5 13h15l-1.1 12.1a2 2 0 0 1-2 1.9h-8.8a2 2 0 0 1-2-1.9L8.5 13Z"
5
+ fill="none"
6
+ stroke="#fafafa"
7
+ stroke-width="2"
8
+ stroke-linecap="round"
9
+ stroke-linejoin="round"
10
+ />
11
+ </svg>
@@ -29,7 +29,7 @@ const FINANCIAL_STATUS_COLOR: Record<string, string> = {
29
29
 
30
30
  export function OrderPaymentBlock({ order, className }: OrderPaymentBlockProps) {
31
31
  const t = useTranslations('account');
32
- if (!order.paymentMethod && !order.financialStatus) return null;
32
+ if (!order.paymentMethod && !order.financialStatus && !order.invoiceUrl) return null;
33
33
 
34
34
  const statusKey = order.financialStatus ? FINANCIAL_STATUS_KEY[order.financialStatus] : null;
35
35
  const statusLabel = statusKey ? t(statusKey) : order.financialStatus || '';
@@ -38,7 +38,7 @@ export function OrderPaymentBlock({ order, className }: OrderPaymentBlockProps)
38
38
  : '';
39
39
 
40
40
  return (
41
- <div className={cn('border-border flex items-center gap-3 border-t pt-2 text-xs', className)}>
41
+ <div className={cn('border-border flex flex-wrap items-center gap-3 border-t pt-2 text-xs', className)}>
42
42
  <p className="text-foreground text-sm font-medium">{t('paymentMethod')}</p>
43
43
  {order.paymentMethod && (
44
44
  <span className="text-foreground capitalize">{order.paymentMethod.replace(/_/g, ' ')}</span>
@@ -53,6 +53,21 @@ export function OrderPaymentBlock({ order, className }: OrderPaymentBlockProps)
53
53
  {statusLabel}
54
54
  </span>
55
55
  )}
56
+ {/* `invoiceUrl` is null until the connected payment provider (e.g.
57
+ Cardcom) generates the document, and stays null when the store has
58
+ no invoicing-capable provider connected — render nothing then,
59
+ never a broken link or a retry loop. */}
60
+ {order.invoiceUrl && (
61
+ <a
62
+ href={order.invoiceUrl}
63
+ target="_blank"
64
+ rel="noopener noreferrer"
65
+ className="text-primary text-sm font-medium hover:underline"
66
+ >
67
+ {t('downloadInvoice')}
68
+ {order.invoiceNumber ? ` (#${order.invoiceNumber})` : ''}
69
+ </a>
70
+ )}
56
71
  </div>
57
72
  );
58
73
  }
@@ -100,7 +100,14 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
100
100
  // is only a fallback for providers that duplicate it there. Reading
101
101
  // clientSecret alone breaks providers that return a real identifier (MAX,
102
102
  // Takbull) — the iframe/link silently points at an id instead of a page.
103
+ //
104
+ // Every allowlist check and embed-detection below must read THIS value, not
105
+ // `paymentIntent.clientSecret` directly — that was the bug: two checks kept
106
+ // reading clientSecret straight off the intent after this fallback was
107
+ // introduced, so a MAX/Takbull-style provider failed `new URL()` on a bare
108
+ // id and the payment step silently rendered nothing.
103
109
  const paymentIntentUrl = paymentIntent?.clientSdk?.renderArg || paymentIntent?.clientSecret || '';
110
+ const isBrainerceEmbed = isBrainerceEmbedUrl(paymentIntentUrl);
104
111
  const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
105
112
  const [loading, setLoading] = useState(true);
106
113
  const [error, setError] = useState<string | null>(null);
@@ -110,6 +117,11 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
110
117
  // from a provider's hosted page — used to narrow the modal + auto-size the
111
118
  // iframe instead of reserving the tall LowProfile footprint.
112
119
  const [embeddedIframeHeight, setEmbeddedIframeHeight] = useState<number | null>(null);
120
+ // Set when the inline embed never posts its first `brainerce:resize` within
121
+ // the timeout below — e.g. the parent's CSP blocks the iframe from framing
122
+ // at all. Without this the shopper stares at a permanently blank/broken box
123
+ // with no error and no way forward.
124
+ const [iframeLoadFailed, setIframeLoadFailed] = useState(false);
113
125
  // Provider-hosted iframe modal, closed by the shopper. Without this the
114
126
  // modal had no closed state at all: the X did a full navigation to
115
127
  // `?canceled=true`, the page resumed on the payment step, a fresh intent
@@ -130,7 +142,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
130
142
  !modalDismissed &&
131
143
  !!paymentIntent &&
132
144
  resolveClientSdk(paymentIntent, preloadedSdk).renderType === 'iframe' &&
133
- !isBrainerceEmbedUrl(paymentIntent.clientSecret);
145
+ !isBrainerceEmbed;
134
146
 
135
147
  // Back closes the modal instead of leaving the shopper inside it. Opening
136
148
  // pushes one history entry tagged as ours; popping it (Back, or the X below
@@ -577,6 +589,26 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
577
589
  return () => cleanups.forEach((fn) => fn());
578
590
  }, [checkoutId]);
579
591
 
592
+ // Brainerce-hosted embed pages announce themselves immediately via the
593
+ // `brainerce:resize` postMessage handled above (it sets
594
+ // `embeddedIframeHeight`). If that never arrives — most commonly the
595
+ // parent origin isn't in the embed page's `frame-ancestors` allowlist and
596
+ // the browser refuses to frame it at all — there is otherwise no signal:
597
+ // cross-origin framing failures are invisible to the parent window, so the
598
+ // shopper is left staring at a blank box forever with no error and no way
599
+ // forward.
600
+ useEffect(() => {
601
+ if (!isBrainerceEmbed || !paymentIntentUrl) return;
602
+ setIframeLoadFailed(false);
603
+ const timer = setTimeout(() => {
604
+ setEmbeddedIframeHeight((h) => {
605
+ if (h === null) setIframeLoadFailed(true);
606
+ return h;
607
+ });
608
+ }, 10000);
609
+ return () => clearTimeout(timer);
610
+ }, [isBrainerceEmbed, paymentIntentUrl]);
611
+
580
612
  // --- UI ---
581
613
 
582
614
  // Invalid checkout id — render the error box instead of ever spinning.
@@ -694,16 +726,31 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
694
726
  }
695
727
 
696
728
  if (sdk.renderType === 'iframe') {
697
- if (!isAllowedPaymentUrl(paymentIntent.clientSecret)) return null;
729
+ if (!isAllowedPaymentUrl(paymentIntentUrl)) return null;
698
730
 
699
731
  // Detect Brainerce-hosted embed (path-based — works across localhost/
700
732
  // staging/prod without a domain list) vs. a provider-hosted page. The
701
733
  // embed page is already brand-styled and compact → render inline in the
702
734
  // checkout flow. Provider-hosted pages carry their own branding/chrome →
703
735
  // keep the modal overlay so they don't fight the checkout layout.
704
- const isBrainerceEmbed = isBrainerceEmbedUrl(paymentIntent.clientSecret);
705
-
706
736
  if (isBrainerceEmbed) {
737
+ if (iframeLoadFailed) {
738
+ return (
739
+ <div className={cn('border-border rounded-lg border px-4 py-6 text-center', className)}>
740
+ <p className="text-foreground text-sm font-medium">{t('paymentError')}</p>
741
+ <p className="text-muted-foreground mt-1 text-sm">{t('paymentLoadFailed')}</p>
742
+ <a
743
+ href={paymentIntentUrl}
744
+ target="_blank"
745
+ rel="noopener noreferrer"
746
+ className="bg-primary text-primary-foreground mt-4 inline-flex items-center rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90"
747
+ >
748
+ {t('openPaymentModal')}
749
+ </a>
750
+ </div>
751
+ );
752
+ }
753
+
707
754
  // Inline: default to a reasonable height until the embed posts its real
708
755
  // height via `brainerce:resize`. Transition smooths the resize into the
709
756
  // final measurement.
@@ -1,5 +1,5 @@
1
1
  import type { Cart, Order, Product, ProductVariant, TrackingEventItem } from 'brainerce';
2
- import { getCartTotals } from 'brainerce';
2
+ import { getCartTotals, buildGa4CommercePayload } from 'brainerce';
3
3
 
4
4
  import { getClient } from './brainerce';
5
5
 
@@ -16,12 +16,14 @@ import { getClient } from './brainerce';
16
16
  *
17
17
  * ## Item ids
18
18
  *
19
- * `itemId` is always the **SKU**. That is the id Brainerce publishes to the
20
- * Google Merchant Center and Meta catalog feeds, so it is the only id the ad
21
- * platforms can match a pixel event against. Sending a product or variant id
22
- * instead breaks attribution and dynamic remarketing silently — the events
23
- * arrive, the platform reports an id its catalog has never seen, and the
24
- * remarketing audience never builds.
19
+ * `itemId` is the SKU-preferring id Meta/TikTok pixels match against (Meta
20
+ * deliberately uses the bare internal id instead, since SKU is nullable
21
+ * there — see `apps/meta-connector/src/lib/mappers.ts`). For GA4/GTM, the SDK
22
+ * builds a SEPARATE id from `productId`/`variantId` (`prod_<id>`/`var_<id>`)
23
+ * matching the Google Merchant Center feed's `offerId` — neither SKU nor a
24
+ * bare unprefixed id matches that feed, which silently breaks Shopping ads /
25
+ * dynamic remarketing item matching. Always pass `productId`/`variantId`
26
+ * alongside `itemId` so both platforms attribute correctly.
25
27
  */
26
28
 
27
29
  /** Parse a Brainerce decimal string (`"29.99"`) into a number. */
@@ -54,6 +56,8 @@ export function trackProductView(
54
56
  items: [
55
57
  {
56
58
  itemId: resolveItemId(product, variant),
59
+ productId: product.id,
60
+ variantId: variant?.id,
57
61
  itemName: product.name,
58
62
  price,
59
63
  quantity: 1,
@@ -77,6 +81,8 @@ export function trackAddToCart(
77
81
  items: [
78
82
  {
79
83
  itemId: resolveItemId(product, variant),
84
+ productId: product.id,
85
+ variantId: variant?.id,
80
86
  itemName: product.name,
81
87
  price,
82
88
  quantity,
@@ -92,6 +98,8 @@ function cartItems(cart: Cart): TrackingEventItem[] {
92
98
  // The cart line carries product + variant snapshots; prefer the variant's
93
99
  // SKU for the same catalog-parity reason as `resolveItemId`.
94
100
  itemId: item.variant?.sku || item.product?.sku || item.variantId || item.productId,
101
+ productId: item.productId,
102
+ variantId: item.variantId ?? undefined,
95
103
  itemName: item.product?.name,
96
104
  price: toNumber(item.currentUnitPrice ?? item.unitPrice),
97
105
  quantity: item.quantity,
@@ -151,18 +159,21 @@ export function trackPurchase(order: Order): void {
151
159
  // vendor-side transaction id de-dupe rather than skipping the conversion.
152
160
  }
153
161
 
162
+ // GA4 spec: `value` is net item revenue only — `tax`/`shipping` are
163
+ // reported alongside it, never folded in. See `buildGa4CommercePayload`.
164
+ const commerce = buildGa4CommercePayload(order);
165
+
154
166
  getClient().trackMarketingEvent('purchase', {
155
167
  transactionId: order.id,
156
- currency: order.currency,
157
- value: toNumber(order.total ?? order.totalAmount),
158
- shipping: toNumber(order.shippingAmount),
159
- // In inclusive (VAT) pricing `taxAmount` is "0" and the real VAT lives in
160
- // the frozen breakdown — reporting the 0 would understate tax on every
161
- // order for every VAT-region merchant.
162
- tax: toNumber(order.taxBreakdown?.totalTax) ?? toNumber(order.taxAmount),
168
+ currency: commerce.currency,
169
+ value: commerce.value,
170
+ shipping: commerce.shipping,
171
+ tax: commerce.tax,
163
172
  coupon: order.couponCode ?? undefined,
164
173
  items: (order.items ?? []).map((item) => ({
165
174
  itemId: item.sku || item.variantId || item.productId,
175
+ productId: item.productId,
176
+ variantId: item.variantId,
166
177
  itemName: item.name,
167
178
  price: toNumber(item.price),
168
179
  quantity: item.quantity,
@@ -1,62 +1,85 @@
1
- 'use client';
2
-
3
- import { formatPrice } from 'brainerce';
4
- import { Badge } from '@/components/ui/badge';
5
- import { useCurrency } from '@/core/lib/use-currency';
6
- import { cn } from '@/core/lib/utils';
7
-
8
- interface PriceDisplayProps {
9
- price: string | number;
10
- salePrice?: string | number | null;
11
- currency?: string;
12
- className?: string;
13
- size?: 'sm' | 'md' | 'lg';
14
- }
15
-
16
- const sizeClasses = {
17
- sm: 'text-sm',
18
- md: 'text-base',
19
- lg: 'text-xl font-semibold',
20
- };
21
-
22
- export function PriceDisplay({
23
- price,
24
- salePrice,
25
- currency,
26
- className,
27
- size = 'md',
28
- }: PriceDisplayProps) {
29
- const currencyCode = useCurrency(currency);
30
-
31
- const basePrice = typeof price === 'string' ? parseFloat(price) : price;
32
- const sale =
33
- salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
34
- const isOnSale = sale !== null && sale < basePrice;
35
-
36
- const discountPercent =
37
- isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
38
-
39
- return (
40
- <span className={cn('inline-flex items-center gap-2', sizeClasses[size], className)}>
41
- {isOnSale ? (
42
- <>
43
- <span className="text-destructive font-medium">
44
- {formatPrice(sale!, { currency: currencyCode }) as string}
45
- </span>
46
- <span className="text-muted-foreground text-[0.85em] line-through">
47
- {formatPrice(basePrice, { currency: currencyCode }) as string}
48
- </span>
49
- {discountPercent > 0 && (
50
- <Badge variant="destructive" className="rounded px-1.5 py-0.5 font-medium">
51
- -{discountPercent}%
52
- </Badge>
53
- )}
54
- </>
55
- ) : (
56
- <span className="text-foreground font-medium">
57
- {formatPrice(basePrice, { currency: currencyCode }) as string}
58
- </span>
59
- )}
60
- </span>
61
- );
62
- }
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { useCurrency } from '@/core/lib/use-currency';
6
+ import { useTranslations } from '@/core/lib/translations';
7
+ import { useStoreInfo } from '@/core/providers/store-provider';
8
+ import { cn } from '@/core/lib/utils';
9
+
10
+ interface PriceDisplayProps {
11
+ price: string | number;
12
+ salePrice?: string | number | null;
13
+ currency?: string;
14
+ className?: string;
15
+ size?: 'sm' | 'md' | 'lg';
16
+ }
17
+
18
+ const sizeClasses = {
19
+ sm: 'text-sm',
20
+ md: 'text-base',
21
+ lg: 'text-xl font-semibold',
22
+ };
23
+
24
+ export function PriceDisplay({
25
+ price,
26
+ salePrice,
27
+ currency,
28
+ className,
29
+ size = 'md',
30
+ }: PriceDisplayProps) {
31
+ const currencyCode = useCurrency(currency);
32
+ const { storeInfo } = useStoreInfo();
33
+ const t = useTranslations('common');
34
+
35
+ const basePrice = typeof price === 'string' ? parseFloat(price) : price;
36
+ const sale =
37
+ salePrice != null ? (typeof salePrice === 'string' ? parseFloat(salePrice) : salePrice) : null;
38
+ const isOnSale = sale !== null && sale < basePrice;
39
+
40
+ const discountPercent =
41
+ isOnSale && basePrice > 0 ? Math.round(((basePrice - sale!) / basePrice) * 100) : 0;
42
+
43
+ // FX overlay disclosure (see core/lib/display-price.ts): `currency` is the
44
+ // region-converted DISPLAY currency when it differs from the store's own
45
+ // base currency. The shopper is still charged in the base currency at
46
+ // checkout, so every converted price needs a visible "estimated" cue —
47
+ // otherwise nothing on screen explains why the cart/checkout total switches
48
+ // currency from what was just browsed.
49
+ const isConverted = !!currency && !!storeInfo?.currency && currency !== storeInfo.currency;
50
+
51
+ return (
52
+ <span className={cn('inline-flex flex-wrap items-center gap-2', sizeClasses[size], className)}>
53
+ {isOnSale ? (
54
+ <>
55
+ <span className="text-destructive font-medium">
56
+ {formatPrice(sale!, { currency: currencyCode }) as string}
57
+ </span>
58
+ <span className="text-muted-foreground text-[0.85em] line-through">
59
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
60
+ </span>
61
+ {discountPercent > 0 && (
62
+ <Badge variant="destructive" className="rounded px-1.5 py-0.5 font-medium">
63
+ -{discountPercent}%
64
+ </Badge>
65
+ )}
66
+ </>
67
+ ) : (
68
+ <span className="text-foreground font-medium">
69
+ {formatPrice(basePrice, { currency: currencyCode }) as string}
70
+ </span>
71
+ )}
72
+ {isConverted && (
73
+ <span
74
+ className="text-muted-foreground cursor-help text-[0.7em] font-normal underline decoration-dotted"
75
+ title={t('estimatedPriceTooltip', {
76
+ currency: currencyCode,
77
+ baseCurrency: storeInfo!.currency,
78
+ })}
79
+ >
80
+ {t('estimatedPrice')}
81
+ </span>
82
+ )}
83
+ </span>
84
+ );
85
+ }