@rpg-engine/long-bow 0.8.219 → 0.8.221

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.
Files changed (36) hide show
  1. package/dist/components/DraggableContainer.d.ts +0 -6
  2. package/dist/components/Store/CartView.d.ts +0 -2
  3. package/dist/components/Store/MetadataCollector.d.ts +2 -2
  4. package/dist/components/Store/Store.d.ts +10 -28
  5. package/dist/components/Store/StoreHeader.d.ts +14 -0
  6. package/dist/components/Store/hooks/useStoreCart.d.ts +2 -0
  7. package/dist/components/Store/hooks/useStoreMetadata.d.ts +4 -11
  8. package/dist/components/Store/hooks/useStoreTabs.d.ts +20 -0
  9. package/dist/components/Store/internal/packToBlueprint.d.ts +2 -0
  10. package/dist/components/Store/sections/StoreItemsSection.d.ts +5 -3
  11. package/dist/hooks/useStoreFiltering.d.ts +7 -4
  12. package/dist/long-bow.cjs.development.js +379 -441
  13. package/dist/long-bow.cjs.development.js.map +1 -1
  14. package/dist/long-bow.cjs.production.min.js +1 -1
  15. package/dist/long-bow.cjs.production.min.js.map +1 -1
  16. package/dist/long-bow.esm.js +381 -443
  17. package/dist/long-bow.esm.js.map +1 -1
  18. package/package.json +1 -1
  19. package/src/components/DraggableContainer.tsx +0 -24
  20. package/src/components/Store/CartView.tsx +116 -137
  21. package/src/components/Store/MetadataCollector.tsx +60 -40
  22. package/src/components/Store/Store.tsx +75 -285
  23. package/src/components/Store/StoreHeader.tsx +74 -0
  24. package/src/components/Store/__test__/MetadataCollector.spec.tsx +94 -164
  25. package/src/components/Store/__test__/Store.spec.tsx +4 -0
  26. package/src/components/Store/__test__/useStoreMetadata.spec.tsx +58 -156
  27. package/src/components/Store/__test__/useStoreTabs.spec.tsx +69 -0
  28. package/src/components/Store/hooks/useStoreCart.ts +5 -2
  29. package/src/components/Store/hooks/useStoreMetadata.ts +30 -48
  30. package/src/components/Store/hooks/useStoreTabs.ts +104 -0
  31. package/src/components/Store/internal/packToBlueprint.ts +21 -0
  32. package/src/components/Store/sections/StoreItemsSection.tsx +19 -60
  33. package/src/components/Store/sections/StorePacksSection.tsx +0 -1
  34. package/src/components/shared/ScrollableContent/ScrollableContent.tsx +3 -6
  35. package/src/hooks/useStoreFiltering.spec.tsx +79 -0
  36. package/src/hooks/useStoreFiltering.ts +27 -9
@@ -8,7 +8,6 @@ import {
8
8
  import { useEffect, useRef, useState } from 'react';
9
9
  import { useStoreMetadata } from './useStoreMetadata';
10
10
 
11
- // Create local cart item interface that uses IProductBlueprint
12
11
  interface ICartItem {
13
12
  item: IProductBlueprint;
14
13
  quantity: number;
@@ -26,6 +25,8 @@ interface IUseStoreCart {
26
25
  getTotalItems: () => number;
27
26
  getTotalPrice: () => number;
28
27
  isCollectingMetadata: boolean;
28
+ currentMetadataItem: IProductBlueprint | null;
29
+ resolveMetadata: (metadata: Record<string, any> | null) => void;
29
30
  }
30
31
 
31
32
  export const useStoreCart = (): IUseStoreCart => {
@@ -39,7 +40,7 @@ export const useStoreCart = (): IUseStoreCart => {
39
40
  };
40
41
  }, []);
41
42
 
42
- const { collectMetadata, isCollectingMetadata } = useStoreMetadata();
43
+ const { collectMetadata, resolveMetadata, isCollectingMetadata, currentMetadataItem } = useStoreMetadata();
43
44
 
44
45
  const handleAddToCart = async (item: IProductBlueprint, quantity: number, preselectedMetadata?: Record<string, any>) => {
45
46
  // If metadata is already provided (from inline selection), use it directly
@@ -153,6 +154,8 @@ export const useStoreCart = (): IUseStoreCart => {
153
154
  getTotalItems,
154
155
  getTotalPrice,
155
156
  isCollectingMetadata,
157
+ currentMetadataItem,
158
+ resolveMetadata,
156
159
  };
157
160
  };
158
161
 
@@ -1,55 +1,37 @@
1
- import { IProductBlueprint, MetadataType } from "@rpg-engine/shared";
2
- import { useState } from "react";
1
+ import { IProductBlueprint, MetadataType } from '@rpg-engine/shared';
2
+ import { useRef, useState } from 'react';
3
3
 
4
- interface IUseStoreMetadata {
5
- collectMetadata: (item: IProductBlueprint) => Promise<Record<string, any> | null>;
6
- isCollectingMetadata: boolean;
4
+ export interface IUseStoreMetadata {
5
+ collectMetadata: (item: IProductBlueprint) => Promise<Record<string, any> | null>;
6
+ resolveMetadata: (metadata: Record<string, any> | null) => void;
7
+ isCollectingMetadata: boolean;
8
+ currentMetadataItem: IProductBlueprint | null;
7
9
  }
8
10
 
9
11
  export const useStoreMetadata = (): IUseStoreMetadata => {
10
- const [isCollectingMetadata, setIsCollectingMetadata] = useState(false);
11
-
12
- const collectMetadata = async (item: IProductBlueprint): Promise<Record<string, any> | null> => {
13
- if (!item.metadataType || item.metadataType !== MetadataType.CharacterSkin) {
14
- return null;
15
- }
12
+ const [isCollectingMetadata, setIsCollectingMetadata] = useState(false);
13
+ const [currentMetadataItem, setCurrentMetadataItem] = useState<IProductBlueprint | null>(null);
14
+ const resolverRef = useRef<((metadata: Record<string, any> | null) => void) | null>(null);
16
15
 
17
- setIsCollectingMetadata(true);
18
-
19
- try {
20
- // This is a promise-based approach that will be resolved when the MetadataCollector
21
- // component calls the onCollect or onCancel callbacks
22
- return await new Promise<Record<string, any> | null>((resolve) => {
23
- // We'll store the resolver functions in a global context
24
- // that will be accessible to the MetadataCollector component
25
- window.__metadataResolvers = {
26
- resolve: (metadata: Record<string, any> | null) => {
27
- resolve(metadata);
28
- },
29
- item,
30
- };
31
- });
32
- } finally {
33
- setIsCollectingMetadata(false);
34
- // Clean up the resolvers
35
- if (window.__metadataResolvers) {
36
- delete window.__metadataResolvers;
37
- }
38
- }
39
- };
16
+ const collectMetadata = (item: IProductBlueprint): Promise<Record<string, any> | null> => {
17
+ if (!item.metadataType || item.metadataType !== MetadataType.CharacterSkin) {
18
+ return Promise.resolve(null);
19
+ }
40
20
 
41
- return {
42
- collectMetadata,
43
- isCollectingMetadata,
44
- };
45
- };
21
+ setIsCollectingMetadata(true);
22
+ setCurrentMetadataItem(item);
46
23
 
47
- // Add TypeScript declaration for the global object
48
- declare global {
49
- interface Window {
50
- __metadataResolvers?: {
51
- resolve: (metadata: Record<string, any> | null) => void;
52
- item: IProductBlueprint;
53
- };
54
- }
55
- }
24
+ return new Promise<Record<string, any> | null>((resolve) => {
25
+ resolverRef.current = resolve;
26
+ });
27
+ };
28
+
29
+ const resolveMetadata = (metadata: Record<string, any> | null) => {
30
+ resolverRef.current?.(metadata);
31
+ resolverRef.current = null;
32
+ setIsCollectingMetadata(false);
33
+ setCurrentMetadataItem(null);
34
+ };
35
+
36
+ return { collectMetadata, resolveMetadata, isCollectingMetadata, currentMetadataItem };
37
+ };
@@ -0,0 +1,104 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+
3
+ export type TabId = 'premium' | 'packs' | 'items' | 'characters' | 'wallet' | 'history' | 'redeem';
4
+
5
+ const DEFAULT_TAB_ORDER: TabId[] = ['premium', 'packs', 'items'];
6
+
7
+ interface IUseStoreTabsParams {
8
+ tabOrder?: TabId[];
9
+ defaultActiveTab?: TabId;
10
+ hidePremiumTab?: boolean;
11
+ hasCharacters?: boolean;
12
+ hasRedeem?: boolean;
13
+ hasWallet?: boolean;
14
+ hasHistory?: boolean;
15
+ onTabChange?: (tab: TabId, itemsShown: number) => void;
16
+ getItemCount?: (tab: TabId) => number;
17
+ }
18
+
19
+ interface IUseStoreTabs {
20
+ availableTabIds: TabId[];
21
+ activeTab: TabId;
22
+ setActiveTab: (tab: TabId) => void;
23
+ handleTabChange: (tabId: string) => void;
24
+ }
25
+
26
+ function isTabAvailable(
27
+ tabId: TabId,
28
+ params: Omit<IUseStoreTabsParams, 'defaultActiveTab' | 'onTabChange' | 'getItemCount'>
29
+ ): boolean {
30
+ const { hidePremiumTab, hasCharacters, hasRedeem, hasWallet, hasHistory } = params;
31
+
32
+ if (tabId === 'premium') return !hidePremiumTab;
33
+ if (tabId === 'characters') return !!hasCharacters;
34
+ if (tabId === 'redeem') return !!hasRedeem;
35
+ if (tabId === 'wallet') return !!hasWallet;
36
+ if (tabId === 'history') return !!hasHistory;
37
+
38
+ return true;
39
+ }
40
+
41
+ function getInitialActiveTab(availableTabIds: TabId[], defaultActiveTab?: TabId): TabId {
42
+ if (defaultActiveTab && availableTabIds.includes(defaultActiveTab)) {
43
+ return defaultActiveTab;
44
+ }
45
+
46
+ return availableTabIds[0] ?? 'items';
47
+ }
48
+
49
+ function buildAvailableTabIds(params: Omit<IUseStoreTabsParams, 'defaultActiveTab' | 'onTabChange' | 'getItemCount'>): TabId[] {
50
+ const { tabOrder, hasCharacters, hasRedeem, hasWallet, hasHistory } = params;
51
+ const ids: TabId[] = [
52
+ ...(tabOrder ?? DEFAULT_TAB_ORDER),
53
+ ...(hasCharacters ? ['characters' as TabId] : []),
54
+ ...(hasRedeem ? ['redeem' as TabId] : []),
55
+ ...(hasWallet ? ['wallet' as TabId] : []),
56
+ ...(hasHistory ? ['history' as TabId] : []),
57
+ ];
58
+ return Array.from(new Set(ids.filter(id => isTabAvailable(id, params))));
59
+ }
60
+
61
+ export function useStoreTabs(params: IUseStoreTabsParams): IUseStoreTabs {
62
+ const {
63
+ tabOrder,
64
+ hidePremiumTab,
65
+ hasCharacters,
66
+ hasRedeem,
67
+ hasWallet,
68
+ hasHistory,
69
+ defaultActiveTab,
70
+ onTabChange,
71
+ getItemCount,
72
+ } = params;
73
+
74
+ const availableTabIds = useMemo(
75
+ () => buildAvailableTabIds({ tabOrder, hidePremiumTab, hasCharacters, hasRedeem, hasWallet, hasHistory }),
76
+ [tabOrder, hidePremiumTab, hasCharacters, hasRedeem, hasWallet, hasHistory]
77
+ );
78
+
79
+ const [activeTab, setActiveTab] = useState<TabId>(() => getInitialActiveTab(availableTabIds, defaultActiveTab));
80
+ const resolvedActiveTab = availableTabIds.includes(activeTab)
81
+ ? activeTab
82
+ : getInitialActiveTab(availableTabIds, defaultActiveTab);
83
+
84
+ useEffect(() => {
85
+ if (resolvedActiveTab === activeTab) {
86
+ return;
87
+ }
88
+
89
+ setActiveTab(resolvedActiveTab);
90
+ }, [activeTab, resolvedActiveTab]);
91
+
92
+ const handleTabChange = (tabId: string) => {
93
+ const nextTab = tabId as TabId;
94
+
95
+ if (!availableTabIds.includes(nextTab) || nextTab === resolvedActiveTab) {
96
+ return;
97
+ }
98
+
99
+ setActiveTab(nextTab);
100
+ onTabChange?.(nextTab, getItemCount?.(nextTab) ?? 0);
101
+ };
102
+
103
+ return { availableTabIds, activeTab: resolvedActiveTab, setActiveTab, handleTabChange };
104
+ }
@@ -0,0 +1,21 @@
1
+ import { IItemPack, IProductBlueprint, ItemRarities, ItemSubType, ItemType, PaymentCurrency, PurchaseType } from '@rpg-engine/shared';
2
+
3
+ export function packToBlueprint(pack: IItemPack): IProductBlueprint {
4
+ return {
5
+ key: pack.key,
6
+ name: pack.title,
7
+ description: pack.description || '',
8
+ price: pack.priceUSD,
9
+ currency: PaymentCurrency.USD,
10
+ texturePath: pack.image.default || pack.image.src,
11
+ type: PurchaseType.Pack,
12
+ onPurchase: async () => {},
13
+ itemType: ItemType.Consumable,
14
+ itemSubType: ItemSubType.Other,
15
+ rarity: ItemRarities.Common,
16
+ weight: 0,
17
+ isStackable: false,
18
+ maxStackSize: 1,
19
+ isUsable: false,
20
+ };
21
+ }
@@ -2,10 +2,8 @@ import {
2
2
  IProductBlueprint,
3
3
  MetadataType,
4
4
  UserAccountTypes,
5
- ItemType,
6
5
  } from '@rpg-engine/shared';
7
- import React, { useEffect, useState } from 'react';
8
- import { FaFilter } from 'react-icons/fa';
6
+ import React, { useEffect } from 'react';
9
7
  import styled from 'styled-components';
10
8
  import { ScrollableContent } from '../../shared/ScrollableContent/ScrollableContent';
11
9
  import { StoreCharacterSkinRow } from '../StoreCharacterSkinRow';
@@ -28,12 +26,11 @@ interface IStoreItemsSectionProps {
28
26
  userAccountType?: UserAccountTypes;
29
27
  textInputItemKeys?: string[];
30
28
  itemBadges?: Record<string, { badges?: IStoreBadge[]; buyCount?: number; viewersCount?: number; saleEndsAt?: string; originalPrice?: number }>;
31
- /** Fires when an item row becomes visible. Passes item and its 0-based position. */
32
29
  onItemView?: (item: IProductBlueprint, position: number) => void;
33
- /** Fires when the category filter changes. Passes new category and item count. */
34
30
  onCategoryChange?: (category: string, itemsShown: number) => void;
35
- /** Currency symbol to display (e.g. "$" for USD, "R$" for BRL). Defaults to "$". */
36
31
  currencySymbol?: string;
32
+ /** Override the auto-derived category filter pills. */
33
+ categoryOptions?: Array<{ value: string; label: string }>;
37
34
  }
38
35
 
39
36
  export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
@@ -48,6 +45,7 @@ export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
48
45
  onItemView,
49
46
  onCategoryChange,
50
47
  currencySymbol = '$',
48
+ categoryOptions: categoryOptionsProp,
51
49
  }) => {
52
50
  const {
53
51
  searchQuery,
@@ -56,9 +54,8 @@ export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
56
54
  setSelectedCategory,
57
55
  categoryOptions,
58
56
  filteredItems,
59
- } = useStoreFiltering(items);
57
+ } = useStoreFiltering(items, categoryOptionsProp);
60
58
 
61
- // Fire category change event when the filter changes
62
59
  useEffect(() => {
63
60
  onCategoryChange?.(selectedCategory, filteredItems.length);
64
61
  }, [selectedCategory, filteredItems.length]);
@@ -66,7 +63,6 @@ export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
66
63
  const renderStoreItem = (item: IProductBlueprint) => {
67
64
  const meta = itemBadges[item.key];
68
65
  const position = filteredItems.indexOf(item);
69
- // Prefer a specialized character skin row when needed
70
66
  if (item.metadataType === MetadataType.CharacterSkin) {
71
67
  return (
72
68
  <StoreCharacterSkinRow
@@ -79,7 +75,6 @@ export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
79
75
  />
80
76
  );
81
77
  }
82
- // Render text input row when configured for this item key
83
78
  if (textInputItemKeys.includes(item.key)) {
84
79
  return (
85
80
  <StoreItemRow
@@ -98,7 +93,6 @@ export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
98
93
  />
99
94
  );
100
95
  }
101
- // Fallback to standard arrow-based row
102
96
  return (
103
97
  <StoreItemRow
104
98
  key={item.key}
@@ -116,40 +110,28 @@ export const StoreItemsSection: React.FC<IStoreItemsSectionProps> = ({
116
110
  );
117
111
  };
118
112
 
119
- const [showFilters, setShowFilters] = useState(false);
120
-
121
113
  return (
122
114
  <StoreContainer>
123
- <FilterBar>
124
- <FilterToggle $active={showFilters} onClick={() => setShowFilters(prev => !prev)}>
125
- <FaFilter size={12} />
126
- <span>Filter</span>
127
- </FilterToggle>
128
- </FilterBar>
129
-
130
- {showFilters && (
131
- <SearchHeader>
132
- <SearchBarContainer>
133
- <SearchBar
134
- value={searchQuery}
135
- onChange={setSearchQuery}
136
- placeholder="Search items..."
137
- />
138
- </SearchBarContainer>
139
- <SegmentedToggle
140
- options={categoryOptions.map(opt => ({ id: opt.value, label: opt.option }))}
141
- activeId={selectedCategory}
142
- onChange={id => setSelectedCategory(id as ItemType | 'all')}
115
+ <SearchHeader>
116
+ <SearchBarContainer>
117
+ <SearchBar
118
+ value={searchQuery}
119
+ onChange={setSearchQuery}
120
+ placeholder="Search items..."
143
121
  />
144
- </SearchHeader>
145
- )}
122
+ </SearchBarContainer>
123
+ <SegmentedToggle
124
+ options={categoryOptions.map(opt => ({ id: opt.value, label: opt.option }))}
125
+ activeId={selectedCategory}
126
+ onChange={setSelectedCategory}
127
+ />
128
+ </SearchHeader>
146
129
 
147
130
  <ScrollableContent
148
131
  items={filteredItems}
149
132
  renderItem={renderStoreItem}
150
133
  emptyMessage="No items match your filters."
151
134
  layout="list"
152
- maxHeight="none"
153
135
  />
154
136
  </StoreContainer>
155
137
  );
@@ -166,32 +148,9 @@ const SearchHeader = styled.div`
166
148
  display: flex;
167
149
  gap: 0.5rem;
168
150
  align-items: center;
169
- padding-top: 0.25rem;
151
+ padding: 0.25rem 1rem 0;
170
152
  `;
171
153
 
172
154
  const SearchBarContainer = styled.div`
173
155
  flex: 0.75;
174
156
  `;
175
-
176
- const FilterBar = styled.div`
177
- display: flex;
178
- padding-top: 0.25rem;
179
- `;
180
-
181
- const FilterToggle = styled.button<{ $active: boolean }>`
182
- display: flex;
183
- align-items: center;
184
- gap: 0.4rem;
185
- background: ${({ $active }) => ($active ? 'rgba(255,255,255,0.15)' : 'transparent')};
186
- border: 1px solid rgba(255, 255, 255, 0.3);
187
- color: #fff;
188
- padding: 0.3rem 0.6rem;
189
- border-radius: 4px;
190
- cursor: pointer;
191
- font-family: 'Press Start 2P', cursive;
192
- font-size: 0.55rem;
193
-
194
- &:hover {
195
- background: rgba(255, 255, 255, 0.1);
196
- }
197
- `;
@@ -165,7 +165,6 @@ export const StorePacksSection: React.FC<IStorePacksSectionProps> = ({
165
165
  placeholder: 'Search packs...',
166
166
  }}
167
167
  layout="list"
168
- maxHeight="50vh"
169
168
  />
170
169
  );
171
170
  };
@@ -32,7 +32,7 @@ export const ScrollableContent = <T extends unknown>({
32
32
  searchOptions,
33
33
  layout = 'list',
34
34
  gridColumns = 4,
35
- maxHeight = '500px',
35
+ maxHeight,
36
36
  }: IScrollableContentProps<T>): React.ReactElement => {
37
37
  if (items.length === 0) {
38
38
  return <EmptyMessage>{emptyMessage}</EmptyMessage>;
@@ -117,16 +117,13 @@ const StyledDropdown = styled(Dropdown)`
117
117
  min-width: 150px;
118
118
  `;
119
119
 
120
- const Content = styled.div<{ $gridColumns: number; $maxHeight: string }>`
120
+ const Content = styled.div<{ $gridColumns: number; $maxHeight?: string }>`
121
121
  display: flex;
122
122
  flex-direction: column;
123
123
  gap: 0.5rem;
124
124
  flex: 1;
125
125
  padding: 1rem;
126
- max-height: ${props => props.$maxHeight};
127
- overflow-y: auto;
128
- overflow-x: hidden;
129
- scrollbar-gutter: stable;
126
+ ${p => p.$maxHeight ? `max-height: ${p.$maxHeight}; overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable;` : ''}
130
127
 
131
128
 
132
129
  &.grid {
@@ -0,0 +1,79 @@
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 { useStoreFiltering } from './useStoreFiltering';
9
+
10
+ const items = [
11
+ { key: 'sword', name: 'Sword', itemType: 'Weapon' },
12
+ { key: 'potion', name: 'Potion', itemType: 'Consumable' },
13
+ ];
14
+
15
+ let hookResult;
16
+
17
+ const TestComponent = ({ overrideCategoryOptions }) => {
18
+ hookResult = useStoreFiltering(items, overrideCategoryOptions);
19
+ return null;
20
+ };
21
+
22
+ describe('useStoreFiltering', () => {
23
+ let container;
24
+
25
+ beforeEach(() => {
26
+ container = document.createElement('div');
27
+ document.body.appendChild(container);
28
+ });
29
+
30
+ afterEach(() => {
31
+ ReactDOM.unmountComponentAtNode(container);
32
+ document.body.removeChild(container);
33
+ });
34
+
35
+ it('prepends the All category when overrides omit it', () => {
36
+ act(() => {
37
+ ReactDOM.render(
38
+ <TestComponent overrideCategoryOptions={[{ value: 'Weapon', label: 'Weapons' }]} />,
39
+ container
40
+ );
41
+ });
42
+
43
+ expect(hookResult.selectedCategory).toBe('all');
44
+ expect(hookResult.categoryOptions.map(option => option.value)).toEqual(['all', 'Weapon']);
45
+ expect(hookResult.filteredItems).toHaveLength(2);
46
+ });
47
+
48
+ it('resets to a valid category when the active override disappears', () => {
49
+ act(() => {
50
+ ReactDOM.render(
51
+ <TestComponent
52
+ overrideCategoryOptions={[
53
+ { value: 'all', label: 'All' },
54
+ { value: 'Weapon', label: 'Weapons' },
55
+ ]}
56
+ />,
57
+ container
58
+ );
59
+ });
60
+
61
+ act(() => {
62
+ hookResult.setSelectedCategory('Weapon');
63
+ });
64
+
65
+ expect(hookResult.selectedCategory).toBe('Weapon');
66
+ expect(hookResult.filteredItems.map(item => item.key)).toEqual(['sword']);
67
+
68
+ act(() => {
69
+ ReactDOM.render(
70
+ <TestComponent overrideCategoryOptions={[{ value: 'Consumable', label: 'Consumables' }]} />,
71
+ container
72
+ );
73
+ });
74
+
75
+ expect(hookResult.selectedCategory).toBe('all');
76
+ expect(hookResult.categoryOptions.map(option => option.value)).toEqual(['all', 'Consumable']);
77
+ expect(hookResult.filteredItems).toHaveLength(2);
78
+ });
79
+ });
@@ -1,24 +1,42 @@
1
- import { useMemo, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import { IProductBlueprint, ItemType } from '@rpg-engine/shared';
3
3
  import { IOptionsProps } from '../components/Dropdown';
4
4
 
5
- export const useStoreFiltering = (items: IProductBlueprint[]) => {
5
+ export const useStoreFiltering = (
6
+ items: IProductBlueprint[],
7
+ overrideCategoryOptions?: Array<{ value: string; label: string }>
8
+ ) => {
6
9
  const [searchQuery, setSearchQuery] = useState('');
7
- const [selectedCategory, setSelectedCategory] = useState<ItemType | 'all'>(
8
- 'all'
9
- );
10
+ const [selectedCategory, setSelectedCategory] = useState<string>('all');
10
11
 
11
12
  const categoryOptions: IOptionsProps[] = useMemo(() => {
12
- const uniqueCategories = Array.from(
13
- new Set(items.map(item => item.itemType))
14
- );
13
+ if (overrideCategoryOptions) {
14
+ const normalizedOptions = overrideCategoryOptions.some(opt => opt.value === 'all')
15
+ ? overrideCategoryOptions
16
+ : [{ value: 'all', label: 'All' }, ...overrideCategoryOptions];
17
+
18
+ return normalizedOptions.map((opt, index) => ({
19
+ id: index,
20
+ value: opt.value,
21
+ option: opt.label,
22
+ }));
23
+ }
24
+ const uniqueCategories = Array.from(new Set(items.map(item => item.itemType)));
15
25
  const allCategories = ['all', ...uniqueCategories] as (ItemType | 'all')[];
16
26
  return allCategories.map((category, index) => ({
17
27
  id: index,
18
28
  value: category,
19
29
  option: category === 'all' ? 'All' : category,
20
30
  }));
21
- }, [items]);
31
+ }, [items, overrideCategoryOptions]);
32
+
33
+ useEffect(() => {
34
+ if (categoryOptions.some(option => option.value === selectedCategory)) {
35
+ return;
36
+ }
37
+
38
+ setSelectedCategory(categoryOptions[0]?.value ?? 'all');
39
+ }, [categoryOptions, selectedCategory]);
22
40
 
23
41
  const filteredItems = useMemo(() => {
24
42
  return items