@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.
Files changed (37) hide show
  1. package/dist/components/Quests/QuestInfo/QuestInfo.d.ts +3 -1
  2. package/dist/components/Quests/QuestInfo/QuestObjectivesSection.d.ts +8 -0
  3. package/dist/components/Quests/QuestInfo/QuestRequirementsSection.d.ts +8 -0
  4. package/dist/components/Quests/QuestInfo/QuestRewardsSection.d.ts +8 -0
  5. package/dist/components/Quests/QuestInfo/QuestSectionTypes.d.ts +8 -0
  6. package/dist/components/Quests/QuestList.d.ts +4 -5
  7. package/dist/components/Quests/QuestListRow.d.ts +15 -0
  8. package/dist/components/Store/CartView.d.ts +11 -1
  9. package/dist/components/Store/PaymentMethodModal.d.ts +1 -0
  10. package/dist/components/Store/Store.d.ts +2 -1
  11. package/dist/components/Store/hooks/useStoreCart.d.ts +2 -1
  12. package/dist/components/Tutorial/TutorialStepper.d.ts +2 -0
  13. package/dist/index.d.ts +1 -0
  14. package/dist/long-bow.cjs.development.js +491 -170
  15. package/dist/long-bow.cjs.development.js.map +1 -1
  16. package/dist/long-bow.cjs.production.min.js +1 -1
  17. package/dist/long-bow.cjs.production.min.js.map +1 -1
  18. package/dist/long-bow.esm.js +492 -172
  19. package/dist/long-bow.esm.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/components/Quests/QuestInfo/QuestInfo.tsx +57 -68
  22. package/src/components/Quests/QuestInfo/QuestObjectivesSection.tsx +114 -0
  23. package/src/components/Quests/QuestInfo/QuestRequirementsSection.tsx +74 -0
  24. package/src/components/Quests/QuestInfo/QuestRewardsSection.tsx +128 -0
  25. package/src/components/Quests/QuestInfo/QuestSectionTypes.ts +8 -0
  26. package/src/components/Quests/QuestList.tsx +12 -87
  27. package/src/components/Quests/QuestListRow.tsx +178 -0
  28. package/src/components/Store/CartView.tsx +26 -7
  29. package/src/components/Store/MetadataCollector.tsx +28 -1
  30. package/src/components/Store/PaymentMethodModal.tsx +19 -9
  31. package/src/components/Store/Store.tsx +2 -2
  32. package/src/components/Store/__test__/CartView.spec.tsx +190 -0
  33. package/src/components/Store/__test__/MetadataCollector.spec.tsx +12 -3
  34. package/src/components/Store/__test__/PaymentMethodModal.spec.tsx +108 -0
  35. package/src/components/Store/hooks/useStoreCart.ts +6 -4
  36. package/src/components/Tutorial/TutorialStepper.tsx +2 -0
  37. 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<boolean>;
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 success = await onPurchase();
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 = 'Purchase failed. Please try again.';
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 && cartItem.metadata?.selectedSkinTextureKey ? getSpriteKey(cartItem.metadata.selectedSkinTextureKey) : cartItem.item.texturePath}
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: any) => handleCollect({ 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={onClose} />
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={onClose} aria-label="Close">
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
- onPointerDown={() => setSelected('card')}
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
- onPointerDown={() => setSelected('pix')}
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<boolean>;
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: ['char1', 'char2'] }}
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('char1');
95
+ callArgs.onConfirm('superior-knight');
91
96
  callArgs.onClose();
92
97
  });
93
98
 
94
- expect(mockOnCollect).toHaveBeenCalledWith({ selectedSkin: 'char1' });
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