import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { View, Text, Image, TouchableOpacity, ActivityIndicator, Modal, Animated, StyleSheet, KeyboardAvoidingView, Platform, } from 'react-native'; import { useDubsTheme } from '../theme'; import { useDubs } from '../../provider'; import { useJoinGame } from '../../hooks/useJoinGame'; import type { JoinGameMutationResult } from '../../hooks/useJoinGame'; import { useCredits } from '../../hooks/useCredits'; import type { GameDetail } from '../../types'; import { SolSlider } from './SolSlider'; import { TeamButton } from './TeamButton'; export interface JoinGameSheetProps { visible: boolean; onDismiss: () => void; game: GameDetail; /** Optional Image component (expo-image, etc.) for team logos */ ImageComponent?: React.ComponentType; /** Custom short-name function for team labels */ shortName?: (name: string | null) => string; /** Override team colors (default blue/red/green) */ homeColor?: string; awayColor?: string; drawColor?: string; /** Callbacks */ onSuccess?: (result: JoinGameMutationResult) => void; onError?: (error: Error) => void; /** Called when the user taps a team card (useful for sound/haptics) */ onTeamSelect?: (team: 'home' | 'away') => void; /** Called when the join succeeds (useful for success sound/animation) */ onJoinSuccess?: (result: JoinGameMutationResult) => void; /** Called on each slider tick (useful for haptics/sound) */ onSliderTick?: (value: number) => void; /** Max wager in SOL (default 5) */ maxWager?: number; /** Pool mode: hides team selection, auto-assigns team, shows "Join Pool" labels */ isPoolModeEnabled?: boolean; } const STATUS_LABELS: Record = { building: 'Building transaction...', signing: 'Approve in wallet...', confirming: 'Confirming...', success: 'Joined!', }; const CUSTOM_GAME_MODE = 6; function formatSol(n: number): string { // Remove trailing zeros: 0.06000000 → 0.06, 1.00000 → 1 return parseFloat(n.toFixed(9)).toString(); } function toPng(url: string | null | undefined): string | null { if (!url) return null; return url.replace('/svg?', '/png?'); } export function JoinGameSheet({ visible, onDismiss, game, ImageComponent, shortName, homeColor = '#3B82F6', awayColor = '#EF4444', drawColor = '#F59E0B', onSuccess, onError, onTeamSelect, onJoinSuccess, onSliderTick, maxWager = 5, isPoolModeEnabled = false, }: JoinGameSheetProps) { const t = useDubsTheme(); const { wallet } = useDubs(); const mutation = useJoinGame(); const { credits, refetch: refetchCredits } = useCredits(); const isCustomGame = game.gameMode === CUSTOM_GAME_MODE; const [selectedTeam, setSelectedTeam] = useState<'home' | 'away' | 'draw' | null>(null); const [wager, setWager] = useState(game.buyIn); const [showSuccess, setShowSuccess] = useState(false); // "Use my streak credit" toggle. Only meaningful when the user has at // least one active credit AND the credit's amount covers buy-in. The // sponsored on-chain instruction transfers exactly the credit amount, // so when this is on we lock wager to the credit value. // // Compare in lamports to dodge float drift between credit.amountSOL // (server-rounded to 5dp) and game.buyIn — a 0.01 SOL credit must // visibly cover a 0.01 SOL buy-in, which `>=` on raw floats can miss. const [useCredit, setUseCredit] = useState(false); const oldestCredit = credits.length > 0 ? credits[0] : null; const canUseCredit = !!oldestCredit && Math.round(oldestCredit.amountSOL * 1e9) >= Math.round(game.buyIn * 1e9); const overlayOpacity = useRef(new Animated.Value(0)).current; const successScale = useRef(new Animated.Value(0)).current; const successOpacity = useRef(new Animated.Value(0)).current; // Animate overlay on visibility change useEffect(() => { Animated.timing(overlayOpacity, { toValue: visible ? 1 : 0, duration: 250, useNativeDriver: true, }).start(); }, [visible, overlayOpacity]); // Reset state when sheet opens useEffect(() => { if (visible) { setSelectedTeam(isPoolModeEnabled ? 'home' : isCustomGame ? 'away' : null); setWager(game.buyIn); setShowSuccess(false); successScale.setValue(0); successOpacity.setValue(0); mutation.reset(); } }, [visible]); // eslint-disable-line react-hooks/exhaustive-deps // Handle success with animation useEffect(() => { if (mutation.status === 'success' && mutation.data) { setShowSuccess(true); onSuccess?.(mutation.data); onJoinSuccess?.(mutation.data); // Animate success Animated.parallel([ Animated.spring(successScale, { toValue: 1, friction: 4, tension: 80, useNativeDriver: true }), Animated.timing(successOpacity, { toValue: 1, duration: 300, useNativeDriver: true }), ]).start(); const timer = setTimeout(() => { Animated.timing(successOpacity, { toValue: 0, duration: 300, useNativeDriver: true }).start(() => { onDismiss(); }); }, 2500); return () => clearTimeout(timer); } }, [mutation.status, mutation.data]); // eslint-disable-line react-hooks/exhaustive-deps // Report errors useEffect(() => { if (mutation.status === 'error' && mutation.error) { onError?.(mutation.error); } }, [mutation.status, mutation.error]); // eslint-disable-line react-hooks/exhaustive-deps // --- Derived values --- const opponents = game.opponents || []; const bettors = game.bettors || []; const totalPool = game.totalPool || 0; const homePool = game.homePool || 0; const awayPool = game.awayPool || 0; const drawPool = game.drawPool || 0; const buyIn = game.buyIn; // Show draw option if any bettor is on draw, or if the league supports it const drawBettors = bettors.filter(b => b.team === 'draw'); const hasDrawOption = drawPool > 0 || drawBettors.length > 0 || (game.league && ['English Premier League', 'EPL', 'MLS', 'La Liga', 'Serie A', 'Bundesliga', 'Ligue 1'].some(l => (game.league || '').includes(l))); const poolAfterJoin = totalPool + wager; const { homeOdds, awayOdds, drawOdds, homeBets, awayBets, drawBets } = useMemo(() => { const homeBetsCount = bettors.filter(b => b.team === 'home').length; const awayBetsCount = bettors.filter(b => b.team === 'away').length; const drawBetsCount = bettors.filter(b => b.team === 'draw').length; // Show comparable odds for every side by computing each as "if I bet // `wager` on this team". Total pool grows by `wager` regardless of // which side you pick, so newPool is constant; only the side pool // shifts. Previously this only added `wager` to the *selected* side, // which left an unbetted side with sidePool === 0 → odds shown as // "—". Empty sides now show their (high) implied multiplier so the // user can actually compare risk/return before committing. const newPool = totalPool + wager; const homeWith = homePool + wager; const awayWith = awayPool + wager; const drawWith = drawPool + wager; return { homeOdds: homeWith > 0 ? (newPool / homeWith).toFixed(2) : '—', awayOdds: awayWith > 0 ? (newPool / awayWith).toFixed(2) : '—', drawOdds: drawWith > 0 ? (newPool / drawWith).toFixed(2) : '—', homeBets: homeBetsCount, awayBets: awayBetsCount, drawBets: drawBetsCount, }; }, [totalPool, homePool, awayPool, drawPool, bettors, wager]); const selectedOdds = selectedTeam === 'home' ? homeOdds : selectedTeam === 'away' ? awayOdds : selectedTeam === 'draw' ? drawOdds : '—'; const potentialWinnings = selectedOdds !== '—' ? formatSol(parseFloat(selectedOdds) * wager) : '—'; const homeName = shortName ? shortName(opponents[0]?.name) : (opponents[0]?.name || 'Home'); const awayName = shortName ? shortName(opponents[1]?.name) : (opponents[1]?.name || 'Away'); const myBet = useMemo(() => { if (!wallet.publicKey) return null; const addr = wallet.publicKey.toBase58(); return bettors.find(b => b.wallet === addr) ?? null; }, [bettors, wallet.publicKey]); const alreadyJoined = myBet !== null; const isMutating = mutation.status !== 'idle' && mutation.status !== 'success' && mutation.status !== 'error'; const canJoin = selectedTeam !== null && !isMutating && mutation.status !== 'success' && !alreadyJoined; const handleJoin = useCallback(async () => { if (!selectedTeam || !wallet.publicKey) return; try { await mutation.execute({ playerWallet: wallet.publicKey.toBase58(), gameId: game.gameId, teamChoice: selectedTeam, // When useCredit is on, the on-chain instruction transfers // exactly the credit's amount; pass that as the wager. amount: useCredit && oldestCredit ? oldestCredit.amountSOL : wager, useCredit: useCredit && canUseCredit ? true : undefined, }); // The credit was just spent — refresh the list so the toggle // disappears (or rolls to the next FIFO credit). if (useCredit) refetchCredits(); } catch { // Error is already captured in mutation state } }, [selectedTeam, wallet.publicKey, mutation.execute, game.gameId, wager, useCredit, oldestCredit, canUseCredit, refetchCredits]); // eslint-disable-line react-hooks/exhaustive-deps const statusLabel = STATUS_LABELS[mutation.status] || ''; return ( {/* Success overlay */} {showSuccess && ( 🎉 You're in! {formatSol(wager)} SOL on {selectedTeam === 'home' ? homeName : awayName} )} {/* Drag handle */} {/* Header */} {isPoolModeEnabled ? 'Join Pool' : 'Join Game'} {'\u2715'} {/* Bettors row */} {bettors.length > 0 && ( {bettors.length} {bettors.length === 1 ? 'player' : 'players'} in this game {bettors.slice(0, 6).map((b, i) => { const pngUrl = toPng(b.avatar); return ( {pngUrl ? ( ) : ( {(b.username ?? b.wallet).charAt(0).toUpperCase()} )} ); })} {bettors.length > 6 && ( +{bettors.length - 6} )} )} {/* Team Selection — hidden in pool mode and custom games */} {!isCustomGame && !isPoolModeEnabled && !alreadyJoined && ( hasDrawOption ? ( /* ── 3-way layout: Home / VS / Away / OR / Draw ── */ Who will win? { setSelectedTeam('home'); onTeamSelect?.('home'); }} ImageComponent={ImageComponent} t={t} /> VS { setSelectedTeam('away'); onTeamSelect?.('away'); }} ImageComponent={ImageComponent} t={t} /> OR { setSelectedTeam('draw' as any); onTeamSelect?.('draw' as any); }} t={t} /> ) : ( /* ── 2-way layout: side by side ── */ Pick Your Side { setSelectedTeam('home'); onTeamSelect?.('home'); }} ImageComponent={ImageComponent} t={t} /> { setSelectedTeam('away'); onTeamSelect?.('away'); }} ImageComponent={ImageComponent} t={t} /> ) )} {/* Already joined — show which side */} {alreadyJoined && myBet && ( YOUR BET {myBet.team === 'home' ? homeName : myBet.team === 'away' ? awayName : 'Draw'} {formatSol(myBet.amount)} SOL )} {/* Summary Card */} Your wager {formatSol(wager)} SOL {isPoolModeEnabled ? ( <> Players in {bettors.length} Current pot {formatSol(totalPool)} SOL ) : ( <> Total pool {formatSol(poolAfterJoin)} SOL Potential winnings {potentialWinnings !== '—' ? `${potentialWinnings} SOL` : '—'} )} {/* Credit toggle — only when user has a credit ≥ buy-in */} {selectedTeam && !alreadyJoined && canUseCredit && oldestCredit && ( setUseCredit(v => !v)} > 🎁 Use my {formatSol(oldestCredit.amountSOL)} SOL credit {useCredit ? 'Treasury covers this bet — wager locked to credit amount' : `${credits.length} credit${credits.length === 1 ? '' : 's'} available from your streak`} {useCredit && } )} {/* SOL Slider — hidden when sponsoring with a credit */} {selectedTeam && !isPoolModeEnabled && !alreadyJoined && !useCredit && ( )} {/* Already Joined Notice */} {alreadyJoined && ( {isPoolModeEnabled ? "You've already joined this pool." : "You've already joined this game."} )} {/* Error Display */} {mutation.error && ( {mutation.error.message} )} {/* CTA Button */} {isMutating ? ( {statusLabel} ) : mutation.status === 'success' ? ( {isPoolModeEnabled ? 'Joined!' : STATUS_LABELS.success} ) : ( {alreadyJoined ? 'Already Joined' : isPoolModeEnabled ? `Join Pool \u2014 ${formatSol(wager)} SOL` : selectedTeam ? `Join Game \u2014 ${formatSol(wager)} SOL` : 'Pick a side to join'} )} ); } // ── PickRow (vertical 3-way layout for draw-supported leagues) ── function PickRow({ name, subtitle, imageUrl, odds, bets, color, selected, onPress, ImageComponent, t, }: { name: string; subtitle?: string; imageUrl?: string | null; odds: string; bets: number; color: string; selected: boolean; onPress: () => void; ImageComponent?: React.ComponentType; t: any; }) { const [imgFailed, setImgFailed] = useState(false); const Img = ImageComponent || require('react-native').Image; const showImage = imageUrl && !imgFailed; return ( {showImage ? ( setImgFailed(true)} /> ) : ( {name === 'Draw' ? '🤝' : name.charAt(0)} )} {name} {subtitle && {subtitle}} {odds !== '—' && {odds}x} {bets > 0 && {bets}} {selected && ( )} ); } const pickStyles = StyleSheet.create({ row: { flexDirection: 'row', alignItems: 'center', borderWidth: 1.5, borderRadius: 14, paddingVertical: 12, paddingHorizontal: 14, gap: 12, }, logo: { width: 36, height: 36, borderRadius: 18, }, logoPlaceholder: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', }, logoEmoji: { fontSize: 16, fontWeight: '800', }, info: { flex: 1, }, name: { fontSize: 15, fontWeight: '700', }, subtitle: { fontSize: 12, marginTop: 1, }, odds: { fontSize: 16, fontWeight: '800', }, bets: { fontSize: 12, }, check: { width: 22, height: 22, borderRadius: 11, alignItems: 'center', justifyContent: 'center', }, checkText: { color: '#FFF', fontSize: 13, fontWeight: '800', }, }); const styles = StyleSheet.create({ overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)', }, overlayTap: { flex: 1, }, keyboardView: { flex: 1, justifyContent: 'flex-end', }, sheetPositioner: { justifyContent: 'flex-end', }, sheet: { borderTopLeftRadius: 24, borderTopRightRadius: 24, paddingHorizontal: 20, paddingBottom: 40, }, handleRow: { alignItems: 'center', paddingTop: 10, paddingBottom: 8, }, handle: { width: 36, height: 4, borderRadius: 2, opacity: 0.4, }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, }, headerTitle: { fontSize: 20, fontWeight: '700', }, closeButton: { fontSize: 20, padding: 4, }, // Bettors row bettorsSection: { paddingBottom: 12, gap: 8, }, bettorsLabel: { fontSize: 12, fontWeight: '600', }, bettorsRow: { flexDirection: 'row', alignItems: 'center', gap: 4, }, bettorCircle: { width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }, bettorImg: { width: 28, height: 28, borderRadius: 14, }, bettorInitial: { color: '#FFF', fontSize: 11, fontWeight: '800', }, bettorOverflow: { color: '#8E8E93', fontSize: 10, fontWeight: '700', }, // Success overlay successOverlay: { ...StyleSheet.absoluteFillObject, zIndex: 100, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0.85)', }, successContent: { alignItems: 'center', gap: 12, }, successEmoji: { fontSize: 64, }, successTitle: { color: '#FFFFFF', fontSize: 28, fontWeight: '900', }, successSub: { color: '#8E8E93', fontSize: 16, }, section: { gap: 12, paddingTop: 8, }, sectionLabel: { fontSize: 15, fontWeight: '600', }, teamsRow: { flexDirection: 'row', gap: 12, }, drawRow: { marginTop: 8, }, dividerRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginVertical: 6, }, dividerLine: { flex: 1, height: 1, }, dividerText: { fontSize: 12, fontWeight: '700', }, summaryCard: { marginTop: 20, borderRadius: 16, borderWidth: 1, overflow: 'hidden', }, summaryRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 14, }, summaryLabel: { fontSize: 14, }, summaryValue: { fontSize: 15, fontWeight: '700', }, summarySep: { height: 1, marginHorizontal: 16, }, myBetCard: { marginTop: 16, borderRadius: 14, borderWidth: 1.5, padding: 16, alignItems: 'center', gap: 4, }, myBetLabel: { fontSize: 10, fontWeight: '900', letterSpacing: 1.5, }, myBetTeam: { fontSize: 18, fontWeight: '700', }, myBetAmount: { fontSize: 14, fontWeight: '600', }, errorBox: { marginTop: 16, borderRadius: 12, borderWidth: 1, padding: 12, }, errorText: { fontSize: 13, fontWeight: '500', }, ctaButton: { marginTop: 20, height: 56, borderRadius: 14, justifyContent: 'center', alignItems: 'center', }, ctaText: { color: '#FFFFFF', fontSize: 16, fontWeight: '700', }, ctaLoading: { flexDirection: 'row', alignItems: 'center', gap: 10, }, creditRow: { flexDirection: 'row', alignItems: 'center', gap: 12, marginTop: 12, padding: 12, borderRadius: 12, borderWidth: 1.5, }, creditRowText: { flex: 1, gap: 2, }, creditTitle: { fontSize: 14, fontWeight: '700', }, creditSub: { fontSize: 12, fontWeight: '500', }, creditCheckbox: { width: 22, height: 22, borderRadius: 11, borderWidth: 2, borderColor: '#3A3A3C', alignItems: 'center', justifyContent: 'center', }, creditCheck: { color: '#FFFFFF', fontSize: 14, fontWeight: '900', }, });