@rpg-engine/long-bow 0.8.69 → 0.8.71

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.69",
3
+ "version": "0.8.71",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -111,6 +111,9 @@ export const CartView: React.FC<ICartViewProps> = ({
111
111
  </ItemIconContainer>
112
112
  <ItemDetails>
113
113
  <ItemName>{cartItem.item.name}</ItemName>
114
+ {cartItem.metadata?.inputValue && (
115
+ <CartMeta>{cartItem.metadata.inputValue}</CartMeta>
116
+ )}
114
117
  <ItemInfo>
115
118
  <span>${formatPrice(cartItem.item.price)}</span>
116
119
  <span>×</span>
@@ -339,3 +342,11 @@ const MetadataValue = styled.div`
339
342
  text-overflow: ellipsis;
340
343
  white-space: nowrap;
341
344
  `;
345
+
346
+ const CartMeta = styled.div`
347
+ font-family: 'Press Start 2P', cursive;
348
+ font-size: 0.75rem;
349
+ color: #ffffff;
350
+ opacity: 0.8;
351
+ margin-top: 0.25rem;
352
+ `;
@@ -1,5 +1,5 @@
1
1
  import { IItemPack, IPurchase, IStoreItem, ItemRarities, ItemSubType, ItemType, UserAccountTypes } from '@rpg-engine/shared';
2
- import React, { useMemo, useState } from 'react';
2
+ import React, { ReactNode, useMemo, useState } from 'react';
3
3
  import { FaHistory, FaShoppingCart } from 'react-icons/fa';
4
4
  import styled from 'styled-components';
5
5
  import { uiColors } from '../../constants/uiColors';
@@ -14,6 +14,9 @@ import { StoreItemsSection } from './sections/StoreItemsSection';
14
14
  import { StorePacksSection } from './sections/StorePacksSection';
15
15
  import { StoreItemDetails } from './StoreItemDetails';
16
16
 
17
+ // Define TabId union type for tab identifiers
18
+ type TabId = 'premium' | 'packs' | 'items';
19
+
17
20
  // Define IStoreProps locally as a workaround
18
21
  export interface IStoreProps {
19
22
  items: IStoreItem[];
@@ -26,6 +29,10 @@ export interface IStoreProps {
26
29
  loading?: boolean;
27
30
  error?: string;
28
31
  onClose?: () => void;
32
+ hidePremiumTab?: boolean;
33
+ tabOrder?: TabId[];
34
+ defaultActiveTab?: TabId;
35
+ textInputItemKeys?: string[];
29
36
  }
30
37
 
31
38
  export const Store: React.FC<IStoreProps> = ({
@@ -39,9 +46,19 @@ export const Store: React.FC<IStoreProps> = ({
39
46
  loading = false,
40
47
  error,
41
48
  onClose,
49
+ hidePremiumTab = false,
50
+ tabOrder,
51
+ defaultActiveTab,
52
+ textInputItemKeys = [],
42
53
  }) => {
43
54
  const [selectedPack, setSelectedPack] = useState<IItemPack | null>(null);
44
- const [activeTab, setActiveTab] = useState('premium');
55
+ const [activeTab, setActiveTab] = useState<TabId>(() => {
56
+ const initialTabs = (tabOrder ?? ['premium', 'packs', 'items']).filter(id => !(hidePremiumTab && id === 'premium'));
57
+ if (defaultActiveTab && initialTabs.includes(defaultActiveTab)) {
58
+ return defaultActiveTab;
59
+ }
60
+ return hidePremiumTab ? 'items' : 'premium';
61
+ });
45
62
  const {
46
63
  cartItems,
47
64
  handleAddToCart,
@@ -136,8 +153,12 @@ export const Store: React.FC<IStoreProps> = ({
136
153
  return <ErrorMessage>{error}</ErrorMessage>;
137
154
  }
138
155
 
139
- const tabs = [
140
- {
156
+ // Build tabs dynamically based on props
157
+ const tabIds: TabId[] = tabOrder ?? ['premium', 'packs', 'items'];
158
+ const availableTabIds: TabId[] = tabIds.filter(id => !(hidePremiumTab && id === 'premium'));
159
+
160
+ const tabsMap: Record<TabId, { id: TabId; title: string; content: ReactNode }> = {
161
+ premium: {
141
162
  id: 'premium',
142
163
  title: 'Premium',
143
164
  content: (
@@ -148,7 +169,7 @@ export const Store: React.FC<IStoreProps> = ({
148
169
  />
149
170
  ),
150
171
  },
151
- {
172
+ packs: {
152
173
  id: 'packs',
153
174
  title: 'Packs',
154
175
  content: (
@@ -159,7 +180,7 @@ export const Store: React.FC<IStoreProps> = ({
159
180
  />
160
181
  ),
161
182
  },
162
- {
183
+ items: {
163
184
  id: 'items',
164
185
  title: 'Items',
165
186
  content: (
@@ -169,10 +190,13 @@ export const Store: React.FC<IStoreProps> = ({
169
190
  atlasJSON={atlasJSON}
170
191
  atlasIMG={atlasIMG}
171
192
  userAccountType={userAccountType}
193
+ textInputItemKeys={textInputItemKeys}
172
194
  />
173
195
  ),
174
196
  },
175
- ];
197
+ };
198
+
199
+ const tabs = availableTabIds.map(id => tabsMap[id]);
176
200
 
177
201
  return (
178
202
  <DraggableContainer
@@ -243,7 +267,7 @@ export const Store: React.FC<IStoreProps> = ({
243
267
  borderColor="#f59e0b"
244
268
  hoverColor="#fef3c7"
245
269
  activeTab={activeTab}
246
- onTabChange={setActiveTab}
270
+ onTabChange={(tabId: string) => setActiveTab(tabId as TabId)}
247
271
  />
248
272
  </MainContent>
249
273
  {cartItems.length > 0 && (
@@ -4,7 +4,6 @@ import { FaCartPlus } from 'react-icons/fa';
4
4
  import styled from 'styled-components';
5
5
  import { SelectArrow } from '../Arrow/SelectArrow';
6
6
  import { ICharacterProps } from '../Character/CharacterSelection';
7
- import { ErrorBoundary } from '../Item/Inventory/ErrorBoundary';
8
7
  import { CTAButton } from '../shared/CTAButton/CTAButton';
9
8
  import { SpriteFromAtlas } from '../shared/SpriteFromAtlas';
10
9
 
@@ -23,7 +22,6 @@ export const StoreCharacterSkinRow: React.FC<IStoreCharacterSkinRowProps> = ({
23
22
  onAddToCart,
24
23
  userAccountType,
25
24
  }) => {
26
- const [quantity, setQuantity] = useState(1);
27
25
  const [currentIndex, setCurrentIndex] = useState(0);
28
26
 
29
27
  // Get available characters from metadata
@@ -40,32 +38,14 @@ export const StoreCharacterSkinRow: React.FC<IStoreCharacterSkinRowProps> = ({
40
38
  setCurrentIndex(0);
41
39
  }, [item._id]);
42
40
 
43
- const handleQuantityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
44
- const value = parseInt(e.target.value) || 1;
45
- setQuantity(Math.min(Math.max(1, value), 99));
46
- };
47
-
48
- const handleBlur = () => {
49
- if (quantity < 1) setQuantity(1);
50
- if (quantity > 99) setQuantity(99);
51
- };
52
-
53
- const incrementQuantity = () => {
54
- setQuantity(prev => Math.min(prev + 1, 99));
55
- };
56
-
57
- const decrementQuantity = () => {
58
- setQuantity(prev => Math.max(1, prev - 1));
59
- };
60
-
61
41
  const handlePreviousSkin = () => {
62
- setCurrentIndex((prevIndex) =>
42
+ setCurrentIndex((prevIndex) =>
63
43
  prevIndex === 0 ? availableCharacters.length - 1 : prevIndex - 1
64
44
  );
65
45
  };
66
46
 
67
47
  const handleNextSkin = () => {
68
- setCurrentIndex((prevIndex) =>
48
+ setCurrentIndex((prevIndex) =>
69
49
  prevIndex === availableCharacters.length - 1 ? 0 : prevIndex + 1
70
50
  );
71
51
  };
@@ -77,17 +57,15 @@ export const StoreCharacterSkinRow: React.FC<IStoreCharacterSkinRowProps> = ({
77
57
  const handleAddToCart = () => {
78
58
  if (!hasRequiredAccount) return;
79
59
 
80
- // If we have character skins, add the selected skin to the purchase
60
+ // Always use a quantity of 1
81
61
  if (availableCharacters.length > 0 && currentCharacter) {
82
- onAddToCart(item, quantity, {
62
+ onAddToCart(item, 1, {
83
63
  selectedSkinName: currentCharacter.name,
84
64
  selectedSkinTextureKey: currentCharacter.textureKey
85
65
  });
86
66
  } else {
87
- onAddToCart(item, quantity);
67
+ onAddToCart(item, 1);
88
68
  }
89
-
90
- setQuantity(1); // Reset quantity after adding to cart
91
69
  };
92
70
 
93
71
  const getSpriteKey = (textureKey: string) => {
@@ -99,34 +77,16 @@ export const StoreCharacterSkinRow: React.FC<IStoreCharacterSkinRowProps> = ({
99
77
  return (
100
78
  <ItemWrapper>
101
79
  <ItemIconContainer>
102
- {availableCharacters.length > 0 && currentCharacter && entityAtlasJSON && entityAtlasIMG ? (
103
- <CharacterSkinPreviewContainer>
104
- <NavArrow
105
- direction="left"
106
- onPointerDown={handlePreviousSkin}
107
- size={24}
108
- />
109
-
110
- <SpriteContainer>
111
- <ErrorBoundary>
112
- <SpriteFromAtlas
113
- atlasJSON={entityAtlasJSON}
114
- atlasIMG={entityAtlasIMG}
115
- spriteKey={getSpriteKey(currentCharacter.textureKey)}
116
- width={32}
117
- height={32}
118
- imgScale={2}
119
- centered
120
- />
121
- </ErrorBoundary>
122
- </SpriteContainer>
123
-
124
- <NavArrow
125
- direction="right"
126
- onPointerDown={handleNextSkin}
127
- size={24}
128
- />
129
- </CharacterSkinPreviewContainer>
80
+ {entityAtlasJSON && entityAtlasIMG && currentCharacter ? (
81
+ <SpriteFromAtlas
82
+ atlasJSON={entityAtlasJSON}
83
+ atlasIMG={entityAtlasIMG}
84
+ spriteKey={getSpriteKey(currentCharacter.textureKey)}
85
+ width={32}
86
+ height={32}
87
+ imgScale={2}
88
+ centered
89
+ />
130
90
  ) : (
131
91
  <SpriteFromAtlas
132
92
  atlasJSON={atlasJSON}
@@ -141,38 +101,20 @@ export const StoreCharacterSkinRow: React.FC<IStoreCharacterSkinRowProps> = ({
141
101
  </ItemIconContainer>
142
102
 
143
103
  <ItemDetails>
144
- <ItemName>{item.name}</ItemName>
104
+ <Header>
105
+ <ItemName>{item.name}</ItemName>
106
+ </Header>
145
107
  {availableCharacters.length > 0 && currentCharacter && (
146
- <SelectedSkin>Selected: {currentCharacter.name}</SelectedSkin>
108
+ <SelectedSkinNav>
109
+ <SelectedSkin>Selected:</SelectedSkin>
110
+ <SkinNavArrow direction="left" onPointerDown={handlePreviousSkin} size={24} />
111
+ <SelectedSkin>{currentCharacter.name}</SelectedSkin>
112
+ <SkinNavArrow direction="right" onPointerDown={handleNextSkin} size={24} />
113
+ </SelectedSkinNav>
147
114
  )}
148
115
  <ItemPrice>${item.price}</ItemPrice>
149
116
  </ItemDetails>
150
-
151
117
  <Controls>
152
- <ArrowsContainer>
153
- <SelectArrow
154
- direction="left"
155
- onPointerDown={decrementQuantity}
156
- size={24}
157
- />
158
-
159
- <QuantityInput
160
- type="number"
161
- value={quantity}
162
- onChange={handleQuantityChange}
163
- onBlur={handleBlur}
164
- min={1}
165
- max={99}
166
- className="rpgui-input"
167
- />
168
-
169
- <SelectArrow
170
- direction="right"
171
- onPointerDown={incrementQuantity}
172
- size={24}
173
- />
174
- </ArrowsContainer>
175
-
176
118
  <CTAButton
177
119
  icon={<FaCartPlus />}
178
120
  label="Add"
@@ -197,7 +139,8 @@ const ItemWrapper = styled.div`
197
139
  `;
198
140
 
199
141
  const ItemIconContainer = styled.div`
200
- min-width: 140px;
142
+ width: 32px;
143
+ height: 32px;
201
144
  display: flex;
202
145
  align-items: center;
203
146
  justify-content: center;
@@ -205,28 +148,6 @@ const ItemIconContainer = styled.div`
205
148
  padding: 4px;
206
149
  `;
207
150
 
208
- const CharacterSkinPreviewContainer = styled.div`
209
- position: relative;
210
- display: flex;
211
- align-items: center;
212
- width: 140px;
213
- height: 42px;
214
- justify-content: space-between;
215
- `;
216
-
217
- const SpriteContainer = styled.div`
218
- display: flex;
219
- align-items: center;
220
- justify-content: center;
221
- position: absolute;
222
- left: 50%;
223
- transform: translateX(-50%);
224
- `;
225
-
226
- const NavArrow = styled(SelectArrow)`
227
- z-index: 2;
228
- `;
229
-
230
151
  const ItemDetails = styled.div`
231
152
  flex: 1;
232
153
  display: flex;
@@ -259,28 +180,19 @@ const Controls = styled.div`
259
180
  min-width: fit-content;
260
181
  `;
261
182
 
262
- const ArrowsContainer = styled.div`
263
- position: relative;
183
+ // Styled arrow override for inline nav arrows
184
+ const SkinNavArrow = styled(SelectArrow)`
185
+ position: static;
186
+ `;
187
+
188
+ const Header = styled.div`
264
189
  display: flex;
265
190
  align-items: center;
266
- width: 120px;
267
- height: 42px;
268
- justify-content: space-between;
191
+ gap: 0.5rem;
269
192
  `;
270
193
 
271
- const QuantityInput = styled.input`
272
- width: 40px;
273
- text-align: center;
274
- margin: 0 auto;
275
- font-size: 0.875rem;
276
- background: rgba(0, 0, 0, 0.2);
277
- color: #ffffff;
278
- border: none;
279
- padding: 0.25rem;
280
-
281
- &::-webkit-inner-spin-button,
282
- &::-webkit-outer-spin-button {
283
- -webkit-appearance: none;
284
- margin: 0;
285
- }
194
+ const SelectedSkinNav = styled.div`
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 0.5rem;
286
198
  `;
@@ -12,6 +12,7 @@ interface IStoreItemRowProps {
12
12
  atlasIMG: string;
13
13
  onAddToCart: (item: IStoreItem, quantity: number, metadata?: Record<string, any>) => void;
14
14
  userAccountType: UserAccountTypes;
15
+ showTextInput?: boolean;
15
16
  }
16
17
 
17
18
  export const StoreItemRow: React.FC<IStoreItemRowProps> = ({
@@ -20,8 +21,10 @@ export const StoreItemRow: React.FC<IStoreItemRowProps> = ({
20
21
  atlasIMG,
21
22
  onAddToCart,
22
23
  userAccountType,
24
+ showTextInput = false,
23
25
  }) => {
24
26
  const [quantity, setQuantity] = useState(1);
27
+ const [textInputValue, setTextInputValue] = useState('');
25
28
 
26
29
  const handleQuantityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
27
30
  const value = parseInt(e.target.value) || 1;
@@ -45,10 +48,15 @@ export const StoreItemRow: React.FC<IStoreItemRowProps> = ({
45
48
  !item.requiredAccountType?.length ||
46
49
  item.requiredAccountType.includes(userAccountType);
47
50
 
48
- const handleAddToCart = () => {
51
+ const handleAddToCartInternal = () => {
49
52
  if (!hasRequiredAccount) return;
53
+ if (showTextInput) {
54
+ onAddToCart(item, 1, { inputValue: textInputValue });
55
+ setTextInputValue('');
56
+ } else {
50
57
  onAddToCart(item, quantity);
51
- setQuantity(1); // Reset quantity after adding to cart
58
+ setQuantity(1);
59
+ }
52
60
  };
53
61
 
54
62
  return (
@@ -71,6 +79,16 @@ export const StoreItemRow: React.FC<IStoreItemRowProps> = ({
71
79
  </ItemDetails>
72
80
 
73
81
  <Controls>
82
+ {/* Show text input if configured, else show arrows only for stackable items */}
83
+ {showTextInput ? (
84
+ <TextInput
85
+ type="text"
86
+ value={textInputValue}
87
+ placeholder="Enter value"
88
+ onChange={e => setTextInputValue(e.target.value)}
89
+ className="rpgui-input"
90
+ />
91
+ ) : item.isStackable ? (
74
92
  <ArrowsContainer>
75
93
  <SelectArrow
76
94
  direction="left"
@@ -94,11 +112,12 @@ export const StoreItemRow: React.FC<IStoreItemRowProps> = ({
94
112
  size={24}
95
113
  />
96
114
  </ArrowsContainer>
115
+ ) : null}
97
116
 
98
117
  <CTAButton
99
118
  icon={<FaCartPlus />}
100
119
  label="Add"
101
- onClick={handleAddToCart}
120
+ onClick={handleAddToCartInternal}
102
121
  disabled={!hasRequiredAccount}
103
122
  />
104
123
  </Controls>
@@ -179,3 +198,14 @@ const QuantityInput = styled.input`
179
198
  margin: 0;
180
199
  }
181
200
  `;
201
+
202
+ const TextInput = styled.input`
203
+ width: 120px;
204
+ text-align: center;
205
+ margin: 0 auto;
206
+ font-size: 0.875rem;
207
+ background: rgba(0, 0, 0, 0.2);
208
+ color: #ffffff;
209
+ border: none;
210
+ padding: 0.25rem;
211
+ `;
@@ -24,9 +24,10 @@ jest.mock('../../Character/CharacterSkinSelectionModal', () => ({
24
24
  jest.mock('../MetadataCollector', () => ({
25
25
  MetadataCollector: jest.fn(({ metadataType, config, onCollect, onCancel }) => {
26
26
  // Set up cleanup function for the useEffect tests
27
+ const React = require('react');
27
28
  React.useEffect(() => {
28
29
  return () => {
29
- if (window.__metadataResolvers) {
30
+ if (globalThis.__metadataResolvers) {
30
31
  onCancel();
31
32
  }
32
33
  };
@@ -20,15 +20,15 @@ jest.mock('../hooks/useStoreMetadata', () => {
20
20
 
21
21
  const collectMetadata = async (item) => {
22
22
  // If no metadata type or None, return null immediately
23
- if (!item.metadataType || item.metadataType === MetadataType.None) {
23
+ if (!item.metadataType || item.metadataType === 'None') {
24
24
  return null;
25
25
  }
26
26
 
27
27
  // Setup for valid metadata types
28
- window.__metadataResolvers = {
28
+ globalThis.__metadataResolvers = {
29
29
  resolve: (metadata) => {
30
30
  // Clean up
31
- window.__metadataResolvers = undefined;
31
+ globalThis.__metadataResolvers = undefined;
32
32
  return metadata;
33
33
  },
34
34
  item
@@ -36,7 +36,7 @@ jest.mock('../hooks/useStoreMetadata', () => {
36
36
 
37
37
  // Handle the last test case specifically
38
38
  if (item.key === 'item-character-skin-metadata-cancel') {
39
- window.__metadataResolvers = undefined; // Make sure it's cleaned up
39
+ globalThis.__metadataResolvers = undefined; // Make sure it's cleaned up
40
40
  return Promise.resolve(null);
41
41
  }
42
42
 
@@ -139,9 +139,9 @@ describe('useStoreMetadata', () => {
139
139
  const metadataPromise = hook.collectMetadata(mockItemWithCharacterSkinMetadata);
140
140
 
141
141
  // Setup our mock resolver
142
- if (window.__metadataResolvers) {
143
- const originalResolve = window.__metadataResolvers.resolve;
144
- window.__metadataResolvers.resolve = function(metadata) {
142
+ if (globalThis.__metadataResolvers) {
143
+ const originalResolve = globalThis.__metadataResolvers.resolve;
144
+ globalThis.__metadataResolvers.resolve = function(metadata) {
145
145
  resolveSpy(metadata);
146
146
  return originalResolve(metadata);
147
147
  };
@@ -149,8 +149,8 @@ describe('useStoreMetadata', () => {
149
149
 
150
150
  // Resolve the metadata
151
151
  const mockMetadata = { selectedSkin: 'test-skin' };
152
- if (window.__metadataResolvers) {
153
- window.__metadataResolvers.resolve(mockMetadata);
152
+ if (globalThis.__metadataResolvers) {
153
+ globalThis.__metadataResolvers.resolve(mockMetadata);
154
154
  }
155
155
 
156
156
  // Wait for the promise to resolve
@@ -159,7 +159,7 @@ describe('useStoreMetadata', () => {
159
159
  // Assertions
160
160
  expect(result).toEqual(mockMetadata);
161
161
  expect(resolveSpy).toHaveBeenCalledWith(mockMetadata);
162
- expect(window.__metadataResolvers).toBeUndefined(); // Should be cleaned up
162
+ expect(globalThis.__metadataResolvers).toBeUndefined(); // Should be cleaned up
163
163
  });
164
164
 
165
165
  it('should clean up window.__metadataResolvers when collection is cancelled', async () => {
@@ -11,6 +11,7 @@ interface IStoreItemsSectionProps {
11
11
  atlasJSON: Record<string, any>;
12
12
  atlasIMG: string;
13
13
  userAccountType?: UserAccountTypes;
14
+ textInputItemKeys?: string[];
14
15
  }
15
16
 
16
17
  export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
@@ -19,6 +20,7 @@ export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
19
20
  atlasJSON,
20
21
  atlasIMG,
21
22
  userAccountType,
23
+ textInputItemKeys = [],
22
24
  }) => {
23
25
  const [searchQuery, setSearchQuery] = useState('');
24
26
 
@@ -27,7 +29,7 @@ export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
27
29
  );
28
30
 
29
31
  const renderStoreItem = (item: IStoreItem) => {
30
- // Use the specialized character skin row for items with character skin metadata
32
+ // Prefer a specialized character skin row when needed
31
33
  if (item.metadataType === MetadataType.CharacterSkin) {
32
34
  return (
33
35
  <StoreCharacterSkinRow
@@ -40,8 +42,21 @@ export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
40
42
  />
41
43
  );
42
44
  }
43
-
44
- // Use the standard item row for all other items
45
+ // Render text input row when configured for this item key
46
+ if (textInputItemKeys.includes(item.key) || textInputItemKeys.includes(item._id)) {
47
+ return (
48
+ <StoreItemRow
49
+ key={item._id}
50
+ item={item}
51
+ atlasJSON={atlasJSON}
52
+ atlasIMG={atlasIMG}
53
+ onAddToCart={onAddToCart}
54
+ userAccountType={userAccountType || UserAccountTypes.Free}
55
+ showTextInput
56
+ />
57
+ );
58
+ }
59
+ // Fallback to standard arrow-based row
45
60
  return (
46
61
  <StoreItemRow
47
62
  key={item._id}
@@ -246,6 +246,10 @@ export const Default: Story = {
246
246
  onClose={() => console.log('Store closed')}
247
247
  atlasJSON={itemsAtlasJSON}
248
248
  atlasIMG={itemsAtlasIMG}
249
+ hidePremiumTab={true}
250
+ tabOrder={['items', 'packs']}
251
+ defaultActiveTab="items"
252
+ textInputItemKeys={['original-greater-life-potion-2', 'original-angelic-sword-1']}
249
253
  />
250
254
  ),
251
255
  };