@dubsdotapp/expo 0.1.2 → 0.2.0
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/README.md +80 -19
- package/dist/index.d.mts +191 -57
- package/dist/index.d.ts +191 -57
- package/dist/index.js +1430 -507
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1484 -556
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -2
- package/src/auth-context.ts +9 -0
- package/src/client.ts +21 -1
- package/src/constants.ts +15 -0
- package/src/hooks/useAuth.ts +16 -4
- package/src/hooks/useClaim.ts +11 -9
- package/src/hooks/useCreateGame.ts +15 -12
- package/src/hooks/useJoinGame.ts +18 -14
- package/src/index.ts +20 -2
- package/src/managed-wallet.tsx +148 -0
- package/src/provider.tsx +228 -9
- package/src/storage.ts +57 -0
- package/src/types.ts +39 -4
- package/src/ui/AuthGate.tsx +407 -308
- package/src/ui/game/GamePoster.tsx +182 -0
- package/src/ui/game/JoinGameButton.tsx +73 -0
- package/src/ui/game/LivePoolsCard.tsx +88 -0
- package/src/ui/game/PickWinnerCard.tsx +126 -0
- package/src/ui/game/PlayersCard.tsx +108 -0
- package/src/ui/game/index.ts +10 -0
- package/src/ui/index.ts +10 -0
- package/src/utils/transaction.ts +8 -49
- package/src/wallet/mwa-adapter.ts +9 -6
- package/src/wallet/types.ts +6 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { StyleSheet, View, Text } from 'react-native';
|
|
3
|
+
import { useDubsTheme } from '../theme';
|
|
4
|
+
import type { GameDetail } from '../../types';
|
|
5
|
+
|
|
6
|
+
export interface GamePosterProps {
|
|
7
|
+
game: GameDetail;
|
|
8
|
+
/** Optional Image component — pass expo-image or RN Image. Defaults to RN Image. */
|
|
9
|
+
ImageComponent?: React.ComponentType<any>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function computeCountdown(lockTimestamp: number | string | null): string {
|
|
13
|
+
if (!lockTimestamp) return '';
|
|
14
|
+
const ts = typeof lockTimestamp === 'string' ? parseInt(lockTimestamp) : lockTimestamp;
|
|
15
|
+
const diff = ts * 1000 - Date.now();
|
|
16
|
+
if (diff <= 0) return 'LIVE';
|
|
17
|
+
const days = Math.floor(diff / 86400000);
|
|
18
|
+
const hours = Math.floor((diff % 86400000) / 3600000);
|
|
19
|
+
const mins = Math.floor((diff % 3600000) / 60000);
|
|
20
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
21
|
+
if (hours > 0) return `${hours}h ${mins}m`;
|
|
22
|
+
return `${mins}m`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Matchup poster hero widget.
|
|
27
|
+
* Shows the game poster image (or team logo fallback), countdown pill, and pool total.
|
|
28
|
+
*/
|
|
29
|
+
export function GamePoster({ game, ImageComponent }: GamePosterProps) {
|
|
30
|
+
const t = useDubsTheme();
|
|
31
|
+
const Img = ImageComponent || require('react-native').Image;
|
|
32
|
+
|
|
33
|
+
const opponents = game.opponents || [];
|
|
34
|
+
const home = opponents[0];
|
|
35
|
+
const away = opponents[1];
|
|
36
|
+
const countdown = computeCountdown(game.lockTimestamp);
|
|
37
|
+
const isLive = countdown === 'LIVE';
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View style={styles.container}>
|
|
41
|
+
{game.media?.poster ? (
|
|
42
|
+
<Img
|
|
43
|
+
source={{ uri: game.media.poster }}
|
|
44
|
+
style={styles.image}
|
|
45
|
+
resizeMode="cover"
|
|
46
|
+
/>
|
|
47
|
+
) : (
|
|
48
|
+
<View style={[styles.image, { backgroundColor: t.surface }]}>
|
|
49
|
+
<View style={styles.fallback}>
|
|
50
|
+
<TeamLogoInternal url={home?.imageUrl} size={56} Img={Img} />
|
|
51
|
+
<Text style={styles.vs}>VS</Text>
|
|
52
|
+
<TeamLogoInternal url={away?.imageUrl} size={56} Img={Img} />
|
|
53
|
+
</View>
|
|
54
|
+
</View>
|
|
55
|
+
)}
|
|
56
|
+
<View style={styles.overlay} />
|
|
57
|
+
|
|
58
|
+
<View style={styles.teamNames}>
|
|
59
|
+
<Text style={styles.teamNameText} numberOfLines={1}>
|
|
60
|
+
{home?.name || 'Home'}
|
|
61
|
+
</Text>
|
|
62
|
+
<Text style={styles.teamNameVs}>vs</Text>
|
|
63
|
+
<Text style={styles.teamNameText} numberOfLines={1}>
|
|
64
|
+
{away?.name || 'Away'}
|
|
65
|
+
</Text>
|
|
66
|
+
</View>
|
|
67
|
+
|
|
68
|
+
{countdown ? (
|
|
69
|
+
<View style={styles.countdownPill}>
|
|
70
|
+
<Text style={[styles.countdownText, isLive && styles.countdownLive]}>
|
|
71
|
+
{countdown}
|
|
72
|
+
</Text>
|
|
73
|
+
</View>
|
|
74
|
+
) : null}
|
|
75
|
+
|
|
76
|
+
<View style={styles.poolPill}>
|
|
77
|
+
<Text style={styles.poolText}>{game.totalPool || 0} SOL</Text>
|
|
78
|
+
</View>
|
|
79
|
+
</View>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function TeamLogoInternal({ url, size, Img }: { url: string | null | undefined; size: number; Img: any }) {
|
|
84
|
+
const [failed, setFailed] = useState(false);
|
|
85
|
+
if (!url || failed) {
|
|
86
|
+
return <View style={[styles.logoPlaceholder, { width: size, height: size, borderRadius: size / 2 }]} />;
|
|
87
|
+
}
|
|
88
|
+
return (
|
|
89
|
+
<Img
|
|
90
|
+
source={{ uri: url }}
|
|
91
|
+
style={{ width: size, height: size, borderRadius: size / 2 }}
|
|
92
|
+
resizeMode="contain"
|
|
93
|
+
onError={() => setFailed(true)}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const styles = StyleSheet.create({
|
|
99
|
+
container: {
|
|
100
|
+
height: 200,
|
|
101
|
+
borderRadius: 16,
|
|
102
|
+
overflow: 'hidden',
|
|
103
|
+
position: 'relative',
|
|
104
|
+
},
|
|
105
|
+
image: {
|
|
106
|
+
...StyleSheet.absoluteFillObject,
|
|
107
|
+
justifyContent: 'center',
|
|
108
|
+
alignItems: 'center',
|
|
109
|
+
},
|
|
110
|
+
overlay: {
|
|
111
|
+
...StyleSheet.absoluteFillObject,
|
|
112
|
+
backgroundColor: 'rgba(0,0,0,0.35)',
|
|
113
|
+
},
|
|
114
|
+
fallback: {
|
|
115
|
+
flexDirection: 'row',
|
|
116
|
+
alignItems: 'center',
|
|
117
|
+
gap: 24,
|
|
118
|
+
zIndex: 2,
|
|
119
|
+
},
|
|
120
|
+
vs: {
|
|
121
|
+
color: '#FFF',
|
|
122
|
+
fontSize: 24,
|
|
123
|
+
fontWeight: '900',
|
|
124
|
+
zIndex: 2,
|
|
125
|
+
},
|
|
126
|
+
logoPlaceholder: {
|
|
127
|
+
backgroundColor: 'rgba(255,255,255,0.15)',
|
|
128
|
+
zIndex: 2,
|
|
129
|
+
},
|
|
130
|
+
teamNames: {
|
|
131
|
+
position: 'absolute',
|
|
132
|
+
top: 12,
|
|
133
|
+
left: 12,
|
|
134
|
+
right: 12,
|
|
135
|
+
flexDirection: 'row',
|
|
136
|
+
alignItems: 'center',
|
|
137
|
+
justifyContent: 'center',
|
|
138
|
+
gap: 8,
|
|
139
|
+
},
|
|
140
|
+
teamNameText: {
|
|
141
|
+
color: '#FFF',
|
|
142
|
+
fontSize: 14,
|
|
143
|
+
fontWeight: '700',
|
|
144
|
+
maxWidth: '40%',
|
|
145
|
+
},
|
|
146
|
+
teamNameVs: {
|
|
147
|
+
color: 'rgba(255,255,255,0.6)',
|
|
148
|
+
fontSize: 12,
|
|
149
|
+
fontWeight: '600',
|
|
150
|
+
},
|
|
151
|
+
countdownPill: {
|
|
152
|
+
position: 'absolute',
|
|
153
|
+
bottom: 12,
|
|
154
|
+
left: 12,
|
|
155
|
+
backgroundColor: 'rgba(0,0,0,0.65)',
|
|
156
|
+
borderRadius: 8,
|
|
157
|
+
paddingHorizontal: 10,
|
|
158
|
+
paddingVertical: 5,
|
|
159
|
+
},
|
|
160
|
+
countdownText: {
|
|
161
|
+
color: '#FFF',
|
|
162
|
+
fontSize: 13,
|
|
163
|
+
fontWeight: '700',
|
|
164
|
+
},
|
|
165
|
+
countdownLive: {
|
|
166
|
+
color: '#EF4444',
|
|
167
|
+
},
|
|
168
|
+
poolPill: {
|
|
169
|
+
position: 'absolute',
|
|
170
|
+
bottom: 12,
|
|
171
|
+
right: 12,
|
|
172
|
+
backgroundColor: '#7C3AED',
|
|
173
|
+
borderRadius: 8,
|
|
174
|
+
paddingHorizontal: 12,
|
|
175
|
+
paddingVertical: 5,
|
|
176
|
+
},
|
|
177
|
+
poolText: {
|
|
178
|
+
color: '#FFF',
|
|
179
|
+
fontSize: 13,
|
|
180
|
+
fontWeight: '800',
|
|
181
|
+
},
|
|
182
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { StyleSheet, View, Text, TouchableOpacity, ActivityIndicator } from 'react-native';
|
|
3
|
+
import { useDubsTheme } from '../theme';
|
|
4
|
+
import type { GameDetail, MutationStatus } from '../../types';
|
|
5
|
+
|
|
6
|
+
export interface JoinGameButtonProps {
|
|
7
|
+
game: GameDetail;
|
|
8
|
+
walletAddress: string;
|
|
9
|
+
selectedTeam: 'home' | 'away' | null;
|
|
10
|
+
status: MutationStatus;
|
|
11
|
+
onJoin: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const STATUS_LABELS: Record<string, string> = {
|
|
15
|
+
building: 'Building transaction...',
|
|
16
|
+
signing: 'Approve in wallet...',
|
|
17
|
+
confirming: 'Confirming on-chain...',
|
|
18
|
+
saving: 'Saving...',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fixed bottom bar with buy-in info and join button.
|
|
23
|
+
* Renders nothing if the user already joined, or the game is locked/resolved.
|
|
24
|
+
*/
|
|
25
|
+
export function JoinGameButton({ game, walletAddress, selectedTeam, status, onJoin }: JoinGameButtonProps) {
|
|
26
|
+
const t = useDubsTheme();
|
|
27
|
+
|
|
28
|
+
const alreadyJoined = useMemo(() => {
|
|
29
|
+
if (!walletAddress) return false;
|
|
30
|
+
return (game.bettors || []).some(b => b.wallet === walletAddress);
|
|
31
|
+
}, [game.bettors, walletAddress]);
|
|
32
|
+
|
|
33
|
+
if (alreadyJoined || game.isLocked || game.isResolved) return null;
|
|
34
|
+
|
|
35
|
+
const isJoining = status !== 'idle' && status !== 'success' && status !== 'error';
|
|
36
|
+
const statusLabel = STATUS_LABELS[status] || '';
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View style={[styles.bar, { backgroundColor: t.background, borderTopColor: t.border }]}>
|
|
40
|
+
<View style={styles.buyInRow}>
|
|
41
|
+
<Text style={[styles.buyInLabel, { color: t.textMuted }]}>Buy-in</Text>
|
|
42
|
+
<Text style={[styles.buyInValue, { color: t.text }]}>{game.buyIn} SOL</Text>
|
|
43
|
+
</View>
|
|
44
|
+
<TouchableOpacity
|
|
45
|
+
style={[styles.button, { backgroundColor: selectedTeam ? '#22D3EE' : t.border }]}
|
|
46
|
+
disabled={!selectedTeam || isJoining}
|
|
47
|
+
onPress={onJoin}
|
|
48
|
+
activeOpacity={0.8}
|
|
49
|
+
>
|
|
50
|
+
{isJoining ? (
|
|
51
|
+
<View style={styles.joiningRow}>
|
|
52
|
+
<ActivityIndicator size="small" color="#000" />
|
|
53
|
+
<Text style={styles.buttonText}>{statusLabel}</Text>
|
|
54
|
+
</View>
|
|
55
|
+
) : (
|
|
56
|
+
<Text style={[styles.buttonText, !selectedTeam && { color: t.textMuted }]}>
|
|
57
|
+
{selectedTeam ? `Join Bet — ${game.buyIn} SOL` : 'Pick a team to bet'}
|
|
58
|
+
</Text>
|
|
59
|
+
)}
|
|
60
|
+
</TouchableOpacity>
|
|
61
|
+
</View>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const styles = StyleSheet.create({
|
|
66
|
+
bar: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingHorizontal: 16, paddingTop: 12, paddingBottom: 36, borderTopWidth: 1 },
|
|
67
|
+
buyInRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 },
|
|
68
|
+
buyInLabel: { fontSize: 13 },
|
|
69
|
+
buyInValue: { fontSize: 15, fontWeight: '800' },
|
|
70
|
+
button: { borderRadius: 14, paddingVertical: 16, alignItems: 'center' },
|
|
71
|
+
buttonText: { color: '#000', fontSize: 16, fontWeight: '800' },
|
|
72
|
+
joiningRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
|
73
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { StyleSheet, View, Text } from 'react-native';
|
|
3
|
+
import { useDubsTheme } from '../theme';
|
|
4
|
+
import type { GameDetail } from '../../types';
|
|
5
|
+
|
|
6
|
+
export interface LivePoolsCardProps {
|
|
7
|
+
game: GameDetail;
|
|
8
|
+
/** Custom short-name function for team labels. Defaults to full name. */
|
|
9
|
+
shortName?: (name: string | null) => string;
|
|
10
|
+
/** Override bar colors. Defaults to blue/red. */
|
|
11
|
+
homeColor?: string;
|
|
12
|
+
awayColor?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pool breakdown card showing each side's SOL amount, bar chart, and implied odds.
|
|
17
|
+
*/
|
|
18
|
+
export function LivePoolsCard({
|
|
19
|
+
game,
|
|
20
|
+
shortName,
|
|
21
|
+
homeColor = '#3B82F6',
|
|
22
|
+
awayColor = '#EF4444',
|
|
23
|
+
}: LivePoolsCardProps) {
|
|
24
|
+
const t = useDubsTheme();
|
|
25
|
+
|
|
26
|
+
const opponents = game.opponents || [];
|
|
27
|
+
const homeName = shortName ? shortName(opponents[0]?.name) : (opponents[0]?.name || 'Home');
|
|
28
|
+
const awayName = shortName ? shortName(opponents[1]?.name) : (opponents[1]?.name || 'Away');
|
|
29
|
+
const homePool = game.homePool || 0;
|
|
30
|
+
const awayPool = game.awayPool || 0;
|
|
31
|
+
const totalPool = game.totalPool || 0;
|
|
32
|
+
|
|
33
|
+
const { homePercent, awayPercent, homeOdds, awayOdds } = useMemo(() => {
|
|
34
|
+
return {
|
|
35
|
+
homePercent: totalPool > 0 ? (homePool / totalPool) * 100 : 50,
|
|
36
|
+
awayPercent: totalPool > 0 ? (awayPool / totalPool) * 100 : 50,
|
|
37
|
+
homeOdds: homePool > 0 ? (totalPool / homePool).toFixed(2) : '—',
|
|
38
|
+
awayOdds: awayPool > 0 ? (totalPool / awayPool).toFixed(2) : '—',
|
|
39
|
+
};
|
|
40
|
+
}, [homePool, awayPool, totalPool]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<View style={[styles.card, { backgroundColor: t.surface, borderColor: t.border }]}>
|
|
44
|
+
<Text style={[styles.title, { color: t.text }]}>Live Pools</Text>
|
|
45
|
+
<Text style={[styles.total, { color: t.accent }]}>{totalPool} SOL total</Text>
|
|
46
|
+
|
|
47
|
+
<View style={styles.bars}>
|
|
48
|
+
<PoolBar name={homeName} amount={homePool} percent={homePercent} color={homeColor} t={t} />
|
|
49
|
+
<PoolBar name={awayName} amount={awayPool} percent={awayPercent} color={awayColor} t={t} />
|
|
50
|
+
</View>
|
|
51
|
+
|
|
52
|
+
<View style={styles.oddsRow}>
|
|
53
|
+
<Text style={[styles.oddsText, { color: t.textMuted }]}>
|
|
54
|
+
{homeName}: <Text style={{ color: t.text, fontWeight: '700' }}>{homeOdds}x</Text>
|
|
55
|
+
</Text>
|
|
56
|
+
<Text style={[styles.oddsText, { color: t.textMuted }]}>
|
|
57
|
+
{awayName}: <Text style={{ color: t.text, fontWeight: '700' }}>{awayOdds}x</Text>
|
|
58
|
+
</Text>
|
|
59
|
+
</View>
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function PoolBar({ name, amount, percent, color, t }: { name: string; amount: number; percent: number; color: string; t: any }) {
|
|
65
|
+
return (
|
|
66
|
+
<View style={styles.barRow}>
|
|
67
|
+
<Text style={[styles.barLabel, { color: t.textSecondary }]} numberOfLines={1}>{name}</Text>
|
|
68
|
+
<View style={[styles.barTrack, { backgroundColor: t.border }]}>
|
|
69
|
+
<View style={[styles.barFill, { width: `${Math.max(percent, 2)}%`, backgroundColor: color }]} />
|
|
70
|
+
</View>
|
|
71
|
+
<Text style={[styles.barAmount, { color: t.text }]}>{amount} SOL</Text>
|
|
72
|
+
</View>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const styles = StyleSheet.create({
|
|
77
|
+
card: { borderRadius: 16, borderWidth: 1, padding: 16 },
|
|
78
|
+
title: { fontSize: 17, fontWeight: '700', marginBottom: 4 },
|
|
79
|
+
total: { fontSize: 14, fontWeight: '600', marginBottom: 14 },
|
|
80
|
+
bars: { gap: 10 },
|
|
81
|
+
barRow: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
|
82
|
+
barLabel: { width: 80, fontSize: 13, fontWeight: '600' },
|
|
83
|
+
barTrack: { flex: 1, height: 10, borderRadius: 5, overflow: 'hidden' },
|
|
84
|
+
barFill: { height: '100%', borderRadius: 5 },
|
|
85
|
+
barAmount: { width: 70, textAlign: 'right', fontSize: 13, fontWeight: '700' },
|
|
86
|
+
oddsRow: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12 },
|
|
87
|
+
oddsText: { fontSize: 12 },
|
|
88
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react';
|
|
2
|
+
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
|
|
3
|
+
import { useDubsTheme } from '../theme';
|
|
4
|
+
import type { GameDetail } from '../../types';
|
|
5
|
+
|
|
6
|
+
export interface PickWinnerCardProps {
|
|
7
|
+
game: GameDetail;
|
|
8
|
+
selectedTeam: 'home' | 'away' | null;
|
|
9
|
+
onSelect: (team: 'home' | 'away') => void;
|
|
10
|
+
/** Custom short-name function for team labels. Defaults to full name. */
|
|
11
|
+
shortName?: (name: string | null) => string;
|
|
12
|
+
/** Override colors. Defaults to blue/red. */
|
|
13
|
+
homeColor?: string;
|
|
14
|
+
awayColor?: string;
|
|
15
|
+
/** Optional Image component (expo-image, etc.). */
|
|
16
|
+
ImageComponent?: React.ComponentType<any>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Team selection card for picking which side to bet on.
|
|
21
|
+
*/
|
|
22
|
+
export function PickWinnerCard({
|
|
23
|
+
game,
|
|
24
|
+
selectedTeam,
|
|
25
|
+
onSelect,
|
|
26
|
+
shortName,
|
|
27
|
+
homeColor = '#3B82F6',
|
|
28
|
+
awayColor = '#EF4444',
|
|
29
|
+
ImageComponent,
|
|
30
|
+
}: PickWinnerCardProps) {
|
|
31
|
+
const t = useDubsTheme();
|
|
32
|
+
|
|
33
|
+
const opponents = game.opponents || [];
|
|
34
|
+
const bettors = game.bettors || [];
|
|
35
|
+
const totalPool = game.totalPool || 0;
|
|
36
|
+
const homePool = game.homePool || 0;
|
|
37
|
+
const awayPool = game.awayPool || 0;
|
|
38
|
+
|
|
39
|
+
const { homeOdds, awayOdds, homeBets, awayBets } = useMemo(() => ({
|
|
40
|
+
homeOdds: homePool > 0 ? (totalPool / homePool).toFixed(2) : '—',
|
|
41
|
+
awayOdds: awayPool > 0 ? (totalPool / awayPool).toFixed(2) : '—',
|
|
42
|
+
homeBets: bettors.filter(b => b.team === 'home').length,
|
|
43
|
+
awayBets: bettors.filter(b => b.team === 'away').length,
|
|
44
|
+
}), [totalPool, homePool, awayPool, bettors]);
|
|
45
|
+
|
|
46
|
+
const homeName = shortName ? shortName(opponents[0]?.name) : (opponents[0]?.name || 'Home');
|
|
47
|
+
const awayName = shortName ? shortName(opponents[1]?.name) : (opponents[1]?.name || 'Away');
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<View style={[styles.card, { backgroundColor: t.surface, borderColor: t.border }]}>
|
|
51
|
+
<Text style={[styles.title, { color: t.text }]}>Pick Your Winner</Text>
|
|
52
|
+
<View style={styles.row}>
|
|
53
|
+
<TeamOption
|
|
54
|
+
name={homeName}
|
|
55
|
+
imageUrl={opponents[0]?.imageUrl}
|
|
56
|
+
odds={homeOdds}
|
|
57
|
+
bets={homeBets}
|
|
58
|
+
color={homeColor}
|
|
59
|
+
selected={selectedTeam === 'home'}
|
|
60
|
+
onPress={() => onSelect('home')}
|
|
61
|
+
ImageComponent={ImageComponent}
|
|
62
|
+
t={t}
|
|
63
|
+
/>
|
|
64
|
+
<TeamOption
|
|
65
|
+
name={awayName}
|
|
66
|
+
imageUrl={opponents[1]?.imageUrl}
|
|
67
|
+
odds={awayOdds}
|
|
68
|
+
bets={awayBets}
|
|
69
|
+
color={awayColor}
|
|
70
|
+
selected={selectedTeam === 'away'}
|
|
71
|
+
onPress={() => onSelect('away')}
|
|
72
|
+
ImageComponent={ImageComponent}
|
|
73
|
+
t={t}
|
|
74
|
+
/>
|
|
75
|
+
</View>
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function TeamOption({
|
|
81
|
+
name, imageUrl, odds, bets, color, selected, onPress, ImageComponent, t,
|
|
82
|
+
}: {
|
|
83
|
+
name: string; imageUrl?: string | null; odds: string; bets: number;
|
|
84
|
+
color: string; selected: boolean; onPress: () => void;
|
|
85
|
+
ImageComponent?: React.ComponentType<any>; t: any;
|
|
86
|
+
}) {
|
|
87
|
+
const [imgFailed, setImgFailed] = useState(false);
|
|
88
|
+
const Img = ImageComponent || require('react-native').Image;
|
|
89
|
+
const showImage = imageUrl && !imgFailed;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<TouchableOpacity
|
|
93
|
+
style={[styles.option, { borderColor: selected ? color : t.border, backgroundColor: selected ? color + '15' : t.background }]}
|
|
94
|
+
onPress={onPress}
|
|
95
|
+
activeOpacity={0.7}
|
|
96
|
+
>
|
|
97
|
+
{showImage ? (
|
|
98
|
+
<Img source={{ uri: imageUrl }} style={styles.logo} resizeMode="contain" onError={() => setImgFailed(true)} />
|
|
99
|
+
) : (
|
|
100
|
+
<View style={[styles.logo, styles.logoPlaceholder]} />
|
|
101
|
+
)}
|
|
102
|
+
<Text style={[styles.name, { color: t.text }]} numberOfLines={1}>{name}</Text>
|
|
103
|
+
<Text style={[styles.odds, { color }]}>{odds}x</Text>
|
|
104
|
+
<Text style={[styles.bets, { color: t.textMuted }]}>{bets} {bets === 1 ? 'bet' : 'bets'}</Text>
|
|
105
|
+
{selected && (
|
|
106
|
+
<View style={[styles.badge, { backgroundColor: color }]}>
|
|
107
|
+
<Text style={styles.badgeText}>Selected</Text>
|
|
108
|
+
</View>
|
|
109
|
+
)}
|
|
110
|
+
</TouchableOpacity>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const styles = StyleSheet.create({
|
|
115
|
+
card: { borderRadius: 16, borderWidth: 1, padding: 16 },
|
|
116
|
+
title: { fontSize: 17, fontWeight: '700', marginBottom: 12 },
|
|
117
|
+
row: { flexDirection: 'row', gap: 12 },
|
|
118
|
+
option: { flex: 1, borderWidth: 2, borderRadius: 16, padding: 16, alignItems: 'center', gap: 8 },
|
|
119
|
+
logo: { width: 48, height: 48, borderRadius: 24 },
|
|
120
|
+
logoPlaceholder: { backgroundColor: 'rgba(128,128,128,0.2)' },
|
|
121
|
+
name: { fontSize: 15, fontWeight: '700' },
|
|
122
|
+
odds: { fontSize: 20, fontWeight: '800' },
|
|
123
|
+
bets: { fontSize: 12 },
|
|
124
|
+
badge: { borderRadius: 8, paddingHorizontal: 12, paddingVertical: 4, marginTop: 4 },
|
|
125
|
+
badgeText: { color: '#FFF', fontSize: 12, fontWeight: '700' },
|
|
126
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { StyleSheet, View, Text } from 'react-native';
|
|
3
|
+
import { useDubsTheme } from '../theme';
|
|
4
|
+
import type { GameDetail } from '../../types';
|
|
5
|
+
|
|
6
|
+
export interface PlayersCardProps {
|
|
7
|
+
game: GameDetail;
|
|
8
|
+
/** How many chars to show on each side of a truncated wallet. Default 4. */
|
|
9
|
+
truncateChars?: number;
|
|
10
|
+
/** Override team dot colors. */
|
|
11
|
+
homeColor?: string;
|
|
12
|
+
awayColor?: string;
|
|
13
|
+
drawColor?: string;
|
|
14
|
+
/** Optional Image component (expo-image, etc.). */
|
|
15
|
+
ImageComponent?: React.ComponentType<any>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function truncateWallet(addr: string, chars: number): string {
|
|
19
|
+
if (addr.length <= chars * 2 + 3) return addr;
|
|
20
|
+
return `${addr.slice(0, chars)}...${addr.slice(-chars)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Card showing all bettors in a game with their avatar, username, team, and wager amount.
|
|
25
|
+
*/
|
|
26
|
+
export function PlayersCard({
|
|
27
|
+
game,
|
|
28
|
+
truncateChars = 4,
|
|
29
|
+
homeColor = '#3B82F6',
|
|
30
|
+
awayColor = '#EF4444',
|
|
31
|
+
drawColor = '#A855F7',
|
|
32
|
+
ImageComponent,
|
|
33
|
+
}: PlayersCardProps) {
|
|
34
|
+
const t = useDubsTheme();
|
|
35
|
+
const bettors = game.bettors || [];
|
|
36
|
+
|
|
37
|
+
const dotColor = (team: 'home' | 'away' | 'draw') => {
|
|
38
|
+
if (team === 'home') return homeColor;
|
|
39
|
+
if (team === 'away') return awayColor;
|
|
40
|
+
return drawColor;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<View style={[styles.card, { backgroundColor: t.surface, borderColor: t.border }]}>
|
|
45
|
+
<Text style={[styles.title, { color: t.text }]}>
|
|
46
|
+
Players{bettors.length > 0 ? ` (${bettors.length})` : ''}
|
|
47
|
+
</Text>
|
|
48
|
+
|
|
49
|
+
{bettors.length === 0 ? (
|
|
50
|
+
<Text style={[styles.empty, { color: t.textMuted }]}>No players yet — be the first!</Text>
|
|
51
|
+
) : (
|
|
52
|
+
bettors.map((b, i) => (
|
|
53
|
+
<BettorRow
|
|
54
|
+
key={`${b.wallet}-${i}`}
|
|
55
|
+
bettor={b}
|
|
56
|
+
dotColor={dotColor(b.team)}
|
|
57
|
+
truncateChars={truncateChars}
|
|
58
|
+
isFirst={i === 0}
|
|
59
|
+
ImageComponent={ImageComponent}
|
|
60
|
+
t={t}
|
|
61
|
+
/>
|
|
62
|
+
))
|
|
63
|
+
)}
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function BettorRow({
|
|
69
|
+
bettor, dotColor, truncateChars, isFirst, ImageComponent, t,
|
|
70
|
+
}: {
|
|
71
|
+
bettor: { wallet: string; username: string | null; avatar: string | null; team: 'home' | 'away' | 'draw'; amount: number };
|
|
72
|
+
dotColor: string; truncateChars: number; isFirst: boolean;
|
|
73
|
+
ImageComponent?: React.ComponentType<any>; t: any;
|
|
74
|
+
}) {
|
|
75
|
+
const [imgFailed, setImgFailed] = useState(false);
|
|
76
|
+
const Img = ImageComponent || require('react-native').Image;
|
|
77
|
+
const showAvatar = bettor.avatar && !imgFailed;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<View style={[styles.row, !isFirst && { borderTopColor: t.border, borderTopWidth: 1 }]}>
|
|
81
|
+
<View style={[styles.dot, { backgroundColor: dotColor }]} />
|
|
82
|
+
{showAvatar ? (
|
|
83
|
+
<Img source={{ uri: bettor.avatar }} style={styles.avatar} resizeMode="cover" onError={() => setImgFailed(true)} />
|
|
84
|
+
) : (
|
|
85
|
+
<View style={[styles.avatar, styles.avatarPlaceholder]} />
|
|
86
|
+
)}
|
|
87
|
+
<View style={styles.nameCol}>
|
|
88
|
+
<Text style={[styles.username, { color: t.text }]} numberOfLines={1}>
|
|
89
|
+
{bettor.username || truncateWallet(bettor.wallet, truncateChars)}
|
|
90
|
+
</Text>
|
|
91
|
+
</View>
|
|
92
|
+
<Text style={[styles.amount, { color: t.textSecondary }]}>{bettor.amount} SOL</Text>
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const styles = StyleSheet.create({
|
|
98
|
+
card: { borderRadius: 16, borderWidth: 1, padding: 16 },
|
|
99
|
+
title: { fontSize: 17, fontWeight: '700', marginBottom: 12 },
|
|
100
|
+
empty: { fontSize: 14, textAlign: 'center', paddingVertical: 16 },
|
|
101
|
+
row: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, gap: 10 },
|
|
102
|
+
dot: { width: 8, height: 8, borderRadius: 4 },
|
|
103
|
+
avatar: { width: 28, height: 28, borderRadius: 14 },
|
|
104
|
+
avatarPlaceholder: { backgroundColor: 'rgba(128,128,128,0.2)' },
|
|
105
|
+
nameCol: { flex: 1 },
|
|
106
|
+
username: { fontSize: 14, fontWeight: '600' },
|
|
107
|
+
amount: { fontSize: 13, fontWeight: '700' },
|
|
108
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { GamePoster } from './GamePoster';
|
|
2
|
+
export type { GamePosterProps } from './GamePoster';
|
|
3
|
+
export { LivePoolsCard } from './LivePoolsCard';
|
|
4
|
+
export type { LivePoolsCardProps } from './LivePoolsCard';
|
|
5
|
+
export { PickWinnerCard } from './PickWinnerCard';
|
|
6
|
+
export type { PickWinnerCardProps } from './PickWinnerCard';
|
|
7
|
+
export { PlayersCard } from './PlayersCard';
|
|
8
|
+
export type { PlayersCardProps } from './PlayersCard';
|
|
9
|
+
export { JoinGameButton } from './JoinGameButton';
|
|
10
|
+
export type { JoinGameButtonProps } from './JoinGameButton';
|
package/src/ui/index.ts
CHANGED
|
@@ -8,3 +8,13 @@ export { SettingsSheet } from './SettingsSheet';
|
|
|
8
8
|
export type { SettingsSheetProps } from './SettingsSheet';
|
|
9
9
|
export { useDubsTheme } from './theme';
|
|
10
10
|
export type { DubsTheme } from './theme';
|
|
11
|
+
|
|
12
|
+
// Game widgets
|
|
13
|
+
export { GamePoster, LivePoolsCard, PickWinnerCard, PlayersCard, JoinGameButton } from './game';
|
|
14
|
+
export type {
|
|
15
|
+
GamePosterProps,
|
|
16
|
+
LivePoolsCardProps,
|
|
17
|
+
PickWinnerCardProps,
|
|
18
|
+
PlayersCardProps,
|
|
19
|
+
JoinGameButtonProps,
|
|
20
|
+
} from './game';
|
package/src/utils/transaction.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { TransactionConfirmationStatus } from '@solana/web3.js';
|
|
1
|
+
import { Transaction } from '@solana/web3.js';
|
|
3
2
|
import type { WalletAdapter } from '../wallet/types';
|
|
4
3
|
|
|
5
4
|
/**
|
|
@@ -9,59 +8,19 @@ import type { WalletAdapter } from '../wallet/types';
|
|
|
9
8
|
export async function signAndSendBase64Transaction(
|
|
10
9
|
base64Tx: string,
|
|
11
10
|
wallet: WalletAdapter,
|
|
12
|
-
connection: Connection,
|
|
13
11
|
): Promise<string> {
|
|
14
12
|
if (!wallet.publicKey) throw new Error('Wallet not connected');
|
|
15
13
|
|
|
16
|
-
const
|
|
17
|
-
const
|
|
14
|
+
const binaryStr = atob(base64Tx);
|
|
15
|
+
const bytes = new Uint8Array(binaryStr.length);
|
|
16
|
+
for (let i = 0; i < binaryStr.length; i++) {
|
|
17
|
+
bytes[i] = binaryStr.charCodeAt(i);
|
|
18
|
+
}
|
|
19
|
+
const transaction = Transaction.from(bytes);
|
|
18
20
|
|
|
19
|
-
// If the wallet supports signAndSend in one step, prefer that
|
|
20
21
|
if (wallet.signAndSendTransaction) {
|
|
21
22
|
return wallet.signAndSendTransaction(transaction);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
const signed = await wallet.signTransaction(transaction);
|
|
26
|
-
const signature = await connection.sendRawTransaction(signed.serialize(), {
|
|
27
|
-
skipPreflight: true,
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
return signature;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Poll for transaction confirmation using getSignatureStatuses.
|
|
35
|
-
* Uses polling instead of deprecated WebSocket-based confirmTransaction.
|
|
36
|
-
*/
|
|
37
|
-
export async function pollTransactionConfirmation(
|
|
38
|
-
signature: string,
|
|
39
|
-
connection: Connection,
|
|
40
|
-
commitment: TransactionConfirmationStatus = 'confirmed',
|
|
41
|
-
timeout: number = 60000,
|
|
42
|
-
interval: number = 1500,
|
|
43
|
-
): Promise<void> {
|
|
44
|
-
const start = Date.now();
|
|
45
|
-
const confirmationOrder: TransactionConfirmationStatus[] = ['processed', 'confirmed', 'finalized'];
|
|
46
|
-
const targetIndex = confirmationOrder.indexOf(commitment);
|
|
47
|
-
|
|
48
|
-
while (Date.now() - start < timeout) {
|
|
49
|
-
const statuses = await connection.getSignatureStatuses([signature]);
|
|
50
|
-
const status = statuses?.value?.[0];
|
|
51
|
-
|
|
52
|
-
if (status?.err) {
|
|
53
|
-
throw new Error(`Transaction failed: ${JSON.stringify(status.err)}`);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (status?.confirmationStatus) {
|
|
57
|
-
const currentIndex = confirmationOrder.indexOf(status.confirmationStatus);
|
|
58
|
-
if (currentIndex >= targetIndex) {
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
await new Promise(resolve => setTimeout(resolve, interval));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
throw new Error(`Transaction confirmation timeout after ${timeout}ms`);
|
|
25
|
+
throw new Error('Wallet does not support signAndSendTransaction');
|
|
67
26
|
}
|