@rpg-engine/long-bow 0.8.197 → 0.8.199

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.197",
3
+ "version": "0.8.199",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -218,6 +218,7 @@ export const GroupedBuyOrderRow: React.FC<IGroupedBuyOrderRowProps> = ({
218
218
  mainRow={makeRow(bestOrder)}
219
219
  subRows={otherOrders.map(makeRow)}
220
220
  badgeLabel="requests"
221
+ metaRightInset={156}
221
222
  />
222
223
  );
223
224
  };
@@ -642,9 +642,12 @@ const WrapperContainer = styled.div<{ $sell: boolean }>`
642
642
  `;
643
643
 
644
644
  const ItemComponentScrollWrapper = styled.div`
645
- overflow-y: scroll;
645
+ display: flex;
646
+ flex-direction: column;
647
+ overflow-y: auto;
646
648
  overflow-x: hidden;
647
- height: 390px;
649
+ max-height: 390px;
650
+ min-height: 120px;
648
651
  width: 95%;
649
652
  margin: 1rem auto 0 auto;
650
653
  background: rgba(0, 0, 0, 0.2);
@@ -652,7 +655,7 @@ const ItemComponentScrollWrapper = styled.div`
652
655
  border-radius: 4px;
653
656
 
654
657
  @media (max-width: 950px) {
655
- height: 250px;
658
+ max-height: 250px;
656
659
  }
657
660
  `;
658
661
 
@@ -1,5 +1,5 @@
1
1
  import { formatDCAmount, ICharacterListing } from '@rpg-engine/shared';
2
- import { Coins } from 'pixelarticons/react/Coins';
2
+ import { Wallet } from 'pixelarticons/react/Wallet';
3
3
  import React from 'react';
4
4
  import styled from 'styled-components';
5
5
  import { ItemRowWrapper } from '../shared/ItemRowWrapper';
@@ -77,7 +77,7 @@ export const CharacterMarketplaceRows: React.FC<ICharacterMarketplaceRowsProps>
77
77
 
78
78
  <ActionSection>
79
79
  <CTAButton
80
- icon={onCharacterBuy ? <Coins width={18} height={18} /> : undefined}
80
+ icon={onCharacterBuy ? <Wallet width={18} height={18} /> : undefined}
81
81
  label={onCharacterBuy ? 'Buy' : 'Delist'}
82
82
  disabled={disabled || isBeingBought}
83
83
  onClick={() => {
@@ -5,12 +5,14 @@ export interface IGroupedRowContainerProps {
5
5
  mainRow: React.ReactNode;
6
6
  subRows: React.ReactNode[];
7
7
  badgeLabel?: string;
8
+ metaRightInset?: number;
8
9
  }
9
10
 
10
11
  export const GroupedRowContainer: React.FC<IGroupedRowContainerProps> = ({
11
12
  mainRow,
12
13
  subRows,
13
14
  badgeLabel = 'offers',
15
+ metaRightInset = 120,
14
16
  }) => {
15
17
  const [expanded, setExpanded] = useState(false);
16
18
  const hasMultiple = subRows.length > 0;
@@ -21,7 +23,7 @@ export const GroupedRowContainer: React.FC<IGroupedRowContainerProps> = ({
21
23
  <GroupHeader $clickable={hasMultiple} onClick={hasMultiple ? () => setExpanded(e => !e) : undefined}>
22
24
  {mainRow}
23
25
  {hasMultiple && (
24
- <GroupMeta>
26
+ <GroupMeta $rightInset={metaRightInset}>
25
27
  <OfferBadge>{totalCount} {badgeLabel}</OfferBadge>
26
28
  <Chevron $expanded={expanded}>&#9656;</Chevron>
27
29
  </GroupMeta>
@@ -48,15 +50,18 @@ const GroupHeader = styled.div<{ $clickable: boolean }>`
48
50
  overflow-x: hidden;
49
51
  `;
50
52
 
51
- const GroupMeta = styled.div`
53
+ const GroupMeta = styled.div<{ $rightInset: number }>`
52
54
  position: absolute;
53
- right: 100px;
55
+ right: ${({ $rightInset }) => `${$rightInset}px`};
54
56
  top: 50%;
55
57
  transform: translateY(-50%);
56
58
  display: flex;
57
59
  align-items: center;
58
- gap: 6px;
60
+ gap: 4px;
61
+ padding-left: 10px;
62
+ background: linear-gradient(to left, rgba(25, 23, 23, 0.85), rgba(25, 23, 23, 0));
59
63
  pointer-events: none;
64
+ z-index: 1;
60
65
  `;
61
66
 
62
67
  const OfferBadge = styled.span`
@@ -1,5 +1,5 @@
1
1
  import { formatDCAmount, goldToDC, IEquipmentSet, IItem, IMarketplaceItem, MarketplaceAcceptedCurrency } from '@rpg-engine/shared';
2
- import { Coins } from 'pixelarticons/react/Coins';
2
+ import { Wallet } from 'pixelarticons/react/Wallet';
3
3
  import { ShoppingBag } from 'pixelarticons/react/ShoppingBag';
4
4
  import React, { useEffect, useRef, useState } from 'react';
5
5
  import styled from 'styled-components';
@@ -185,7 +185,7 @@ export const ManagmentPanel: React.FC<IManagmentPanelProps> = ({
185
185
  </BalanceRow>
186
186
  )}
187
187
  <SmallCTAButton
188
- icon={<Coins width={18} height={18} />}
188
+ icon={<Wallet width={18} height={18} />}
189
189
  label="Withdraw Gold"
190
190
  disabled={availableGold === 0}
191
191
  onClick={() => availableGold > 0 && onMoneyWithdraw()}
@@ -9,7 +9,7 @@ import {
9
9
  IMarketplaceItem,
10
10
  IMarketplaceTransaction,
11
11
  } from '@rpg-engine/shared';
12
- import { Clock } from 'pixelarticons/react/Clock';
12
+ import { DateTime } from 'pixelarticons/react/DateTime';
13
13
  import { Settings2 } from 'pixelarticons/react/Settings2';
14
14
  import { ShoppingBag } from 'pixelarticons/react/ShoppingBag';
15
15
  import { ShoppingCart } from 'pixelarticons/react/ShoppingCart';
@@ -262,7 +262,7 @@ export const Marketplace: React.FC<IMarketPlaceProps> = props => {
262
262
  {
263
263
  id: 'history',
264
264
  label: 'History',
265
- icon: <Clock width={18} height={18} />,
265
+ icon: <DateTime width={18} height={18} />,
266
266
  },
267
267
  ...(showWalletTab && walletProps
268
268
  ? [
@@ -6,7 +6,7 @@ import {
6
6
  IMarketplaceItem,
7
7
  MarketplaceAcceptedCurrency,
8
8
  } from '@rpg-engine/shared';
9
- import { Coins } from 'pixelarticons/react/Coins';
9
+ import { Wallet } from 'pixelarticons/react/Wallet';
10
10
  import { Delete } from 'pixelarticons/react/Delete';
11
11
  import React from 'react';
12
12
  import styled from 'styled-components';
@@ -134,7 +134,7 @@ export const MarketplaceRows: React.FC<IMarketPlaceRowsPropos> = ({
134
134
 
135
135
  <ActionSection>
136
136
  <CTAButton
137
- icon={onMarketPlaceItemBuy ? <Coins width={18} height={18} /> : <Delete width={18} height={18} />}
137
+ icon={onMarketPlaceItemBuy ? <Wallet width={18} height={18} /> : <Delete width={18} height={18} />}
138
138
  label={onMarketPlaceItemBuy ? 'Buy' : 'Remove'}
139
139
  disabled={disabled}
140
140
  onClick={() => {
@@ -201,6 +201,7 @@ export const GroupedMarketplaceRow: React.FC<IGroupedMarketplaceRowProps> = ({
201
201
  mainRow={makeRow(bestListing)}
202
202
  subRows={otherListings.map(makeRow)}
203
203
  badgeLabel="offers"
204
+ metaRightInset={132}
204
205
  />
205
206
  );
206
207
  };
@@ -1,10 +1,10 @@
1
1
  import { IItemPack, IPurchase, IProductBlueprint, ItemRarities, ItemSubType, ItemType, UserAccountTypes, PaymentCurrency, PurchaseType } from '@rpg-engine/shared';
2
- import { Box } from 'pixelarticons/react/Box';
3
2
  import { Crown } from 'pixelarticons/react/Crown';
4
3
  import { Gift } from 'pixelarticons/react/Gift';
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,6 +123,9 @@ export const Store: React.FC<IStoreProps> = ({
116
123
  onPurchaseError,
117
124
  onBuyDC,
118
125
  currencySymbol = '$',
126
+ onRedeem,
127
+ onRedeemInputFocus,
128
+ onRedeemInputBlur,
119
129
  }) => {
120
130
  const [selectedPack, setSelectedPack] = useState<IItemPack | null>(null);
121
131
  const [activeTab, setActiveTab] = useState<TabId>(() => {
@@ -230,6 +240,7 @@ export const Store: React.FC<IStoreProps> = ({
230
240
  // Build tabs dynamically based on props
231
241
  const tabIds: TabId[] = [
232
242
  ...(tabOrder ?? ['premium', 'packs', 'items']),
243
+ ...(onRedeem ? ['redeem' as TabId] : []),
233
244
  ...((onShowWallet || customWalletContent) ? ['wallet' as TabId] : []),
234
245
  ...((onShowHistory || customHistoryContent) ? ['history' as TabId] : [])
235
246
  ];
@@ -318,7 +329,7 @@ export const Store: React.FC<IStoreProps> = ({
318
329
  items: {
319
330
  id: 'items',
320
331
  title: 'Items',
321
- icon: <Box width={18} height={18} />,
332
+ icon: <Package width={18} height={18} />,
322
333
  content: (
323
334
  <StoreItemsSection
324
335
  items={filteredItems.items}
@@ -335,6 +346,18 @@ export const Store: React.FC<IStoreProps> = ({
335
346
  />
336
347
  ),
337
348
  },
349
+ redeem: {
350
+ id: 'redeem',
351
+ title: 'Redeem',
352
+ icon: <FaTicketAlt size={16} />,
353
+ content: onRedeem ? (
354
+ <StoreRedeemSection
355
+ onRedeem={onRedeem}
356
+ onInputFocus={onRedeemInputFocus}
357
+ onInputBlur={onRedeemInputBlur}
358
+ />
359
+ ) : null,
360
+ },
338
361
  wallet: {
339
362
  id: 'wallet',
340
363
  title: walletLabel ?? 'Wallet',
@@ -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
+ });