@rpg-engine/long-bow 0.8.226 → 0.8.228

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.226",
3
+ "version": "0.8.228",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -84,7 +84,7 @@
84
84
  "dependencies": {
85
85
  "@capacitor/core": "^6.1.0",
86
86
  "@rollup/plugin-image": "^2.1.1",
87
- "@rpg-engine/shared": "^0.10.115",
87
+ "@rpg-engine/shared": "^0.10.119",
88
88
  "dayjs": "^1.11.2",
89
89
  "font-awesome": "^4.7.0",
90
90
  "fs-extra": "^10.1.0",
@@ -4,6 +4,7 @@ import React, { useState } from 'react';
4
4
  import styled from 'styled-components';
5
5
  import { CTAButton } from '../shared/CTAButton/CTAButton';
6
6
  import { CharacterListingModal } from './CharacterListingModal';
7
+ import { getEligibleCharactersForListing } from './characterListingUtils';
7
8
 
8
9
  export interface ICharacterListingFormProps {
9
10
  accountCharacters: ICharacter[];
@@ -30,9 +31,7 @@ export const CharacterListingForm: React.FC<ICharacterListingFormProps> = ({
30
31
  }) => {
31
32
  const [isModalOpen, setIsModalOpen] = useState(false);
32
33
 
33
- const eligibleCount = accountCharacters.filter(
34
- c => !c.isListedForSale && !c.tradedAt
35
- ).length;
34
+ const eligibleCount = getEligibleCharactersForListing(accountCharacters).length;
36
35
 
37
36
  return (
38
37
  <Wrapper>
@@ -1,6 +1,6 @@
1
1
  import { ICharacter } from '@rpg-engine/shared';
2
2
  import { ShoppingBag } from 'pixelarticons/react/ShoppingBag';
3
- import React, { useCallback, useState } from 'react';
3
+ import React, { useCallback, useMemo, useState } from 'react';
4
4
  import { FaTimes } from 'react-icons/fa';
5
5
  import styled, { keyframes } from 'styled-components';
6
6
  import ModalPortal from '../Abstractions/ModalPortal';
@@ -10,6 +10,7 @@ import { CTAButton } from '../shared/CTAButton/CTAButton';
10
10
  import { DCRateStrip } from '../shared/DCRateStrip';
11
11
  import { SpriteFromAtlas } from '../shared/SpriteFromAtlas';
12
12
  import { MMORPGNumber } from '../../components/shared/MMORPGNumber';
13
+ import { getEligibleCharactersForListing } from './characterListingUtils';
13
14
 
14
15
  export interface ICharacterListingModalProps {
15
16
  isOpen: boolean;
@@ -52,12 +53,13 @@ export const CharacterListingModal: React.FC<ICharacterListingModalProps> = ({
52
53
  []
53
54
  );
54
55
 
55
- if (!isOpen) return null;
56
-
57
- const eligibleCharacters = accountCharacters.filter(
58
- c => !c.isListedForSale
56
+ const eligibleCharacters = useMemo(
57
+ () => getEligibleCharactersForListing(accountCharacters, activeCharacterId),
58
+ [accountCharacters, activeCharacterId]
59
59
  );
60
60
 
61
+ if (!isOpen) return null;
62
+
61
63
  const isActiveCharacter = (c: ICharacter) => !!activeCharacterId && c._id === activeCharacterId;
62
64
 
63
65
  const canList = !!selectedId && Number(price) > 0 && selectedId !== activeCharacterId;
@@ -0,0 +1,151 @@
1
+ import { ICharacter, ICharacterListing } from '@rpg-engine/shared';
2
+ import React, { useState } from 'react';
3
+ import styled from 'styled-components';
4
+ import { CharacterMarketplacePanel } from './CharacterMarketplacePanel';
5
+ import { MyCharacterListingsPanel } from './MyCharacterListingsPanel';
6
+
7
+ export interface ICharacterTradePanelProps {
8
+ characterListings: ICharacterListing[];
9
+ characterListingsTotal: number;
10
+ characterListingsPage: number;
11
+ characterListingsItemsPerPage: number;
12
+ onCharacterListingsPageChange: (page: number) => void;
13
+ onCharacterBuy: (listingId: string) => void;
14
+ characterNameFilter?: string;
15
+ onCharacterNameFilterChange?: (name: string) => void;
16
+ characterListingsLoading?: boolean;
17
+ myCharacterListings: ICharacterListing[];
18
+ myCharacterListingsTotal: number;
19
+ myCharacterListingsPage: number;
20
+ onMyCharacterListingsPageChange: (page: number) => void;
21
+ onCharacterDelist: (listingId: string) => void;
22
+ accountCharacters: ICharacter[];
23
+ onCharacterList: (characterId: string, price: number) => void;
24
+ activeCharacterId?: string;
25
+ atlasJSON: any;
26
+ atlasIMG: any;
27
+ characterAtlasJSON?: any;
28
+ characterAtlasIMG?: any;
29
+ enableHotkeys?: () => void;
30
+ disableHotkeys?: () => void;
31
+ }
32
+
33
+ export const CharacterTradePanel: React.FC<ICharacterTradePanelProps> = ({
34
+ characterListings,
35
+ characterListingsTotal,
36
+ characterListingsPage,
37
+ characterListingsItemsPerPage,
38
+ onCharacterListingsPageChange,
39
+ onCharacterBuy,
40
+ characterNameFilter,
41
+ onCharacterNameFilterChange,
42
+ characterListingsLoading,
43
+ myCharacterListings,
44
+ myCharacterListingsTotal,
45
+ myCharacterListingsPage,
46
+ onMyCharacterListingsPageChange,
47
+ onCharacterDelist,
48
+ accountCharacters,
49
+ onCharacterList,
50
+ activeCharacterId,
51
+ atlasJSON,
52
+ atlasIMG,
53
+ characterAtlasJSON,
54
+ characterAtlasIMG,
55
+ enableHotkeys,
56
+ disableHotkeys,
57
+ }) => {
58
+ const [subTab, setSubTab] = useState<'browse' | 'my-listings'>('browse');
59
+
60
+ return (
61
+ <>
62
+ <CharacterSubTabs>
63
+ <CharacterSubTab
64
+ $active={subTab === 'browse'}
65
+ onClick={() => setSubTab('browse')}
66
+ type="button"
67
+ >
68
+ Browse
69
+ </CharacterSubTab>
70
+ <CharacterSubTab
71
+ $active={subTab === 'my-listings'}
72
+ onClick={() => setSubTab('my-listings')}
73
+ type="button"
74
+ >
75
+ My Listings
76
+ </CharacterSubTab>
77
+ </CharacterSubTabs>
78
+
79
+ {subTab === 'browse' && (
80
+ <CharacterMarketplacePanel
81
+ characterListings={characterListings}
82
+ totalCount={characterListingsTotal}
83
+ currentPage={characterListingsPage}
84
+ itemsPerPage={characterListingsItemsPerPage}
85
+ onPageChange={onCharacterListingsPageChange}
86
+ onCharacterBuy={onCharacterBuy}
87
+ atlasJSON={atlasJSON}
88
+ atlasIMG={atlasIMG}
89
+ characterAtlasJSON={characterAtlasJSON ?? atlasJSON}
90
+ characterAtlasIMG={characterAtlasIMG ?? atlasIMG}
91
+ enableHotkeys={enableHotkeys}
92
+ disableHotkeys={disableHotkeys}
93
+ nameFilter={characterNameFilter}
94
+ onNameFilterChange={onCharacterNameFilterChange}
95
+ isLoading={characterListingsLoading}
96
+ />
97
+ )}
98
+
99
+ {subTab === 'my-listings' && (
100
+ <MyCharacterListingsPanel
101
+ myCharacterListings={myCharacterListings}
102
+ totalCount={myCharacterListingsTotal}
103
+ currentPage={myCharacterListingsPage}
104
+ itemsPerPage={10}
105
+ onPageChange={onMyCharacterListingsPageChange}
106
+ onCharacterDelist={onCharacterDelist}
107
+ accountCharacters={accountCharacters}
108
+ onCharacterList={onCharacterList}
109
+ activeCharacterId={activeCharacterId}
110
+ atlasJSON={atlasJSON}
111
+ atlasIMG={atlasIMG}
112
+ characterAtlasJSON={characterAtlasJSON ?? atlasJSON}
113
+ characterAtlasIMG={characterAtlasIMG ?? atlasIMG}
114
+ enableHotkeys={enableHotkeys}
115
+ disableHotkeys={disableHotkeys}
116
+ />
117
+ )}
118
+ </>
119
+ );
120
+ };
121
+
122
+ const CharacterSubTabs = styled.div`
123
+ display: flex;
124
+ gap: 8px;
125
+ width: 95%;
126
+ margin: 10px auto 0 auto;
127
+ padding: 8px;
128
+ background: rgba(0, 0, 0, 0.2);
129
+ border-radius: 4px;
130
+ border: 1px solid rgba(255, 255, 255, 0.05);
131
+ `;
132
+
133
+ const CharacterSubTab = styled.button<{ $active: boolean }>`
134
+ flex: 1;
135
+ padding: 8px 12px;
136
+ font-family: 'Press Start 2P', cursive;
137
+ font-size: 0.45rem;
138
+ border-radius: 4px;
139
+ border: 1px solid ${({ $active }) => ($active ? '#f59e0b' : 'rgba(255,255,255,0.12)')};
140
+ background: ${({ $active }) => ($active ? 'rgba(245,158,11,0.15)' : 'rgba(0,0,0,0.3)')};
141
+ color: ${({ $active }) => ($active ? '#f59e0b' : '#777')};
142
+ cursor: pointer;
143
+ transition: border-color 0.15s, background 0.15s, color 0.15s;
144
+ text-transform: uppercase;
145
+ letter-spacing: 0.5px;
146
+
147
+ &:hover {
148
+ border-color: ${({ $active }) => ($active ? '#f59e0b' : 'rgba(255,255,255,0.3)')};
149
+ color: ${({ $active }) => ($active ? '#f59e0b' : '#bbb')};
150
+ }
151
+ `;
@@ -30,8 +30,7 @@ import { HistoryPanel } from './HistoryPanel';
30
30
  import { ManagmentPanel } from './ManagmentPanel';
31
31
  import { MarketplacePaymentMethod } from './MarketplaceBuyModal';
32
32
  import { MarketplaceAcceptedCurrency, MarketplaceSettingsPanel } from './MarketplaceSettingsPanel';
33
- import { CharacterMarketplacePanel } from './CharacterMarketplacePanel';
34
- import { MyCharacterListingsPanel } from './MyCharacterListingsPanel';
33
+ import { CharacterTradePanel } from './CharacterTradePanel';
35
34
 
36
35
  export interface IMarketPlaceProps {
37
36
  items: IMarketplaceItem[];
@@ -207,8 +206,6 @@ export const Marketplace: React.FC<IMarketPlaceProps> = props => {
207
206
  const [activeTab, setActiveTab] = useState<ActiveTab>('marketplace');
208
207
  const [acceptedCurrency, setAcceptedCurrency] = useState<MarketplaceAcceptedCurrency>(acceptedCurrencyProp ?? MarketplaceAcceptedCurrency.GoldOrDc);
209
208
  const [isBlueprintSearchOpen, setIsBlueprintSearchOpen] = useState(false);
210
- const [characterSubTab, setCharacterSubTab] = useState<'browse' | 'my-listings'>('browse');
211
-
212
209
  const handleCurrencyChange = (value: MarketplaceAcceptedCurrency) => {
213
210
  setAcceptedCurrency(value);
214
211
  onAcceptedCurrencyChange?.(value);
@@ -292,65 +289,31 @@ export const Marketplace: React.FC<IMarketPlaceProps> = props => {
292
289
  )}
293
290
 
294
291
  {activeTab === 'characters' && (
295
- <>
296
- <CharacterSubTabs>
297
- <CharacterSubTab
298
- $active={characterSubTab === 'browse'}
299
- onClick={() => setCharacterSubTab('browse')}
300
- type="button"
301
- >
302
- Browse
303
- </CharacterSubTab>
304
- <CharacterSubTab
305
- $active={characterSubTab === 'my-listings'}
306
- onClick={() => setCharacterSubTab('my-listings')}
307
- type="button"
308
- >
309
- My Listings
310
- </CharacterSubTab>
311
- </CharacterSubTabs>
312
-
313
- {characterSubTab === 'browse' && (
314
- <CharacterMarketplacePanel
315
- characterListings={characterListings ?? []}
316
- totalCount={characterListingsTotal ?? 0}
317
- currentPage={characterListingsPage ?? 1}
318
- itemsPerPage={characterListingsItemsPerPage ?? 10}
319
- onPageChange={onCharacterListingsPageChange ?? (() => {})}
320
- onCharacterBuy={onCharacterBuy ?? (() => {})}
321
- atlasJSON={props.atlasJSON}
322
- atlasIMG={props.atlasIMG}
323
- characterAtlasJSON={characterAtlasJSON ?? props.atlasJSON}
324
- characterAtlasIMG={characterAtlasIMG ?? props.atlasIMG}
325
- enableHotkeys={props.enableHotkeys}
326
- disableHotkeys={props.disableHotkeys}
327
- nameFilter={characterNameFilter}
328
- onNameFilterChange={onCharacterNameFilterChange}
329
- isLoading={characterListingsLoading}
330
- />
331
- )}
332
-
333
- {characterSubTab === 'my-listings' && (
334
- <MyCharacterListingsPanel
335
- myCharacterListings={myCharacterListings ?? []}
336
- totalCount={myCharacterListingsTotal ?? 0}
337
- currentPage={myCharacterListingsPage ?? 1}
338
- itemsPerPage={10}
339
- onPageChange={onMyCharacterListingsPageChange ?? (() => {})}
340
- onCharacterDelist={onCharacterDelist ?? (() => {})}
341
- accountCharacters={accountCharacters ?? []}
342
- onCharacterList={onCharacterList ?? (() => {})}
343
- activeCharacterId={props.characterId}
344
- atlasJSON={props.atlasJSON}
345
- atlasIMG={props.atlasIMG}
346
- characterAtlasJSON={characterAtlasJSON ?? props.atlasJSON}
347
- characterAtlasIMG={characterAtlasIMG ?? props.atlasIMG}
348
- enableHotkeys={props.enableHotkeys}
349
- disableHotkeys={props.disableHotkeys}
350
- />
351
- )}
352
-
353
- </>
292
+ <CharacterTradePanel
293
+ characterListings={characterListings ?? []}
294
+ characterListingsTotal={characterListingsTotal ?? 0}
295
+ characterListingsPage={characterListingsPage ?? 1}
296
+ characterListingsItemsPerPage={characterListingsItemsPerPage ?? 10}
297
+ onCharacterListingsPageChange={onCharacterListingsPageChange ?? (() => {})}
298
+ onCharacterBuy={onCharacterBuy ?? (() => {})}
299
+ characterNameFilter={characterNameFilter}
300
+ onCharacterNameFilterChange={onCharacterNameFilterChange}
301
+ characterListingsLoading={characterListingsLoading}
302
+ myCharacterListings={myCharacterListings ?? []}
303
+ myCharacterListingsTotal={myCharacterListingsTotal ?? 0}
304
+ myCharacterListingsPage={myCharacterListingsPage ?? 1}
305
+ onMyCharacterListingsPageChange={onMyCharacterListingsPageChange ?? (() => {})}
306
+ onCharacterDelist={onCharacterDelist ?? (() => {})}
307
+ accountCharacters={accountCharacters ?? []}
308
+ onCharacterList={onCharacterList ?? (() => {})}
309
+ activeCharacterId={props.characterId}
310
+ atlasJSON={props.atlasJSON}
311
+ atlasIMG={props.atlasIMG}
312
+ characterAtlasJSON={characterAtlasJSON}
313
+ characterAtlasIMG={characterAtlasIMG}
314
+ enableHotkeys={props.enableHotkeys}
315
+ disableHotkeys={props.disableHotkeys}
316
+ />
354
317
  )}
355
318
 
356
319
  {activeTab === 'sell' && (
@@ -442,33 +405,3 @@ const PagerContainer = styled.div`
442
405
  padding: 4px 10px;
443
406
  `;
444
407
 
445
- const CharacterSubTabs = styled.div`
446
- display: flex;
447
- gap: 8px;
448
- width: 95%;
449
- margin: 10px auto 0 auto;
450
- padding: 8px;
451
- background: rgba(0, 0, 0, 0.2);
452
- border-radius: 4px;
453
- border: 1px solid rgba(255, 255, 255, 0.05);
454
- `;
455
-
456
- const CharacterSubTab = styled.button<{ $active: boolean }>`
457
- flex: 1;
458
- padding: 8px 12px;
459
- font-family: 'Press Start 2P', cursive;
460
- font-size: 0.45rem;
461
- border-radius: 4px;
462
- border: 1px solid ${({ $active }) => ($active ? '#f59e0b' : 'rgba(255,255,255,0.12)')};
463
- background: ${({ $active }) => ($active ? 'rgba(245,158,11,0.15)' : 'rgba(0,0,0,0.3)')};
464
- color: ${({ $active }) => ($active ? '#f59e0b' : '#777')};
465
- cursor: pointer;
466
- transition: border-color 0.15s, background 0.15s, color 0.15s;
467
- text-transform: uppercase;
468
- letter-spacing: 0.5px;
469
-
470
- &:hover {
471
- border-color: ${({ $active }) => ($active ? '#f59e0b' : 'rgba(255,255,255,0.3)')};
472
- color: ${({ $active }) => ($active ? '#f59e0b' : '#bbb')};
473
- }
474
- `;
@@ -9,6 +9,7 @@ import { Pagination } from '../shared/Pagination/Pagination';
9
9
  import { SpriteFromAtlas } from '../shared/SpriteFromAtlas';
10
10
  import { CTAButton } from '../shared/CTAButton/CTAButton';
11
11
  import { CharacterListingModal } from './CharacterListingModal';
12
+ import { getEligibleCharactersForListing } from './characterListingUtils';
12
13
 
13
14
  export interface IMyCharacterListingsPanelProps {
14
15
  myCharacterListings: ICharacterListing[];
@@ -65,9 +66,7 @@ export const MyCharacterListingsPanel: React.FC<IMyCharacterListingsPanelProps>
65
66
  }
66
67
  };
67
68
 
68
- const eligibleCount = accountCharacters.filter(
69
- c => !c.isListedForSale && !c.tradedAt
70
- ).length;
69
+ const eligibleCount = getEligibleCharactersForListing(accountCharacters).length;
71
70
 
72
71
  return (
73
72
  <>
@@ -358,4 +357,3 @@ const EmptySubtext = styled.span`
358
357
  max-width: 260px;
359
358
  line-height: 1.6;
360
359
  `;
361
-
@@ -0,0 +1,55 @@
1
+ import { ICharacter } from '@rpg-engine/shared';
2
+ import {
3
+ getEligibleCharactersForListing,
4
+ isCharacterEligibleForListing,
5
+ } from '../characterListingUtils';
6
+
7
+ describe('characterListingUtils', () => {
8
+ it('keeps traded characters eligible as long as they are not currently listed', () => {
9
+ const tradedCharacter = {
10
+ _id: 'traded-character',
11
+ isListedForSale: false,
12
+ tradedAt: '2026-04-17T00:00:00.000Z',
13
+ } as ICharacter;
14
+
15
+ expect(isCharacterEligibleForListing(tradedCharacter)).toBe(true);
16
+ });
17
+
18
+ it('sorts recently traded characters first and moves the active character to the end', () => {
19
+ const characters = [
20
+ {
21
+ _id: 'older-character',
22
+ name: 'Older',
23
+ isListedForSale: false,
24
+ tradedAt: '2026-04-10T00:00:00.000Z',
25
+ },
26
+ {
27
+ _id: 'newly-traded-character',
28
+ name: 'Newly Traded',
29
+ isListedForSale: false,
30
+ tradedAt: '2026-04-17T00:00:00.000Z',
31
+ },
32
+ {
33
+ _id: 'already-listed-character',
34
+ name: 'Already Listed',
35
+ isListedForSale: true,
36
+ },
37
+ {
38
+ _id: 'active-character',
39
+ name: 'Active',
40
+ isListedForSale: false,
41
+ },
42
+ ] as ICharacter[];
43
+
44
+ const eligibleCharacters = getEligibleCharactersForListing(
45
+ characters,
46
+ 'active-character'
47
+ );
48
+
49
+ expect(eligibleCharacters.map(character => character._id)).toEqual([
50
+ 'newly-traded-character',
51
+ 'older-character',
52
+ 'active-character',
53
+ ]);
54
+ });
55
+ });
@@ -0,0 +1,38 @@
1
+ import { ICharacter } from '@rpg-engine/shared';
2
+
3
+ const toTimestamp = (value?: Date | string | null): number => {
4
+ if (!value) return 0;
5
+
6
+ const timestamp = new Date(value).getTime();
7
+
8
+ return Number.isFinite(timestamp) ? timestamp : 0;
9
+ };
10
+
11
+ export const isCharacterEligibleForListing = (character: ICharacter): boolean =>
12
+ !character.isListedForSale;
13
+
14
+ export const getEligibleCharactersForListing = (
15
+ accountCharacters: ICharacter[],
16
+ activeCharacterId?: string
17
+ ): ICharacter[] =>
18
+ accountCharacters
19
+ .filter(isCharacterEligibleForListing)
20
+ .sort((left, right) => {
21
+ const leftIsActive =
22
+ !!activeCharacterId && left._id === activeCharacterId;
23
+ const rightIsActive =
24
+ !!activeCharacterId && right._id === activeCharacterId;
25
+
26
+ if (leftIsActive !== rightIsActive) {
27
+ return leftIsActive ? 1 : -1;
28
+ }
29
+
30
+ const tradedAtDiff =
31
+ toTimestamp(right.tradedAt) - toTimestamp(left.tradedAt);
32
+
33
+ if (tradedAtDiff !== 0) {
34
+ return tradedAtDiff;
35
+ }
36
+
37
+ return 0;
38
+ });
@@ -13,6 +13,7 @@ export interface ISpellProps {
13
13
  charMagicLevel: number;
14
14
  charSkillLevels?: Record<string, number>;
15
15
  onPointerUp?: (spellKey: string) => void;
16
+ onClick?: (spellKey: string) => void;
16
17
  isSettingShortcut?: boolean;
17
18
  spellKey: string;
18
19
  spell: ISpell;
@@ -43,6 +44,7 @@ export const Spell: React.FC<ISpellProps> = ({
43
44
  charMagicLevel,
44
45
  charSkillLevels,
45
46
  onPointerUp,
47
+ onClick,
46
48
  isSettingShortcut,
47
49
  spell,
48
50
  activeCooldown,
@@ -77,10 +79,12 @@ export const Spell: React.FC<ISpellProps> = ({
77
79
  const CONTAINER_STYLE = { width: '32px', height: '32px' };
78
80
  const IMAGE_SCALE = 2;
79
81
 
82
+ const handleClick = onClick ?? onPointerUp;
83
+
80
84
  return (
81
85
  <SpellInfoWrapper spell={spell}>
82
86
  <Container
83
- onPointerUp={onPointerUp?.bind(null, spellKey)}
87
+ onClick={handleClick?.bind(null, spellKey)}
84
88
  isSettingShortcut={isSettingShortcut && !disabled}
85
89
  className="spell"
86
90
  >
@@ -316,7 +316,7 @@ export const Store: React.FC<IStoreProps> = ({
316
316
  cartItems={cartItems}
317
317
  onRemoveFromCart={handleRemoveFromCartTracked}
318
318
  onClose={closeCart}
319
- onPurchase={async () => { handleCartPurchase(onPurchase); return true; }}
319
+ onPurchase={() => handleCartPurchase(onPurchase)}
320
320
  atlasJSON={atlasJSON}
321
321
  atlasIMG={atlasIMG}
322
322
  onCheckoutStart={onCheckoutStart}
@@ -127,6 +127,22 @@ jest.mock('pixelarticons/react/Wallet', () => ({
127
127
  Wallet: () => <span>wallet</span>,
128
128
  }));
129
129
 
130
+ jest.mock('pixelarticons/react/DateTime', () => ({
131
+ DateTime: () => <span>datetime</span>,
132
+ }));
133
+
134
+ jest.mock('pixelarticons/react/Receipt', () => ({
135
+ Receipt: () => <span>receipt</span>,
136
+ }));
137
+
138
+ jest.mock('pixelarticons/react/ShoppingCart', () => ({
139
+ ShoppingCart: () => <span>shopping-cart</span>,
140
+ }));
141
+
142
+ jest.mock('pixelarticons/react/Users', () => ({
143
+ Users: () => <span>users</span>,
144
+ }));
145
+
130
146
  jest.mock('react-icons/fa', () => ({
131
147
  FaHistory: () => <span>history</span>,
132
148
  FaShoppingCart: () => <span>cart</span>,
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ // @ts-nocheck
5
+ import { PurchaseType } from '@rpg-engine/shared';
6
+ import React from 'react';
7
+ import ReactDOM from 'react-dom';
8
+ import { act } from 'react-dom/test-utils';
9
+ import { useStoreCart } from '../hooks/useStoreCart';
10
+
11
+ jest.mock('../hooks/useStoreMetadata', () => ({
12
+ useStoreMetadata: () => ({
13
+ collectMetadata: jest.fn(),
14
+ resolveMetadata: jest.fn(),
15
+ isCollectingMetadata: false,
16
+ currentMetadataItem: null,
17
+ }),
18
+ }));
19
+
20
+ let hookResult;
21
+ const TestComponent = () => {
22
+ hookResult = useStoreCart();
23
+ return null;
24
+ };
25
+
26
+ describe('useStoreCart', () => {
27
+ let container;
28
+
29
+ const baseItem = {
30
+ key: 'account-email-change',
31
+ name: 'Account Email Change',
32
+ price: 2.99,
33
+ type: PurchaseType.Service,
34
+ };
35
+
36
+ beforeEach(() => {
37
+ container = document.createElement('div');
38
+ document.body.appendChild(container);
39
+ act(() => {
40
+ ReactDOM.render(<TestComponent />, container);
41
+ });
42
+ });
43
+
44
+ afterEach(() => {
45
+ ReactDOM.unmountComponentAtNode(container);
46
+ document.body.removeChild(container);
47
+ });
48
+
49
+ it('clears and closes the cart after a successful purchase', async () => {
50
+ await act(async () => {
51
+ await hookResult.handleAddToCart(baseItem, 1, { inputValue: 'new@example.com' });
52
+ });
53
+ act(() => {
54
+ hookResult.openCart();
55
+ });
56
+
57
+ const onPurchase = jest.fn().mockResolvedValue(true);
58
+ let result;
59
+
60
+ await act(async () => {
61
+ result = await hookResult.handlePurchase(onPurchase);
62
+ });
63
+
64
+ expect(result).toBe(true);
65
+ expect(onPurchase).toHaveBeenCalledWith(
66
+ expect.objectContaining({
67
+ purchases: [
68
+ expect.objectContaining({
69
+ purchaseKey: 'account-email-change',
70
+ metadata: { inputValue: 'new@example.com' },
71
+ }),
72
+ ],
73
+ })
74
+ );
75
+ expect(hookResult.cartItems).toEqual([]);
76
+ expect(hookResult.isCartOpen).toBe(false);
77
+ });
78
+
79
+ it('keeps the cart intact when purchase returns false', async () => {
80
+ await act(async () => {
81
+ await hookResult.handleAddToCart(baseItem, 1, { inputValue: 'new@example.com' });
82
+ });
83
+ act(() => {
84
+ hookResult.openCart();
85
+ });
86
+
87
+ const onPurchase = jest.fn().mockResolvedValue(false);
88
+ let result;
89
+
90
+ await act(async () => {
91
+ result = await hookResult.handlePurchase(onPurchase);
92
+ });
93
+
94
+ expect(result).toBe(false);
95
+ expect(hookResult.cartItems).toHaveLength(1);
96
+ expect(hookResult.isCartOpen).toBe(true);
97
+ });
98
+ });
@@ -19,7 +19,7 @@ interface IUseStoreCart {
19
19
  isCartOpen: boolean;
20
20
  handleAddToCart: (item: IProductBlueprint, quantity: number, metadata?: Record<string, any>) => void;
21
21
  handleRemoveFromCart: (itemKey: string) => void;
22
- handlePurchase: (onPurchase: (purchase: IPurchase) => void) => void;
22
+ handlePurchase: (onPurchase: (purchase: IPurchase) => Promise<boolean>) => Promise<boolean>;
23
23
  openCart: () => void;
24
24
  closeCart: () => void;
25
25
  getTotalItems: () => number;
@@ -104,7 +104,7 @@ export const useStoreCart = (): IUseStoreCart => {
104
104
  );
105
105
  };
106
106
 
107
- const handlePurchase = (onPurchase: (purchase: IPurchase) => void) => {
107
+ const handlePurchase = async (onPurchase: (purchase: IPurchase) => Promise<boolean>): Promise<boolean> => {
108
108
  const purchaseUnits: IPurchaseUnit[] = cartItems.map(cartItem => ({
109
109
  purchaseKey: cartItem.item.key,
110
110
  qty: cartItem.quantity,
@@ -122,12 +122,14 @@ export const useStoreCart = (): IUseStoreCart => {
122
122
  purchases: purchaseUnits,
123
123
  };
124
124
 
125
- onPurchase(purchase);
125
+ const success = await onPurchase(purchase);
126
126
 
127
- if (isMounted.current) {
127
+ if (success && isMounted.current) {
128
128
  setCartItems([]);
129
129
  setIsCartOpen(false);
130
130
  }
131
+
132
+ return success;
131
133
  };
132
134
 
133
135
  const openCart = () => setIsCartOpen(true);