import React, { useState, useEffect, useRef, useCallback } 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 { useCreateGame } from '../../hooks/useCreateGame'; import type { CreateGameMutationResult } from '../../hooks/useCreateGame'; import { useCredits } from '../../hooks/useCredits'; import type { UnifiedEvent } from '../../types'; import { SolSlider } from './SolSlider'; import { TeamButton } from './TeamButton'; export interface CreateGameSheetProps { visible: boolean; onDismiss: () => void; event: UnifiedEvent; /** Override team colors */ homeColor?: string; awayColor?: string; /** Callbacks */ onSuccess?: (result: CreateGameMutationResult) => void; onError?: (error: Error) => void; /** Called when the user taps a team card */ onTeamSelect?: (team: 'home' | 'away') => void; /** Called on success (for sound/animation) */ onCreateSuccess?: (result: CreateGameMutationResult) => void; /** Called on each slider tick */ onSliderTick?: (value: number) => void; /** Max wager in SOL */ maxWager?: number; /** Custom short-name function for team labels */ shortName?: (name: string | null) => string; } const STATUS_LABELS: Record = { building: 'Building transaction...', signing: 'Approve in wallet...', confirming: 'Confirming...', success: 'Game Created!', }; function formatSol(n: number): string { 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 CreateGameSheet({ visible, onDismiss, event, homeColor = '#3B82F6', awayColor = '#EF4444', onSuccess, onError, onTeamSelect, onCreateSuccess, onSliderTick, maxWager = 1, shortName, }: CreateGameSheetProps) { const t = useDubsTheme(); const { wallet } = useDubs(); const mutation = useCreateGame(); const { credits, refetch: refetchCredits } = useCredits(); const [selectedTeam, setSelectedTeam] = useState<'home' | 'away' | null>(null); const [wager, setWager] = useState(0.01); const [showSuccess, setShowSuccess] = useState(false); const [useCredit, setUseCredit] = useState(false); const oldestCredit = credits.length > 0 ? credits[0] : null; // Compare in lamports to avoid float drift between credit.amountSOL and // the slider's wager value. The on-chain instruction requires exact // match anyway — if either side has a sub-lamport rounding error we'd // get a confusing on-chain failure instead of a clean toggle gate. const canUseCredit = !!oldestCredit; // Any 0.01 SOL credit covers the 0.01 SOL minimum buy-in const overlayOpacity = useRef(new Animated.Value(0)).current; const successScale = useRef(new Animated.Value(0)).current; const successOpacity = useRef(new Animated.Value(0)).current; useEffect(() => { Animated.timing(overlayOpacity, { toValue: visible ? 1 : 0, duration: 250, useNativeDriver: true, }).start(); }, [visible]); useEffect(() => { if (visible) { setSelectedTeam(null); setWager(0.01); setShowSuccess(false); successScale.setValue(0); successOpacity.setValue(0); mutation.reset(); } }, [visible]); // eslint-disable-line react-hooks/exhaustive-deps // Handle success useEffect(() => { if (mutation.status === 'success' && mutation.data) { setShowSuccess(true); onSuccess?.(mutation.data); onCreateSuccess?.(mutation.data); 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 useEffect(() => { if (mutation.status === 'error' && mutation.error) { onError?.(mutation.error); } }, [mutation.status, mutation.error]); // eslint-disable-line react-hooks/exhaustive-deps const opponents = event.opponents || []; const homeName = shortName ? shortName(opponents[0]?.name) : (opponents[0]?.name || 'Home'); const awayName = shortName ? shortName(opponents[1]?.name) : (opponents[1]?.name || 'Away'); const isMutating = mutation.status !== 'idle' && mutation.status !== 'success' && mutation.status !== 'error'; const canCreate = selectedTeam !== null && !isMutating && mutation.status !== 'success'; const handleCreate = useCallback(async () => { if (!selectedTeam || !wallet.publicKey) return; try { await mutation.execute({ id: event.id, playerWallet: wallet.publicKey.toBase58(), teamChoice: selectedTeam, // When sponsoring, the on-chain instruction forces buy-in to // the credit's amount; pass that so client-side wager state // matches what gets recorded. wagerAmount: useCredit && oldestCredit ? oldestCredit.amountSOL : wager, useCredit: useCredit && canUseCredit ? true : undefined, }); if (useCredit) refetchCredits(); } catch { // Error captured in mutation state } }, [selectedTeam, wallet.publicKey, mutation.execute, event.id, wager, useCredit, oldestCredit, canUseCredit, refetchCredits]); // eslint-disable-line react-hooks/exhaustive-deps const statusLabel = STATUS_LABELS[mutation.status] || ''; // Countdown const startTime = event.startTime ? new Date(event.startTime) : null; const timeLabel = startTime ? startTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) + ' at ' + startTime.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : 'TBD'; return ( {/* Success overlay */} {showSuccess && ( 🎯 Game Created! {formatSol(wager)} SOL on {selectedTeam === 'home' ? homeName : awayName} )} {/* Drag handle */} {/* Header */} Create Game {timeLabel} {'\u2715'} {/* Team Selection */} Pick Your Side { setSelectedTeam('home'); onTeamSelect?.('home'); }} t={t} /> { setSelectedTeam('away'); onTeamSelect?.('away'); }} t={t} /> {/* Summary */} Your wager {formatSol(wager)} SOL You're the first Set the odds {/* Credit toggle — only when user has at least one credit */} {selectedTeam && canUseCredit && oldestCredit && ( setUseCredit(v => !v)} > 🎁 Use my {formatSol(oldestCredit.amountSOL)} SOL credit {useCredit ? 'Treasury covers rent + buy-in' : `${credits.length} credit${credits.length === 1 ? '' : 's'} from your streak`} {useCredit && } )} {/* SOL Slider — hidden when sponsoring (wager locked to credit) */} {selectedTeam && !useCredit && ( )} {/* Error */} {mutation.error && ( {mutation.error.message} )} {/* CTA */} {isMutating ? ( {statusLabel} ) : mutation.status === 'success' ? ( Game Created! ) : ( {selectedTeam ? `Create Game \u2014 ${formatSol(wager)} SOL` : 'Pick a side to start'} )} ); } 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: 'flex-start', justifyContent: 'space-between', paddingVertical: 8 }, headerTitle: { fontSize: 20, fontWeight: '700' }, headerSub: { fontSize: 13, marginTop: 2 }, closeButton: { fontSize: 20, padding: 4 }, section: { gap: 10, paddingTop: 12 }, sectionLabel: { fontSize: 14, fontWeight: '600' }, teamsRow: { flexDirection: 'row', gap: 12 }, summaryCard: { marginTop: 12, borderRadius: 14, borderWidth: 1, overflow: 'hidden' }, summaryRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 12 }, summaryLabel: { fontSize: 14 }, summaryValue: { fontSize: 15, fontWeight: '700' }, summarySep: { height: 1, marginHorizontal: 16 }, errorBox: { marginTop: 12, borderRadius: 12, borderWidth: 1, padding: 12 }, errorText: { fontSize: 13, fontWeight: '500' }, ctaButton: { marginTop: 16, height: 54, 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' }, 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 }, });