@rpg-engine/long-bow 0.8.4 → 0.8.5

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 (34) hide show
  1. package/dist/components/InformationCenter/sections/bestiary/BestiarySection.d.ts +1 -0
  2. package/dist/components/InformationCenter/sections/faq/FaqSection.d.ts +1 -0
  3. package/dist/components/InformationCenter/sections/items/ItemsSection.d.ts +1 -0
  4. package/dist/components/InternalTabs/InternalTabs.d.ts +2 -0
  5. package/dist/components/Store/InternalStoreTab.d.ts +15 -0
  6. package/dist/components/Store/Store.d.ts +3 -0
  7. package/dist/components/Store/StoreItemRow.d.ts +13 -0
  8. package/dist/components/Store/StoreTabContent.d.ts +14 -0
  9. package/dist/components/Store/StoreTypes.d.ts +19 -0
  10. package/dist/components/shared/PaginatedContent/PaginatedContent.d.ts +24 -0
  11. package/dist/long-bow.cjs.development.js +19 -7
  12. package/dist/long-bow.cjs.development.js.map +1 -1
  13. package/dist/long-bow.cjs.production.min.js +1 -1
  14. package/dist/long-bow.cjs.production.min.js.map +1 -1
  15. package/dist/long-bow.esm.js +19 -7
  16. package/dist/long-bow.esm.js.map +1 -1
  17. package/dist/stories/Features/store/Store.stories.d.ts +1 -0
  18. package/dist/utils/itemUtils.d.ts +8 -0
  19. package/package.json +1 -1
  20. package/src/components/InformationCenter/InformationCenter.tsx +15 -1
  21. package/src/components/InformationCenter/InformationCenterCell.tsx +7 -0
  22. package/src/components/InformationCenter/sections/bestiary/BestiarySection.tsx +31 -42
  23. package/src/components/InformationCenter/sections/faq/FaqSection.tsx +14 -34
  24. package/src/components/InformationCenter/sections/items/ItemsSection.tsx +40 -40
  25. package/src/components/InternalTabs/InternalTabs.tsx +9 -5
  26. package/src/components/Item/Inventory/itemContainerHelper.ts +13 -0
  27. package/src/components/Store/InternalStoreTab.tsx +142 -0
  28. package/src/components/Store/Store.tsx +192 -0
  29. package/src/components/Store/StoreItemRow.tsx +198 -0
  30. package/src/components/Store/StoreTabContent.tsx +46 -0
  31. package/src/components/Store/StoreTypes.ts +21 -0
  32. package/src/components/shared/PaginatedContent/PaginatedContent.tsx +182 -0
  33. package/src/stories/Features/store/Store.stories.tsx +102 -0
  34. package/src/utils/itemUtils.ts +36 -0
@@ -0,0 +1,192 @@
1
+ import { ItemType } from '@rpg-engine/shared';
2
+ import React, { useMemo, useState } from 'react';
3
+ import styled from 'styled-components';
4
+ import { uiColors } from '../../constants/uiColors';
5
+ import { DraggableContainer } from '../DraggableContainer';
6
+ import { InternalTabs } from '../InternalTabs/InternalTabs';
7
+ import { RPGUIContainerTypes } from '../RPGUI/RPGUIContainer';
8
+ import { SearchBar } from '../shared/SearchBar/SearchBar';
9
+
10
+ import { StoreTabContent } from './StoreTabContent';
11
+ import { IStoreItem, IStoreProps } from './StoreTypes';
12
+
13
+ export const Store: React.FC<IStoreProps> = ({
14
+ items,
15
+ atlasJSON,
16
+ atlasIMG,
17
+ onPurchase,
18
+ userGold,
19
+ userAccountType,
20
+ loading = false,
21
+ error,
22
+ initialSearchQuery = '',
23
+ onClose,
24
+ }) => {
25
+ const [activeTab, setActiveTab] = useState('all');
26
+ const [searchQuery, setSearchQuery] = useState(initialSearchQuery);
27
+
28
+ const filterItems = (
29
+ itemsToFilter: IStoreItem[],
30
+ type: ItemType | 'premium' | 'all'
31
+ ): IStoreItem[] => {
32
+ return itemsToFilter.filter(item => {
33
+ const matchesSearch = item.name
34
+ .toLowerCase()
35
+ .includes(searchQuery.toLowerCase());
36
+
37
+ if (!matchesSearch) return false;
38
+
39
+ switch (type) {
40
+ case ItemType.Weapon:
41
+ return item.type === ItemType.Weapon;
42
+ case ItemType.Consumable:
43
+ return item.type === ItemType.Consumable;
44
+ case 'premium':
45
+ return item.requiredAccountType?.length ?? 0 > 0;
46
+ default:
47
+ return true;
48
+ }
49
+ });
50
+ };
51
+
52
+ // Memoize filtered items for each tab to prevent unnecessary recalculations
53
+ const filteredItems = useMemo(
54
+ () => ({
55
+ all: filterItems(items, 'all'),
56
+ weapons: filterItems(items, ItemType.Weapon),
57
+ consumables: filterItems(items, ItemType.Consumable),
58
+ premium: filterItems(items, 'premium'),
59
+ }),
60
+ [items, searchQuery]
61
+ );
62
+
63
+ if (loading) {
64
+ return <LoadingMessage>Loading...</LoadingMessage>;
65
+ }
66
+
67
+ if (error) {
68
+ return <ErrorMessage>{error}</ErrorMessage>;
69
+ }
70
+
71
+ const tabs = [
72
+ {
73
+ id: 'all',
74
+ title: 'All',
75
+ content: (
76
+ <StoreTabContent
77
+ items={filteredItems.all}
78
+ atlasJSON={atlasJSON}
79
+ atlasIMG={atlasIMG}
80
+ onPurchase={onPurchase}
81
+ userGold={userGold}
82
+ userAccountType={userAccountType}
83
+ tabId="all"
84
+ />
85
+ ),
86
+ },
87
+ {
88
+ id: 'weapons',
89
+ title: 'Weapons',
90
+ content: (
91
+ <StoreTabContent
92
+ items={filteredItems.weapons}
93
+ atlasJSON={atlasJSON}
94
+ atlasIMG={atlasIMG}
95
+ onPurchase={onPurchase}
96
+ userGold={userGold}
97
+ userAccountType={userAccountType}
98
+ tabId="weapons"
99
+ />
100
+ ),
101
+ },
102
+ {
103
+ id: 'consumables',
104
+ title: 'Consumables',
105
+ content: (
106
+ <StoreTabContent
107
+ items={filteredItems.consumables}
108
+ atlasJSON={atlasJSON}
109
+ atlasIMG={atlasIMG}
110
+ onPurchase={onPurchase}
111
+ userGold={userGold}
112
+ userAccountType={userAccountType}
113
+ tabId="consumables"
114
+ />
115
+ ),
116
+ },
117
+ {
118
+ id: 'premium',
119
+ title: 'Premium',
120
+ content: (
121
+ <StoreTabContent
122
+ items={filteredItems.premium}
123
+ atlasJSON={atlasJSON}
124
+ atlasIMG={atlasIMG}
125
+ onPurchase={onPurchase}
126
+ userGold={userGold}
127
+ userAccountType={userAccountType}
128
+ tabId="premium"
129
+ />
130
+ ),
131
+ },
132
+ ];
133
+
134
+ return (
135
+ <DraggableContainer
136
+ title="Store"
137
+ onCloseButton={onClose}
138
+ width="800px"
139
+ minWidth="600px"
140
+ type={RPGUIContainerTypes.Framed}
141
+ >
142
+ <Container>
143
+ <SearchContainer>
144
+ <StyledSearchBar
145
+ value={searchQuery}
146
+ onChange={setSearchQuery}
147
+ placeholder="Search items..."
148
+ />
149
+ </SearchContainer>
150
+ <InternalTabs
151
+ tabs={tabs}
152
+ activeTextColor="#000000"
153
+ activeColor="#fef08a"
154
+ inactiveColor="#6b7280"
155
+ borderColor="#f59e0b"
156
+ hoverColor="#fef3c7"
157
+ activeTab={activeTab}
158
+ onTabChange={setActiveTab}
159
+ />
160
+ </Container>
161
+ </DraggableContainer>
162
+ );
163
+ };
164
+
165
+ const Container = styled.div`
166
+ display: flex;
167
+ flex-direction: column;
168
+ width: 100%;
169
+ min-height: 400px;
170
+ gap: 1rem;
171
+ padding: 1rem;
172
+ `;
173
+
174
+ const SearchContainer = styled.div`
175
+ padding: 0 1rem;
176
+ `;
177
+
178
+ const StyledSearchBar = styled(SearchBar)`
179
+ width: 100%;
180
+ `;
181
+
182
+ const LoadingMessage = styled.div`
183
+ text-align: center;
184
+ color: ${uiColors.white};
185
+ padding: 2rem;
186
+ `;
187
+
188
+ const ErrorMessage = styled.div`
189
+ text-align: center;
190
+ color: ${uiColors.red};
191
+ padding: 2rem;
192
+ `;
@@ -0,0 +1,198 @@
1
+ import { UserAccountTypes } from '@rpg-engine/shared';
2
+ import React, { useState } from 'react';
3
+ import styled from 'styled-components';
4
+ import { SelectArrow } from '../Arrow/SelectArrow';
5
+ import { Button, ButtonTypes } from '../Button';
6
+ import { SpriteFromAtlas } from '../shared/SpriteFromAtlas';
7
+ import { IStoreItem } from './StoreTypes';
8
+
9
+ interface IStoreItemRowProps {
10
+ item: IStoreItem;
11
+ atlasJSON: Record<string, any>;
12
+ atlasIMG: string;
13
+ onPurchase: (item: IStoreItem, quantity: number) => void;
14
+ userGold: number;
15
+ userAccountType: UserAccountTypes;
16
+ }
17
+
18
+ export const StoreItemRow: React.FC<IStoreItemRowProps> = ({
19
+ item,
20
+ atlasJSON,
21
+ atlasIMG,
22
+ onPurchase,
23
+ userGold,
24
+ userAccountType,
25
+ }) => {
26
+ const [quantity, setQuantity] = useState(1);
27
+
28
+ const handleQuantityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
29
+ const value = parseInt(e.target.value) || 1;
30
+ setQuantity(Math.min(Math.max(1, value), item.stock));
31
+ };
32
+
33
+ const handleBlur = () => {
34
+ if (quantity < 1) setQuantity(1);
35
+ if (quantity > item.stock) setQuantity(item.stock);
36
+ };
37
+
38
+ const incrementQuantity = (amount = 1) => {
39
+ setQuantity(prev => Math.min(prev + amount, item.stock));
40
+ };
41
+
42
+ const decrementQuantity = (amount = 1) => {
43
+ setQuantity(prev => Math.max(1, prev - amount));
44
+ };
45
+
46
+ const canAfford = userGold >= item.price * quantity;
47
+ const hasRequiredAccount =
48
+ !item.requiredAccountType?.length ||
49
+ item.requiredAccountType.includes(userAccountType);
50
+
51
+ const renderAccountTypeIndicator = () => {
52
+ if (!item.requiredAccountType?.length) return null;
53
+
54
+ return (
55
+ <span>
56
+ {item.requiredAccountType
57
+ .map(type => {
58
+ const typeName = String(type).toLowerCase();
59
+ return typeName.includes('premium')
60
+ ? typeName.replace('premium', '')
61
+ : typeName;
62
+ })
63
+ .join('/')}
64
+ </span>
65
+ );
66
+ };
67
+
68
+ return (
69
+ <ItemWrapper>
70
+ <ItemIconContainer>
71
+ <SpriteFromAtlas
72
+ atlasJSON={atlasJSON}
73
+ atlasIMG={atlasIMG}
74
+ spriteKey={item.texturePath}
75
+ width={32}
76
+ height={32}
77
+ imgScale={2}
78
+ centered
79
+ />
80
+ </ItemIconContainer>
81
+
82
+ <ItemDetails>
83
+ <ItemName>{item.name}</ItemName>
84
+ <ItemPrice>
85
+ Price: {item.price} gold {renderAccountTypeIndicator()}
86
+ </ItemPrice>
87
+ </ItemDetails>
88
+
89
+ <Controls>
90
+ <ArrowsContainer>
91
+ <SelectArrow
92
+ direction="left"
93
+ onPointerDown={() => decrementQuantity()}
94
+ size={24}
95
+ />
96
+
97
+ <QuantityInput
98
+ type="number"
99
+ value={quantity}
100
+ onChange={handleQuantityChange}
101
+ onBlur={handleBlur}
102
+ min={1}
103
+ max={item.stock}
104
+ className="rpgui-input"
105
+ />
106
+
107
+ <SelectArrow
108
+ direction="right"
109
+ onPointerDown={() => incrementQuantity()}
110
+ size={24}
111
+ />
112
+ </ArrowsContainer>
113
+
114
+ <Button
115
+ buttonType={ButtonTypes.RPGUIButton}
116
+ disabled={!canAfford || !hasRequiredAccount}
117
+ onClick={() => onPurchase(item, quantity)}
118
+ >
119
+ Purchase
120
+ </Button>
121
+ </Controls>
122
+ </ItemWrapper>
123
+ );
124
+ };
125
+
126
+ const ItemWrapper = styled.div`
127
+ display: flex;
128
+ align-items: center;
129
+ gap: 1rem;
130
+ padding: 1rem;
131
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
132
+
133
+ &:last-child {
134
+ border-bottom: none;
135
+ }
136
+ `;
137
+
138
+ const ItemIconContainer = styled.div`
139
+ width: 32px;
140
+ height: 32px;
141
+ display: flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ border-radius: 4px;
145
+ padding: 4px;
146
+ `;
147
+
148
+ const ItemDetails = styled.div`
149
+ flex: 1;
150
+ display: flex;
151
+ flex-direction: column;
152
+ gap: 0.5rem;
153
+ `;
154
+
155
+ const ItemName = styled.div`
156
+ font-family: 'Press Start 2P', cursive;
157
+ font-size: 0.875rem;
158
+ color: #ffffff;
159
+ `;
160
+
161
+ const ItemPrice = styled.div`
162
+ font-family: 'Press Start 2P', cursive;
163
+ font-size: 0.75rem;
164
+ color: #fef08a;
165
+ `;
166
+
167
+ const Controls = styled.div`
168
+ display: flex;
169
+ align-items: center;
170
+ gap: 1rem;
171
+ min-width: fit-content;
172
+ `;
173
+
174
+ const ArrowsContainer = styled.div`
175
+ position: relative;
176
+ display: flex;
177
+ align-items: center;
178
+ width: 120px;
179
+ height: 42px;
180
+ justify-content: space-between;
181
+ `;
182
+
183
+ const QuantityInput = styled.input`
184
+ width: 40px;
185
+ text-align: center;
186
+ margin: 0 auto;
187
+ font-size: 0.875rem;
188
+ background: rgba(0, 0, 0, 0.2);
189
+ color: #ffffff;
190
+ border: none;
191
+ padding: 0.25rem;
192
+
193
+ &::-webkit-inner-spin-button,
194
+ &::-webkit-outer-spin-button {
195
+ -webkit-appearance: none;
196
+ margin: 0;
197
+ }
198
+ `;
@@ -0,0 +1,46 @@
1
+ import { UserAccountTypes } from '@rpg-engine/shared';
2
+ import React from 'react';
3
+ import { PaginatedContent } from '../shared/PaginatedContent/PaginatedContent';
4
+ import { StoreItemRow } from './StoreItemRow';
5
+ import { IStoreItem } from './StoreTypes';
6
+
7
+ interface IStoreTabContentProps {
8
+ items: IStoreItem[];
9
+ atlasJSON: Record<string, any>;
10
+ atlasIMG: string;
11
+ onPurchase: (item: IStoreItem, quantity: number) => void;
12
+ userGold: number;
13
+ userAccountType: UserAccountTypes;
14
+ tabId: string;
15
+ }
16
+
17
+ export const StoreTabContent: React.FC<IStoreTabContentProps> = ({
18
+ items,
19
+ atlasJSON,
20
+ atlasIMG,
21
+ onPurchase,
22
+ userGold,
23
+ userAccountType,
24
+ tabId,
25
+ }) => {
26
+ const renderItem = (item: IStoreItem) => (
27
+ <StoreItemRow
28
+ key={item._id}
29
+ item={item}
30
+ atlasJSON={atlasJSON}
31
+ atlasIMG={atlasIMG}
32
+ onPurchase={onPurchase}
33
+ userGold={userGold}
34
+ userAccountType={userAccountType}
35
+ />
36
+ );
37
+
38
+ return (
39
+ <PaginatedContent
40
+ items={items}
41
+ renderItem={renderItem}
42
+ emptyMessage="No items found"
43
+ tabId={tabId}
44
+ />
45
+ );
46
+ };
@@ -0,0 +1,21 @@
1
+ import { IItem, UserAccountTypes } from '@rpg-engine/shared';
2
+
3
+ export interface IStoreItem extends Omit<IItem, 'canSell'> {
4
+ price: number;
5
+ stock: number;
6
+ requiredAccountType?: UserAccountTypes[];
7
+ canSell: boolean;
8
+ }
9
+
10
+ export interface IStoreProps {
11
+ items: IStoreItem[];
12
+ atlasJSON: Record<string, any>;
13
+ atlasIMG: string;
14
+ onPurchase: (item: IStoreItem, quantity: number) => void;
15
+ userGold: number;
16
+ userAccountType: UserAccountTypes;
17
+ loading?: boolean;
18
+ error?: string;
19
+ initialSearchQuery?: string;
20
+ onClose?: () => void;
21
+ }
@@ -0,0 +1,182 @@
1
+ import React from 'react';
2
+ import styled from 'styled-components';
3
+ import { usePagination } from '../../CraftBook/hooks/usePagination';
4
+ import { Dropdown, IOptionsProps } from '../../Dropdown';
5
+ import { Pagination } from '../Pagination/Pagination';
6
+ import { SearchBar } from '../SearchBar/SearchBar';
7
+
8
+ interface IPaginatedContentProps<T> {
9
+ items: T[];
10
+ itemsPerPage?: number;
11
+ renderItem: (item: T) => React.ReactNode;
12
+ emptyMessage?: string;
13
+ className?: string;
14
+ filterOptions?: {
15
+ options: IOptionsProps[];
16
+ selectedOption: string;
17
+ onOptionChange: (value: string) => void;
18
+ };
19
+ searchOptions?: {
20
+ value: string;
21
+ onChange: (value: string) => void;
22
+ placeholder?: string;
23
+ };
24
+ dependencies?: any[];
25
+ tabId?: string;
26
+ layout?: 'grid' | 'list';
27
+ }
28
+
29
+ export const PaginatedContent = <T extends unknown>({
30
+ items,
31
+ itemsPerPage = 5,
32
+ renderItem,
33
+ emptyMessage = 'No items found',
34
+ className,
35
+ filterOptions,
36
+ searchOptions,
37
+ dependencies = [],
38
+ tabId,
39
+ layout = 'list',
40
+ }: IPaginatedContentProps<T>): React.ReactElement => {
41
+ const {
42
+ currentPage,
43
+ setCurrentPage,
44
+ paginatedItems,
45
+ totalPages,
46
+ } = usePagination({
47
+ items,
48
+ itemsPerPage,
49
+ dependencies: [...dependencies, tabId],
50
+ });
51
+
52
+ if (items.length === 0) {
53
+ return <EmptyMessage>{emptyMessage}</EmptyMessage>;
54
+ }
55
+
56
+ return (
57
+ <Container className={className}>
58
+ {(searchOptions || filterOptions) && (
59
+ <HeaderContainer>
60
+ <HeaderContent>
61
+ {searchOptions && (
62
+ <SearchContainer>
63
+ <StyledSearchBar
64
+ value={searchOptions.value}
65
+ onChange={searchOptions.onChange}
66
+ placeholder={searchOptions.placeholder || 'Search...'}
67
+ />
68
+ </SearchContainer>
69
+ )}
70
+ {filterOptions && (
71
+ <FilterContainer>
72
+ <StyledDropdown
73
+ options={filterOptions.options}
74
+ onChange={filterOptions.onOptionChange}
75
+ width="200px"
76
+ />
77
+ </FilterContainer>
78
+ )}
79
+ </HeaderContent>
80
+ </HeaderContainer>
81
+ )}
82
+ <Content className={`PaginatedContent-content ${layout}`}>
83
+ {paginatedItems.map((item, index) => (
84
+ <div key={index} className="PaginatedContent-item">
85
+ {renderItem(item)}
86
+ </div>
87
+ ))}
88
+ </Content>
89
+ <PaginationContainer className="PaginatedContent-pagination">
90
+ <Pagination
91
+ currentPage={currentPage}
92
+ totalPages={totalPages}
93
+ onPageChange={setCurrentPage}
94
+ />
95
+ </PaginationContainer>
96
+ </Container>
97
+ );
98
+ };
99
+
100
+ const Container = styled.div`
101
+ display: flex;
102
+ flex-direction: column;
103
+ gap: 1rem;
104
+ min-height: 400px;
105
+ width: 100%;
106
+ `;
107
+
108
+ const HeaderContainer = styled.div`
109
+ padding: 0 1rem;
110
+ `;
111
+
112
+ const HeaderContent = styled.div`
113
+ display: flex;
114
+ justify-content: space-between;
115
+ align-items: center;
116
+ gap: 1rem;
117
+ background: rgba(0, 0, 0, 0.2);
118
+ padding: 1rem;
119
+ border-radius: 4px;
120
+ `;
121
+
122
+ const SearchContainer = styled.div`
123
+ flex: 1;
124
+ `;
125
+
126
+ const FilterContainer = styled.div`
127
+ display: flex;
128
+ justify-content: flex-end;
129
+ `;
130
+
131
+ const StyledSearchBar = styled(SearchBar)`
132
+ width: 100%;
133
+ `;
134
+
135
+ const Content = styled.div`
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: 0.5rem;
139
+ flex: 1;
140
+ padding: 1rem;
141
+ min-height: 200px;
142
+ overflow-y: auto;
143
+
144
+ &.grid {
145
+ display: grid;
146
+ grid-template-columns: repeat(4, minmax(0, 1fr));
147
+ gap: 1rem;
148
+
149
+ .PaginatedContent-item {
150
+ aspect-ratio: 1;
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: center;
154
+ }
155
+ }
156
+
157
+ &.list {
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: 0.5rem;
161
+ }
162
+ `;
163
+
164
+ const PaginationContainer = styled.div`
165
+ display: flex;
166
+ justify-content: center;
167
+ padding: 1rem;
168
+ `;
169
+
170
+ const EmptyMessage = styled.div`
171
+ text-align: center;
172
+ color: #9ca3af;
173
+ padding: 2rem;
174
+ flex: 1;
175
+ display: flex;
176
+ align-items: center;
177
+ justify-content: center;
178
+ `;
179
+
180
+ const StyledDropdown = styled(Dropdown)`
181
+ min-width: 150px;
182
+ `;