@rpg-engine/long-bow 0.8.171 → 0.8.173
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 +1279 -303
- 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 +1276 -305
- 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/RPGUI/RPGUIScrollbar.tsx +2 -2
- package/src/components/Store/CartView.tsx +143 -33
- package/src/components/Store/CountdownTimer.tsx +86 -0
- package/src/components/Store/FeaturedBanner.tsx +270 -0
- package/src/components/Store/PurchaseSuccess.tsx +255 -0
- package/src/components/Store/Store.tsx +236 -51
- 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 +1 -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.173",
|
|
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
|
}
|
|
@@ -14,11 +14,11 @@ const Container = styled.div`
|
|
|
14
14
|
|
|
15
15
|
.rpgui-content ::-webkit-scrollbar,
|
|
16
16
|
.rpgui-content::-webkit-scrollbar {
|
|
17
|
-
width:
|
|
17
|
+
width: 12px !important;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
.rpgui-content ::-webkit-scrollbar-track,
|
|
21
21
|
.rpgui-content::-webkit-scrollbar-track {
|
|
22
|
-
background-size:
|
|
22
|
+
background-size: 12px 60px !important;
|
|
23
23
|
}
|
|
24
24
|
`;
|
|
@@ -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}
|
|
@@ -221,26 +294,6 @@ const CartItems = styled.div`
|
|
|
221
294
|
flex: 1;
|
|
222
295
|
min-height: 200px;
|
|
223
296
|
padding-right: 0.5rem;
|
|
224
|
-
|
|
225
|
-
/* Custom scrollbar styling */
|
|
226
|
-
&::-webkit-scrollbar {
|
|
227
|
-
width: 8px;
|
|
228
|
-
background-color: rgba(0, 0, 0, 0.2);
|
|
229
|
-
border-radius: 4px;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
&::-webkit-scrollbar-thumb {
|
|
233
|
-
background-color: rgba(255, 255, 255, 0.2);
|
|
234
|
-
border-radius: 4px;
|
|
235
|
-
|
|
236
|
-
&:hover {
|
|
237
|
-
background-color: rgba(255, 255, 255, 0.3);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/* Firefox scrollbar styling */
|
|
242
|
-
scrollbar-width: thin;
|
|
243
|
-
scrollbar-color: rgba(255, 255, 255, 0.2) rgba(0, 0, 0, 0.2);
|
|
244
297
|
`;
|
|
245
298
|
|
|
246
299
|
const EmptyCart = styled.div`
|
|
@@ -310,20 +363,77 @@ const TotalInfo = styled.div`
|
|
|
310
363
|
gap: 0.5rem;
|
|
311
364
|
`;
|
|
312
365
|
|
|
313
|
-
const
|
|
366
|
+
const OrderSummaryLabel = styled.div`
|
|
367
|
+
font-family: 'Press Start 2P', cursive;
|
|
368
|
+
font-size: 0.55rem;
|
|
369
|
+
color: rgba(255, 255, 255, 0.5);
|
|
370
|
+
text-transform: uppercase;
|
|
371
|
+
letter-spacing: 0.05em;
|
|
372
|
+
margin-bottom: 0.25rem;
|
|
373
|
+
`;
|
|
374
|
+
|
|
375
|
+
const TotalRow = styled.div<{ $isTotal?: boolean }>`
|
|
314
376
|
display: flex;
|
|
315
377
|
align-items: center;
|
|
316
378
|
justify-content: space-between;
|
|
317
379
|
gap: 1rem;
|
|
318
380
|
font-family: 'Press Start 2P', cursive;
|
|
319
|
-
font-size: 1rem;
|
|
320
|
-
color: #ffffff;
|
|
381
|
+
font-size: ${p => p.$isTotal ? '1rem' : '0.75rem'};
|
|
382
|
+
color: ${p => p.$isTotal ? '#ffffff' : 'rgba(255,255,255,0.7)'};
|
|
383
|
+
${p => p.$isTotal && `
|
|
384
|
+
padding-top: 0.5rem;
|
|
385
|
+
border-top: 1px solid rgba(255,255,255,0.15);
|
|
386
|
+
margin-top: 0.25rem;
|
|
387
|
+
`}
|
|
321
388
|
|
|
322
389
|
span:last-child {
|
|
323
390
|
color: #fef08a;
|
|
324
391
|
}
|
|
325
392
|
`;
|
|
326
393
|
|
|
394
|
+
const PaymentMethodRow = styled.div`
|
|
395
|
+
display: flex;
|
|
396
|
+
align-items: center;
|
|
397
|
+
justify-content: space-between;
|
|
398
|
+
gap: 1rem;
|
|
399
|
+
font-family: 'Press Start 2P', cursive;
|
|
400
|
+
font-size: 0.5rem;
|
|
401
|
+
color: rgba(255, 255, 255, 0.5);
|
|
402
|
+
margin-top: 0.25rem;
|
|
403
|
+
|
|
404
|
+
span:last-child {
|
|
405
|
+
color: rgba(255, 255, 255, 0.8);
|
|
406
|
+
}
|
|
407
|
+
`;
|
|
408
|
+
|
|
409
|
+
const DCNudge = styled.div`
|
|
410
|
+
display: flex;
|
|
411
|
+
align-items: center;
|
|
412
|
+
gap: 0.5rem;
|
|
413
|
+
padding: 0.5rem 0.75rem;
|
|
414
|
+
background: rgba(245, 158, 11, 0.1);
|
|
415
|
+
border: 1px solid rgba(245, 158, 11, 0.3);
|
|
416
|
+
border-radius: 4px;
|
|
417
|
+
cursor: pointer;
|
|
418
|
+
font-family: 'Press Start 2P', cursive;
|
|
419
|
+
font-size: 0.45rem;
|
|
420
|
+
color: #fbbf24;
|
|
421
|
+
transition: background 0.15s;
|
|
422
|
+
|
|
423
|
+
&:hover {
|
|
424
|
+
background: rgba(245, 158, 11, 0.18);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
svg { flex-shrink: 0; font-size: 0.7rem; }
|
|
428
|
+
span { flex: 1; }
|
|
429
|
+
`;
|
|
430
|
+
|
|
431
|
+
const DCNudgeLink = styled.span`
|
|
432
|
+
color: #f59e0b;
|
|
433
|
+
white-space: nowrap;
|
|
434
|
+
text-decoration: underline;
|
|
435
|
+
`;
|
|
436
|
+
|
|
327
437
|
const ErrorMessage = styled.div`
|
|
328
438
|
color: #ef4444;
|
|
329
439
|
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,270 @@
|
|
|
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
|
+
@media (max-width: 950px) {
|
|
156
|
+
flex-direction: column;
|
|
157
|
+
overflow-x: unset;
|
|
158
|
+
}
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
const FeaturedCard = styled.div`
|
|
162
|
+
flex: 0 0 auto;
|
|
163
|
+
width: 200px;
|
|
164
|
+
display: flex;
|
|
165
|
+
flex-direction: column;
|
|
166
|
+
background: rgba(0, 0, 0, 0.35);
|
|
167
|
+
border: 1px solid rgba(245, 158, 11, 0.35);
|
|
168
|
+
border-radius: 6px;
|
|
169
|
+
overflow: hidden;
|
|
170
|
+
cursor: pointer;
|
|
171
|
+
transition: border-color 0.2s, transform 0.15s;
|
|
172
|
+
|
|
173
|
+
&:hover {
|
|
174
|
+
border-color: #f59e0b;
|
|
175
|
+
transform: translateY(-2px);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
@media (max-width: 950px) {
|
|
179
|
+
width: 100%;
|
|
180
|
+
flex-direction: row;
|
|
181
|
+
}
|
|
182
|
+
`;
|
|
183
|
+
|
|
184
|
+
const CardImageArea = styled.div`
|
|
185
|
+
position: relative;
|
|
186
|
+
width: 100%;
|
|
187
|
+
height: 88px;
|
|
188
|
+
background: rgba(0, 0, 0, 0.3);
|
|
189
|
+
display: flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
justify-content: center;
|
|
192
|
+
flex-shrink: 0;
|
|
193
|
+
|
|
194
|
+
img {
|
|
195
|
+
width: 100%;
|
|
196
|
+
height: 100%;
|
|
197
|
+
object-fit: cover;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@media (max-width: 950px) {
|
|
201
|
+
width: 88px;
|
|
202
|
+
height: 88px;
|
|
203
|
+
}
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
const BadgeOverlay = styled.div`
|
|
207
|
+
position: absolute;
|
|
208
|
+
top: 4px;
|
|
209
|
+
left: 4px;
|
|
210
|
+
`;
|
|
211
|
+
|
|
212
|
+
const PlaceholderIcon = styled.div`
|
|
213
|
+
font-family: 'Press Start 2P', cursive;
|
|
214
|
+
font-size: 1.5rem;
|
|
215
|
+
color: rgba(255,255,255,0.2);
|
|
216
|
+
`;
|
|
217
|
+
|
|
218
|
+
const CardBody = styled.div`
|
|
219
|
+
padding: 0.5rem 0.6rem 0.25rem;
|
|
220
|
+
display: flex;
|
|
221
|
+
flex-direction: column;
|
|
222
|
+
gap: 0.3rem;
|
|
223
|
+
flex: 1;
|
|
224
|
+
`;
|
|
225
|
+
|
|
226
|
+
const CardName = styled.div`
|
|
227
|
+
font-family: 'Press Start 2P', cursive;
|
|
228
|
+
font-size: 0.55rem;
|
|
229
|
+
color: #fff;
|
|
230
|
+
white-space: nowrap;
|
|
231
|
+
overflow: hidden;
|
|
232
|
+
text-overflow: ellipsis;
|
|
233
|
+
`;
|
|
234
|
+
|
|
235
|
+
const CardDesc = styled.div`
|
|
236
|
+
font-family: 'Press Start 2P', cursive;
|
|
237
|
+
font-size: 0.45rem;
|
|
238
|
+
color: rgba(255,255,255,0.6);
|
|
239
|
+
line-height: 1.4;
|
|
240
|
+
overflow: hidden;
|
|
241
|
+
display: -webkit-box;
|
|
242
|
+
-webkit-line-clamp: 2;
|
|
243
|
+
-webkit-box-orient: vertical;
|
|
244
|
+
`;
|
|
245
|
+
|
|
246
|
+
const PriceRow = styled.div`
|
|
247
|
+
display: flex;
|
|
248
|
+
align-items: center;
|
|
249
|
+
gap: 0.4rem;
|
|
250
|
+
`;
|
|
251
|
+
|
|
252
|
+
const OriginalPrice = styled.span`
|
|
253
|
+
font-family: 'Press Start 2P', cursive;
|
|
254
|
+
font-size: 0.45rem;
|
|
255
|
+
color: rgba(255,255,255,0.4);
|
|
256
|
+
text-decoration: line-through;
|
|
257
|
+
`;
|
|
258
|
+
|
|
259
|
+
const CurrentPrice = styled.span<{ $onSale: boolean }>`
|
|
260
|
+
font-family: 'Press Start 2P', cursive;
|
|
261
|
+
font-size: 0.6rem;
|
|
262
|
+
color: ${p => p.$onSale ? '#4ade80' : '#fef08a'};
|
|
263
|
+
`;
|
|
264
|
+
|
|
265
|
+
const CardActions = styled.div`
|
|
266
|
+
padding: 0.4rem 0.6rem 0.6rem;
|
|
267
|
+
display: flex;
|
|
268
|
+
gap: 0.4rem;
|
|
269
|
+
justify-content: flex-end;
|
|
270
|
+
`;
|