@rpg-engine/long-bow 0.8.231 → 0.8.233
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/Quests/QuestInfo/QuestInfo.d.ts +3 -1
- package/dist/components/Quests/QuestInfo/QuestObjectivesSection.d.ts +8 -0
- package/dist/components/Quests/QuestInfo/QuestRequirementsSection.d.ts +8 -0
- package/dist/components/Quests/QuestInfo/QuestRewardsSection.d.ts +8 -0
- package/dist/components/Quests/QuestInfo/QuestSectionTypes.d.ts +8 -0
- package/dist/components/Quests/QuestList.d.ts +4 -5
- package/dist/components/Quests/QuestListRow.d.ts +15 -0
- package/dist/components/Store/CartView.d.ts +11 -1
- package/dist/components/Store/PaymentMethodModal.d.ts +1 -0
- package/dist/components/Store/Store.d.ts +2 -1
- package/dist/components/Store/hooks/useStoreCart.d.ts +2 -1
- package/dist/components/Tutorial/TutorialStepper.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/long-bow.cjs.development.js +491 -170
- 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 +492 -172
- package/dist/long-bow.esm.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Quests/QuestInfo/QuestInfo.tsx +57 -68
- package/src/components/Quests/QuestInfo/QuestObjectivesSection.tsx +114 -0
- package/src/components/Quests/QuestInfo/QuestRequirementsSection.tsx +74 -0
- package/src/components/Quests/QuestInfo/QuestRewardsSection.tsx +128 -0
- package/src/components/Quests/QuestInfo/QuestSectionTypes.ts +8 -0
- package/src/components/Quests/QuestList.tsx +12 -87
- package/src/components/Quests/QuestListRow.tsx +178 -0
- package/src/components/Store/CartView.tsx +26 -7
- package/src/components/Store/MetadataCollector.tsx +28 -1
- package/src/components/Store/PaymentMethodModal.tsx +19 -9
- package/src/components/Store/Store.tsx +2 -2
- package/src/components/Store/__test__/CartView.spec.tsx +190 -0
- package/src/components/Store/__test__/MetadataCollector.spec.tsx +12 -3
- package/src/components/Store/__test__/PaymentMethodModal.spec.tsx +108 -0
- package/src/components/Store/hooks/useStoreCart.ts +6 -4
- package/src/components/Tutorial/TutorialStepper.tsx +2 -0
- package/src/index.tsx +1 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { IQuest, QuestStatus, QuestType } from '@rpg-engine/shared';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import styled from 'styled-components';
|
|
4
|
+
import { uiColors } from '../../constants/uiColors';
|
|
5
|
+
import { ItemInfoWrapper } from '../Item/Cards/ItemInfoWrapper';
|
|
6
|
+
import { SpriteFromAtlas } from '../shared/SpriteFromAtlas';
|
|
7
|
+
|
|
8
|
+
export interface IQuestListAtlasProps {
|
|
9
|
+
itemsAtlasJSON?: any;
|
|
10
|
+
itemsAtlasIMG?: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface IProps extends IQuestListAtlasProps {
|
|
14
|
+
quest: IQuest;
|
|
15
|
+
compact?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const getProgressText = (quest: IQuest): string | null => {
|
|
19
|
+
const objective = (quest.objectives || [])[0] as any;
|
|
20
|
+
if (!objective) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (objective.type === QuestType.Kill) {
|
|
25
|
+
return `${objective.killCount || 0}/${objective.killCountTarget}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (objective.type === QuestType.Interaction && objective.items?.length) {
|
|
29
|
+
const item = objective.items[0];
|
|
30
|
+
return `Bring ${item.playerHasQty || 0}/${item.qty}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return objective.type === QuestType.Interaction && objective.targetNPCkey ? 'Talk' : null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const QuestListRow: React.FC<IProps> = ({
|
|
37
|
+
quest,
|
|
38
|
+
compact,
|
|
39
|
+
itemsAtlasJSON,
|
|
40
|
+
itemsAtlasIMG,
|
|
41
|
+
}) => {
|
|
42
|
+
const rewardItems = (quest.rewards || []).flatMap((reward: any) => reward.items || []);
|
|
43
|
+
const visibleRewards = rewardItems.slice(0, 6);
|
|
44
|
+
const overflowCount = Math.max(rewardItems.length - visibleRewards.length, 0);
|
|
45
|
+
const progressText = getProgressText(quest);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<QuestCard>
|
|
49
|
+
<Header>
|
|
50
|
+
<Title>{formatQuestText(quest.title)}</Title>
|
|
51
|
+
<Status style={{ color: getQuestStatusColor(quest.status) }}>
|
|
52
|
+
{formatQuestStatus(quest.status) || 'Unknown'}
|
|
53
|
+
</Status>
|
|
54
|
+
</Header>
|
|
55
|
+
{!compact && (
|
|
56
|
+
<>
|
|
57
|
+
<Description>{quest.description}</Description>
|
|
58
|
+
<MetaRow>
|
|
59
|
+
{quest.status === QuestStatus.Completed ? 'Completed' : progressText}
|
|
60
|
+
</MetaRow>
|
|
61
|
+
{!!visibleRewards.length && (
|
|
62
|
+
<RewardStrip>
|
|
63
|
+
{visibleRewards.map((item: any) => (
|
|
64
|
+
<RewardIcon key={`${item.key}-${item.qty}`}>
|
|
65
|
+
{itemsAtlasJSON && itemsAtlasIMG && item.texturePath ? (
|
|
66
|
+
<ItemInfoWrapper item={{ ...item, stackQty: item.qty } as any} atlasJSON={itemsAtlasJSON} atlasIMG={itemsAtlasIMG}>
|
|
67
|
+
<SpriteFromAtlas
|
|
68
|
+
atlasJSON={itemsAtlasJSON}
|
|
69
|
+
atlasIMG={itemsAtlasIMG}
|
|
70
|
+
spriteKey={item.texturePath}
|
|
71
|
+
width={24}
|
|
72
|
+
height={24}
|
|
73
|
+
imgScale={1}
|
|
74
|
+
centered
|
|
75
|
+
/>
|
|
76
|
+
</ItemInfoWrapper>
|
|
77
|
+
) : (
|
|
78
|
+
<FallbackIcon>{item.name?.[0] || '?'}</FallbackIcon>
|
|
79
|
+
)}
|
|
80
|
+
</RewardIcon>
|
|
81
|
+
))}
|
|
82
|
+
{overflowCount > 0 && <MoreRewards>+{overflowCount} more</MoreRewards>}
|
|
83
|
+
</RewardStrip>
|
|
84
|
+
)}
|
|
85
|
+
</>
|
|
86
|
+
)}
|
|
87
|
+
</QuestCard>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const formatQuestText = (text: string) => {
|
|
92
|
+
if (!text) return '';
|
|
93
|
+
return text
|
|
94
|
+
.split('-')
|
|
95
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
96
|
+
.join(' ');
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const getQuestStatusColor = (status?: QuestStatus) => {
|
|
100
|
+
switch (status) {
|
|
101
|
+
case QuestStatus.Pending:
|
|
102
|
+
return uiColors.orange;
|
|
103
|
+
case QuestStatus.InProgress:
|
|
104
|
+
return uiColors.blue;
|
|
105
|
+
case QuestStatus.Completed:
|
|
106
|
+
return uiColors.lightGreen;
|
|
107
|
+
default:
|
|
108
|
+
return uiColors.white;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const formatQuestStatus = (status?: QuestStatus) => {
|
|
113
|
+
if (!status) return '';
|
|
114
|
+
return status
|
|
115
|
+
.split(/(?=[A-Z])/)
|
|
116
|
+
.join(' ')
|
|
117
|
+
.replace(/^\w/, c => c.toUpperCase());
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const QuestCard = styled.div`
|
|
121
|
+
background-color: ${uiColors.darkGray};
|
|
122
|
+
padding: 12px;
|
|
123
|
+
margin-bottom: 10px;
|
|
124
|
+
border-radius: 8px;
|
|
125
|
+
border: 1px solid ${uiColors.gray};
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const Header = styled.div`
|
|
129
|
+
display: flex;
|
|
130
|
+
justify-content: space-between;
|
|
131
|
+
gap: 10px;
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
const Title = styled.span`
|
|
135
|
+
color: ${uiColors.yellow};
|
|
136
|
+
font-weight: bold;
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
const Status = styled.span`
|
|
140
|
+
white-space: nowrap;
|
|
141
|
+
`;
|
|
142
|
+
|
|
143
|
+
const Description = styled.p`
|
|
144
|
+
color: ${uiColors.white};
|
|
145
|
+
margin: 8px 0;
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
const MetaRow = styled.div`
|
|
149
|
+
color: ${uiColors.lightGreen};
|
|
150
|
+
font-size: 0.7rem;
|
|
151
|
+
margin-bottom: 6px;
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
const RewardStrip = styled.div`
|
|
155
|
+
display: flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: 6px;
|
|
158
|
+
`;
|
|
159
|
+
|
|
160
|
+
const RewardIcon = styled.div`
|
|
161
|
+
width: 24px;
|
|
162
|
+
height: 24px;
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
const FallbackIcon = styled.div`
|
|
166
|
+
width: 24px;
|
|
167
|
+
height: 24px;
|
|
168
|
+
border: 1px solid ${uiColors.gray};
|
|
169
|
+
color: ${uiColors.yellow};
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
justify-content: center;
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
const MoreRewards = styled.span`
|
|
176
|
+
color: ${uiColors.lightGray};
|
|
177
|
+
font-size: 0.65rem;
|
|
178
|
+
`;
|
|
@@ -21,11 +21,29 @@ interface ICartItem {
|
|
|
21
21
|
metadata?: Record<string, any>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export type PurchaseResult =
|
|
25
|
+
| { status: 'success' }
|
|
26
|
+
| { status: 'cancelled' }
|
|
27
|
+
| { status: 'failed'; error?: string };
|
|
28
|
+
|
|
29
|
+
export type LegacyPurchaseResult = boolean;
|
|
30
|
+
export type CartPurchaseResult = PurchaseResult | LegacyPurchaseResult;
|
|
31
|
+
|
|
32
|
+
const GENERIC_PURCHASE_FAILED_MESSAGE = 'Purchase failed. Please try again.';
|
|
33
|
+
|
|
34
|
+
const normalizePurchaseResult = (result: CartPurchaseResult): PurchaseResult => {
|
|
35
|
+
if (typeof result === 'boolean') {
|
|
36
|
+
return result ? { status: 'success' } : { status: 'failed' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return result;
|
|
40
|
+
};
|
|
41
|
+
|
|
24
42
|
export interface ICartViewProps {
|
|
25
43
|
cartItems: ICartItem[];
|
|
26
44
|
onRemoveFromCart: (itemKey: string) => void;
|
|
27
45
|
onClose: () => void;
|
|
28
|
-
onPurchase: () => Promise<
|
|
46
|
+
onPurchase: () => Promise<CartPurchaseResult>;
|
|
29
47
|
atlasJSON: Record<string, any>;
|
|
30
48
|
atlasIMG: string;
|
|
31
49
|
paymentMethodLabel?: string;
|
|
@@ -53,7 +71,7 @@ const MetadataDisplay: React.FC<{
|
|
|
53
71
|
<InfoBox />
|
|
54
72
|
<span>Skin:</span>
|
|
55
73
|
</MetadataLabel>
|
|
56
|
-
<MetadataValue>{metadata.selectedSkinName || 'Custom skin'}</MetadataValue>
|
|
74
|
+
<MetadataValue>{metadata.selectedSkinName || metadata.selectedSkin || 'Custom skin'}</MetadataValue>
|
|
57
75
|
</MetadataInfo>
|
|
58
76
|
);
|
|
59
77
|
default:
|
|
@@ -99,13 +117,13 @@ export const CartView: React.FC<ICartViewProps> = ({
|
|
|
99
117
|
const snapshot = [...cartItems];
|
|
100
118
|
const trackItems = snapshot.map(ci => ({ key: ci.item.key, name: ci.item.name, quantity: ci.quantity }));
|
|
101
119
|
onCheckoutStart?.(trackItems, total);
|
|
102
|
-
const
|
|
120
|
+
const purchaseResult = normalizePurchaseResult(await onPurchase());
|
|
103
121
|
|
|
104
|
-
if (success) {
|
|
122
|
+
if (purchaseResult.status === 'success') {
|
|
105
123
|
onPurchaseSuccess?.(trackItems, total);
|
|
106
124
|
setPurchasedItems(snapshot);
|
|
107
|
-
} else {
|
|
108
|
-
const errMsg =
|
|
125
|
+
} else if (purchaseResult.status === 'failed') {
|
|
126
|
+
const errMsg = purchaseResult.error ?? GENERIC_PURCHASE_FAILED_MESSAGE;
|
|
109
127
|
onPurchaseError?.(errMsg);
|
|
110
128
|
setError(errMsg);
|
|
111
129
|
}
|
|
@@ -159,6 +177,7 @@ export const CartView: React.FC<ICartViewProps> = ({
|
|
|
159
177
|
const getSpriteKey = (textureKey: string) => {
|
|
160
178
|
return textureKey + '/down/standing/0.png';
|
|
161
179
|
};
|
|
180
|
+
const selectedSkinTextureKey = cartItem.metadata?.selectedSkinTextureKey ?? cartItem.metadata?.selectedSkin;
|
|
162
181
|
|
|
163
182
|
return (
|
|
164
183
|
<CartItemRow key={cartItem.item.key}>
|
|
@@ -166,7 +185,7 @@ export const CartView: React.FC<ICartViewProps> = ({
|
|
|
166
185
|
<SpriteFromAtlas
|
|
167
186
|
atlasJSON={cartItem.item.metadataType === MetadataType.CharacterSkin ? characterAtlasJSON : atlasJSON}
|
|
168
187
|
atlasIMG={cartItem.item.metadataType === MetadataType.CharacterSkin ? characterAtlasIMG : atlasIMG}
|
|
169
|
-
spriteKey={cartItem.item.metadataType === MetadataType.CharacterSkin &&
|
|
188
|
+
spriteKey={cartItem.item.metadataType === MetadataType.CharacterSkin && selectedSkinTextureKey ? getSpriteKey(selectedSkinTextureKey) : cartItem.item.texturePath}
|
|
170
189
|
width={24}
|
|
171
190
|
height={24}
|
|
172
191
|
imgScale={1.5}
|
|
@@ -2,6 +2,11 @@ import { MetadataType } from '@rpg-engine/shared';
|
|
|
2
2
|
import React, { useCallback, useEffect, useRef } from 'react';
|
|
3
3
|
import { CharacterSkinSelectionModal } from '../Character/CharacterSkinSelectionModal';
|
|
4
4
|
|
|
5
|
+
interface ICharacterSkinOption {
|
|
6
|
+
name: string;
|
|
7
|
+
textureKey: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
export interface IMetadataCollectorProps {
|
|
6
11
|
metadataType: MetadataType;
|
|
7
12
|
config: Record<string, any>;
|
|
@@ -9,6 +14,21 @@ export interface IMetadataCollectorProps {
|
|
|
9
14
|
onCancel: () => void;
|
|
10
15
|
}
|
|
11
16
|
|
|
17
|
+
const getCharacterSkinMetadata = (
|
|
18
|
+
selectedSkinTextureKey: string,
|
|
19
|
+
availableCharacters: ICharacterSkinOption[]
|
|
20
|
+
) => {
|
|
21
|
+
const selectedSkin = availableCharacters.find(
|
|
22
|
+
(character) => character.textureKey === selectedSkinTextureKey
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
selectedSkin: selectedSkinTextureKey,
|
|
27
|
+
selectedSkinName: selectedSkin?.name ?? selectedSkinTextureKey,
|
|
28
|
+
selectedSkinTextureKey,
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
12
32
|
export const MetadataCollector: React.FC<IMetadataCollectorProps> = ({
|
|
13
33
|
metadataType,
|
|
14
34
|
config,
|
|
@@ -54,7 +74,14 @@ export const MetadataCollector: React.FC<IMetadataCollectorProps> = ({
|
|
|
54
74
|
<CharacterSkinSelectionModal
|
|
55
75
|
isOpen
|
|
56
76
|
onClose={handleCancel}
|
|
57
|
-
onConfirm={(selectedSkin:
|
|
77
|
+
onConfirm={(selectedSkin: string) =>
|
|
78
|
+
handleCollect(
|
|
79
|
+
getCharacterSkinMetadata(
|
|
80
|
+
selectedSkin,
|
|
81
|
+
config.availableCharacters || []
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
}
|
|
58
85
|
availableCharacters={config.availableCharacters || []}
|
|
59
86
|
atlasJSON={config.atlasJSON}
|
|
60
87
|
atlasIMG={config.atlasIMG}
|
|
@@ -13,6 +13,7 @@ export interface IPaymentMethodModalProps {
|
|
|
13
13
|
onPayWithPix: () => void;
|
|
14
14
|
onClose: () => void;
|
|
15
15
|
showPix?: boolean;
|
|
16
|
+
isProcessing?: boolean;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
type PaymentMethod = 'dc' | 'card' | 'pix';
|
|
@@ -25,6 +26,7 @@ export const PaymentMethodModal: React.FC<IPaymentMethodModalProps> = ({
|
|
|
25
26
|
onPayWithPix,
|
|
26
27
|
onClose,
|
|
27
28
|
showPix = false,
|
|
29
|
+
isProcessing = false,
|
|
28
30
|
}) => {
|
|
29
31
|
const [selected, setSelected] = useState<PaymentMethod>('card');
|
|
30
32
|
|
|
@@ -44,6 +46,8 @@ export const PaymentMethodModal: React.FC<IPaymentMethodModalProps> = ({
|
|
|
44
46
|
);
|
|
45
47
|
|
|
46
48
|
const handleConfirm = useCallback(() => {
|
|
49
|
+
if (isProcessing) return;
|
|
50
|
+
|
|
47
51
|
if (selected === 'dc' && !dcDisabled) {
|
|
48
52
|
onPayWithDC();
|
|
49
53
|
} else if (selected === 'pix') {
|
|
@@ -51,7 +55,11 @@ export const PaymentMethodModal: React.FC<IPaymentMethodModalProps> = ({
|
|
|
51
55
|
} else {
|
|
52
56
|
onPayWithCard();
|
|
53
57
|
}
|
|
54
|
-
}, [selected, dcDisabled, onPayWithDC, onPayWithCard, onPayWithPix]);
|
|
58
|
+
}, [selected, dcDisabled, isProcessing, onPayWithDC, onPayWithCard, onPayWithPix]);
|
|
59
|
+
|
|
60
|
+
const handleClose = useCallback(() => {
|
|
61
|
+
if (!isProcessing) onClose();
|
|
62
|
+
}, [isProcessing, onClose]);
|
|
55
63
|
|
|
56
64
|
const dcSubText =
|
|
57
65
|
dcRequired !== undefined ? (
|
|
@@ -67,7 +75,7 @@ export const PaymentMethodModal: React.FC<IPaymentMethodModalProps> = ({
|
|
|
67
75
|
|
|
68
76
|
return (
|
|
69
77
|
<ModalPortal>
|
|
70
|
-
<Overlay onPointerDown={
|
|
78
|
+
<Overlay onPointerDown={handleClose} />
|
|
71
79
|
<ModalContainer>
|
|
72
80
|
<ModalContent
|
|
73
81
|
onClick={stopPropagation as React.MouseEventHandler}
|
|
@@ -76,7 +84,7 @@ export const PaymentMethodModal: React.FC<IPaymentMethodModalProps> = ({
|
|
|
76
84
|
>
|
|
77
85
|
<Header>
|
|
78
86
|
<Title>How would you like to pay?</Title>
|
|
79
|
-
<CloseButton onPointerDown={
|
|
87
|
+
<CloseButton onPointerDown={handleClose} aria-label="Close" disabled={isProcessing}>
|
|
80
88
|
<Cancel />
|
|
81
89
|
</CloseButton>
|
|
82
90
|
</Header>
|
|
@@ -84,7 +92,8 @@ export const PaymentMethodModal: React.FC<IPaymentMethodModalProps> = ({
|
|
|
84
92
|
<Options>
|
|
85
93
|
<RadioOption
|
|
86
94
|
$selected={selected === 'card'}
|
|
87
|
-
|
|
95
|
+
$disabled={isProcessing}
|
|
96
|
+
onPointerDown={isProcessing ? undefined : () => setSelected('card')}
|
|
88
97
|
>
|
|
89
98
|
<RadioCircle $selected={selected === 'card'} />
|
|
90
99
|
<OptionText>
|
|
@@ -96,7 +105,8 @@ export const PaymentMethodModal: React.FC<IPaymentMethodModalProps> = ({
|
|
|
96
105
|
{showPix && (
|
|
97
106
|
<RadioOption
|
|
98
107
|
$selected={selected === 'pix'}
|
|
99
|
-
|
|
108
|
+
$disabled={isProcessing}
|
|
109
|
+
onPointerDown={isProcessing ? undefined : () => setSelected('pix')}
|
|
100
110
|
>
|
|
101
111
|
<RadioCircle $selected={selected === 'pix'} />
|
|
102
112
|
<OptionText>
|
|
@@ -108,8 +118,8 @@ export const PaymentMethodModal: React.FC<IPaymentMethodModalProps> = ({
|
|
|
108
118
|
|
|
109
119
|
<RadioOption
|
|
110
120
|
$selected={selected === 'dc'}
|
|
111
|
-
$disabled={dcDisabled}
|
|
112
|
-
onPointerDown={dcDisabled ? undefined : () => setSelected('dc')}
|
|
121
|
+
$disabled={dcDisabled || isProcessing}
|
|
122
|
+
onPointerDown={dcDisabled || isProcessing ? undefined : () => setSelected('dc')}
|
|
113
123
|
>
|
|
114
124
|
<RadioCircle $selected={selected === 'dc'} />
|
|
115
125
|
<OptionText>
|
|
@@ -120,8 +130,8 @@ export const PaymentMethodModal: React.FC<IPaymentMethodModalProps> = ({
|
|
|
120
130
|
</Options>
|
|
121
131
|
|
|
122
132
|
<ConfirmRow>
|
|
123
|
-
<Button buttonType={ButtonTypes.RPGUIButton} onPointerDown={handleConfirm}>
|
|
124
|
-
Confirm
|
|
133
|
+
<Button buttonType={ButtonTypes.RPGUIButton} onPointerDown={handleConfirm} disabled={isProcessing}>
|
|
134
|
+
{isProcessing ? 'Processing...' : 'Confirm'}
|
|
125
135
|
</Button>
|
|
126
136
|
</ConfirmRow>
|
|
127
137
|
</ModalContent>
|
|
@@ -14,7 +14,7 @@ import { DraggableContainer } from '../DraggableContainer';
|
|
|
14
14
|
import { RPGUIContainerTypes } from '../RPGUI/RPGUIContainer';
|
|
15
15
|
import { CTAButton } from '../shared/CTAButton/CTAButton';
|
|
16
16
|
import { LabelPill } from '../shared/LabelPill/LabelPill';
|
|
17
|
-
import { CartView } from './CartView';
|
|
17
|
+
import { CartPurchaseResult, CartView } from './CartView';
|
|
18
18
|
import { FeaturedBanner, IFeaturedItem } from './FeaturedBanner';
|
|
19
19
|
import { packToBlueprint } from './internal/packToBlueprint';
|
|
20
20
|
import { useStoreCart } from './hooks/useStoreCart';
|
|
@@ -31,7 +31,7 @@ export interface IStoreProps {
|
|
|
31
31
|
packs?: IItemPack[];
|
|
32
32
|
atlasJSON: any;
|
|
33
33
|
atlasIMG: string;
|
|
34
|
-
onPurchase: (purchase: Partial<IPurchase>) => Promise<
|
|
34
|
+
onPurchase: (purchase: Partial<IPurchase>) => Promise<CartPurchaseResult>;
|
|
35
35
|
onShowHistory?: () => void;
|
|
36
36
|
onShowWallet?: () => void;
|
|
37
37
|
walletLabel?: string;
|
|
@@ -0,0 +1,190 @@
|
|
|
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 { CartView } from '../CartView';
|
|
9
|
+
|
|
10
|
+
jest.mock(
|
|
11
|
+
'../../../mocks/atlas/entities/entities.json',
|
|
12
|
+
() => ({ frames: {} }),
|
|
13
|
+
{ virtual: true }
|
|
14
|
+
);
|
|
15
|
+
jest.mock('../../../mocks/atlas/entities/entities.png', () => 'entities.png', {
|
|
16
|
+
virtual: true,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
jest.mock('../../shared/SpriteFromAtlas', () => ({
|
|
20
|
+
SpriteFromAtlas: ({ spriteKey }) => (
|
|
21
|
+
<div data-testid="sprite">{spriteKey}</div>
|
|
22
|
+
),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.mock('../../shared/MMORPGNumber', () => ({
|
|
26
|
+
MMORPGNumber: ({ value }) => <span>{value}</span>,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
jest.mock('../../shared/CTAButton/CTAButton', () => ({
|
|
30
|
+
CTAButton: ({ label, onClick, disabled }) => (
|
|
31
|
+
<button type="button" onClick={onClick} disabled={disabled}>
|
|
32
|
+
{label}
|
|
33
|
+
</button>
|
|
34
|
+
),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
jest.mock('../TrustBar', () => ({
|
|
38
|
+
TrustBar: () => <div data-testid="trust-bar" />,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.mock('../PurchaseSuccess', () => ({
|
|
42
|
+
PurchaseSuccess: () => <div>Purchase Success</div>,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
jest.mock('styled-components', () => {
|
|
46
|
+
const withoutTransientProps = (props) =>
|
|
47
|
+
Object.fromEntries(
|
|
48
|
+
Object.entries(props).filter(([key]) => !key.startsWith('$'))
|
|
49
|
+
);
|
|
50
|
+
const createTag = (tag) => () => {
|
|
51
|
+
if (tag === 'button')
|
|
52
|
+
return ({ children, ...props }) => (
|
|
53
|
+
<button {...withoutTransientProps(props)}>{children}</button>
|
|
54
|
+
);
|
|
55
|
+
if (tag === 'span')
|
|
56
|
+
return ({ children, ...props }) => (
|
|
57
|
+
<span {...withoutTransientProps(props)}>{children}</span>
|
|
58
|
+
);
|
|
59
|
+
return ({ children, ...props }) => (
|
|
60
|
+
<div {...withoutTransientProps(props)}>{children}</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
const styled = new Proxy(() => {}, {
|
|
64
|
+
get: (_target, prop) => {
|
|
65
|
+
if (prop === '__esModule') return true;
|
|
66
|
+
return createTag(prop);
|
|
67
|
+
},
|
|
68
|
+
apply: () => createTag('div'),
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
__esModule: true,
|
|
72
|
+
default: styled,
|
|
73
|
+
keyframes: () => '',
|
|
74
|
+
css: () => '',
|
|
75
|
+
};
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
jest.mock('pixelarticons/react/Cancel', () => ({
|
|
79
|
+
Cancel: () => <span>x</span>,
|
|
80
|
+
}));
|
|
81
|
+
jest.mock('pixelarticons/react/Delete', () => ({
|
|
82
|
+
Delete: () => <span>delete</span>,
|
|
83
|
+
}));
|
|
84
|
+
jest.mock('pixelarticons/react/InfoBox', () => ({
|
|
85
|
+
InfoBox: () => <span>info</span>,
|
|
86
|
+
}));
|
|
87
|
+
jest.mock('pixelarticons/react/Box', () => ({ Box: () => <span>box</span> }));
|
|
88
|
+
jest.mock('pixelarticons/react/ShoppingBag', () => ({
|
|
89
|
+
ShoppingBag: () => <span>bag</span>,
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
const cartItems = [
|
|
93
|
+
{
|
|
94
|
+
item: {
|
|
95
|
+
_id: 'item-1',
|
|
96
|
+
key: 'life-potion',
|
|
97
|
+
name: 'Life Potion',
|
|
98
|
+
price: 1.5,
|
|
99
|
+
texturePath: 'items/life-potion.png',
|
|
100
|
+
},
|
|
101
|
+
quantity: 2,
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const renderCart = (onPurchase, onPurchaseError = jest.fn()) => {
|
|
106
|
+
const container = document.createElement('div');
|
|
107
|
+
document.body.appendChild(container);
|
|
108
|
+
|
|
109
|
+
act(() => {
|
|
110
|
+
ReactDOM.render(
|
|
111
|
+
<CartView
|
|
112
|
+
cartItems={cartItems}
|
|
113
|
+
onRemoveFromCart={jest.fn()}
|
|
114
|
+
onClose={jest.fn()}
|
|
115
|
+
onPurchase={onPurchase}
|
|
116
|
+
atlasJSON={{}}
|
|
117
|
+
atlasIMG="items.png"
|
|
118
|
+
onPurchaseError={onPurchaseError}
|
|
119
|
+
/>,
|
|
120
|
+
container
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return { container, onPurchaseError };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const clickPay = async (container) => {
|
|
128
|
+
const button = Array.from(container.querySelectorAll('button')).find(
|
|
129
|
+
(button) => button.textContent.includes('Pay')
|
|
130
|
+
);
|
|
131
|
+
await act(async () => {
|
|
132
|
+
button.click();
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
describe('CartView purchase result handling', () => {
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
ReactDOM.unmountComponentAtNode(document.body.lastElementChild);
|
|
139
|
+
document.body.innerHTML = '';
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('treats cancelled purchases as a quiet dismissal without generic error telemetry', async () => {
|
|
143
|
+
const onPurchaseError = jest.fn();
|
|
144
|
+
const { container } = renderCart(
|
|
145
|
+
jest.fn().mockResolvedValue({ status: 'cancelled' }),
|
|
146
|
+
onPurchaseError
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
await clickPay(container);
|
|
150
|
+
|
|
151
|
+
expect(container.textContent).not.toContain(
|
|
152
|
+
'Purchase failed. Please try again.'
|
|
153
|
+
);
|
|
154
|
+
expect(container.textContent).not.toContain(
|
|
155
|
+
'An error occurred during purchase. Please try again.'
|
|
156
|
+
);
|
|
157
|
+
expect(onPurchaseError).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('shows and reports the exact failed purchase error message', async () => {
|
|
161
|
+
const onPurchaseError = jest.fn();
|
|
162
|
+
const exactError = 'Your session has expired. Please log in again.';
|
|
163
|
+
const { container } = renderCart(
|
|
164
|
+
jest.fn().mockResolvedValue({ status: 'failed', error: exactError }),
|
|
165
|
+
onPurchaseError
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
await clickPay(container);
|
|
169
|
+
|
|
170
|
+
expect(container.textContent).toContain(exactError);
|
|
171
|
+
expect(onPurchaseError).toHaveBeenCalledWith(exactError);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('keeps legacy false purchase results as the generic failure fallback', async () => {
|
|
175
|
+
const onPurchaseError = jest.fn();
|
|
176
|
+
const { container } = renderCart(
|
|
177
|
+
jest.fn().mockResolvedValue(false),
|
|
178
|
+
onPurchaseError
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
await clickPay(container);
|
|
182
|
+
|
|
183
|
+
expect(container.textContent).toContain(
|
|
184
|
+
'Purchase failed. Please try again.'
|
|
185
|
+
);
|
|
186
|
+
expect(onPurchaseError).toHaveBeenCalledWith(
|
|
187
|
+
'Purchase failed. Please try again.'
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -72,11 +72,16 @@ describe('MetadataCollector', () => {
|
|
|
72
72
|
});
|
|
73
73
|
|
|
74
74
|
it('collects once without cancelling again when the modal closes after confirm', () => {
|
|
75
|
+
const availableCharacters = [
|
|
76
|
+
{ name: 'Superior Knight', textureKey: 'superior-knight' },
|
|
77
|
+
{ name: 'Black Knight', textureKey: 'black-knight' },
|
|
78
|
+
];
|
|
79
|
+
|
|
75
80
|
act(() => {
|
|
76
81
|
ReactDOM.render(
|
|
77
82
|
<MetadataCollector
|
|
78
83
|
metadataType={MetadataType.CharacterSkin}
|
|
79
|
-
config={{ availableCharacters
|
|
84
|
+
config={{ availableCharacters }}
|
|
80
85
|
onCollect={mockOnCollect}
|
|
81
86
|
onCancel={mockOnCancel}
|
|
82
87
|
/>,
|
|
@@ -87,11 +92,15 @@ describe('MetadataCollector', () => {
|
|
|
87
92
|
const callArgs = CharacterSkinSelectionModal.mock.calls[0][0];
|
|
88
93
|
|
|
89
94
|
act(() => {
|
|
90
|
-
callArgs.onConfirm('
|
|
95
|
+
callArgs.onConfirm('superior-knight');
|
|
91
96
|
callArgs.onClose();
|
|
92
97
|
});
|
|
93
98
|
|
|
94
|
-
expect(mockOnCollect).toHaveBeenCalledWith({
|
|
99
|
+
expect(mockOnCollect).toHaveBeenCalledWith({
|
|
100
|
+
selectedSkin: 'superior-knight',
|
|
101
|
+
selectedSkinName: 'Superior Knight',
|
|
102
|
+
selectedSkinTextureKey: 'superior-knight',
|
|
103
|
+
});
|
|
95
104
|
expect(mockOnCollect).toHaveBeenCalledTimes(1);
|
|
96
105
|
expect(mockOnCancel).not.toHaveBeenCalled();
|
|
97
106
|
|