@rpg-engine/long-bow 0.8.219 → 0.8.220
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/dist/components/DraggableContainer.d.ts +0 -6
- package/dist/components/Store/MetadataCollector.d.ts +2 -2
- package/dist/components/Store/Store.d.ts +10 -27
- package/dist/components/Store/StoreHeader.d.ts +14 -0
- package/dist/components/Store/hooks/useStoreCart.d.ts +2 -0
- package/dist/components/Store/hooks/useStoreMetadata.d.ts +4 -11
- package/dist/components/Store/hooks/useStoreTabs.d.ts +20 -0
- package/dist/components/Store/internal/packToBlueprint.d.ts +2 -0
- package/dist/components/Store/sections/StoreItemsSection.d.ts +5 -3
- package/dist/hooks/useStoreFiltering.d.ts +7 -4
- package/dist/long-bow.cjs.development.js +349 -396
- package/dist/long-bow.cjs.development.js.map +1 -1
- package/dist/long-bow.cjs.production.min.js +1 -1
- package/dist/long-bow.cjs.production.min.js.map +1 -1
- package/dist/long-bow.esm.js +351 -398
- package/dist/long-bow.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/components/DraggableContainer.tsx +0 -24
- package/src/components/Store/CartView.tsx +7 -2
- package/src/components/Store/MetadataCollector.tsx +60 -40
- package/src/components/Store/Store.tsx +75 -282
- package/src/components/Store/StoreHeader.tsx +74 -0
- package/src/components/Store/__test__/MetadataCollector.spec.tsx +94 -164
- package/src/components/Store/__test__/Store.spec.tsx +4 -0
- package/src/components/Store/__test__/useStoreMetadata.spec.tsx +58 -156
- package/src/components/Store/__test__/useStoreTabs.spec.tsx +69 -0
- package/src/components/Store/hooks/useStoreCart.ts +5 -2
- package/src/components/Store/hooks/useStoreMetadata.ts +30 -48
- package/src/components/Store/hooks/useStoreTabs.ts +104 -0
- package/src/components/Store/internal/packToBlueprint.ts +21 -0
- package/src/components/Store/sections/StoreItemsSection.tsx +19 -60
- package/src/components/Store/sections/StorePacksSection.tsx +0 -1
- package/src/components/shared/ScrollableContent/ScrollableContent.tsx +3 -6
- package/src/hooks/useStoreFiltering.spec.tsx +79 -0
- 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
|
|
2
|
-
import { useState } from
|
|
1
|
+
import { IProductBlueprint, MetadataType } from '@rpg-engine/shared';
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
3
|
|
|
4
|
-
interface IUseStoreMetadata {
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
isCollectingMetadata,
|
|
44
|
-
};
|
|
45
|
-
};
|
|
21
|
+
setIsCollectingMetadata(true);
|
|
22
|
+
setCurrentMetadataItem(item);
|
|
46
23
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
<
|
|
124
|
-
<
|
|
125
|
-
<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
</
|
|
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
|
|
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
|
-
`;
|
|
@@ -32,7 +32,7 @@ export const ScrollableContent = <T extends unknown>({
|
|
|
32
32
|
searchOptions,
|
|
33
33
|
layout = 'list',
|
|
34
34
|
gridColumns = 4,
|
|
35
|
-
maxHeight
|
|
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
|
|
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: ${
|
|
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 = (
|
|
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<
|
|
8
|
-
'all'
|
|
9
|
-
);
|
|
10
|
+
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
|
10
11
|
|
|
11
12
|
const categoryOptions: IOptionsProps[] = useMemo(() => {
|
|
12
|
-
|
|
13
|
-
|
|
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
|