import React, { useEffect, useMemo, useState } from "react"; import { useSearchParams, useNavigate } from "react-router-dom"; import apiclient from "../apiclient"; import type { GetTableInfoParams, TableInfoResponse, StartGameRequest, GetRoundMeParams, RoundMeResponse, DrawRequest, DiscardRequest, DiscardCard, DeclareRequest, ScoreboardResponse, RoundScoreboardParams, GetRevealedHandsParams, RevealedHandsResponse, LockSequenceRequest, GrantSpectateRequest } from "../apiclient/data-contracts"; import { Copy, Check, Crown, User2, Play, ArrowDown, Trash2, Trophy, X, ChevronDown, ChevronUp, LogOut, Mic, MicOff, UserX, Eye } from "lucide-react"; import { toast } from "sonner"; import { HandStrip } from "components/HandStrip"; import { useUser } from "@stackframe/react"; import { TableDiagram } from "components/TableDiagram"; import { GameRules } from 'components/GameRules'; import { CasinoTable3D } from 'components/CasinoTable3D'; import { PlayerProfile } from 'components/PlayerProfile'; import { PlayingCard } from "components/PlayingCard"; import { Button } from "@/components/ui/button"; import { ScoreboardModal } from "components/ScoreboardModal"; import { WildJokerRevealModal } from "components/WildJokerRevealModal"; import { PointsTable } from "components/PointsTable"; import { parseCardCode } from "utils/cardCodeUtils"; import ChatSidebar from "components/ChatSidebar"; import VoicePanel from 'components/VoicePanel'; import SpectateControls from 'components/SpectateControls'; import HistoryTable from 'components/HistoryTable'; // CardBack component with red checkered pattern const CardBack = ({ className = "" }: { className?: string }) => (
); // Helper component for a 3-card meld slot box interface MeldSlotBoxProps { title: string; slots: (RoundMeResponse["hand"][number] | null)[]; setSlots: (slots: (RoundMeResponse["hand"][number] | null)[]) => void; myRound: RoundMeResponse | null; setMyRound: (round: RoundMeResponse) => void; isLocked?: boolean; onToggleLock?: () => void; tableId: string; onRefresh: () => void; hideLockButton?: boolean; gameMode?: string; // Add game mode prop } const MeldSlotBox = ({ title, slots, setSlots, myRound, setMyRound, isLocked = false, onToggleLock, tableId, onRefresh, hideLockButton, gameMode }: MeldSlotBoxProps) => { const [locking, setLocking] = useState(false); const [showRevealModal, setShowRevealModal] = useState(false); const [revealedRank, setRevealedRank] = useState(null); const handleSlotDrop = (slotIndex: number, cardData: string) => { if (!myRound || isLocked) { if (isLocked) toast.error('Unlock meld first to modify'); return; } const card = JSON.parse(cardData); // Check if slot is already occupied if (slots[slotIndex] !== null) { toast.error('Slot already occupied'); return; } // Place card in slot const newSlots = [...slots]; newSlots[slotIndex] = card; setSlots(newSlots); toast.success(`Card placed in ${title} slot ${slotIndex + 1}`); }; const handleSlotClick = (slotIndex: number) => { if (!myRound || slots[slotIndex] === null || isLocked) { if (isLocked) toast.error('Unlock meld first to modify'); return; } // Return card to hand const card = slots[slotIndex]!; const newSlots = [...slots]; newSlots[slotIndex] = null; setSlots(newSlots); toast.success('Card returned to hand'); }; const handleLockSequence = async () => { console.log('πŸ”’ Lock button clicked!'); console.log('Slots:', slots); console.log('Table ID:', tableId); // Validate that all 3 slots are filled const cards = slots.filter(s => s !== null); console.log('Filled cards:', cards); if (cards.length !== 3) { console.log('❌ Not enough cards:', cards.length); toast.error('Fill all 3 slots to lock a sequence'); return; } console.log('βœ… Starting lock sequence API call...'); setLocking(true); try { // Safely map cards with explicit null-checking const meldCards = cards.map(card => { if (!card) { throw new Error('Null card found in meld'); } return { rank: card.rank, suit: card.suit || null }; }); const body: LockSequenceRequest = { table_id: tableId, meld: meldCards }; console.log('Request body:', body); console.log('πŸ“‘ Calling apiclient.lock_sequence...'); const res = await apiclient.lock_sequence(body); console.log('βœ… API response received:', res); console.log('Response status:', res.status); console.log('Response ok:', res.ok); const data = await res.json(); console.log('πŸ“¦ Response data:', data); if (data.success) { toast.success(data.message); if (onToggleLock) onToggleLock(); // Lock the meld in UI // Show flip animation popup if wild joker was just revealed if (data.wild_joker_revealed && data.wild_joker_rank) { setRevealedRank(data.wild_joker_rank); setShowRevealModal(true); setTimeout(() => fetchRoundMe(), 500); // Refresh to show revealed wild joker } } else { toast.error(data.message); } } catch (err: any) { console.log('❌ Lock sequence error:'); console.log(err?.status, err?.statusText, '-', err?.error?.detail || 'An unexpected error occurred.'); console.log('Error type:', typeof err); console.log('Error name:', err?.name); console.log('Error message:', err?.message); console.log('Error stack:', err?.stack); toast.error(err?.error?.detail || err?.message || 'Failed to lock sequence'); } finally { setLocking(false); console.log('🏁 Lock sequence attempt completed'); } }; return ( <>

{title} (3 cards)

{/* Only show lock button if game mode uses wild jokers */} {!isLocked && gameMode !== 'no_joker' && ( )} {onToggleLock && ( )}
{slots.map((card, i) => (
{ e.preventDefault(); e.currentTarget.classList.add('ring-2', 'ring-purple-400'); }} onDragLeave={(e) => { e.currentTarget.classList.remove('ring-2', 'ring-purple-400'); }} onDrop={(e) => { e.preventDefault(); e.currentTarget.classList.remove('ring-2', 'ring-purple-400'); const cardData = e.dataTransfer.getData('card'); if (cardData) handleSlotDrop(i, cardData); }} onClick={() => handleSlotClick(i)} className="w-[60px] h-[84px] border-2 border-dashed border-muted-foreground/20 rounded bg-background/50 flex items-center justify-center cursor-pointer hover:border-purple-400/50 transition-all" > {card ? (
{}} />
) : ( {i + 1} )}
))}
{/* Wild Joker Reveal Modal */} {revealedRank && ( setShowRevealModal(false)} wildJokerRank={revealedRank} /> )} ); }; // Helper component for 4-card leftover slot box interface LeftoverSlotBoxProps { slots: (RoundMeResponse["hand"][number] | null)[]; setSlots: (slots: (RoundMeResponse["hand"][number] | null)[]) => void; myRound: RoundMeResponse | null; setMyRound: (round: RoundMeResponse) => void; isLocked?: boolean; onToggleLock?: () => void; tableId: string; onRefresh: () => void; gameMode?: string; // Add game mode prop } const LeftoverSlotBox = ({ slots, setSlots, myRound, setMyRound, isLocked = false, onToggleLock, tableId, onRefresh, gameMode }: LeftoverSlotBoxProps) => { const [locking, setLocking] = useState(false); const [showRevealModal, setShowRevealModal] = useState(false); const [revealedRank, setRevealedRank] = useState(null); const handleSlotDrop = (slotIndex: number, cardData: string) => { if (!myRound || isLocked) return; const card = JSON.parse(cardData); // Check if slot is already occupied if (slots[slotIndex] !== null) { toast.error('Slot already occupied'); return; } // Place card in slot const newSlots = [...slots]; newSlots[slotIndex] = card; setSlots(newSlots); toast.success(`Card placed in leftover slot ${slotIndex + 1}`); }; const handleSlotClick = (slotIndex: number) => { if (!myRound || slots[slotIndex] === null) return; // Return card to hand const card = slots[slotIndex]!; const newSlots = [...slots]; newSlots[slotIndex] = null; setSlots(newSlots); toast.success('Card returned to hand'); }; const handleLockSequence = async () => { console.log('πŸ”’ Lock button clicked (4-card)!'); console.log('Slots:', slots); console.log('Table ID:', tableId); // Validate that all 4 slots are filled const cards = slots.filter(s => s !== null); console.log('Filled cards:', cards); if (cards.length !== 4) { console.log('❌ Not enough cards:', cards.length); toast.error('Fill all 4 slots to lock a sequence'); return; } console.log('βœ… Starting lock sequence API call (4-card)...'); setLocking(true); try { // Safely map cards with explicit null-checking const meldCards = cards.map(card => { if (!card) { throw new Error('Null card found in meld'); } return { rank: card.rank, suit: card.suit || null }; }); const body: LockSequenceRequest = { table_id: tableId, meld: meldCards }; console.log('Request body:', body); console.log('πŸ“‘ Calling apiclient.lock_sequence...'); const res = await apiclient.lock_sequence(body); console.log('βœ… API response received:', res); console.log('Response status:', res.status); console.log('Response ok:', res.ok); const data = await res.json(); console.log('πŸ“¦ Response data:', data); if (data.success) { toast.success(data.message); if (onToggleLock) onToggleLock(); // Lock the meld in UI // Show flip animation popup if wild joker was just revealed if (data.wild_joker_revealed && data.wild_joker_rank) { setRevealedRank(data.wild_joker_rank); setShowRevealModal(true); } onRefresh(); // Refresh to get updated wild joker status } else { toast.error(data.message); } } catch (err: any) { console.log('❌ Lock sequence error (4-card):'); console.log(err?.status, err?.statusText, '-', err?.error?.detail || 'An unexpected error occurred.'); console.log('Error type:', typeof err); console.log('Error name:', err?.name); console.log('Error message:', err?.message); console.log('Error stack:', err?.stack); console.log('Full error object:', err); toast.error(err?.error?.detail || err?.message || 'Failed to lock sequence'); } finally { setLocking(false); console.log('🏁 Lock sequence attempt completed (4-card)'); } }; return ( <>

Leftover / 4-Card Seq

{/* Only show lock button if game mode uses wild jokers */} {!isLocked && gameMode !== 'no_joker' && ( )} {onToggleLock && ( )}
{slots.map((card, i) => (
{ e.preventDefault(); e.currentTarget.classList.add('ring-2', 'ring-blue-400'); }} onDragLeave={(e) => { e.currentTarget.classList.remove('ring-2', 'ring-blue-400'); }} onDrop={(e) => { e.preventDefault(); e.currentTarget.classList.remove('ring-2', 'ring-blue-400'); const cardData = e.dataTransfer.getData('card'); if (cardData) handleSlotDrop(i, cardData); }} onClick={() => handleSlotClick(i)} className="w-[60px] h-[84px] border-2 border-dashed border-muted-foreground/20 rounded bg-background/50 flex items-center justify-center cursor-pointer hover:border-blue-400/50 transition-all" > {card ? (
{}} />
) : ( {i + 1} )}
))}
{/* Wild Joker Reveal Modal */} {revealedRank && ( setShowRevealModal(false)} wildJokerRank={revealedRank} /> )} ); }; export default function Table() { const navigate = useNavigate(); const [sp] = useSearchParams(); const user = useUser(); const tableId = sp.get("tableId"); // State const [loading, setLoading] = useState(true); const [info, setInfo] = useState(null); const [myRound, setMyRound] = useState(null); const [copied, setCopied] = useState(false); const [acting, setActing] = useState(false); const [starting, setStarting] = useState(false); const [scoreboard, setScoreboard] = useState(null); const [showScoreboard, setShowScoreboard] = useState(false); const [showWildJokerReveal, setShowWildJokerReveal] = useState(false); const [revealedWildJoker, setRevealedWildJoker] = useState(null); const [roundHistory, setRoundHistory] = useState([]); const [tableColor, setTableColor] = useState<'green' | 'red-brown'>('green'); const [voiceMuted, setVoiceMuted] = useState(false); const [droppingGame, setDroppingGame] = useState(false); const [spectateRequested, setSpectateRequested] = useState(false); const [spectateRequests, setSpectateRequests] = useState([]); const [showScoreboardModal, setShowScoreboardModal] = useState(false); const [showScoreboardPanel, setShowScoreboardPanel] = useState(false); const [revealedHands, setRevealedHands] = useState(null); // DEBUG: Monitor tableId changes and URL useEffect(() => { console.log('πŸ” Table Component - tableId from URL:', tableId); console.log('πŸ” Full URL search params:', sp.toString()); console.log('πŸ” Current full URL:', window.location.href); if (!tableId) { console.error('❌ CRITICAL: tableId is missing from URL!'); console.error('This could be caused by:'); console.error(' 1. Browser navigation/refresh losing URL params'); console.error(' 2. React Router navigation without tableId'); console.error(' 3. Component remounting unexpectedly'); } }, [tableId, sp]); const [selectedCard, setSelectedCard] = useState<{ rank: string; suit: string | null; joker: boolean } | null>(null); const [lastDrawnCard, setLastDrawnCard] = useState<{ rank: string; suit: string | null } | null>(null); const [hasDrawn, setHasDrawn] = useState(false); const [pureSeq, setPureSeq] = useState<{ rank: string; suit: string | null; joker: boolean }[]>([]); const [meld1, setMeld1] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null]); const [meld2, setMeld2] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null]); const [meld3, setMeld3] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null]); const [leftover, setLeftover] = useState<(RoundMeResponse["hand"][number] | null)[]>([null, null, null, null]); const [prevRoundFinished, setPrevRoundFinished] = useState(null); const [showPointsTable, setShowPointsTable] = useState(true); // Table Info box state const [tableInfoVisible, setTableInfoVisible] = useState(true); const [tableInfoMinimized, setTableInfoMinimized] = useState(false); const [activeTab, setActiveTab] = useState<'info' | 'history' | 'spectate'>('info'); console.log('🎨 Table.tsx render - current tableColor:', tableColor); // Meld lock state const [meldLocks, setMeldLocks] = useState<{ meld1: boolean; meld2: boolean; meld3: boolean; leftover: boolean; }>({ meld1: false, meld2: false, meld3: false, leftover: false }); // Load locked melds from localStorage on mount useEffect(() => { if (!tableId) return; const storageKey = `rummy_melds_${tableId}`; const saved = localStorage.getItem(storageKey); if (saved) { try { const { meld1: m1, meld2: m2, meld3: m3, leftover: lo, locks } = JSON.parse(saved); if (locks.meld1) setMeld1(m1); if (locks.meld2) setMeld2(m2); if (locks.meld3) setMeld3(m3); if (locks.leftover) setLeftover(lo); setMeldLocks(locks); } catch (e) { console.error('Failed to load melds from localStorage:', e); } } }, [tableId]); // Save locked melds to localStorage whenever they change useEffect(() => { if (!tableId) return; const storageKey = `rummy_melds_${tableId}`; const data = { meld1, meld2, meld3, leftover, locks: meldLocks }; localStorage.setItem(storageKey, JSON.stringify(data)); }, [tableId, meld1, meld2, meld3, leftover, meldLocks]); // Toggle lock for a specific meld const toggleMeldLock = (meldName: 'meld1' | 'meld2' | 'meld3' | 'leftover') => { setMeldLocks(prev => ({ ...prev, [meldName]: !prev[meldName] })); toast.success(`${meldName} ${!meldLocks[meldName] ? 'locked' : 'unlocked'}`); }; // Debug user object useEffect(() => { if (user) { console.log('User object:', { id: user.id, sub: user.id, displayName: user.displayName }); } }, [user]); // Get cards that are placed in slots (not in hand anymore) const placedCards = useMemo(() => { const placed = [...meld1, ...meld2, ...meld3, ...leftover].filter(c => c !== null) as RoundMeResponse["hand"]; return placed; }, [meld1, meld2, meld3, leftover]); // Filter hand to exclude placed cards - FIX for duplicate cards // Track which cards are used by counting occurrences const availableHand = useMemo(() => { if (!myRound) return []; // Count how many times each card (rank+suit combo) is placed in melds const placedCounts = new Map(); placedCards.forEach(card => { const key = `${card.rank}-${card.suit || 'null'}`; placedCounts.set(key, (placedCounts.get(key) || 0) + 1); }); // Filter hand, keeping track of how many of each card we've already filtered const seenCounts = new Map(); return myRound.hand.filter(handCard => { const key = `${handCard.rank}-${handCard.suit || 'null'}`; const placedCount = placedCounts.get(key) || 0; const seenCount = seenCounts.get(key) || 0; if (seenCount < placedCount) { // This card should be filtered out (it's in a meld) seenCounts.set(key, seenCount + 1); return false; } return true; }); }, [myRound, placedCards]); const refresh = async () => { if (!tableId) { console.error('❌ refresh() called without tableId'); return; } try { const query: GetTableInfoParams = { table_id: tableId }; const res = await apiclient.get_table_info(query); if (!res.ok) { console.error('❌ get_table_info failed with status:', res.status); // DO NOT navigate away - just log the error toast.error('Failed to refresh table info'); setLoading(false); return; } const data = await res.json(); // Check if turn changed const turnChanged = info?.active_user_id !== data.active_user_id; console.log('πŸ”„ Refresh:', { prevActiveUser: info?.active_user_id, newActiveUser: data.active_user_id, turnChanged }); setInfo(data); // If playing, also fetch my hand if (data.status === "playing") { const r: GetRoundMeParams = { table_id: tableId }; const rr = await apiclient.get_round_me(r); if (!rr.ok) { console.error('❌ get_round_me failed with status:', rr.status); toast.error('Failed to refresh hand'); setLoading(false); return; } const roundData = await rr.json(); setMyRound(roundData); // ALWAYS sync hasDrawn with actual hand length // 14 cards = player has drawn, 13 cards = player hasn't drawn yet const newHasDrawn = roundData.hand.length === 14; console.log('πŸ”„ Syncing hasDrawn with hand length:', { handLength: roundData.hand.length, newHasDrawn, previousHasDrawn: hasDrawn }); setHasDrawn(newHasDrawn); } // Clear loading state after successful fetch setLoading(false); } catch (e) { console.error("❌ Failed to refresh:", e); // DO NOT call navigate("/") here - this would cause auto-leave! toast.error('Connection error - retrying...'); setLoading(false); } }; const fetchRoundHistory = async () => { if (!info?.table_id) return; try { const response = await apiclient.get_round_history({ table_id: info.table_id }); const data = await response.json(); setRoundHistory(data.rounds || []); } catch (error) { console.error("Failed to fetch round history:", error); } }; // Auto-refresh table info and round data every 15s instead of 5s useEffect(() => { if (!tableId) return; const interval = setInterval(() => { refresh(); }, 15000); // Changed from 5000 to 15000 return () => clearInterval(interval); }, [tableId]); // Initial load on mount useEffect(() => { if (!tableId) return; refresh(); }, [tableId]); const canStart = useMemo(() => { if (!info || !user) return false; const seated = info.players.length; const isHost = user.id === info.host_user_id; return info.status === "waiting" && seated >= 2 && isHost; }, [info, user]); const isMyTurn = useMemo(() => { if (!user) return false; const userId = user.id; console.log('Turn check - active_user_id:', info?.active_user_id, 'user.id:', userId, 'match:', info?.active_user_id === userId); return info?.active_user_id === userId; }, [info, user]); // Reset hasDrawn when turn changes useEffect(() => { console.log('Turn state changed - isMyTurn:', isMyTurn, 'hasDrawn:', hasDrawn); if (!isMyTurn) { console.log('Not my turn - clearing all selection state'); setHasDrawn(false); setSelectedCard(null); setLastDrawnCard(null); } }, [isMyTurn]); const onCopy = () => { if (!info?.code) return; navigator.clipboard.writeText(info.code); setCopied(true); setTimeout(() => setCopied(false), 2000); }; const onStart = async () => { if (!info || !tableId) return; console.log("Starting game for table:", tableId, "User:", user?.id, "Host:", info.host_user_id); setStarting(true); try { const body: StartGameRequest = { table_id: tableId }; console.log("Calling start_game API with body:", body); const res = await apiclient.start_game(body); console.log("Start game response status:", res.status); if (!res.ok) { const errorText = await res.text(); console.error("Start game failed:", errorText); toast.error(`Failed to start game: ${errorText}`); return; } const data = await res.json(); toast.success(`Round #${data.number} started`); await refresh(); } catch (e: any) { console.error("Start game error:", e); toast.error(e?.message || "Failed to start game"); } finally { setStarting(false); } }; const onDrawStock = async () => { if (!tableId || !isMyTurn || hasDrawn) return; setActing(true); try { const body: DrawRequest = { table_id: tableId }; const res = await apiclient.draw_stock(body); const data = await res.json(); // Find the new card by comparing lengths const newCard = data.hand.find((card: any) => !myRound?.hand.some(c => c.rank === card.rank && c.suit === card.suit) ); if (newCard) { setLastDrawnCard({ rank: newCard.rank, suit: newCard.suit }); } setMyRound(data); setHasDrawn(true); toast.success("Drew from stock"); } catch (e: any) { toast.error("Failed to draw from stock"); } finally { setActing(false); } }; const onDrawDiscard = async () => { if (!tableId || !isMyTurn || hasDrawn) return; setActing(true); try { const body: DrawRequest = { table_id: tableId }; const res = await apiclient.draw_discard(body); const data = await res.json(); // The card being drawn is the CURRENT discard_top (before the draw) if (myRound?.discard_top) { // Parse the card code properly (e.g., "7S" -> rank="7", suit="S") const code = myRound.discard_top; let rank: string; let suit: string | null; if (code === 'JOKER') { rank = 'JOKER'; suit = null; } else { // Last char is suit, rest is rank suit = code.slice(-1); rank = code.slice(0, -1); } setLastDrawnCard({ rank, suit }); } setMyRound(data); setHasDrawn(true); toast.success("Drew from discard pile"); } catch (e: any) { toast.error("Failed to draw from discard"); } finally { setActing(false); } }; const onDiscard = async () => { if (!tableId || !selectedCard || !hasDrawn) return; setActing(true); try { const body: DiscardRequest = { table_id: tableId, card: selectedCard }; const res = await apiclient.discard_card(body); const data = await res.json(); toast.success("Card discarded. Next player's turn."); setSelectedCard(null); setLastDrawnCard(null); setHasDrawn(false); await refresh(); } catch (e: any) { toast.error("Failed to discard card"); } finally { setActing(false); } }; const fetchRevealedHands = async () => { console.log("πŸ“Š Fetching revealed hands..."); let lastError: any = null; for (let attempt = 1; attempt <= 3; attempt++) { try { const resp = await apiclient.get_revealed_hands({ table_id: tableId! }); if (!resp.ok) { const errorText = await resp.text(); console.error(`❌ API returned error (attempt ${attempt}/3):`, { status: resp.status, body: errorText }); lastError = { status: resp.status, message: errorText }; if (attempt < 3 && resp.status === 400) { console.log(`⏳ Waiting 500ms before retry ${attempt + 1}...`); await new Promise((resolve) => setTimeout(resolve, 500)); continue; } else { break; } } const data = await resp.json(); console.log("βœ… Revealed hands fetched:", data); setRevealedHands(data); setShowScoreboardModal(true); // ← CHANGED: Set modal state to true setShowScoreboardPanel(true); return data; } catch (error: any) { console.error(`❌ Error fetching revealed hands (attempt ${attempt}/3):`, error); lastError = error; if (attempt < 3) { console.log(`⏳ Waiting 500ms before retry ${attempt + 1}...`); await new Promise((resolve) => setTimeout(resolve, 500)); } else { break; } } } const errorMsg = lastError?.message || lastError?.status || "Network error"; toast.error(`Failed to load scoreboard: ${errorMsg}`); console.error("🚨 Final scoreboard error:", lastError); return null; }; const onDeclare = async () => { console.log('🎯 Declare clicked'); if (!meld1 || !meld2 || !meld3) { toast.error('Please create all 3 melds before declaring'); return; } // Count cards in melds INCLUDING leftover as meld4 const m1 = meld1?.length || 0; const m2 = meld2?.length || 0; const m3 = meld3?.length || 0; const m4 = leftover?.length || 0; const totalPlaced = m1 + m2 + m3 + m4; // Identify leftover cards (not in any meld OR leftover slot) const allMeldCards = [...(meld1 || []), ...(meld2 || []), ...(meld3 || []), ...(leftover || [])]; const unplacedCards = myRound?.hand.filter(card => { const cardKey = `${card.rank}-${card.suit || 'null'}`; return !allMeldCards.some(m => `${m.rank}-${m.suit || 'null'}` === cardKey); }) || []; if (totalPlaced !== 13) { const unplacedCount = unplacedCards.length; const unplacedDisplay = unplacedCards .map(c => `${c.rank}${c.suit || ''}`) .join(', '); toast.error( `You must place all 13 cards in melds. Currently ${totalPlaced}/13 cards placed.\n\n` + `Unplaced ${unplacedCount} card${unplacedCount > 1 ? 's' : ''}: ${unplacedDisplay}\n\n` + `Place these in Meld 1, Meld 2, Meld 3, or Leftover slots.`, { duration: 6000 } ); console.log(`❌ Not all 13 cards placed. Total: ${totalPlaced}`); return; } console.log('🎯 DECLARE BUTTON CLICKED!'); console.log('tableId:', tableId); console.log('isMyTurn:', isMyTurn); console.log('hasDrawn:', hasDrawn); console.log('hand length:', myRound?.hand.length); if (!tableId) return; if (!isMyTurn) { toast.error("It's not your turn!"); return; } // Check if player has drawn (must have 14 cards) const handLength = myRound?.hand.length || 0; if (handLength !== 14) { toast.error( `You must draw a card before declaring!\n` + `You have ${handLength} cards, but need 14 cards (13 to meld + 1 to discard).`, { duration: 5000 } ); return; } // Collect meld groups (filter out null slots) const groups: RoundMeResponse["hand"][] = []; const meld1Cards = meld1?.filter(c => c !== null) as RoundMeResponse["hand"]; if (meld1Cards.length > 0) groups.push(meld1Cards); const meld2Cards = meld2?.filter(c => c !== null) as RoundMeResponse["hand"]; if (meld2Cards.length > 0) groups.push(meld2Cards); const meld3Cards = meld3?.filter(c => c !== null) as RoundMeResponse["hand"]; if (meld3Cards.length > 0) groups.push(meld3Cards); const leftoverCards = leftover?.filter(c => c !== null) as RoundMeResponse["hand"]; if (leftoverCards.length > 0) groups.push(leftoverCards); // Skip to API call - validation already done above console.log('βœ… All checks passed, preparing API call...'); setActing(true); try { // Transform CardView to DiscardCard (remove 'code' field) const discardGroups = groups.map(group => group.map(card => ({ rank: card.rank, suit: card.suit, joker: card.joker })) ); const body: DeclareRequest = { table_id: tableId, groups: discardGroups }; console.log('πŸ“€ Sending declare request:', JSON.stringify(body, null, 2)); console.log('πŸ“‘ About to call apiclient.declare()...'); const res = await apiclient.declare(body); console.log('πŸ“¨ Received response:', res); if (res.ok) { const data = await res.json(); console.log("βœ… DECLARE COMPLETED:", data); // Show appropriate message based on valid/invalid if (data.status === 'valid') { toast.success(`πŸ† Valid declaration! You win round #${data.round_number} with 0 points!`); } else { toast.warning(`⚠️ Invalid declaration! You received 80 penalty points for round #${data.round_number}`); } console.log('🎯 Fetching revealed hands...'); await fetchRevealedHands(); console.log('βœ… Revealed hands fetched'); // Log state right after fetch console.log("πŸ” POST-FETCH STATE CHECK:"); console.log(" - showScoreboardModal:", showScoreboardModal); console.log(" - revealedHands:", revealedHands); } else { // Handle HTTP errors from backend let errorMessage = 'Failed to declare'; try { const errorData = await res.json(); errorMessage = errorData.detail || errorData.message || errorMessage; } catch { const errorText = await res.text(); errorMessage = errorText || errorMessage; } console.log('❌ Backend error:', errorMessage); toast.error(`❌ ${errorMessage}`, { duration: 5000 }); } } catch (error: any) { // Network errors or other exceptions console.error('🚨 DECLARE EXCEPTION CAUGHT:'); console.error(' Error object:', error); console.error(' Error type:', typeof error); console.error(' Error constructor:', error?.constructor?.name); console.error(' Error message:', error?.message); console.error(' Error stack:', error?.stack); console.error(' Error keys:', Object.keys(error || {})); console.error(' Full error JSON:', JSON.stringify(error, Object.getOwnPropertyNames(error))); // Try to get more details about the request that failed if (error.response) { console.error(' Response status:', error.response.status); console.error(' Response data:', error.response.data); } if (error.request) { console.error(' Request:', error.request); } // Extract actual error message from Response object or other error types let errorMsg = 'Network error'; // PRIORITY 1: Check if it's a Response object if (error instanceof Response) { try { const errorData = await error.json(); errorMsg = errorData.detail || errorData.message || 'Failed to declare'; } catch { try { const errorText = await error.text(); errorMsg = errorText || 'Failed to declare'; } catch { errorMsg = 'Failed to declare'; } } } // PRIORITY 2: Check for error.message else if (error?.message) { errorMsg = error.message; } // PRIORITY 3: Check if it's a string else if (typeof error === 'string') { errorMsg = error; } // PRIORITY 4: Try toString (but avoid [object Object]) else if (error?.toString && typeof error.toString === 'function') { const stringified = error.toString(); if (stringified !== '[object Object]' && stringified !== '[object Response]') { errorMsg = stringified; } } toast.error(`❌ Failed to declare: ${errorMsg}`, { duration: 5000 }); } finally { setActing(false); } }; const onNextRound = async () => { if (!tableId || !info) return; setStarting(true); try { const body = { table_id: tableId }; const res = await apiclient.start_next_round(body); const data = await res.json(); toast.success(`Round #${data.number} started!`); await refresh(); } catch (e: any) { toast.error(e?.message || "Failed to start next round"); } finally { setStarting(false); } }; // Drop game handler const onDropGame = async () => { if (!tableId || droppingGame) return; setDroppingGame(true); try { const body = { table_id: tableId }; const res = await apiclient.drop_game(body); await res.json(); toast.success("You have dropped from the game (20 point penalty)"); await refresh(); } catch (e: any) { toast.error(e?.message || "Failed to drop game"); } finally { setDroppingGame(false); } }; // Spectate handlers const requestSpectate = async (playerId: string) => { if (!tableId || spectateRequested) return; setSpectateRequested(true); try { const body = { table_id: tableId, player_id: playerId }; await apiclient.request_spectate(body); toast.success("Spectate request sent"); } catch (e: any) { toast.error(e?.message || "Failed to request spectate"); } }; const grantSpectate = async (spectatorId: string) => { if (!tableId) return; try { const body: GrantSpectateRequest = { table_id: tableId, spectator_id: spectatorId, granted: true }; await apiclient.grant_spectate(body); setSpectateRequests(prev => prev.filter(id => id !== spectatorId)); toast.success("Spectate access granted"); } catch (e: any) { toast.error(e?.message || "Failed to grant spectate"); } }; // Voice control handlers const toggleVoiceMute = async () => { if (!tableId || !user) return; try { const body = { table_id: tableId, user_id: user.id, muted: !voiceMuted }; await apiclient.mute_player(body); setVoiceMuted(!voiceMuted); toast.success(voiceMuted ? "Unmuted" : "Muted"); } catch (e: any) { toast.error(e?.message || "Failed to toggle mute"); } }; const onCardSelect = (card: RoundMeResponse["hand"][number], idx: number) => { if (!hasDrawn) return; setSelectedCard({ rank: card.rank, suit: card.suit || null, joker: card.joker || false }); }; const onReorderHand = (reorderedHand: RoundMeResponse["hand"]) => { if (myRound) { setMyRound({ ...myRound, hand: reorderedHand }); } }; const onSelectCard = (card: DiscardCard) => { if (!hasDrawn) return; setSelectedCard(card); }; const onClearMelds = () => { setMeld1([null, null, null]); setMeld2([null, null, null]); setMeld3([null, null, null]); setLeftover([null, null, null, null]); toast.success('Melds cleared'); }; // Debug logging for button visibility useEffect(() => { console.log('πŸ” Discard Button Visibility Check:', { isMyTurn, hasDrawn, selectedCard, handLength: myRound?.hand.length, showDiscardButton: isMyTurn && hasDrawn && selectedCard !== null, user_id: user?.id, active_user_id: info?.active_user_id }); }, [isMyTurn, hasDrawn, selectedCard, myRound, user, info]); if (!tableId) { return (

Missing tableId.

); } return (
{/* Collapsible Game Rules - positioned top right */} {/* Remove the separate PointsTable component - it's now inside Table Info */}

Table

{/* Voice Mute Toggle */} {info?.status === 'playing' && ( )} {/* Drop Game Button (only before first draw) */} {info?.status === 'playing' && !hasDrawn && ( )}
{/* Responsive Layout: Single column on mobile, two columns on desktop */}
{/* Main Game Area */}
{loading &&

Loading…

} {!loading && info && (

Room Code

{info.code}

Players

{info.players.map((p) => (

Seat {p.seat}

{p.display_name || p.user_id.slice(0,6)}

{p.user_id === info.host_user_id && ( Host )} {info.status === "playing" && p.user_id === info.active_user_id && ( Active )}
))}
{info.current_round_number && myRound && (

Round #{info.current_round_number}

{isMyTurn ? (

Your turn!

) : (

Wait for your turn

)}
Stock: {myRound.stock_count}
{myRound.discard_top && (
Discard Top: {myRound.discard_top}
)}
Wild Joker:{" "} {myRound.wild_joker_revealed ? ( {myRound.wild_joker_rank} ) : ( ??? )}
{/* Table Color Picker */}
Table Color:
{/* 3D Casino Table - Contains ONLY Stock, Discard, Wild Joker */} {/* Player Positions Around Table - Only show if player exists */}
{/* Top players (P2, P3, P4) */}
{info?.players?.[1] && } {info?.players?.[2] && } {info?.players?.[3] && }
{/* Left player (P1) */} {info?.players?.[0] && (
)} {/* Right player (P5) */} {info?.players?.[4] && (
)} {/* Bottom player (current user) */}
{/* Cards ON the Table Surface - HORIZONTAL ROW */}
{/* Stock Pile - NOW CLICKABLE */}

STOCK PILE

{myRound.stock_count > 0 && (
{myRound.stock_count} cards
)}
{/* Wild Joker Card - only show if game mode uses wild jokers */} {info?.game_mode !== 'no_joker' && (
{myRound.wild_joker_revealed && myRound.wild_joker_rank ? (
{myRound.wild_joker_rank} All {myRound.wild_joker_rank}s
) : (
πŸƒ
)}

{myRound.wild_joker_revealed ? ( WILD JOKER ) : ( WILD JOKER )}

)} {/* Discard Pile */}

DISCARD PILE

{/* Meld Grouping Zone - Outside the 3D table with clean design */}

{hasDrawn ? "Organize your 13 cards into melds (drag cards to slots)" : "Organize melds (draw a card first)"}

{/* Three 3-card meld boxes */}
{/* Meld 1 - with lock button */} toggleMeldLock('meld1')} tableId={tableId} onRefresh={refresh} gameMode={info?.game_mode} /> {/* Meld 2 - no lock button */} toggleMeldLock('meld2')} tableId={tableId} onRefresh={refresh} hideLockButton={true} gameMode={info?.game_mode} /> {/* Meld 3 - no lock button */} toggleMeldLock('meld3')} tableId={tableId} onRefresh={refresh} hideLockButton={true} gameMode={info?.game_mode} />
{/* Leftover cards */} toggleMeldLock('leftover')} tableId={tableId} onRefresh={refresh} gameMode={info?.game_mode} /> {/* Clear melds button only */} {hasDrawn && (
)}
{/* Hand */}

Your Hand ({availableHand.length} cards) {lastDrawnCard && β˜… New card highlighted}

c.rank === selectedCard.rank && c.suit === selectedCard.suit ) : undefined} highlightIndex={lastDrawnCard ? availableHand.findIndex( c => c.rank === lastDrawnCard.rank && c.suit === lastDrawnCard.suit ) : undefined} onReorder={onReorderHand} /> {/* Discard Button - Only shown when card is selected */} {isMyTurn && hasDrawn && selectedCard && (

βœ“ Card selected - Click to discard

)}
{/* Declare & Discard Actions - When turn is active */} {isMyTurn && hasDrawn && (

Organize 13 cards into valid melds, then declare. The 14th card will be auto-discarded.

{selectedCard && ( )}
)}
)} {/* Scoreboard Display */} {scoreboard && info?.status === "finished" && (

Round #{scoreboard.round_number} Complete!

{scoreboard.winner_user_id && (

πŸŽ‰ Winner: {info.players.find(p => p.user_id === scoreboard.winner_user_id)?.display_name || "Unknown"}

)}
{scoreboard.scores .sort((a, b) => a.points - b.points) .map((score, idx) => { const player = info.players.find(p => p.user_id === score.user_id); const isWinner = score.user_id === scoreboard.winner_user_id; return ( ); })}
Player Points
{isWinner && } {player?.display_name || score.user_id.slice(0, 6)}
{score.points}
{/* Next Round Button */} {user && info.host_user_id === user.id && (
)} {user && info.host_user_id !== user.id && (

Waiting for host to start next round...

)}
)} {/* Show Next Round button if user is host and round is complete */} {info?.status === 'round_complete' && info?.host_id === user?.id && (
)}
)}
{/* Sidebar - Table Info with Round History */} {tableInfoVisible && (
{/* Header with Minimize/Close */}

{tableInfoMinimized ? 'Table' : 'Table Info'}

{/* Content - only show when not minimized */} {!tableInfoMinimized && (
{loading &&

Loading…

} {!loading && info && ( <> {/* Room Code */}

Room Code

{info.code}
{/* Players */}

Players ({info.players.length})

{info.players.map((p) => (

Seat {p.seat}

{p.display_name || p.user_id.slice(0,6)}

{p.user_id === info.host_user_id && ( Host )} {info.status === "playing" && p.user_id === info.active_user_id && ( Active )}
))}
{/* Status */}

Status: {info?.status ?? "-"}

{user && info.host_user_id === user.id && ( )} {info && info.status === "waiting" && user && user.id !== info.host_user_id && (

Waiting for host to start...

)}
{/* Round History & Points Table */} {roundHistory.length > 0 && (

Round History

{roundHistory.map((round, idx) => ( ))} {info.players.map((player) => { let runningTotal = 0; return ( {roundHistory.map((round, idx) => { const isWinner = round.winner_user_id === player.user_id; const roundScore = round.scores[player.user_id] || 0; runningTotal += roundScore; return ( ); })} ); })}
Player R{round.round_number} Total
{player.display_name || 'Player'}
{roundScore} {isWinner && }
{runningTotal}
)} )}
)}
)} {/* Show Table Info button when closed */} {!tableInfoVisible && ( )} {/* Spectate Requests Panel (Host Only) */} {info?.host_user_id === user?.id && spectateRequests.length > 0 && (

Spectate Requests

{spectateRequests.map(userId => (
{userId.slice(0, 8)}...
))}
)}
{/* Scoreboard Modal */} setShowScoreboardModal(false)} data={revealedHands} players={info?.players || []} currentUserId={user?.id || ''} tableId={tableId || ''} hostUserId={info?.host_user_id || ''} onNextRound={() => { setShowScoreboardModal(false); onNextRound(); }} /> {/* Side Panel for Scoreboard - Legacy */} {showScoreboardPanel && revealedHands && (

Round Results

{/* Round Scores */}

Scores

{Object.entries(revealedHands.scores || {}).map(([uid, score]: [string, any]) => { const playerName = revealedHands.player_names?.[uid] || "Unknown"; return (
{playerName} {score} pts
); })}
{/* All Players' Hands */}
{Object.entries(revealedHands.organized_melds || {}).map(([uid, melds]: [string, any]) => { const playerName = revealedHands.player_names?.[uid] || "Unknown"; const playerScore = revealedHands.scores?.[uid] || 0; const isWinner = playerScore === 0; return (

{playerName} {isWinner && " πŸ†"}

{playerScore} pts
{melds && melds.length > 0 ? (
{melds.map((meld: any, idx: number) => { const meldType = meld.type || "unknown"; let bgColor = "bg-gray-700"; let borderColor = "border-gray-600"; let label = "Cards"; if (meldType === "pure") { bgColor = "bg-blue-900/40"; borderColor = "border-blue-500"; label = "Pure Sequence"; } else if (meldType === "impure") { bgColor = "bg-purple-900/40"; borderColor = "border-purple-500"; label = "Impure Sequence"; } else if (meldType === "set") { bgColor = "bg-orange-900/40"; borderColor = "border-orange-500"; label = "Set"; } return (
{label}
{(meld.cards || []).map((card: any, cardIdx: number) => (
{card.name || card.code || "??"}
))}
); })}
) : (
No melds
)}
); })}
{/* Next Round Button */} {revealedHands.can_start_next && ( )}
)} {/* Chat Sidebar - Fixed position */} {user && info && tableId && ( ({ userId: p.user_id, displayName: p.display_name || p.user_id.slice(0, 6) }))} /> )} {/* Voice Panel - Fixed position */} {user && info && tableId && ( )}
); }