create-brainerce-store 1.85.0 → 1.86.1
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 +1 -1
- package/messages/en.json +6 -2
- package/messages/he.json +6 -2
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +2 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +1325 -1302
- package/templates/nextjs/base/src/app/icon.svg +11 -0
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +17 -2
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +51 -4
- package/templates/nextjs/base/src/core/lib/tracking.ts +68 -13
- package/templates/nextjs/base/src/ui/product/price-display.tsx +85 -62
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +539 -537
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +2 -2
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +90 -69
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +3 -1
- package/templates/nextjs/ui-canvas/product/price-display.tsx +73 -52
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +428 -426
|
@@ -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
|
-
!
|
|
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(
|
|
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.
|
|
@@ -16,12 +16,14 @@ import { getClient } from './brainerce';
|
|
|
16
16
|
*
|
|
17
17
|
* ## Item ids
|
|
18
18
|
*
|
|
19
|
-
* `itemId` is
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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. */
|
|
@@ -31,6 +33,50 @@ function toNumber(value: string | number | null | undefined): number | undefined
|
|
|
31
33
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Builds a GA4-spec-correct `{value, tax, shipping}` triple for the
|
|
38
|
+
* `purchase` event: `value` must be net item revenue only, with `tax`/
|
|
39
|
+
* `shipping` reported alongside it, never folded in.
|
|
40
|
+
*
|
|
41
|
+
* Inlined rather than imported from `buildGa4CommercePayload` in `brainerce`
|
|
42
|
+
* — that export only exists from SDK 3.1.0 (published 2026-09-18), and
|
|
43
|
+
* `packages/cli-shared/src/versions.ts`'s exact pin cannot move to a
|
|
44
|
+
* same-day release (its own ⛔⛔ RAISING THIS FLOOR rule: a pinned version
|
|
45
|
+
* must already be 7+ days old, so cooldown-enforcing installers like pnpm's
|
|
46
|
+
* `minimumReleaseAge` or ChatGPT Sites don't refuse the scaffold outright).
|
|
47
|
+
* Once 3.1.0 clears that week, swap this back to the SDK import and raise
|
|
48
|
+
* the pin in the same change — keep this in sync with
|
|
49
|
+
* `packages/sdk/src/types.ts`'s `buildGa4CommercePayload` until then.
|
|
50
|
+
*/
|
|
51
|
+
function buildGa4CommercePayload(
|
|
52
|
+
order: Pick<
|
|
53
|
+
Order,
|
|
54
|
+
| 'subtotal'
|
|
55
|
+
| 'totalAmount'
|
|
56
|
+
| 'discountAmount'
|
|
57
|
+
| 'shippingAmount'
|
|
58
|
+
| 'taxAmount'
|
|
59
|
+
| 'taxBreakdown'
|
|
60
|
+
| 'currency'
|
|
61
|
+
>
|
|
62
|
+
): { value: number; tax: number; shipping: number; currency?: string } {
|
|
63
|
+
const pricesIncludeTax = order.taxBreakdown?.pricesIncludeTax ?? false;
|
|
64
|
+
const subtotal = parseFloat(order.subtotal ?? order.totalAmount) || 0;
|
|
65
|
+
const discountAmount = parseFloat(order.discountAmount ?? '0') || 0;
|
|
66
|
+
const shippingAmount = parseFloat(order.shippingAmount ?? '0') || 0;
|
|
67
|
+
const taxAmount = parseFloat(order.taxAmount ?? '0') || 0;
|
|
68
|
+
const tax = pricesIncludeTax ? (order.taxBreakdown?.totalTax ?? 0) : taxAmount;
|
|
69
|
+
const netSubtotal = pricesIncludeTax ? subtotal - tax : subtotal;
|
|
70
|
+
const round2 = (n: number) => Math.round((n + Number.EPSILON) * 100) / 100;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
value: round2(Math.max(0, netSubtotal - discountAmount)),
|
|
74
|
+
tax: round2(tax),
|
|
75
|
+
shipping: round2(shippingAmount),
|
|
76
|
+
currency: order.currency,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
34
80
|
/**
|
|
35
81
|
* Resolve the SKU for a product/variant pair, falling back to the variant then
|
|
36
82
|
* product id only when the catalog has no SKU at all — a feed-less product
|
|
@@ -54,6 +100,8 @@ export function trackProductView(
|
|
|
54
100
|
items: [
|
|
55
101
|
{
|
|
56
102
|
itemId: resolveItemId(product, variant),
|
|
103
|
+
productId: product.id,
|
|
104
|
+
variantId: variant?.id,
|
|
57
105
|
itemName: product.name,
|
|
58
106
|
price,
|
|
59
107
|
quantity: 1,
|
|
@@ -77,6 +125,8 @@ export function trackAddToCart(
|
|
|
77
125
|
items: [
|
|
78
126
|
{
|
|
79
127
|
itemId: resolveItemId(product, variant),
|
|
128
|
+
productId: product.id,
|
|
129
|
+
variantId: variant?.id,
|
|
80
130
|
itemName: product.name,
|
|
81
131
|
price,
|
|
82
132
|
quantity,
|
|
@@ -92,6 +142,8 @@ function cartItems(cart: Cart): TrackingEventItem[] {
|
|
|
92
142
|
// The cart line carries product + variant snapshots; prefer the variant's
|
|
93
143
|
// SKU for the same catalog-parity reason as `resolveItemId`.
|
|
94
144
|
itemId: item.variant?.sku || item.product?.sku || item.variantId || item.productId,
|
|
145
|
+
productId: item.productId,
|
|
146
|
+
variantId: item.variantId ?? undefined,
|
|
95
147
|
itemName: item.product?.name,
|
|
96
148
|
price: toNumber(item.currentUnitPrice ?? item.unitPrice),
|
|
97
149
|
quantity: item.quantity,
|
|
@@ -151,18 +203,21 @@ export function trackPurchase(order: Order): void {
|
|
|
151
203
|
// vendor-side transaction id de-dupe rather than skipping the conversion.
|
|
152
204
|
}
|
|
153
205
|
|
|
206
|
+
// GA4 spec: `value` is net item revenue only — `tax`/`shipping` are
|
|
207
|
+
// reported alongside it, never folded in. See `buildGa4CommercePayload`.
|
|
208
|
+
const commerce = buildGa4CommercePayload(order);
|
|
209
|
+
|
|
154
210
|
getClient().trackMarketingEvent('purchase', {
|
|
155
211
|
transactionId: order.id,
|
|
156
|
-
currency:
|
|
157
|
-
value:
|
|
158
|
-
shipping:
|
|
159
|
-
|
|
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),
|
|
212
|
+
currency: commerce.currency,
|
|
213
|
+
value: commerce.value,
|
|
214
|
+
shipping: commerce.shipping,
|
|
215
|
+
tax: commerce.tax,
|
|
163
216
|
coupon: order.couponCode ?? undefined,
|
|
164
217
|
items: (order.items ?? []).map((item) => ({
|
|
165
218
|
itemId: item.sku || item.variantId || item.productId,
|
|
219
|
+
productId: item.productId,
|
|
220
|
+
variantId: item.variantId,
|
|
166
221
|
itemName: item.name,
|
|
167
222
|
price: toNumber(item.price),
|
|
168
223
|
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 {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|