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 && (
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 */}
{myRound.stock_count > 0 ? (
<>
π
>
) : (
Empty
)}
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 */}
{myRound.discard_top ? (
<>
{(() => {
const card = parseCardCode(myRound.discard_top);
if (card.joker) {
return (
π
JOKER
);
}
const isRed = card.suit === 'H' || card.suit === 'D';
const suitSymbol = card.suit ? { H: 'β₯', D: 'β¦', S: 'β ', C: 'β£' }[card.suit] : '';
return (
<>
{card.rank}{suitSymbol}
{suitSymbol}
{card.rank}
>
);
})()}
>
) : (
Empty
)}
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 && (
Clear Melds
)}
{/* 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
{acting ? "Discarding..." : `Discard ${selectedCard.rank}${selectedCard.suit || ''}`}
)}
{/* Declare & Discard Actions - When turn is active */}
{isMyTurn && hasDrawn && (
Organize 13 cards into valid melds, then declare. The 14th card will be auto-discarded.
{
console.log('π΄ DECLARE BUTTON CLICKED!');
console.log('π΄ Button state:', { isMyTurn, hasDrawn, acting, tableId });
console.log('π΄ Melds:', { meld1: meld1?.length, meld2: meld2?.length, meld3: meld3?.length, leftover: leftover?.length });
onDeclare();
}}
disabled={acting}
className="inline-flex items-center gap-2 px-4 py-3 bg-purple-700 text-purple-100 rounded hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
{acting ? "Declaring..." : "Declare & Win"}
{selectedCard && (
Discard Selected
)}
)}
)}
{/* 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"}
)}
| Player |
Points |
{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 (
|
{isWinner && }
{player?.display_name || score.user_id.slice(0, 6)}
|
{score.points}
|
);
})}
{/* Next Round Button */}
{user && info.host_user_id === user.id && (
{acting ? "Starting..." : "Start Next Round"}
)}
{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 && (
{acting ? 'Starting...' : 'βΆ Start Next Round'}
)}
)}
{/* Sidebar - Table Info with Round History */}
{tableInfoVisible && (
{/* Header with Minimize/Close */}
{tableInfoMinimized ? 'Table' : 'Table Info'}
setTableInfoMinimized(!tableInfoMinimized)}
className="p-1 hover:bg-muted rounded"
title={tableInfoMinimized ? 'Expand' : 'Minimize'}
>
{tableInfoMinimized ? : }
setTableInfoVisible(false)}
className="p-1 hover:bg-muted rounded"
title="Close"
>
{/* Content - only show when not minimized */}
{!tableInfoMinimized && (
{loading &&
Loadingβ¦
}
{!loading && info && (
<>
{/* Room Code */}
Room Code
{info.code}
{
navigator.clipboard.writeText(info.code);
toast.success("Code copied!");
}}
className="p-1.5 hover:bg-muted rounded"
>
{/* 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 && (
{starting ? "Startingβ¦" : "Start Game"}
)}
{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
| Player |
{roundHistory.map((round, idx) => (
R{round.round_number}
|
))}
Total
|
{info.players.map((player) => {
let runningTotal = 0;
return (
|
{player.display_name || 'Player'}
|
{roundHistory.map((round, idx) => {
const isWinner = round.winner_user_id === player.user_id;
const roundScore = round.scores[player.user_id] || 0;
runningTotal += roundScore;
return (
{roundScore}
{isWinner && }
|
);
})}
{runningTotal}
|
);
})}
)}
>
)}
)}
)}
{/* Show Table Info button when closed */}
{!tableInfoVisible && (
setTableInfoVisible(true)}
className="fixed top-20 right-4 z-20 bg-card border border-border rounded-lg shadow-lg px-4 py-2 hover:bg-accent/50 transition-colors"
>
Show Table Info
)}
{/* Spectate Requests Panel (Host Only) */}
{info?.host_user_id === user?.id && spectateRequests.length > 0 && (
Spectate Requests
{spectateRequests.map(userId => (
{userId.slice(0, 8)}...
grantSpectate(userId)}
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
>
Allow
setSpectateRequests(prev => prev.filter(id => id !== userId))}
variant="destructive"
size="sm"
className="h-6 px-2 text-xs"
>
Deny
))}
)}
{/* 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
setShowScoreboardPanel(false)}
className="text-gray-400 hover:text-white transition-colors"
>
{/* 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 && (
{
try {
await apiclient.start_next_round();
setShowScoreboardPanel(false);
setRevealedHands(null);
await refresh();
toast.success("New round started!");
} catch (error) {
console.error("Error starting next round:", error);
toast.error("Failed to start next round");
}
}}
className="w-full mt-6 bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg transition-colors"
>
Start Next Round
)}
)}
{/* 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 && (
)}
);
}