@rpg-engine/long-bow 0.8.198 → 0.8.199

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpg-engine/long-bow",
3
- "version": "0.8.198",
3
+ "version": "0.8.199",
4
4
  "license": "MIT",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -4,7 +4,7 @@ import { Gift } from 'pixelarticons/react/Gift';
4
4
  import { Package } from 'pixelarticons/react/Package';
5
5
  import { Wallet } from 'pixelarticons/react/Wallet';
6
6
  import React, { ReactNode, useMemo, useState } from 'react';
7
- import { FaHistory, FaShoppingCart, FaWallet } from 'react-icons/fa';
7
+ import { FaHistory, FaShoppingCart, FaTicketAlt, FaWallet } from 'react-icons/fa';
8
8
  import styled from 'styled-components';
9
9
  import { uiColors } from '../../constants/uiColors';
10
10
  import { DraggableContainer } from '../DraggableContainer';
@@ -18,10 +18,11 @@ import { useStoreCart } from './hooks/useStoreCart';
18
18
  import { MetadataCollector } from './MetadataCollector';
19
19
  import { StoreItemsSection } from './sections/StoreItemsSection';
20
20
  import { StorePacksSection } from './sections/StorePacksSection';
21
+ import { StoreRedeemSection } from './sections/StoreRedeemSection';
21
22
  import { StoreItemDetails } from './StoreItemDetails';
22
23
 
23
24
  // Define TabId union type for tab identifiers
24
- type TabId = 'premium' | 'packs' | 'items' | 'wallet' | 'history';
25
+ type TabId = 'premium' | 'packs' | 'items' | 'wallet' | 'history' | 'redeem';
25
26
 
26
27
  // Define IStoreProps locally as a workaround
27
28
  export interface IStoreProps {
@@ -74,6 +75,12 @@ export interface IStoreProps {
74
75
  onBuyDC?: () => void;
75
76
  /** Currency symbol to display (e.g. "$" for USD, "R$" for BRL). Defaults to "$". */
76
77
  currencySymbol?: string;
78
+ /** Callback to redeem a voucher code. When provided, the Redeem tab is shown. */
79
+ onRedeem?: (code: string) => Promise<{ success: boolean; dcAmount?: number; error?: string }>;
80
+ /** Called when the voucher code input gains focus. */
81
+ onRedeemInputFocus?: () => void;
82
+ /** Called when the voucher code input loses focus. */
83
+ onRedeemInputBlur?: () => void;
77
84
  }
78
85
 
79
86
  export type { IFeaturedItem };
@@ -116,6 +123,9 @@ export const Store: React.FC<IStoreProps> = ({
116
123
  onPurchaseError,
117
124
  onBuyDC,
118
125
  currencySymbol = '$',
126
+ onRedeem,
127
+ onRedeemInputFocus,
128
+ onRedeemInputBlur,
119
129
  }) => {
120
130
  const [selectedPack, setSelectedPack] = useState<IItemPack | null>(null);
121
131
  const [activeTab, setActiveTab] = useState<TabId>(() => {
@@ -230,6 +240,7 @@ export const Store: React.FC<IStoreProps> = ({
230
240
  // Build tabs dynamically based on props
231
241
  const tabIds: TabId[] = [
232
242
  ...(tabOrder ?? ['premium', 'packs', 'items']),
243
+ ...(onRedeem ? ['redeem' as TabId] : []),
233
244
  ...((onShowWallet || customWalletContent) ? ['wallet' as TabId] : []),
234
245
  ...((onShowHistory || customHistoryContent) ? ['history' as TabId] : [])
235
246
  ];
@@ -335,6 +346,18 @@ export const Store: React.FC<IStoreProps> = ({
335
346
  />
336
347
  ),
337
348
  },
349
+ redeem: {
350
+ id: 'redeem',
351
+ title: 'Redeem',
352
+ icon: <FaTicketAlt size={16} />,
353
+ content: onRedeem ? (
354
+ <StoreRedeemSection
355
+ onRedeem={onRedeem}
356
+ onInputFocus={onRedeemInputFocus}
357
+ onInputBlur={onRedeemInputBlur}
358
+ />
359
+ ) : null,
360
+ },
338
361
  wallet: {
339
362
  id: 'wallet',
340
363
  title: walletLabel ?? 'Wallet',
@@ -0,0 +1,232 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ // @ts-nocheck
5
+ import React from 'react';
6
+ import ReactDOM from 'react-dom';
7
+ import { act, Simulate } from 'react-dom/test-utils';
8
+ import { StoreRedeemSection } from '../sections/StoreRedeemSection';
9
+
10
+ jest.mock('../../shared/CTAButton/CTAButton', () => ({
11
+ CTAButton: ({ label, onClick, disabled }) => (
12
+ <button onClick={onClick} disabled={disabled} data-testid="cta-button">
13
+ {label}
14
+ </button>
15
+ ),
16
+ }));
17
+
18
+ jest.mock('styled-components', () => {
19
+ const createTag = (tag) => () => {
20
+ if (tag === 'input') return (props) => <input {...props} />;
21
+ if (tag === 'h3') return ({ children }) => <h3>{children}</h3>;
22
+ if (tag === 'p') return ({ children }) => <p>{children}</p>;
23
+ return ({ children, ...props }) => <div {...props}>{children}</div>;
24
+ };
25
+ const styled = new Proxy(() => {}, {
26
+ get: (_target, prop) => {
27
+ if (prop === '__esModule') return true;
28
+ return createTag(prop);
29
+ },
30
+ apply: () => createTag('div'),
31
+ });
32
+ return { __esModule: true, default: styled, keyframes: () => '', css: () => '' };
33
+ });
34
+
35
+ jest.mock('react-icons/fa', () => ({
36
+ FaCheckCircle: () => <span>check</span>,
37
+ FaExclamationCircle: () => <span>error</span>,
38
+ FaTicketAlt: () => <span>ticket</span>,
39
+ }));
40
+
41
+ jest.mock('../../../constants/uiColors', () => ({
42
+ uiColors: {
43
+ white: '#fff',
44
+ lightGray: '#888',
45
+ green: '#4CAF50',
46
+ red: '#D04648',
47
+ },
48
+ }));
49
+
50
+ const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
51
+
52
+ describe('StoreRedeemSection', () => {
53
+ let container;
54
+
55
+ beforeEach(() => {
56
+ container = document.createElement('div');
57
+ document.body.appendChild(container);
58
+ });
59
+
60
+ afterEach(() => {
61
+ ReactDOM.unmountComponentAtNode(container);
62
+ document.body.removeChild(container);
63
+ });
64
+
65
+ it('should render input and redeem button', () => {
66
+ act(() => {
67
+ ReactDOM.render(
68
+ <StoreRedeemSection onRedeem={jest.fn()} />,
69
+ container
70
+ );
71
+ });
72
+ expect(container.querySelector('input')).toBeTruthy();
73
+ expect(container.textContent).toContain('Redeem Code');
74
+ });
75
+
76
+ it('should disable button when input is empty', () => {
77
+ act(() => {
78
+ ReactDOM.render(
79
+ <StoreRedeemSection onRedeem={jest.fn()} />,
80
+ container
81
+ );
82
+ });
83
+ const button = container.querySelector('[data-testid="cta-button"]');
84
+ expect(button.disabled).toBe(true);
85
+ });
86
+
87
+ it('should enable button when input has text', () => {
88
+ act(() => {
89
+ ReactDOM.render(
90
+ <StoreRedeemSection onRedeem={jest.fn()} />,
91
+ container
92
+ );
93
+ });
94
+ const input = container.querySelector('input');
95
+ act(() => {
96
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
97
+ window.HTMLInputElement.prototype,
98
+ 'value'
99
+ ).set;
100
+ nativeInputValueSetter.call(input, 'ABC-123');
101
+ input.dispatchEvent(new Event('change', { bubbles: true }));
102
+ });
103
+ const button = container.querySelector('[data-testid="cta-button"]');
104
+ expect(button.disabled).toBe(false);
105
+ });
106
+
107
+ it('should show success with DC amount on valid redeem', async () => {
108
+ const onRedeem = jest.fn().mockResolvedValue({ success: true, dcAmount: 550 });
109
+
110
+ act(() => {
111
+ ReactDOM.render(
112
+ <StoreRedeemSection onRedeem={onRedeem} />,
113
+ container
114
+ );
115
+ });
116
+
117
+ const input = container.querySelector('input');
118
+ act(() => {
119
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
120
+ window.HTMLInputElement.prototype,
121
+ 'value'
122
+ ).set;
123
+ nativeInputValueSetter.call(input, 'CHB-550-ABCDEF1234');
124
+ input.dispatchEvent(new Event('change', { bubbles: true }));
125
+ });
126
+
127
+ const button = container.querySelector('[data-testid="cta-button"]');
128
+ await act(async () => {
129
+ button.click();
130
+ await flushPromises();
131
+ });
132
+
133
+ expect(onRedeem).toHaveBeenCalledWith('CHB-550-ABCDEF1234');
134
+ expect(container.textContent).toContain('Code Redeemed');
135
+ expect(container.textContent).toContain('+550 DC');
136
+ });
137
+
138
+ it('should show error on failed redeem', async () => {
139
+ const onRedeem = jest.fn().mockResolvedValue({ success: false, error: 'Invalid voucher code.' });
140
+
141
+ act(() => {
142
+ ReactDOM.render(
143
+ <StoreRedeemSection onRedeem={onRedeem} />,
144
+ container
145
+ );
146
+ });
147
+
148
+ const input = container.querySelector('input');
149
+ act(() => {
150
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
151
+ window.HTMLInputElement.prototype,
152
+ 'value'
153
+ ).set;
154
+ nativeInputValueSetter.call(input, 'INVALID-CODE');
155
+ input.dispatchEvent(new Event('change', { bubbles: true }));
156
+ });
157
+
158
+ const button = container.querySelector('[data-testid="cta-button"]');
159
+ await act(async () => {
160
+ button.click();
161
+ await flushPromises();
162
+ });
163
+
164
+ expect(container.textContent).toContain('Invalid voucher code.');
165
+ expect(container.textContent).toContain('Try Again');
166
+ });
167
+
168
+ it('should reset to idle when "Redeem Another" is clicked after success', async () => {
169
+ const onRedeem = jest.fn().mockResolvedValue({ success: true, dcAmount: 100 });
170
+
171
+ act(() => {
172
+ ReactDOM.render(
173
+ <StoreRedeemSection onRedeem={onRedeem} />,
174
+ container
175
+ );
176
+ });
177
+
178
+ const input = container.querySelector('input');
179
+ act(() => {
180
+ const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
181
+ window.HTMLInputElement.prototype,
182
+ 'value'
183
+ ).set;
184
+ nativeInputValueSetter.call(input, 'CODE123');
185
+ input.dispatchEvent(new Event('change', { bubbles: true }));
186
+ });
187
+
188
+ const submitBtn = container.querySelector('[data-testid="cta-button"]');
189
+ await act(async () => {
190
+ submitBtn.click();
191
+ await flushPromises();
192
+ });
193
+
194
+ expect(container.textContent).toContain('Redeem Another');
195
+
196
+ const redeemAnotherBtn = Array.from(container.querySelectorAll('[data-testid="cta-button"]'))
197
+ .find(b => b.textContent.includes('Redeem Another'));
198
+
199
+ act(() => { redeemAnotherBtn.click(); });
200
+
201
+ expect(container.querySelector('input')).toBeTruthy();
202
+ expect(container.textContent).toContain('Redeem Code');
203
+ });
204
+
205
+ it('should call onInputFocus when input is focused', () => {
206
+ const onInputFocus = jest.fn();
207
+ act(() => {
208
+ ReactDOM.render(
209
+ <StoreRedeemSection onRedeem={jest.fn()} onInputFocus={onInputFocus} />,
210
+ container
211
+ );
212
+ });
213
+
214
+ const input = container.querySelector('input');
215
+ act(() => { Simulate.focus(input); });
216
+ expect(onInputFocus).toHaveBeenCalledTimes(1);
217
+ });
218
+
219
+ it('should call onInputBlur when input loses focus', () => {
220
+ const onInputBlur = jest.fn();
221
+ act(() => {
222
+ ReactDOM.render(
223
+ <StoreRedeemSection onRedeem={jest.fn()} onInputBlur={onInputBlur} />,
224
+ container
225
+ );
226
+ });
227
+
228
+ const input = container.querySelector('input');
229
+ act(() => { Simulate.blur(input); });
230
+ expect(onInputBlur).toHaveBeenCalledTimes(1);
231
+ });
232
+ });
@@ -0,0 +1,258 @@
1
+ import React, { useState } from 'react';
2
+ import { FaCheckCircle, FaExclamationCircle, FaTicketAlt } from 'react-icons/fa';
3
+ import styled, { keyframes } from 'styled-components';
4
+ import { uiColors } from '../../../constants/uiColors';
5
+ import { CTAButton } from '../../shared/CTAButton/CTAButton';
6
+
7
+ type RedeemStatus = 'idle' | 'loading' | 'success' | 'error';
8
+
9
+ export interface IStoreRedeemSectionProps {
10
+ onRedeem: (code: string) => Promise<{ success: boolean; dcAmount?: number; error?: string }>;
11
+ onInputFocus?: () => void;
12
+ onInputBlur?: () => void;
13
+ }
14
+
15
+ export const StoreRedeemSection: React.FC<IStoreRedeemSectionProps> = ({
16
+ onRedeem,
17
+ onInputFocus,
18
+ onInputBlur,
19
+ }) => {
20
+ const [code, setCode] = useState('');
21
+ const [status, setStatus] = useState<RedeemStatus>('idle');
22
+ const [dcAmount, setDcAmount] = useState<number | undefined>();
23
+ const [errorMessage, setErrorMessage] = useState('');
24
+
25
+ const canSubmit = code.trim().length > 0 && status !== 'loading';
26
+
27
+ const handleSubmit = async (): Promise<void> => {
28
+ if (!canSubmit) return;
29
+
30
+ const normalizedCode = code.trim().toUpperCase();
31
+ setStatus('loading');
32
+ setErrorMessage('');
33
+
34
+ try {
35
+ const result = await onRedeem(normalizedCode);
36
+ if (result.success) {
37
+ setStatus('success');
38
+ setDcAmount(result.dcAmount);
39
+ } else {
40
+ setStatus('error');
41
+ setErrorMessage(result.error ?? 'Redemption failed. Please try again.');
42
+ }
43
+ } catch {
44
+ setStatus('error');
45
+ setErrorMessage('Something went wrong. Please try again.');
46
+ }
47
+ };
48
+
49
+ const handleReset = (): void => {
50
+ setCode('');
51
+ setStatus('idle');
52
+ setDcAmount(undefined);
53
+ setErrorMessage('');
54
+ };
55
+
56
+ const handleKeyDown = (e: React.KeyboardEvent): void => {
57
+ if (e.key === 'Enter') {
58
+ void handleSubmit();
59
+ }
60
+ };
61
+
62
+ if (status === 'success') {
63
+ return (
64
+ <Container>
65
+ <ResultContainer>
66
+ <SuccessIcon>
67
+ <FaCheckCircle size={32} />
68
+ </SuccessIcon>
69
+ <SuccessTitle>Code Redeemed!</SuccessTitle>
70
+ {dcAmount != null && (
71
+ <DCAmountDisplay>+{dcAmount.toLocaleString()} DC</DCAmountDisplay>
72
+ )}
73
+ <SuccessHint>Your wallet balance has been updated.</SuccessHint>
74
+ <ButtonWrapper>
75
+ <CTAButton
76
+ icon={<FaTicketAlt />}
77
+ label="Redeem Another"
78
+ onClick={handleReset}
79
+ />
80
+ </ButtonWrapper>
81
+ </ResultContainer>
82
+ </Container>
83
+ );
84
+ }
85
+
86
+ if (status === 'error') {
87
+ return (
88
+ <Container>
89
+ <ResultContainer>
90
+ <ErrorIcon>
91
+ <FaExclamationCircle size={32} />
92
+ </ErrorIcon>
93
+ <ErrorTitle>{errorMessage}</ErrorTitle>
94
+ <ButtonWrapper>
95
+ <CTAButton
96
+ icon={<FaTicketAlt />}
97
+ label="Try Again"
98
+ onClick={handleReset}
99
+ />
100
+ </ButtonWrapper>
101
+ </ResultContainer>
102
+ </Container>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <Container>
108
+ <Title>Redeem a Voucher Code</Title>
109
+ <Description>
110
+ Enter your voucher code below to receive Definya Coins.
111
+ </Description>
112
+ <InputRow>
113
+ <CodeInput
114
+ type="text"
115
+ value={code}
116
+ onChange={(e) => setCode(e.target.value)}
117
+ onFocus={onInputFocus}
118
+ onBlur={onInputBlur}
119
+ onKeyDown={handleKeyDown}
120
+ placeholder="Enter code..."
121
+ disabled={status === 'loading'}
122
+ autoComplete="off"
123
+ spellCheck={false}
124
+ />
125
+ </InputRow>
126
+ <ButtonWrapper>
127
+ <CTAButton
128
+ icon={<FaTicketAlt />}
129
+ label={status === 'loading' ? 'Redeeming...' : 'Redeem Code'}
130
+ onClick={() => { void handleSubmit(); }}
131
+ disabled={!canSubmit}
132
+ fullWidth
133
+ />
134
+ </ButtonWrapper>
135
+ </Container>
136
+ );
137
+ };
138
+
139
+ const Container = styled.div`
140
+ display: flex;
141
+ flex-direction: column;
142
+ align-items: center;
143
+ justify-content: center;
144
+ padding: 2rem 1.5rem;
145
+ max-width: 420px;
146
+ margin: 0 auto;
147
+ gap: 1rem;
148
+ `;
149
+
150
+ const Title = styled.h3`
151
+ font-family: 'Press Start 2P', cursive;
152
+ font-size: 0.85rem;
153
+ color: ${uiColors.white};
154
+ margin: 0;
155
+ text-align: center;
156
+ `;
157
+
158
+ const Description = styled.p`
159
+ font-family: 'Press Start 2P', cursive;
160
+ font-size: 0.55rem;
161
+ color: ${uiColors.lightGray};
162
+ margin: 0;
163
+ text-align: center;
164
+ line-height: 1.6;
165
+ `;
166
+
167
+ const InputRow = styled.div`
168
+ width: 100%;
169
+ `;
170
+
171
+ const CodeInput = styled.input`
172
+ width: 100%;
173
+ padding: 12px 14px;
174
+ font-family: 'Press Start 2P', cursive;
175
+ font-size: 0.75rem;
176
+ color: ${uiColors.white};
177
+ background: rgba(0, 0, 0, 0.4);
178
+ border: 2px solid #f59e0b;
179
+ border-radius: 4px;
180
+ text-transform: uppercase;
181
+ letter-spacing: 2px;
182
+ text-align: center;
183
+ box-sizing: border-box;
184
+ outline: none;
185
+
186
+ &::placeholder {
187
+ color: ${uiColors.lightGray};
188
+ text-transform: none;
189
+ letter-spacing: 0;
190
+ }
191
+
192
+ &:focus {
193
+ border-color: #fbbf24;
194
+ box-shadow: 0 0 8px rgba(251, 191, 36, 0.3);
195
+ }
196
+
197
+ &:disabled {
198
+ opacity: 0.5;
199
+ cursor: not-allowed;
200
+ }
201
+ `;
202
+
203
+ const ButtonWrapper = styled.div`
204
+ width: 100%;
205
+ margin-top: 0.5rem;
206
+ `;
207
+
208
+ const ResultContainer = styled.div`
209
+ display: flex;
210
+ flex-direction: column;
211
+ align-items: center;
212
+ gap: 1rem;
213
+ padding: 1rem 0;
214
+ `;
215
+
216
+ const glowPulse = keyframes`
217
+ 0%, 100% { opacity: 1; }
218
+ 50% { opacity: 0.7; }
219
+ `;
220
+
221
+ const SuccessIcon = styled.div`
222
+ color: ${uiColors.green};
223
+ animation: ${glowPulse} 1.5s ease-in-out infinite;
224
+ `;
225
+
226
+ const SuccessTitle = styled.h3`
227
+ font-family: 'Press Start 2P', cursive;
228
+ font-size: 0.85rem;
229
+ color: ${uiColors.green};
230
+ margin: 0;
231
+ `;
232
+
233
+ const DCAmountDisplay = styled.div`
234
+ font-family: 'Press Start 2P', cursive;
235
+ font-size: 1.2rem;
236
+ color: #fef08a;
237
+ text-shadow: 0 0 10px rgba(254, 240, 138, 0.5);
238
+ `;
239
+
240
+ const SuccessHint = styled.p`
241
+ font-family: 'Press Start 2P', cursive;
242
+ font-size: 0.5rem;
243
+ color: ${uiColors.lightGray};
244
+ margin: 0;
245
+ `;
246
+
247
+ const ErrorIcon = styled.div`
248
+ color: ${uiColors.red};
249
+ `;
250
+
251
+ const ErrorTitle = styled.p`
252
+ font-family: 'Press Start 2P', cursive;
253
+ font-size: 0.65rem;
254
+ color: ${uiColors.red};
255
+ margin: 0;
256
+ text-align: center;
257
+ line-height: 1.6;
258
+ `;
package/src/index.tsx CHANGED
@@ -85,6 +85,7 @@ export * from './components/Store/PaymentMethodModal';
85
85
  export * from './components/Store/PurchaseSuccess';
86
86
  export * from './components/Store/Store';
87
87
  export * from './components/Store/StoreBadges';
88
+ export * from './components/Store/sections/StoreRedeemSection';
88
89
  export * from './components/Store/TrustBar';
89
90
  export * from './components/Table/Table';
90
91
  export * from './components/TextArea';