@rpg-engine/long-bow 0.8.199 → 0.8.202

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpg-engine/long-bow",
3
- "version": "0.8.199",
3
+ "version": "0.8.202",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -23,6 +23,8 @@ import { itemRarityOptions, itemTypeOptions, orderByOptions } from './filters';
23
23
  type MarketplaceBrowseMode = 'sell' | 'buy';
24
24
 
25
25
  const BUY_REQUESTS_PER_PAGE = 5;
26
+ const MARKETPLACE_PANEL_HEIGHT = '390px';
27
+ const MARKETPLACE_PANEL_MOBILE_HEIGHT = '250px';
26
28
 
27
29
  const formatBlueprintKey = (key: string): string => {
28
30
  const name = key.includes('/') ? key.split('/').pop()! : key;
@@ -646,8 +648,7 @@ const ItemComponentScrollWrapper = styled.div`
646
648
  flex-direction: column;
647
649
  overflow-y: auto;
648
650
  overflow-x: hidden;
649
- max-height: 390px;
650
- min-height: 120px;
651
+ height: ${MARKETPLACE_PANEL_HEIGHT};
651
652
  width: 95%;
652
653
  margin: 1rem auto 0 auto;
653
654
  background: rgba(0, 0, 0, 0.2);
@@ -655,7 +656,7 @@ const ItemComponentScrollWrapper = styled.div`
655
656
  border-radius: 4px;
656
657
 
657
658
  @media (max-width: 950px) {
658
- max-height: 250px;
659
+ height: ${MARKETPLACE_PANEL_MOBILE_HEIGHT};
659
660
  }
660
661
  `;
661
662
 
@@ -8,6 +8,10 @@ import ReactDOM from 'react-dom';
8
8
  import { act } from 'react-dom/test-utils';
9
9
  import { BuyPanel } from '../BuyPanel';
10
10
 
11
+ jest.mock('pixelarticons/react/SortVertical', () => ({
12
+ SortVertical: () => <svg data-testid="sort-icon" />,
13
+ }));
14
+
11
15
  jest.mock('../../ConfirmModal', () => ({
12
16
  ConfirmModal: ({ message, onConfirm, onClose }) => (
13
17
  <div data-testid="confirm-modal">
@@ -22,6 +26,18 @@ jest.mock('../../shared/CTAButton/CTAButton', () => ({
22
26
  CTAButton: ({ label, onClick }) => <button onClick={onClick}>{label}</button>,
23
27
  }));
24
28
 
29
+ jest.mock('../MarketplaceRows', () => ({
30
+ GroupedMarketplaceRow: () => <div data-testid="grouped-marketplace-row" />,
31
+ }));
32
+
33
+ jest.mock('../BuyOrderRows', () => ({
34
+ GroupedBuyOrderRow: ({ bestOrder, onFulfill }) => (
35
+ <div data-testid="grouped-buy-order-row">
36
+ <button onClick={() => onFulfill(bestOrder._id)}>Fulfill</button>
37
+ </div>
38
+ ),
39
+ }));
40
+
25
41
  describe('BuyPanel fulfill flow', () => {
26
42
  let container;
27
43
 
@@ -35,7 +51,7 @@ describe('BuyPanel fulfill flow', () => {
35
51
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
36
52
  };
37
53
 
38
- const renderBuyPanel = (onFulfillBuyOrder = jest.fn()) => {
54
+ const renderBuyPanel = (onFulfillBuyOrder = jest.fn(), extraProps = {}) => {
39
55
  act(() => {
40
56
  ReactDOM.render(
41
57
  <BuyPanel
@@ -60,6 +76,7 @@ describe('BuyPanel fulfill flow', () => {
60
76
  openBuyOrdersPage={1}
61
77
  onOpenBuyOrdersPageChange={jest.fn()}
62
78
  onFulfillBuyOrder={onFulfillBuyOrder}
79
+ {...extraProps}
63
80
  />,
64
81
  container
65
82
  );
@@ -83,8 +100,16 @@ describe('BuyPanel fulfill flow', () => {
83
100
  it('waits for confirmation before calling onFulfillBuyOrder', () => {
84
101
  const onFulfillBuyOrder = renderBuyPanel();
85
102
 
103
+ const buyRequestsButton = Array.from(container.querySelectorAll('button')).find(
104
+ button => button.textContent === 'Buy Requests'
105
+ );
106
+
107
+ act(() => {
108
+ buyRequestsButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
109
+ });
110
+
86
111
  const fulfillButton = Array.from(container.querySelectorAll('button')).find(
87
- (button) => button.textContent === 'Fulfill'
112
+ button => button.textContent === 'Fulfill'
88
113
  );
89
114
 
90
115
  act(() => {
@@ -108,8 +133,16 @@ describe('BuyPanel fulfill flow', () => {
108
133
  it('does not call onFulfillBuyOrder when the confirm modal is cancelled', () => {
109
134
  const onFulfillBuyOrder = renderBuyPanel();
110
135
 
136
+ const buyRequestsButton = Array.from(container.querySelectorAll('button')).find(
137
+ button => button.textContent === 'Buy Requests'
138
+ );
139
+
140
+ act(() => {
141
+ buyRequestsButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
142
+ });
143
+
111
144
  const fulfillButton = Array.from(container.querySelectorAll('button')).find(
112
- (button) => button.textContent === 'Fulfill'
145
+ button => button.textContent === 'Fulfill'
113
146
  );
114
147
 
115
148
  act(() => {
@@ -126,4 +159,13 @@ describe('BuyPanel fulfill flow', () => {
126
159
 
127
160
  expect(onFulfillBuyOrder).not.toHaveBeenCalled();
128
161
  });
162
+
163
+ it('keeps the marketplace viewport height fixed while loading', () => {
164
+ renderBuyPanel(jest.fn(), { isLoading: true });
165
+
166
+ const marketContainer = container.querySelector('#MarketContainer');
167
+
168
+ expect(marketContainer).not.toBeNull();
169
+ expect(getComputedStyle(marketContainer).height).toBe('390px');
170
+ });
129
171
  });
@@ -18,6 +18,7 @@ export interface INPCDialogProps {
18
18
  text?: string;
19
19
  type: NPCDialogType;
20
20
  imagePath?: string;
21
+ isTranslated?: boolean;
21
22
  onClose?: () => void;
22
23
  isQuestionDialog?: boolean;
23
24
  answers?: IQuestionDialogAnswer[];
@@ -29,6 +30,7 @@ export const NPCDialog: React.FC<INPCDialogProps> = ({
29
30
  type,
30
31
  onClose,
31
32
  imagePath,
33
+ isTranslated = false,
32
34
  isQuestionDialog = false,
33
35
  questions,
34
36
  answers,
@@ -70,6 +72,7 @@ export const NPCDialog: React.FC<INPCDialogProps> = ({
70
72
  <NPCDialogText
71
73
  type={type}
72
74
  text={text || 'No text provided.'}
75
+ isTranslated={isTranslated}
73
76
  onClose={() => {
74
77
  if (onClose) {
75
78
  onClose();
@@ -1,6 +1,6 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
2
  import styled from 'styled-components';
3
- import { NPCDialogType } from '../..';
3
+ import type { NPCDialogType } from './NPCDialog';
4
4
  import { IS_MOBILE_OR_TABLET } from '../../constants/uiDevices';
5
5
  import { chunkString } from '../../libs/StringHelpers';
6
6
  import { DynamicText } from '../typography/DynamicText';
@@ -13,6 +13,7 @@ interface IProps {
13
13
  onEndStep?: () => void;
14
14
  onStartStep?: () => void;
15
15
  type?: NPCDialogType;
16
+ isTranslated?: boolean;
16
17
  }
17
18
 
18
19
  export const NPCDialogText: React.FC<IProps> = ({
@@ -21,6 +22,7 @@ export const NPCDialogText: React.FC<IProps> = ({
21
22
  onEndStep,
22
23
  onStartStep,
23
24
  type,
25
+ isTranslated = false,
24
26
  }) => {
25
27
  const windowSize = useRef([window.innerWidth, window.innerHeight]);
26
28
  function maxCharacters(width: number) {
@@ -43,6 +45,11 @@ export const NPCDialogText: React.FC<IProps> = ({
43
45
  const textChunks = chunkString(text, maxCharacters(windowSize.current[0]));
44
46
 
45
47
  const [chunkIndex, setChunkIndex] = useState<number>(0);
48
+
49
+ useEffect(() => {
50
+ setChunkIndex(0);
51
+ }, [text]);
52
+
46
53
  const onHandleSpacePress = (event: KeyboardEvent) => {
47
54
  if (event.code === 'Space') {
48
55
  goToNextStep();
@@ -70,24 +77,37 @@ export const NPCDialogText: React.FC<IProps> = ({
70
77
  false
71
78
  );
72
79
 
80
+ useEffect(() => {
81
+ setShowGoNextIndicator(isTranslated);
82
+
83
+ if (isTranslated) {
84
+ onStartStep && onStartStep();
85
+ onEndStep && onEndStep();
86
+ }
87
+ }, [chunkIndex, isTranslated, onEndStep, onStartStep, text]);
88
+
73
89
  return (
74
90
  <Container>
75
- <DynamicText
76
- text={textChunks?.[chunkIndex] || ''}
77
- onFinish={() => {
78
- setShowGoNextIndicator(true);
79
-
80
- onEndStep && onEndStep();
81
- }}
82
- onStart={() => {
83
- setShowGoNextIndicator(false);
84
-
85
- onStartStep && onStartStep();
86
- }}
87
- />
91
+ {isTranslated ? (
92
+ <TextContainer>{textChunks?.[chunkIndex] || ''}</TextContainer>
93
+ ) : (
94
+ <DynamicText
95
+ text={textChunks?.[chunkIndex] || ''}
96
+ onFinish={() => {
97
+ setShowGoNextIndicator(true);
98
+
99
+ onEndStep && onEndStep();
100
+ }}
101
+ onStart={() => {
102
+ setShowGoNextIndicator(false);
103
+
104
+ onStartStep && onStartStep();
105
+ }}
106
+ />
107
+ )}
88
108
  {showGoNextIndicator && (
89
109
  <PressSpaceIndicator
90
- right={type === NPCDialogType.TextOnly ? '1rem' : '10.5rem'}
110
+ right={type === 'TextOnly' ? '1rem' : '10.5rem'}
91
111
  src={IS_MOBILE_OR_TABLET ? pressButtonGif : pressSpaceGif}
92
112
  onPointerDown={() => {
93
113
  goToNextStep();
@@ -100,6 +120,14 @@ export const NPCDialogText: React.FC<IProps> = ({
100
120
 
101
121
  const Container = styled.div``;
102
122
 
123
+ const TextContainer = styled.p`
124
+ font-size: 0.7rem !important;
125
+ color: white;
126
+ text-shadow: 1px 1px 0px #000000;
127
+ letter-spacing: 1.2px;
128
+ word-break: normal;
129
+ `;
130
+
103
131
  interface IPressSpaceIndicatorProps {
104
132
  right: string;
105
133
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ // @ts-nocheck
5
+ import React from 'react';
6
+ import ReactDOM from 'react-dom';
7
+ import { act } from 'react-dom/test-utils';
8
+ import { NPCDialogText } from '../NPCDialogText';
9
+
10
+ jest.mock('../img/press-button.gif', () => 'press-button.gif');
11
+ jest.mock('../img/space.gif', () => 'space.gif');
12
+
13
+ describe('NPCDialogText', () => {
14
+ let container;
15
+
16
+ beforeEach(() => {
17
+ container = document.createElement('div');
18
+ document.body.appendChild(container);
19
+ jest.useFakeTimers();
20
+ });
21
+
22
+ afterEach(() => {
23
+ ReactDOM.unmountComponentAtNode(container);
24
+ document.body.removeChild(container);
25
+ jest.useRealTimers();
26
+ jest.clearAllMocks();
27
+ });
28
+
29
+ it('renders the full translated text immediately and skips the typewriter delay', () => {
30
+ act(() => {
31
+ ReactDOM.render(
32
+ <NPCDialogText
33
+ text="Translated NPC dialog"
34
+ type={'TextOnly'}
35
+ isTranslated
36
+ onClose={jest.fn()}
37
+ />,
38
+ container
39
+ );
40
+ });
41
+
42
+ expect(container.textContent).toContain('Translated NPC dialog');
43
+ expect(container.querySelectorAll('img')).toHaveLength(1);
44
+ });
45
+
46
+ it('keeps the typewriter effect for untranslated text until the animation finishes', () => {
47
+ act(() => {
48
+ ReactDOM.render(
49
+ <NPCDialogText
50
+ text="Hi"
51
+ type={'TextOnly'}
52
+ onClose={jest.fn()}
53
+ />,
54
+ container
55
+ );
56
+ });
57
+
58
+ expect(container.textContent).toBe('');
59
+ expect(container.querySelectorAll('img')).toHaveLength(0);
60
+
61
+ act(() => {
62
+ jest.advanceTimersByTime(100);
63
+ });
64
+
65
+ expect(container.textContent).toContain('Hi');
66
+ expect(container.querySelectorAll('img')).toHaveLength(1);
67
+ });
68
+ });
@@ -127,13 +127,14 @@ export const Store: React.FC<IStoreProps> = ({
127
127
  onRedeemInputFocus,
128
128
  onRedeemInputBlur,
129
129
  }) => {
130
+ const defaultTabOrder: TabId[] = ['premium', 'packs', 'items'];
130
131
  const [selectedPack, setSelectedPack] = useState<IItemPack | null>(null);
131
132
  const [activeTab, setActiveTab] = useState<TabId>(() => {
132
- const initialTabs = (tabOrder ?? ['premium', 'packs', 'items']).filter(id => !(hidePremiumTab && id === 'premium'));
133
+ const initialTabs = (tabOrder ?? defaultTabOrder).filter(id => !(hidePremiumTab && id === 'premium'));
133
134
  if (defaultActiveTab && initialTabs.includes(defaultActiveTab)) {
134
135
  return defaultActiveTab;
135
136
  }
136
- return hidePremiumTab ? 'items' : 'premium';
137
+ return initialTabs[0] ?? (hidePremiumTab ? 'items' : 'premium');
137
138
  });
138
139
  const {
139
140
  cartItems,
@@ -239,7 +240,7 @@ export const Store: React.FC<IStoreProps> = ({
239
240
 
240
241
  // Build tabs dynamically based on props
241
242
  const tabIds: TabId[] = [
242
- ...(tabOrder ?? ['premium', 'packs', 'items']),
243
+ ...(tabOrder ?? defaultTabOrder),
243
244
  ...(onRedeem ? ['redeem' as TabId] : []),
244
245
  ...((onShowWallet || customWalletContent) ? ['wallet' as TabId] : []),
245
246
  ...((onShowHistory || customHistoryContent) ? ['history' as TabId] : [])
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ // @ts-nocheck
5
+ import React from 'react';
6
+ import ReactDOM from 'react-dom';
7
+ import { act } from 'react-dom/test-utils';
8
+ import { Store } from '../Store';
9
+
10
+ const mockTabs = jest.fn(({ options, activeTabId }) => (
11
+ <div data-testid="tabs" data-active-tab-id={activeTabId}>
12
+ {options.map(option => (
13
+ <button key={option.id} type="button">
14
+ {option.id}
15
+ </button>
16
+ ))}
17
+ </div>
18
+ ));
19
+
20
+ jest.mock('styled-components', () => {
21
+ const createTag = (tag) => () => {
22
+ if (tag === 'button') return ({ children, ...props }) => <button {...props}>{children}</button>;
23
+ return ({ children, ...props }) => <div {...props}>{children}</div>;
24
+ };
25
+ const styled = new Proxy(() => {}, {
26
+ get: (_target, prop) => {
27
+ if (prop === '__esModule') return true;
28
+ return createTag(prop);
29
+ },
30
+ apply: () => createTag('div'),
31
+ });
32
+ return { __esModule: true, default: styled, keyframes: () => '', css: () => '' };
33
+ });
34
+
35
+ jest.mock('../../../constants/uiColors', () => ({
36
+ uiColors: {
37
+ black: '#000',
38
+ white: '#fff',
39
+ red: '#f00',
40
+ },
41
+ }));
42
+
43
+ jest.mock('../../DraggableContainer', () => ({
44
+ DraggableContainer: ({ children }) => <div data-testid="draggable-container">{children}</div>,
45
+ }));
46
+
47
+ jest.mock('../../RPGUI/RPGUIContainer', () => ({
48
+ RPGUIContainerTypes: {
49
+ Framed: 'Framed',
50
+ },
51
+ }));
52
+
53
+ jest.mock('../../shared/Tabs', () => ({
54
+ Tabs: (props) => mockTabs(props),
55
+ }));
56
+
57
+ jest.mock('../../shared/LabelPill/LabelPill', () => ({
58
+ LabelPill: ({ children }) => <span>{children}</span>,
59
+ }));
60
+
61
+ jest.mock('../../shared/CTAButton/CTAButton', () => ({
62
+ CTAButton: ({ children, label, onClick }) => (
63
+ <button type="button" onClick={onClick}>
64
+ {children ?? label}
65
+ </button>
66
+ ),
67
+ }));
68
+
69
+ jest.mock('../CartView', () => ({
70
+ CartView: () => <div>Cart View</div>,
71
+ }));
72
+
73
+ jest.mock('../FeaturedBanner', () => ({
74
+ FeaturedBanner: () => <div>Featured Banner</div>,
75
+ }));
76
+
77
+ jest.mock('../MetadataCollector', () => ({
78
+ MetadataCollector: () => <div>Metadata Collector</div>,
79
+ }));
80
+
81
+ jest.mock('../sections/StoreItemsSection', () => ({
82
+ StoreItemsSection: () => <div>Items Section</div>,
83
+ }));
84
+
85
+ jest.mock('../sections/StorePacksSection', () => ({
86
+ StorePacksSection: () => <div>Packs Section</div>,
87
+ }));
88
+
89
+ jest.mock('../sections/StoreRedeemSection', () => ({
90
+ StoreRedeemSection: () => <div>Redeem Section</div>,
91
+ }));
92
+
93
+ jest.mock('../StoreItemDetails', () => ({
94
+ StoreItemDetails: () => <div>Store Item Details</div>,
95
+ }));
96
+
97
+ jest.mock('../hooks/useStoreCart', () => ({
98
+ useStoreCart: () => ({
99
+ cartItems: [],
100
+ handleAddToCart: jest.fn(),
101
+ handleRemoveFromCart: jest.fn(),
102
+ handlePurchase: jest.fn(),
103
+ openCart: jest.fn(),
104
+ closeCart: jest.fn(),
105
+ getTotalItems: () => 0,
106
+ getTotalPrice: () => 0,
107
+ isCartOpen: false,
108
+ }),
109
+ }));
110
+
111
+ jest.mock('pixelarticons/react/Crown', () => ({
112
+ Crown: () => <span>crown</span>,
113
+ }));
114
+
115
+ jest.mock('pixelarticons/react/Gift', () => ({
116
+ Gift: () => <span>gift</span>,
117
+ }));
118
+
119
+ jest.mock('pixelarticons/react/Package', () => ({
120
+ Package: () => <span>package</span>,
121
+ }));
122
+
123
+ jest.mock('pixelarticons/react/Wallet', () => ({
124
+ Wallet: () => <span>wallet</span>,
125
+ }));
126
+
127
+ jest.mock('react-icons/fa', () => ({
128
+ FaHistory: () => <span>history</span>,
129
+ FaShoppingCart: () => <span>cart</span>,
130
+ FaTicketAlt: () => <span>ticket</span>,
131
+ FaWallet: () => <span>wallet</span>,
132
+ }));
133
+
134
+ describe('Store', () => {
135
+ let container;
136
+
137
+ const baseProps = {
138
+ items: [],
139
+ packs: [],
140
+ atlasJSON: {},
141
+ atlasIMG: '',
142
+ onPurchase: jest.fn().mockResolvedValue(true),
143
+ userAccountType: 'Free',
144
+ };
145
+
146
+ beforeEach(() => {
147
+ container = document.createElement('div');
148
+ document.body.appendChild(container);
149
+ mockTabs.mockClear();
150
+ });
151
+
152
+ afterEach(() => {
153
+ ReactDOM.unmountComponentAtNode(container);
154
+ document.body.removeChild(container);
155
+ });
156
+
157
+ it('activates the first tab from tabOrder when no defaultActiveTab is provided', () => {
158
+ act(() => {
159
+ ReactDOM.render(
160
+ <Store
161
+ {...baseProps}
162
+ hidePremiumTab
163
+ tabOrder={['packs', 'items', 'wallet']}
164
+ customWalletContent={<div>Wallet</div>}
165
+ />,
166
+ container
167
+ );
168
+ });
169
+
170
+ const tabsProps = mockTabs.mock.calls[mockTabs.mock.calls.length - 1][0];
171
+ expect(tabsProps.options.map(option => option.id)).toEqual(['packs', 'items', 'wallet']);
172
+ expect(tabsProps.activeTabId).toBe('packs');
173
+ });
174
+
175
+ it('skips hidden premium when picking the initial tab from tabOrder', () => {
176
+ act(() => {
177
+ ReactDOM.render(
178
+ <Store
179
+ {...baseProps}
180
+ hidePremiumTab
181
+ tabOrder={['premium', 'packs', 'items']}
182
+ />,
183
+ container
184
+ );
185
+ });
186
+
187
+ const tabsProps = mockTabs.mock.calls[mockTabs.mock.calls.length - 1][0];
188
+ expect(tabsProps.options.map(option => option.id)).toEqual(['packs', 'items']);
189
+ expect(tabsProps.activeTabId).toBe('packs');
190
+ });
191
+ });
@@ -564,3 +564,65 @@ export const INR: Story = {
564
564
  />
565
565
  ),
566
566
  };
567
+
568
+ export const WithRedeemTab: Story = {
569
+ render: () => (
570
+ <Store
571
+ items={duplicatedItems}
572
+ packs={mockPacks}
573
+ userAccountType={UserAccountTypes.Free}
574
+ onPurchase={(purchase: Partial<IPurchase>) => {
575
+ console.log('Purchase details:', purchase);
576
+ return Promise.resolve(true);
577
+ }}
578
+ onRedeem={async (code: string) => {
579
+ if (code === 'CHB-550-ABCDEF1234') {
580
+ return { success: true, dcAmount: 550 };
581
+ }
582
+
583
+ return { success: false, error: 'Invalid voucher code.' };
584
+ }}
585
+ customWalletContent={<DCWalletContent
586
+ dcBalance={1200}
587
+ historyData={{ transactions: [], totalPages: 1, currentPage: 1 }}
588
+ historyLoading={false}
589
+ onRequestHistory={() => {}}
590
+ transferLoading={false}
591
+ transferResult={null}
592
+ onSendTransfer={() => {}}
593
+ onClearTransferResult={() => {}}
594
+ onBuyDC={() => console.log('Buy DC')}
595
+ />}
596
+ customHistoryContent={<DCHistoryPanel
597
+ transactions={[]}
598
+ totalPages={1}
599
+ currentPage={1}
600
+ loading={false}
601
+ onRequestHistory={() => {}}
602
+ />}
603
+ onClose={() => console.log('Store closed')}
604
+ atlasJSON={itemsAtlasJSON}
605
+ atlasIMG={itemsAtlasIMG}
606
+ hidePremiumTab={true}
607
+ tabOrder={['items', 'packs', 'redeem']}
608
+ defaultActiveTab="redeem"
609
+ textInputItemKeys={['original-greater-life-potion-2', 'original-angelic-sword-1', 'character-name-change']}
610
+ onTabChange={(tab, count) => console.log('[tracking] tab_change', { tab, count })}
611
+ onCategoryChange={(category, count) => console.log('[tracking] category_change', { category, count })}
612
+ onItemView={(item, position) => console.log('[tracking] item_viewed', { key: item.key, position })}
613
+ onPackView={(pack, position) => console.log('[tracking] pack_viewed', { key: pack.key, position })}
614
+ onCartOpen={() => console.log('[tracking] cart_opened')}
615
+ onAddToCart={(item, qty) => console.log('[tracking] add_to_cart', { key: item.key, qty })}
616
+ onRemoveFromCart={(key) => console.log('[tracking] remove_from_cart', { key })}
617
+ onCheckoutStart={(items, total) => console.log('[tracking] checkout_start', { items, total })}
618
+ onPurchaseSuccess={(items, total) => console.log('[tracking] purchase_success', { items, total })}
619
+ onPurchaseError={(error) => console.log('[tracking] purchase_error', { error })}
620
+ itemBadges={{
621
+ 'original-greater-life-potion-0': { originalPrice: 15.00 },
622
+ 'character-name-change': { originalPrice: 15.00 },
623
+ 'skin-character-customization': { originalPrice: 20.00 },
624
+ 'starter-pack': { originalPrice: 9.99 }
625
+ }}
626
+ />
627
+ ),
628
+ };