@rpg-engine/long-bow 0.8.170 → 0.8.172
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/Store/CartView.d.ts +21 -1
- package/dist/components/Store/CountdownTimer.d.ts +7 -0
- package/dist/components/Store/FeaturedBanner.d.ts +23 -0
- package/dist/components/Store/PurchaseSuccess.d.ts +18 -0
- package/dist/components/Store/Store.d.ts +50 -2
- package/dist/components/Store/StoreBadges.d.ts +13 -0
- package/dist/components/Store/StoreCharacterSkinRow.d.ts +1 -0
- package/dist/components/Store/StoreItemRow.d.ts +10 -0
- package/dist/components/Store/TrustBar.d.ts +9 -0
- package/dist/components/Store/sections/StoreItemsSection.d.ts +13 -0
- package/dist/components/Store/sections/StorePacksSection.d.ts +11 -0
- package/dist/components/shared/CTAButton/CTAButton.d.ts +1 -0
- package/dist/components/shared/CustomScrollbar.d.ts +9 -0
- package/dist/index.d.ts +6 -1
- package/dist/long-bow.cjs.development.js +1284 -302
- 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 +1281 -304
- package/dist/long-bow.esm.js.map +1 -1
- package/dist/stories/Features/store/FeaturedBanner.stories.d.ts +1 -0
- package/dist/stories/Features/store/PurchaseSuccess.stories.d.ts +1 -0
- package/dist/stories/Features/store/StoreBadges.stories.d.ts +1 -0
- package/dist/stories/Features/store/TrustBar.stories.d.ts +1 -0
- package/package.json +2 -2
- package/src/components/Marketplace/BuyPanel.tsx +1 -1
- package/src/components/Store/CartView.tsx +143 -13
- package/src/components/Store/CountdownTimer.tsx +86 -0
- package/src/components/Store/FeaturedBanner.tsx +273 -0
- package/src/components/Store/PurchaseSuccess.tsx +258 -0
- package/src/components/Store/Store.tsx +236 -50
- package/src/components/Store/StoreBadges.tsx +94 -0
- package/src/components/Store/StoreCharacterSkinRow.tsx +113 -22
- package/src/components/Store/StoreItemRow.tsx +135 -17
- package/src/components/Store/TrustBar.tsx +69 -0
- package/src/components/Store/__test__/CountdownTimer.spec.tsx +100 -0
- package/src/components/Store/__test__/FeaturedBanner.spec.tsx +207 -0
- package/src/components/Store/__test__/PurchaseSuccess.spec.tsx +174 -0
- package/src/components/Store/__test__/StoreBadges.spec.tsx +133 -0
- package/src/components/Store/__test__/TrustBar.spec.tsx +85 -0
- package/src/components/Store/sections/StoreItemsSection.tsx +27 -1
- package/src/components/Store/sections/StorePacksSection.tsx +92 -28
- package/src/components/shared/CTAButton/CTAButton.tsx +25 -1
- package/src/components/shared/CustomScrollbar.ts +41 -0
- package/src/components/shared/ItemRowWrapper.tsx +26 -12
- package/src/components/shared/ScrollableContent/ScrollableContent.tsx +3 -0
- package/src/components/shared/SpriteFromAtlas.tsx +4 -1
- package/src/index.tsx +6 -1
- package/src/stories/Features/store/FeaturedBanner.stories.tsx +121 -0
- package/src/stories/Features/store/PurchaseSuccess.stories.tsx +74 -0
- package/src/stories/Features/store/Store.stories.tsx +39 -3
- package/src/stories/Features/store/StoreBadges.stories.tsx +83 -0
- package/src/stories/Features/store/TrustBar.stories.tsx +51 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rpg-engine/long-bow",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.172",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"typings": "dist/index.d.ts",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"dependencies": {
|
|
85
85
|
"@capacitor/core": "^6.1.0",
|
|
86
86
|
"@rollup/plugin-image": "^2.1.1",
|
|
87
|
-
"@rpg-engine/shared": "0.10.
|
|
87
|
+
"@rpg-engine/shared": "^0.10.109",
|
|
88
88
|
"dayjs": "^1.11.2",
|
|
89
89
|
"font-awesome": "^4.7.0",
|
|
90
90
|
"fs-extra": "^10.1.0",
|
|
@@ -192,7 +192,7 @@ export const BuyPanel: React.FC<IBuyPanelProps> = ({
|
|
|
192
192
|
const groupedBuyOrders = useMemo(() => {
|
|
193
193
|
const groups = new Map<string, IMarketplaceBuyOrderItem[]>();
|
|
194
194
|
for (const order of visibleBuyOrders) {
|
|
195
|
-
const key = order.itemBlueprintKey
|
|
195
|
+
const key = `${order.itemBlueprintKey}|${order.itemRarity ?? ''}`;
|
|
196
196
|
if (!groups.has(key)) groups.set(key, []);
|
|
197
197
|
groups.get(key)!.push(order);
|
|
198
198
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { IProductBlueprint, MetadataType } from '@rpg-engine/shared';
|
|
2
2
|
import React, { useState } from 'react';
|
|
3
|
-
import { FaInfoCircle, FaShoppingBag, FaTimes, FaTrash } from 'react-icons/fa';
|
|
3
|
+
import { FaCoins, FaInfoCircle, FaShoppingBag, FaTimes, FaTrash } from 'react-icons/fa';
|
|
4
4
|
import styled from 'styled-components';
|
|
5
5
|
import characterAtlasJSON from '../../mocks/atlas/entities/entities.json';
|
|
6
6
|
import characterAtlasIMG from '../../mocks/atlas/entities/entities.png';
|
|
7
7
|
import { CTAButton } from '../shared/CTAButton/CTAButton';
|
|
8
8
|
import { SpriteFromAtlas } from '../shared/SpriteFromAtlas';
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import { ITrustSignal, TrustBar } from './TrustBar';
|
|
10
|
+
import { PurchaseSuccess } from './PurchaseSuccess';
|
|
11
11
|
|
|
12
12
|
// Local cart item interface
|
|
13
13
|
interface ICartItem {
|
|
@@ -16,13 +16,24 @@ interface ICartItem {
|
|
|
16
16
|
metadata?: Record<string, any>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
interface ICartViewProps {
|
|
19
|
+
export interface ICartViewProps {
|
|
20
20
|
cartItems: ICartItem[];
|
|
21
21
|
onRemoveFromCart: (itemKey: string) => void;
|
|
22
22
|
onClose: () => void;
|
|
23
23
|
onPurchase: () => Promise<boolean>;
|
|
24
24
|
atlasJSON: Record<string, any>;
|
|
25
25
|
atlasIMG: string;
|
|
26
|
+
paymentMethodLabel?: string;
|
|
27
|
+
trustSignals?: ITrustSignal[];
|
|
28
|
+
onCloseStore?: () => void;
|
|
29
|
+
/** Called when user taps the "Buy DC" nudge — open wallet/DC purchase flow */
|
|
30
|
+
onBuyDC?: () => void;
|
|
31
|
+
/** Fires when user taps the pay button — before the purchase resolves */
|
|
32
|
+
onCheckoutStart?: (items: Array<{ key: string; name: string; quantity: number }>, total: number) => void;
|
|
33
|
+
/** Fires after a successful purchase */
|
|
34
|
+
onPurchaseSuccess?: (items: Array<{ key: string; name: string; quantity: number }>, total: number) => void;
|
|
35
|
+
/** Fires when a purchase fails */
|
|
36
|
+
onPurchaseError?: (error: string) => void;
|
|
26
37
|
}
|
|
27
38
|
|
|
28
39
|
const MetadataDisplay: React.FC<{
|
|
@@ -52,9 +63,17 @@ export const CartView: React.FC<ICartViewProps> = ({
|
|
|
52
63
|
onPurchase,
|
|
53
64
|
atlasJSON,
|
|
54
65
|
atlasIMG,
|
|
66
|
+
paymentMethodLabel,
|
|
67
|
+
trustSignals,
|
|
68
|
+
onCloseStore,
|
|
69
|
+
onBuyDC,
|
|
70
|
+
onCheckoutStart,
|
|
71
|
+
onPurchaseSuccess,
|
|
72
|
+
onPurchaseError,
|
|
55
73
|
}) => {
|
|
56
74
|
const [isLoading, setIsLoading] = useState(false);
|
|
57
75
|
const [error, setError] = useState<string | null>(null);
|
|
76
|
+
const [purchasedItems, setPurchasedItems] = useState<ICartItem[] | null>(null);
|
|
58
77
|
|
|
59
78
|
const total = cartItems.reduce(
|
|
60
79
|
(sum, cartItem) => sum + cartItem.item.price * cartItem.quantity,
|
|
@@ -72,23 +91,56 @@ export const CartView: React.FC<ICartViewProps> = ({
|
|
|
72
91
|
try {
|
|
73
92
|
setIsLoading(true);
|
|
74
93
|
setError(null);
|
|
94
|
+
const snapshot = [...cartItems];
|
|
95
|
+
const trackItems = snapshot.map(ci => ({ key: ci.item.key, name: ci.item.name, quantity: ci.quantity }));
|
|
96
|
+
onCheckoutStart?.(trackItems, total);
|
|
75
97
|
const success = await onPurchase();
|
|
76
98
|
|
|
77
|
-
if (
|
|
78
|
-
|
|
99
|
+
if (success) {
|
|
100
|
+
onPurchaseSuccess?.(trackItems, total);
|
|
101
|
+
setPurchasedItems(snapshot);
|
|
102
|
+
} else {
|
|
103
|
+
const errMsg = 'Purchase failed. Please try again.';
|
|
104
|
+
onPurchaseError?.(errMsg);
|
|
105
|
+
setError(errMsg);
|
|
79
106
|
}
|
|
80
107
|
} catch (err) {
|
|
81
|
-
|
|
108
|
+
const errMsg = 'An error occurred during purchase. Please try again.';
|
|
109
|
+
onPurchaseError?.(errMsg);
|
|
110
|
+
setError(errMsg);
|
|
82
111
|
console.error('Purchase error:', err);
|
|
83
112
|
} finally {
|
|
84
113
|
setIsLoading(false);
|
|
85
114
|
}
|
|
86
115
|
};
|
|
87
116
|
|
|
117
|
+
// Show DC discount nudge when items have DC pricing and user might benefit
|
|
118
|
+
const hasDCItems = cartItems.some(ci => (ci.item as any).dcPrice);
|
|
119
|
+
const showDCNudge = hasDCItems && onBuyDC;
|
|
120
|
+
|
|
121
|
+
if (purchasedItems) {
|
|
122
|
+
return (
|
|
123
|
+
<PurchaseSuccess
|
|
124
|
+
items={purchasedItems.map(ci => ({
|
|
125
|
+
name: ci.item.name,
|
|
126
|
+
texturePath: ci.item.texturePath,
|
|
127
|
+
quantity: ci.quantity,
|
|
128
|
+
metadataType: ci.item.metadataType,
|
|
129
|
+
metadata: ci.metadata,
|
|
130
|
+
}))}
|
|
131
|
+
totalPrice={purchasedItems.reduce((s, ci) => s + ci.item.price * ci.quantity, 0)}
|
|
132
|
+
atlasJSON={atlasJSON}
|
|
133
|
+
atlasIMG={atlasIMG}
|
|
134
|
+
onContinueShopping={onClose}
|
|
135
|
+
onClose={onCloseStore ?? onClose}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
88
140
|
return (
|
|
89
141
|
<Container>
|
|
90
142
|
<Header>
|
|
91
|
-
<Title>Shopping Cart</Title>
|
|
143
|
+
<Title>Shopping Cart ({cartItems.reduce((s, ci) => s + ci.quantity, 0)})</Title>
|
|
92
144
|
<CloseButton onPointerDown={onClose}>
|
|
93
145
|
<FaTimes />
|
|
94
146
|
</CloseButton>
|
|
@@ -154,9 +206,20 @@ export const CartView: React.FC<ICartViewProps> = ({
|
|
|
154
206
|
</CartItems>
|
|
155
207
|
|
|
156
208
|
<Footer>
|
|
209
|
+
{showDCNudge && (
|
|
210
|
+
<DCNudge onPointerDown={onBuyDC}>
|
|
211
|
+
<FaCoins />
|
|
212
|
+
<span>Save more with DC — volume discounts available</span>
|
|
213
|
+
<DCNudgeLink>Buy DC →</DCNudgeLink>
|
|
214
|
+
</DCNudge>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
<TrustBar signals={trustSignals} />
|
|
218
|
+
|
|
157
219
|
<TotalInfo>
|
|
220
|
+
<OrderSummaryLabel>Order Summary</OrderSummaryLabel>
|
|
158
221
|
<TotalRow>
|
|
159
|
-
<span>
|
|
222
|
+
<span>Subtotal:</span>
|
|
160
223
|
<span>${formatPrice(total)}</span>
|
|
161
224
|
</TotalRow>
|
|
162
225
|
{dcTotal > 0 && (
|
|
@@ -165,11 +228,21 @@ export const CartView: React.FC<ICartViewProps> = ({
|
|
|
165
228
|
<span>{dcTotal.toLocaleString()} DC</span>
|
|
166
229
|
</TotalRow>
|
|
167
230
|
)}
|
|
231
|
+
<TotalRow $isTotal>
|
|
232
|
+
<span>Total:</span>
|
|
233
|
+
<span>${formatPrice(total)}</span>
|
|
234
|
+
</TotalRow>
|
|
235
|
+
{paymentMethodLabel && (
|
|
236
|
+
<PaymentMethodRow>
|
|
237
|
+
<span>Paying with:</span>
|
|
238
|
+
<span>{paymentMethodLabel}</span>
|
|
239
|
+
</PaymentMethodRow>
|
|
240
|
+
)}
|
|
168
241
|
{error && <ErrorMessage>{error}</ErrorMessage>}
|
|
169
242
|
</TotalInfo>
|
|
170
243
|
<CTAButton
|
|
171
244
|
icon={<FaShoppingBag />}
|
|
172
|
-
label={isLoading ? 'Processing...' :
|
|
245
|
+
label={isLoading ? 'Processing...' : `Pay $${formatPrice(total)}`}
|
|
173
246
|
onClick={handlePurchase}
|
|
174
247
|
fullWidth
|
|
175
248
|
disabled={cartItems.length === 0 || isLoading}
|
|
@@ -310,20 +383,77 @@ const TotalInfo = styled.div`
|
|
|
310
383
|
gap: 0.5rem;
|
|
311
384
|
`;
|
|
312
385
|
|
|
313
|
-
const
|
|
386
|
+
const OrderSummaryLabel = styled.div`
|
|
387
|
+
font-family: 'Press Start 2P', cursive;
|
|
388
|
+
font-size: 0.55rem;
|
|
389
|
+
color: rgba(255, 255, 255, 0.5);
|
|
390
|
+
text-transform: uppercase;
|
|
391
|
+
letter-spacing: 0.05em;
|
|
392
|
+
margin-bottom: 0.25rem;
|
|
393
|
+
`;
|
|
394
|
+
|
|
395
|
+
const TotalRow = styled.div<{ $isTotal?: boolean }>`
|
|
314
396
|
display: flex;
|
|
315
397
|
align-items: center;
|
|
316
398
|
justify-content: space-between;
|
|
317
399
|
gap: 1rem;
|
|
318
400
|
font-family: 'Press Start 2P', cursive;
|
|
319
|
-
font-size: 1rem;
|
|
320
|
-
color: #ffffff;
|
|
401
|
+
font-size: ${p => p.$isTotal ? '1rem' : '0.75rem'};
|
|
402
|
+
color: ${p => p.$isTotal ? '#ffffff' : 'rgba(255,255,255,0.7)'};
|
|
403
|
+
${p => p.$isTotal && `
|
|
404
|
+
padding-top: 0.5rem;
|
|
405
|
+
border-top: 1px solid rgba(255,255,255,0.15);
|
|
406
|
+
margin-top: 0.25rem;
|
|
407
|
+
`}
|
|
321
408
|
|
|
322
409
|
span:last-child {
|
|
323
410
|
color: #fef08a;
|
|
324
411
|
}
|
|
325
412
|
`;
|
|
326
413
|
|
|
414
|
+
const PaymentMethodRow = styled.div`
|
|
415
|
+
display: flex;
|
|
416
|
+
align-items: center;
|
|
417
|
+
justify-content: space-between;
|
|
418
|
+
gap: 1rem;
|
|
419
|
+
font-family: 'Press Start 2P', cursive;
|
|
420
|
+
font-size: 0.5rem;
|
|
421
|
+
color: rgba(255, 255, 255, 0.5);
|
|
422
|
+
margin-top: 0.25rem;
|
|
423
|
+
|
|
424
|
+
span:last-child {
|
|
425
|
+
color: rgba(255, 255, 255, 0.8);
|
|
426
|
+
}
|
|
427
|
+
`;
|
|
428
|
+
|
|
429
|
+
const DCNudge = styled.div`
|
|
430
|
+
display: flex;
|
|
431
|
+
align-items: center;
|
|
432
|
+
gap: 0.5rem;
|
|
433
|
+
padding: 0.5rem 0.75rem;
|
|
434
|
+
background: rgba(245, 158, 11, 0.1);
|
|
435
|
+
border: 1px solid rgba(245, 158, 11, 0.3);
|
|
436
|
+
border-radius: 4px;
|
|
437
|
+
cursor: pointer;
|
|
438
|
+
font-family: 'Press Start 2P', cursive;
|
|
439
|
+
font-size: 0.45rem;
|
|
440
|
+
color: #fbbf24;
|
|
441
|
+
transition: background 0.15s;
|
|
442
|
+
|
|
443
|
+
&:hover {
|
|
444
|
+
background: rgba(245, 158, 11, 0.18);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
svg { flex-shrink: 0; font-size: 0.7rem; }
|
|
448
|
+
span { flex: 1; }
|
|
449
|
+
`;
|
|
450
|
+
|
|
451
|
+
const DCNudgeLink = styled.span`
|
|
452
|
+
color: #f59e0b;
|
|
453
|
+
white-space: nowrap;
|
|
454
|
+
text-decoration: underline;
|
|
455
|
+
`;
|
|
456
|
+
|
|
327
457
|
const ErrorMessage = styled.div`
|
|
328
458
|
color: #ef4444;
|
|
329
459
|
font-size: 0.875rem;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import styled, { keyframes } from 'styled-components';
|
|
3
|
+
|
|
4
|
+
export interface ICountdownTimerProps {
|
|
5
|
+
endsAt: string;
|
|
6
|
+
onExpired?: () => void;
|
|
7
|
+
size?: 'small' | 'default';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ITimeLeft {
|
|
11
|
+
days: number;
|
|
12
|
+
hours: number;
|
|
13
|
+
minutes: number;
|
|
14
|
+
seconds: number;
|
|
15
|
+
expired: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function calcTimeLeft(endsAt: string): ITimeLeft {
|
|
19
|
+
const diff = new Date(endsAt).getTime() - Date.now();
|
|
20
|
+
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, expired: true };
|
|
21
|
+
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
22
|
+
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
23
|
+
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
|
24
|
+
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
|
25
|
+
return { days, hours, minutes, seconds, expired: false };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const CountdownTimer: React.FC<ICountdownTimerProps> = ({ endsAt, onExpired, size = 'default' }) => {
|
|
29
|
+
const [timeLeft, setTimeLeft] = useState<ITimeLeft>(() => calcTimeLeft(endsAt));
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (timeLeft.expired) {
|
|
33
|
+
onExpired?.();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const id = setInterval(() => {
|
|
37
|
+
const next = calcTimeLeft(endsAt);
|
|
38
|
+
setTimeLeft(next);
|
|
39
|
+
if (next.expired) {
|
|
40
|
+
clearInterval(id);
|
|
41
|
+
onExpired?.();
|
|
42
|
+
}
|
|
43
|
+
}, 1000);
|
|
44
|
+
return () => clearInterval(id);
|
|
45
|
+
}, [endsAt, onExpired]);
|
|
46
|
+
|
|
47
|
+
if (timeLeft.expired) {
|
|
48
|
+
return <ExpiredLabel $size={size}>EXPIRED</ExpiredLabel>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const parts: string[] = [];
|
|
52
|
+
if (timeLeft.days > 0) parts.push(`${timeLeft.days}d`);
|
|
53
|
+
parts.push(`${String(timeLeft.hours).padStart(2, '0')}h`);
|
|
54
|
+
parts.push(`${String(timeLeft.minutes).padStart(2, '0')}m`);
|
|
55
|
+
if (timeLeft.days === 0) parts.push(`${String(timeLeft.seconds).padStart(2, '0')}s`);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<TimerLabel $size={size}>
|
|
59
|
+
⏱ {parts.join(' ')}
|
|
60
|
+
</TimerLabel>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const pulse = keyframes`
|
|
65
|
+
0%, 100% { opacity: 1; }
|
|
66
|
+
50% { opacity: 0.6; }
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
const baseLabel = `
|
|
70
|
+
font-family: 'Press Start 2P', cursive;
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
const TimerLabel = styled.span<{ $size: 'small' | 'default' }>`
|
|
76
|
+
${baseLabel}
|
|
77
|
+
font-size: ${p => p.$size === 'small' ? '0.45rem' : '0.6rem'};
|
|
78
|
+
color: #fbbf24;
|
|
79
|
+
animation: ${pulse} 2s ease-in-out infinite;
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
const ExpiredLabel = styled.span<{ $size: 'small' | 'default' }>`
|
|
83
|
+
${baseLabel}
|
|
84
|
+
font-size: ${p => p.$size === 'small' ? '0.45rem' : '0.6rem'};
|
|
85
|
+
color: #6b7280;
|
|
86
|
+
`;
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { FaBolt } from 'react-icons/fa';
|
|
3
|
+
import styled from 'styled-components';
|
|
4
|
+
import { uiColors } from '../../constants/uiColors';
|
|
5
|
+
import { CTAButton } from '../shared/CTAButton/CTAButton';
|
|
6
|
+
import { LabelPill } from '../shared/LabelPill/LabelPill';
|
|
7
|
+
import { SpriteFromAtlas } from '../shared/SpriteFromAtlas';
|
|
8
|
+
import { CountdownTimer } from './CountdownTimer';
|
|
9
|
+
|
|
10
|
+
export interface IFeaturedItem {
|
|
11
|
+
key: string;
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
imageUrl?: string | { src: string; default?: string };
|
|
15
|
+
texturePath?: string;
|
|
16
|
+
price: number;
|
|
17
|
+
originalPrice?: number;
|
|
18
|
+
endsAt?: string;
|
|
19
|
+
badge?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface IFeaturedBannerProps {
|
|
23
|
+
items: IFeaturedItem[];
|
|
24
|
+
atlasJSON?: any;
|
|
25
|
+
atlasIMG?: string;
|
|
26
|
+
onSelectItem: (item: IFeaturedItem) => void;
|
|
27
|
+
onQuickBuy?: (item: IFeaturedItem) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const BADGE_COLORS: Record<string, { bg: string; border: string; color: string }> = {
|
|
31
|
+
SALE: { bg: uiColors.orange, border: uiColors.orange, color: '#fff' },
|
|
32
|
+
NEW: { bg: uiColors.green, border: uiColors.green, color: '#fff' },
|
|
33
|
+
LIMITED: { bg: uiColors.cardinal, border: uiColors.cardinal, color: '#fff' },
|
|
34
|
+
POPULAR: { bg: uiColors.navyBlue, border: uiColors.navyBlue, color: '#fff' },
|
|
35
|
+
EVENT: { bg: uiColors.purple, border: uiColors.purple, color: '#fff' },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function getImageSrc(imageUrl?: string | { src: string; default?: string }): string | undefined {
|
|
39
|
+
if (!imageUrl) return undefined;
|
|
40
|
+
if (typeof imageUrl === 'string') return imageUrl;
|
|
41
|
+
return imageUrl.default || imageUrl.src;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const FeaturedBanner: React.FC<IFeaturedBannerProps> = ({
|
|
45
|
+
items,
|
|
46
|
+
atlasJSON,
|
|
47
|
+
atlasIMG,
|
|
48
|
+
onSelectItem,
|
|
49
|
+
onQuickBuy,
|
|
50
|
+
}) => {
|
|
51
|
+
if (!items.length) return null;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<BannerWrapper>
|
|
55
|
+
<BannerHeader>
|
|
56
|
+
<FaBolt />
|
|
57
|
+
<span>FEATURED OFFERS</span>
|
|
58
|
+
</BannerHeader>
|
|
59
|
+
<CardsRow>
|
|
60
|
+
{items.map(item => {
|
|
61
|
+
const badgeStyle = item.badge ? (BADGE_COLORS[item.badge.toUpperCase()] ?? BADGE_COLORS.SALE) : null;
|
|
62
|
+
const imgSrc = getImageSrc(item.imageUrl);
|
|
63
|
+
const canUseAtlas = atlasJSON && atlasIMG && item.texturePath && atlasJSON?.frames?.[item.texturePath];
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<FeaturedCard key={item.key} onClick={() => onSelectItem(item)}>
|
|
67
|
+
<CardImageArea>
|
|
68
|
+
{canUseAtlas ? (
|
|
69
|
+
<SpriteFromAtlas
|
|
70
|
+
atlasJSON={atlasJSON}
|
|
71
|
+
atlasIMG={atlasIMG}
|
|
72
|
+
spriteKey={item.texturePath!}
|
|
73
|
+
width={72}
|
|
74
|
+
height={72}
|
|
75
|
+
imgScale={2}
|
|
76
|
+
centered
|
|
77
|
+
/>
|
|
78
|
+
) : imgSrc ? (
|
|
79
|
+
<img src={imgSrc} alt={item.name} />
|
|
80
|
+
) : (
|
|
81
|
+
<PlaceholderIcon>?</PlaceholderIcon>
|
|
82
|
+
)}
|
|
83
|
+
{badgeStyle && (
|
|
84
|
+
<BadgeOverlay>
|
|
85
|
+
<LabelPill
|
|
86
|
+
background={badgeStyle.bg}
|
|
87
|
+
borderColor={badgeStyle.border}
|
|
88
|
+
color={badgeStyle.color}
|
|
89
|
+
>
|
|
90
|
+
{item.badge}
|
|
91
|
+
</LabelPill>
|
|
92
|
+
</BadgeOverlay>
|
|
93
|
+
)}
|
|
94
|
+
</CardImageArea>
|
|
95
|
+
|
|
96
|
+
<CardBody>
|
|
97
|
+
<CardName>{item.name}</CardName>
|
|
98
|
+
{item.description && <CardDesc>{item.description}</CardDesc>}
|
|
99
|
+
|
|
100
|
+
<PriceRow>
|
|
101
|
+
{item.originalPrice != null && (
|
|
102
|
+
<OriginalPrice>${item.originalPrice.toFixed(2)}</OriginalPrice>
|
|
103
|
+
)}
|
|
104
|
+
<CurrentPrice $onSale={item.originalPrice != null}>
|
|
105
|
+
${item.price.toFixed(2)}
|
|
106
|
+
</CurrentPrice>
|
|
107
|
+
</PriceRow>
|
|
108
|
+
|
|
109
|
+
{item.endsAt && (
|
|
110
|
+
<CountdownTimer endsAt={item.endsAt} size="small" />
|
|
111
|
+
)}
|
|
112
|
+
</CardBody>
|
|
113
|
+
|
|
114
|
+
<CardActions onClick={e => e.stopPropagation()}>
|
|
115
|
+
{onQuickBuy && (
|
|
116
|
+
<CTAButton
|
|
117
|
+
icon={<FaBolt />}
|
|
118
|
+
label="Buy Now"
|
|
119
|
+
onClick={() => onQuickBuy(item)}
|
|
120
|
+
/>
|
|
121
|
+
)}
|
|
122
|
+
</CardActions>
|
|
123
|
+
</FeaturedCard>
|
|
124
|
+
);
|
|
125
|
+
})}
|
|
126
|
+
</CardsRow>
|
|
127
|
+
</BannerWrapper>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const BannerWrapper = styled.div`
|
|
132
|
+
padding: 0.5rem 1rem 0.75rem;
|
|
133
|
+
border-bottom: 1px solid rgba(245, 158, 11, 0.3);
|
|
134
|
+
background: linear-gradient(180deg, rgba(245, 158, 11, 0.06) 0%, transparent 100%);
|
|
135
|
+
`;
|
|
136
|
+
|
|
137
|
+
const BannerHeader = styled.div`
|
|
138
|
+
display: flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: 0.4rem;
|
|
141
|
+
font-family: 'Press Start 2P', cursive;
|
|
142
|
+
font-size: 0.55rem;
|
|
143
|
+
color: #f59e0b;
|
|
144
|
+
margin-bottom: 0.6rem;
|
|
145
|
+
|
|
146
|
+
svg { font-size: 0.7rem; }
|
|
147
|
+
`;
|
|
148
|
+
|
|
149
|
+
const CardsRow = styled.div`
|
|
150
|
+
display: flex;
|
|
151
|
+
gap: 0.75rem;
|
|
152
|
+
overflow-x: auto;
|
|
153
|
+
padding-bottom: 0.25rem;
|
|
154
|
+
|
|
155
|
+
&::-webkit-scrollbar { height: 4px; background: rgba(0,0,0,0.2); }
|
|
156
|
+
&::-webkit-scrollbar-thumb { background: rgba(245,158,11,0.3); border-radius: 2px; }
|
|
157
|
+
|
|
158
|
+
@media (max-width: 950px) {
|
|
159
|
+
flex-direction: column;
|
|
160
|
+
overflow-x: unset;
|
|
161
|
+
}
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const FeaturedCard = styled.div`
|
|
165
|
+
flex: 0 0 auto;
|
|
166
|
+
width: 200px;
|
|
167
|
+
display: flex;
|
|
168
|
+
flex-direction: column;
|
|
169
|
+
background: rgba(0, 0, 0, 0.35);
|
|
170
|
+
border: 1px solid rgba(245, 158, 11, 0.35);
|
|
171
|
+
border-radius: 6px;
|
|
172
|
+
overflow: hidden;
|
|
173
|
+
cursor: pointer;
|
|
174
|
+
transition: border-color 0.2s, transform 0.15s;
|
|
175
|
+
|
|
176
|
+
&:hover {
|
|
177
|
+
border-color: #f59e0b;
|
|
178
|
+
transform: translateY(-2px);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
@media (max-width: 950px) {
|
|
182
|
+
width: 100%;
|
|
183
|
+
flex-direction: row;
|
|
184
|
+
}
|
|
185
|
+
`;
|
|
186
|
+
|
|
187
|
+
const CardImageArea = styled.div`
|
|
188
|
+
position: relative;
|
|
189
|
+
width: 100%;
|
|
190
|
+
height: 88px;
|
|
191
|
+
background: rgba(0, 0, 0, 0.3);
|
|
192
|
+
display: flex;
|
|
193
|
+
align-items: center;
|
|
194
|
+
justify-content: center;
|
|
195
|
+
flex-shrink: 0;
|
|
196
|
+
|
|
197
|
+
img {
|
|
198
|
+
width: 100%;
|
|
199
|
+
height: 100%;
|
|
200
|
+
object-fit: cover;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
@media (max-width: 950px) {
|
|
204
|
+
width: 88px;
|
|
205
|
+
height: 88px;
|
|
206
|
+
}
|
|
207
|
+
`;
|
|
208
|
+
|
|
209
|
+
const BadgeOverlay = styled.div`
|
|
210
|
+
position: absolute;
|
|
211
|
+
top: 4px;
|
|
212
|
+
left: 4px;
|
|
213
|
+
`;
|
|
214
|
+
|
|
215
|
+
const PlaceholderIcon = styled.div`
|
|
216
|
+
font-family: 'Press Start 2P', cursive;
|
|
217
|
+
font-size: 1.5rem;
|
|
218
|
+
color: rgba(255,255,255,0.2);
|
|
219
|
+
`;
|
|
220
|
+
|
|
221
|
+
const CardBody = styled.div`
|
|
222
|
+
padding: 0.5rem 0.6rem 0.25rem;
|
|
223
|
+
display: flex;
|
|
224
|
+
flex-direction: column;
|
|
225
|
+
gap: 0.3rem;
|
|
226
|
+
flex: 1;
|
|
227
|
+
`;
|
|
228
|
+
|
|
229
|
+
const CardName = styled.div`
|
|
230
|
+
font-family: 'Press Start 2P', cursive;
|
|
231
|
+
font-size: 0.55rem;
|
|
232
|
+
color: #fff;
|
|
233
|
+
white-space: nowrap;
|
|
234
|
+
overflow: hidden;
|
|
235
|
+
text-overflow: ellipsis;
|
|
236
|
+
`;
|
|
237
|
+
|
|
238
|
+
const CardDesc = styled.div`
|
|
239
|
+
font-family: 'Press Start 2P', cursive;
|
|
240
|
+
font-size: 0.45rem;
|
|
241
|
+
color: rgba(255,255,255,0.6);
|
|
242
|
+
line-height: 1.4;
|
|
243
|
+
overflow: hidden;
|
|
244
|
+
display: -webkit-box;
|
|
245
|
+
-webkit-line-clamp: 2;
|
|
246
|
+
-webkit-box-orient: vertical;
|
|
247
|
+
`;
|
|
248
|
+
|
|
249
|
+
const PriceRow = styled.div`
|
|
250
|
+
display: flex;
|
|
251
|
+
align-items: center;
|
|
252
|
+
gap: 0.4rem;
|
|
253
|
+
`;
|
|
254
|
+
|
|
255
|
+
const OriginalPrice = styled.span`
|
|
256
|
+
font-family: 'Press Start 2P', cursive;
|
|
257
|
+
font-size: 0.45rem;
|
|
258
|
+
color: rgba(255,255,255,0.4);
|
|
259
|
+
text-decoration: line-through;
|
|
260
|
+
`;
|
|
261
|
+
|
|
262
|
+
const CurrentPrice = styled.span<{ $onSale: boolean }>`
|
|
263
|
+
font-family: 'Press Start 2P', cursive;
|
|
264
|
+
font-size: 0.6rem;
|
|
265
|
+
color: ${p => p.$onSale ? '#4ade80' : '#fef08a'};
|
|
266
|
+
`;
|
|
267
|
+
|
|
268
|
+
const CardActions = styled.div`
|
|
269
|
+
padding: 0.4rem 0.6rem 0.6rem;
|
|
270
|
+
display: flex;
|
|
271
|
+
gap: 0.4rem;
|
|
272
|
+
justify-content: flex-end;
|
|
273
|
+
`;
|