@rpg-engine/long-bow 0.8.198 → 0.8.200
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/components/Store/Store.d.ts +11 -1
- package/dist/components/Store/sections/StoreRedeemSection.d.ts +11 -0
- package/dist/index.d.ts +1 -0
- package/dist/long-bow.cjs.development.js +207 -20
- package/dist/long-bow.cjs.development.js.map +1 -1
- package/dist/long-bow.cjs.production.min.js +1 -1
- package/dist/long-bow.cjs.production.min.js.map +1 -1
- package/dist/long-bow.esm.js +208 -22
- package/dist/long-bow.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Marketplace/BuyPanel.tsx +4 -3
- package/src/components/Marketplace/__test__/BuyPanel.spec.tsx +45 -3
- package/src/components/Store/Store.tsx +29 -5
- package/src/components/Store/__test__/Store.spec.tsx +191 -0
- package/src/components/Store/__test__/StoreRedeemSection.spec.tsx +232 -0
- package/src/components/Store/sections/StoreRedeemSection.tsx +258 -0
- package/src/index.tsx +1 -0
- package/src/stories/Features/store/Store.stories.tsx +62 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
|
@@ -4,7 +4,7 @@ import { Gift } from 'pixelarticons/react/Gift';
|
|
|
4
4
|
import { Package } from 'pixelarticons/react/Package';
|
|
5
5
|
import { Wallet } from 'pixelarticons/react/Wallet';
|
|
6
6
|
import React, { ReactNode, useMemo, useState } from 'react';
|
|
7
|
-
import { FaHistory, FaShoppingCart, FaWallet } from 'react-icons/fa';
|
|
7
|
+
import { FaHistory, FaShoppingCart, FaTicketAlt, FaWallet } from 'react-icons/fa';
|
|
8
8
|
import styled from 'styled-components';
|
|
9
9
|
import { uiColors } from '../../constants/uiColors';
|
|
10
10
|
import { DraggableContainer } from '../DraggableContainer';
|
|
@@ -18,10 +18,11 @@ import { useStoreCart } from './hooks/useStoreCart';
|
|
|
18
18
|
import { MetadataCollector } from './MetadataCollector';
|
|
19
19
|
import { StoreItemsSection } from './sections/StoreItemsSection';
|
|
20
20
|
import { StorePacksSection } from './sections/StorePacksSection';
|
|
21
|
+
import { StoreRedeemSection } from './sections/StoreRedeemSection';
|
|
21
22
|
import { StoreItemDetails } from './StoreItemDetails';
|
|
22
23
|
|
|
23
24
|
// Define TabId union type for tab identifiers
|
|
24
|
-
type TabId = 'premium' | 'packs' | 'items' | 'wallet' | 'history';
|
|
25
|
+
type TabId = 'premium' | 'packs' | 'items' | 'wallet' | 'history' | 'redeem';
|
|
25
26
|
|
|
26
27
|
// Define IStoreProps locally as a workaround
|
|
27
28
|
export interface IStoreProps {
|
|
@@ -74,6 +75,12 @@ export interface IStoreProps {
|
|
|
74
75
|
onBuyDC?: () => void;
|
|
75
76
|
/** Currency symbol to display (e.g. "$" for USD, "R$" for BRL). Defaults to "$". */
|
|
76
77
|
currencySymbol?: string;
|
|
78
|
+
/** Callback to redeem a voucher code. When provided, the Redeem tab is shown. */
|
|
79
|
+
onRedeem?: (code: string) => Promise<{ success: boolean; dcAmount?: number; error?: string }>;
|
|
80
|
+
/** Called when the voucher code input gains focus. */
|
|
81
|
+
onRedeemInputFocus?: () => void;
|
|
82
|
+
/** Called when the voucher code input loses focus. */
|
|
83
|
+
onRedeemInputBlur?: () => void;
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
export type { IFeaturedItem };
|
|
@@ -116,14 +123,18 @@ export const Store: React.FC<IStoreProps> = ({
|
|
|
116
123
|
onPurchaseError,
|
|
117
124
|
onBuyDC,
|
|
118
125
|
currencySymbol = '$',
|
|
126
|
+
onRedeem,
|
|
127
|
+
onRedeemInputFocus,
|
|
128
|
+
onRedeemInputBlur,
|
|
119
129
|
}) => {
|
|
130
|
+
const defaultTabOrder: TabId[] = ['premium', 'packs', 'items'];
|
|
120
131
|
const [selectedPack, setSelectedPack] = useState<IItemPack | null>(null);
|
|
121
132
|
const [activeTab, setActiveTab] = useState<TabId>(() => {
|
|
122
|
-
const initialTabs = (tabOrder ??
|
|
133
|
+
const initialTabs = (tabOrder ?? defaultTabOrder).filter(id => !(hidePremiumTab && id === 'premium'));
|
|
123
134
|
if (defaultActiveTab && initialTabs.includes(defaultActiveTab)) {
|
|
124
135
|
return defaultActiveTab;
|
|
125
136
|
}
|
|
126
|
-
return hidePremiumTab ? 'items' : 'premium';
|
|
137
|
+
return initialTabs[0] ?? (hidePremiumTab ? 'items' : 'premium');
|
|
127
138
|
});
|
|
128
139
|
const {
|
|
129
140
|
cartItems,
|
|
@@ -229,7 +240,8 @@ export const Store: React.FC<IStoreProps> = ({
|
|
|
229
240
|
|
|
230
241
|
// Build tabs dynamically based on props
|
|
231
242
|
const tabIds: TabId[] = [
|
|
232
|
-
...(tabOrder ??
|
|
243
|
+
...(tabOrder ?? defaultTabOrder),
|
|
244
|
+
...(onRedeem ? ['redeem' as TabId] : []),
|
|
233
245
|
...((onShowWallet || customWalletContent) ? ['wallet' as TabId] : []),
|
|
234
246
|
...((onShowHistory || customHistoryContent) ? ['history' as TabId] : [])
|
|
235
247
|
];
|
|
@@ -335,6 +347,18 @@ export const Store: React.FC<IStoreProps> = ({
|
|
|
335
347
|
/>
|
|
336
348
|
),
|
|
337
349
|
},
|
|
350
|
+
redeem: {
|
|
351
|
+
id: 'redeem',
|
|
352
|
+
title: 'Redeem',
|
|
353
|
+
icon: <FaTicketAlt size={16} />,
|
|
354
|
+
content: onRedeem ? (
|
|
355
|
+
<StoreRedeemSection
|
|
356
|
+
onRedeem={onRedeem}
|
|
357
|
+
onInputFocus={onRedeemInputFocus}
|
|
358
|
+
onInputBlur={onRedeemInputBlur}
|
|
359
|
+
/>
|
|
360
|
+
) : null,
|
|
361
|
+
},
|
|
338
362
|
wallet: {
|
|
339
363
|
id: 'wallet',
|
|
340
364
|
title: walletLabel ?? 'Wallet',
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
// @ts-nocheck
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import ReactDOM from 'react-dom';
|
|
7
|
+
import { act, Simulate } from 'react-dom/test-utils';
|
|
8
|
+
import { StoreRedeemSection } from '../sections/StoreRedeemSection';
|
|
9
|
+
|
|
10
|
+
jest.mock('../../shared/CTAButton/CTAButton', () => ({
|
|
11
|
+
CTAButton: ({ label, onClick, disabled }) => (
|
|
12
|
+
<button onClick={onClick} disabled={disabled} data-testid="cta-button">
|
|
13
|
+
{label}
|
|
14
|
+
</button>
|
|
15
|
+
),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
jest.mock('styled-components', () => {
|
|
19
|
+
const createTag = (tag) => () => {
|
|
20
|
+
if (tag === 'input') return (props) => <input {...props} />;
|
|
21
|
+
if (tag === 'h3') return ({ children }) => <h3>{children}</h3>;
|
|
22
|
+
if (tag === 'p') return ({ children }) => <p>{children}</p>;
|
|
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('react-icons/fa', () => ({
|
|
36
|
+
FaCheckCircle: () => <span>check</span>,
|
|
37
|
+
FaExclamationCircle: () => <span>error</span>,
|
|
38
|
+
FaTicketAlt: () => <span>ticket</span>,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.mock('../../../constants/uiColors', () => ({
|
|
42
|
+
uiColors: {
|
|
43
|
+
white: '#fff',
|
|
44
|
+
lightGray: '#888',
|
|
45
|
+
green: '#4CAF50',
|
|
46
|
+
red: '#D04648',
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
51
|
+
|
|
52
|
+
describe('StoreRedeemSection', () => {
|
|
53
|
+
let container;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
container = document.createElement('div');
|
|
57
|
+
document.body.appendChild(container);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
ReactDOM.unmountComponentAtNode(container);
|
|
62
|
+
document.body.removeChild(container);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should render input and redeem button', () => {
|
|
66
|
+
act(() => {
|
|
67
|
+
ReactDOM.render(
|
|
68
|
+
<StoreRedeemSection onRedeem={jest.fn()} />,
|
|
69
|
+
container
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
expect(container.querySelector('input')).toBeTruthy();
|
|
73
|
+
expect(container.textContent).toContain('Redeem Code');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should disable button when input is empty', () => {
|
|
77
|
+
act(() => {
|
|
78
|
+
ReactDOM.render(
|
|
79
|
+
<StoreRedeemSection onRedeem={jest.fn()} />,
|
|
80
|
+
container
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
const button = container.querySelector('[data-testid="cta-button"]');
|
|
84
|
+
expect(button.disabled).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should enable button when input has text', () => {
|
|
88
|
+
act(() => {
|
|
89
|
+
ReactDOM.render(
|
|
90
|
+
<StoreRedeemSection onRedeem={jest.fn()} />,
|
|
91
|
+
container
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
const input = container.querySelector('input');
|
|
95
|
+
act(() => {
|
|
96
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
97
|
+
window.HTMLInputElement.prototype,
|
|
98
|
+
'value'
|
|
99
|
+
).set;
|
|
100
|
+
nativeInputValueSetter.call(input, 'ABC-123');
|
|
101
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
102
|
+
});
|
|
103
|
+
const button = container.querySelector('[data-testid="cta-button"]');
|
|
104
|
+
expect(button.disabled).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should show success with DC amount on valid redeem', async () => {
|
|
108
|
+
const onRedeem = jest.fn().mockResolvedValue({ success: true, dcAmount: 550 });
|
|
109
|
+
|
|
110
|
+
act(() => {
|
|
111
|
+
ReactDOM.render(
|
|
112
|
+
<StoreRedeemSection onRedeem={onRedeem} />,
|
|
113
|
+
container
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const input = container.querySelector('input');
|
|
118
|
+
act(() => {
|
|
119
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
120
|
+
window.HTMLInputElement.prototype,
|
|
121
|
+
'value'
|
|
122
|
+
).set;
|
|
123
|
+
nativeInputValueSetter.call(input, 'CHB-550-ABCDEF1234');
|
|
124
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const button = container.querySelector('[data-testid="cta-button"]');
|
|
128
|
+
await act(async () => {
|
|
129
|
+
button.click();
|
|
130
|
+
await flushPromises();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(onRedeem).toHaveBeenCalledWith('CHB-550-ABCDEF1234');
|
|
134
|
+
expect(container.textContent).toContain('Code Redeemed');
|
|
135
|
+
expect(container.textContent).toContain('+550 DC');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should show error on failed redeem', async () => {
|
|
139
|
+
const onRedeem = jest.fn().mockResolvedValue({ success: false, error: 'Invalid voucher code.' });
|
|
140
|
+
|
|
141
|
+
act(() => {
|
|
142
|
+
ReactDOM.render(
|
|
143
|
+
<StoreRedeemSection onRedeem={onRedeem} />,
|
|
144
|
+
container
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const input = container.querySelector('input');
|
|
149
|
+
act(() => {
|
|
150
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
151
|
+
window.HTMLInputElement.prototype,
|
|
152
|
+
'value'
|
|
153
|
+
).set;
|
|
154
|
+
nativeInputValueSetter.call(input, 'INVALID-CODE');
|
|
155
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const button = container.querySelector('[data-testid="cta-button"]');
|
|
159
|
+
await act(async () => {
|
|
160
|
+
button.click();
|
|
161
|
+
await flushPromises();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(container.textContent).toContain('Invalid voucher code.');
|
|
165
|
+
expect(container.textContent).toContain('Try Again');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should reset to idle when "Redeem Another" is clicked after success', async () => {
|
|
169
|
+
const onRedeem = jest.fn().mockResolvedValue({ success: true, dcAmount: 100 });
|
|
170
|
+
|
|
171
|
+
act(() => {
|
|
172
|
+
ReactDOM.render(
|
|
173
|
+
<StoreRedeemSection onRedeem={onRedeem} />,
|
|
174
|
+
container
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const input = container.querySelector('input');
|
|
179
|
+
act(() => {
|
|
180
|
+
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
181
|
+
window.HTMLInputElement.prototype,
|
|
182
|
+
'value'
|
|
183
|
+
).set;
|
|
184
|
+
nativeInputValueSetter.call(input, 'CODE123');
|
|
185
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const submitBtn = container.querySelector('[data-testid="cta-button"]');
|
|
189
|
+
await act(async () => {
|
|
190
|
+
submitBtn.click();
|
|
191
|
+
await flushPromises();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(container.textContent).toContain('Redeem Another');
|
|
195
|
+
|
|
196
|
+
const redeemAnotherBtn = Array.from(container.querySelectorAll('[data-testid="cta-button"]'))
|
|
197
|
+
.find(b => b.textContent.includes('Redeem Another'));
|
|
198
|
+
|
|
199
|
+
act(() => { redeemAnotherBtn.click(); });
|
|
200
|
+
|
|
201
|
+
expect(container.querySelector('input')).toBeTruthy();
|
|
202
|
+
expect(container.textContent).toContain('Redeem Code');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should call onInputFocus when input is focused', () => {
|
|
206
|
+
const onInputFocus = jest.fn();
|
|
207
|
+
act(() => {
|
|
208
|
+
ReactDOM.render(
|
|
209
|
+
<StoreRedeemSection onRedeem={jest.fn()} onInputFocus={onInputFocus} />,
|
|
210
|
+
container
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const input = container.querySelector('input');
|
|
215
|
+
act(() => { Simulate.focus(input); });
|
|
216
|
+
expect(onInputFocus).toHaveBeenCalledTimes(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should call onInputBlur when input loses focus', () => {
|
|
220
|
+
const onInputBlur = jest.fn();
|
|
221
|
+
act(() => {
|
|
222
|
+
ReactDOM.render(
|
|
223
|
+
<StoreRedeemSection onRedeem={jest.fn()} onInputBlur={onInputBlur} />,
|
|
224
|
+
container
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const input = container.querySelector('input');
|
|
229
|
+
act(() => { Simulate.blur(input); });
|
|
230
|
+
expect(onInputBlur).toHaveBeenCalledTimes(1);
|
|
231
|
+
});
|
|
232
|
+
});
|