@rpg-engine/long-bow 0.8.225 → 0.8.227

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.225",
3
+ "version": "0.8.227",
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;
@@ -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);