Stock-Rivals / App.tsx
RonitP1's picture
Upload 16 files
b05b936 verified
Raw
History Blame Contribute Delete
86.8 kB
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { io, Socket } from 'socket.io-client';
import { motion, AnimatePresence } from 'motion/react';
import {
TrendingUp,
TrendingDown,
Users,
Wallet,
ArrowRight,
Play,
Plus,
Minus,
RefreshCw,
Trophy,
Info,
Zap,
Landmark,
Radio,
Building2,
Cpu,
CreditCard,
Code,
Flame,
Droplets,
Bolt,
Coins,
Globe,
Activity,
Shield,
X
} from 'lucide-react';
const STOCK_ICONS: Record<string, any> = {
Zap,
Landmark,
Radio,
Building2,
Cpu,
CreditCard,
Code,
Flame,
Droplets,
Bolt,
Coins,
Globe,
Activity
};
// --- Constants ---
const INITIAL_CASH = 800000;
const MIN_BUY_AMOUNT = 1000;
const INITIAL_STOCK_PRICE = 100;
const ROUNDS_COUNT = 5;
const TURNS_PER_ROUND = 3;
const MIN_STOCK_PRICE = 10;
const CARD_VALUES = [-15, -10, -5, 5, 10, 15, 30];
const MARKET_CAP_PER_STOCK = 200000;
type WindfallType = 'SHARE_SUSPENDED' | 'LOAN_STOCK_MATURED' | 'DEBENTURE' | 'RIGHTS_ISSUE';
const WINDFALL_DETAILS: Record<WindfallType, { name: string, icon: string, description: string, label: string }> = {
SHARE_SUSPENDED: {
name: 'Share Suspended',
icon: '🔒',
description: 'Revert a company price to start of turn.',
label: 'Play Share Suspended'
},
LOAN_STOCK_MATURED: {
name: 'Loan Stock Matured',
icon: '💰',
description: 'Receive ₹1,00,000 cash.',
label: 'Claim Loan Stock Matured (+₹1,00,000)'
},
DEBENTURE: {
name: 'Debenture',
icon: '📜',
description: 'Redeem insolvent shares at opening price.',
label: 'Play Debenture — Redeem Bankrupt Shares at Opening Price'
},
RIGHTS_ISSUE: {
name: 'Rights Issue',
icon: '📋',
description: 'Buy 1 share for every 2 at ₹10.',
label: 'Play Rights Issue'
},
};
const STOCKS = [
{ id: 'WOCKHARDT', name: 'Wockhardt', icon: 'Activity', initialPrice: 20, color: 'text-pink-500', bgColor: 'bg-pink-500/10', borderColor: 'border-pink-500/20', cardGradient: 'from-pink-600 to-pink-900 border-pink-400/30' },
{ id: 'HDFC', name: 'HDFC', icon: 'Landmark', initialPrice: 25, color: 'text-red-500', bgColor: 'bg-red-500/10', borderColor: 'border-red-500/20', cardGradient: 'from-red-600 to-red-900 border-red-400/30' },
{ id: 'TATA', name: 'Tata', icon: 'Zap', initialPrice: 30, color: 'text-yellow-500', bgColor: 'bg-yellow-500/10', borderColor: 'border-yellow-500/20', cardGradient: 'from-yellow-600 to-yellow-900 border-yellow-400/30' },
{ id: 'ITC', name: 'ITC', icon: 'Flame', initialPrice: 40, color: 'text-emerald-500', bgColor: 'bg-emerald-500/10', borderColor: 'border-emerald-500/20', cardGradient: 'from-emerald-600 to-emerald-900 border-emerald-400/30' },
{ id: 'ONGC', name: 'ONGC', icon: 'Droplets', initialPrice: 55, color: 'text-orange-500', bgColor: 'bg-orange-500/10', borderColor: 'border-orange-500/20', cardGradient: 'from-orange-600 to-orange-900 border-orange-400/30' },
{ id: 'SBI', name: 'SBI', icon: 'Building2', initialPrice: 60, color: 'text-violet-500', bgColor: 'bg-violet-500/10', borderColor: 'border-violet-500/20', cardGradient: 'from-violet-600 to-violet-900 border-violet-400/30' },
{ id: 'REL', name: 'Rel', icon: 'Zap', initialPrice: 75, color: 'text-indigo-500', bgColor: 'bg-indigo-500/10', borderColor: 'border-indigo-500/20', cardGradient: 'from-indigo-600 to-indigo-900 border-indigo-400/30' },
{ id: 'INFOSYS', name: 'Infosys', icon: 'Cpu', initialPrice: 80, color: 'text-blue-500', bgColor: 'bg-blue-500/10', borderColor: 'border-blue-500/20', cardGradient: 'from-blue-600 to-blue-900 border-blue-400/30' },
];
// --- Types ---
type Stock = {
id: string;
name: string;
price: number;
history: number[];
icon: string;
availableShares: number;
color: string;
bgColor: string;
borderColor: string;
cardGradient: string;
isInsolvent: boolean;
chairmanId?: string;
};
type GameCard = {
stockId?: string;
value?: number;
windfallType?: WindfallType;
};
type Player = {
id: string;
playerId: string;
name: string;
cash: number;
portfolio: Record<string, number>;
cards: GameCard[];
playedCards?: GameCard[];
isHost: boolean;
isReady: boolean;
lastAction?: string;
};
type RevealStep = {
stockId: string;
originalCards: { playerId: string, value: number }[];
vetoedCard?: { playerId: string, value: number };
directorDiscarded?: { playerId: string, value: number };
finalChange: number;
newPrice: number;
recovered?: boolean;
becameInsolvent?: boolean;
};
type GameState = {
status: 'setup' | 'lobby' | 'playing' | 'reveal' | 'ended';
players: Player[];
stocks: Stock[];
round: number;
turn: number;
currentPlayerIndex: number;
hostId: string;
roomId: string;
turnActionsCount: number;
maxPlayers?: number;
maxRounds?: number;
revealSteps?: RevealStep[];
windfallDeck: WindfallType[];
suspendedStockId?: string;
pendingRightsIssue?: {
initiatorId: string;
stockId: string;
decisions: Record<string, boolean | null>; // playerId -> true/false/null
};
};
// --- Game Logic Helpers ---
const generateCards = (windfallDeck: WindfallType[]) => {
const cards: GameCard[] = [];
// Helper for weighted selection (higher index = higher probability)
const getWeightedIndex = (length: number) => {
const totalWeight = (length * (length + 1)) / 2;
let r = Math.random() * totalWeight;
for (let i = 0; i < length; i++) {
const weight = i + 1;
if (r < weight) return i;
r -= weight;
}
return length - 1;
};
for (let i = 0; i < 10; i++) {
// 10% chance of a windfall card if deck is not empty
if (Math.random() < 0.1 && windfallDeck.length > 0) {
cards.push({ windfallType: windfallDeck.pop() });
} else {
// 1. Weighted Stock Selection (higher index = more likely)
const stockIndex = getWeightedIndex(STOCKS.length);
const stock = STOCKS[stockIndex];
// 2. Dynamic Caps based on stock index
// Wockhardt (index 0): min -5, max 10
// Infosys (index 7): min -15, max 30
// Linear interpolation: min = -5 - (index * 10/7), max = 10 + (index * 20/7)
const minCap = -5 - (stockIndex * (10 / 7));
const maxCap = 10 + (stockIndex * (20 / 7));
// 3. Filter and Weighted Value Selection (higher value = more likely)
const validValues = CARD_VALUES.filter(v => v >= minCap && v <= maxCap);
const valueIndex = getWeightedIndex(validValues.length);
const value = validValues[valueIndex];
cards.push({ stockId: stock.id, value });
}
}
return cards;
};
const shuffle = <T,>(array: T[]): T[] => {
const newArray = [...array];
for (let i = newArray.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
}
return newArray;
};
const processAction = (state: GameState, playerId: string, action: any): GameState => {
const newState = JSON.parse(JSON.stringify(state)) as GameState;
const player = newState.players.find(p => p.id === playerId);
if (!player) return state;
if (action.type === 'buy') {
const stock = newState.stocks.find(s => s.id === action.stockId);
if (!stock) return state;
if (stock.isInsolvent) {
player.lastAction = `Failed: ${stock.id} is Insolvent`;
return newState;
}
if (player.cash >= stock.price * action.amount &&
action.amount >= MIN_BUY_AMOUNT &&
action.amount % 1000 === 0 &&
stock.availableShares >= action.amount) {
const oldShares = player.portfolio[action.stockId] || 0;
const newShares = oldShares + action.amount;
player.cash -= stock.price * action.amount;
player.portfolio[action.stockId] = newShares;
stock.availableShares -= action.amount;
player.lastAction = `Bought ${action.amount} ${stock.id}`;
// Check for Chairman
if (newShares >= 100000 && !stock.chairmanId) {
stock.chairmanId = player.id;
}
}
} else if (action.type === 'sell') {
const stock = newState.stocks.find(s => s.id === action.stockId);
if (!stock) return state;
if (stock.isInsolvent) {
player.lastAction = `Failed: ${stock.id} is Insolvent`;
return newState;
}
const owned = player.portfolio[action.stockId] || 0;
if (owned >= action.amount && action.amount % 1000 === 0) {
player.cash += stock.price * action.amount;
player.portfolio[action.stockId] = owned - action.amount;
stock.availableShares += action.amount;
player.lastAction = `Sold ${action.amount} ${stock.id}`;
// If Chairman sells below 100k, they lose it?
// Rule says "first to reach 1,00,000 gets it; in a tie, the player who reached it first keeps it."
// Usually Chairman is lost if you drop below. Let's assume they lose it.
if (player.id === stock.chairmanId && player.portfolio[action.stockId] < 100000) {
stock.chairmanId = undefined;
// Check if anyone else qualifies now?
const nextChairman = newState.players
.filter(p => (p.portfolio[action.stockId] || 0) >= 100000)
.sort((a, b) => 0) // We don't have time history, so just pick one or leave empty
[0];
if (nextChairman) stock.chairmanId = nextChairman.id;
}
}
} else if (action.type === 'pass') {
player.lastAction = 'Passed';
} else if (action.type === 'play_windfall') {
const cardIndex = player.cards.findIndex(c => c.windfallType === action.cardType);
if (cardIndex === -1) return state;
if (action.cardType === 'LOAN_STOCK_MATURED') {
player.cash += 100000;
player.lastAction = 'Played Loan Stock Matured (+₹1,00,000)';
player.cards.splice(cardIndex, 1);
} else if (action.cardType === 'DEBENTURE') {
let totalRedeemed = 0;
newState.stocks.forEach(stock => {
if (stock.isInsolvent) {
const shares = player.portfolio[stock.id] || 0;
if (shares > 0) {
const initialStock = STOCKS.find(s => s.id === stock.id);
const openingPrice = initialStock?.initialPrice || 100;
const amount = shares * openingPrice;
player.cash += amount;
player.portfolio[stock.id] = 0;
stock.availableShares += shares;
totalRedeemed += amount;
}
}
});
player.lastAction = `Played Debenture (Redeemed ₹${totalRedeemed.toLocaleString()})`;
player.cards.splice(cardIndex, 1);
} else if (action.cardType === 'RIGHTS_ISSUE') {
const stock = newState.stocks.find(s => s.id === action.stockId);
if (stock) {
newState.pendingRightsIssue = {
initiatorId: playerId,
stockId: action.stockId,
decisions: {}
};
newState.players.forEach(p => {
if ((p.portfolio[action.stockId] || 0) > 0) {
newState.pendingRightsIssue!.decisions[p.id] = null;
}
});
player.lastAction = `Initiated Rights Issue for ${stock.id}`;
// We don't remove the card yet, we'll remove it when the rights issue is finalized
// Actually, let's remove it now to prevent multiple initiations
player.cards.splice(cardIndex, 1);
}
} else if (action.cardType === 'SHARE_SUSPENDED') {
const stock = newState.stocks.find(s => s.id === action.stockId);
if (stock) {
newState.suspendedStockId = stock.id;
const oldPrice = stock.history.length > 1 ? stock.history[stock.history.length - 2] : stock.price;
stock.price = oldPrice;
stock.history[stock.history.length - 1] = oldPrice;
player.lastAction = `Suspended ${stock.id} price movement`;
player.cards.splice(cardIndex, 1);
}
}
return newState;
} else if (action.type === 'rights_issue_decision') {
if (!newState.pendingRightsIssue) return state;
newState.pendingRightsIssue.decisions[playerId] = action.participate;
const allDecided = Object.values(newState.pendingRightsIssue.decisions).every(d => d !== null);
if (allDecided) {
const stockId = newState.pendingRightsIssue.stockId;
const stock = newState.stocks.find(s => s.id === stockId)!;
const initiatorIndex = newState.players.findIndex(p => p.id === newState.pendingRightsIssue!.initiatorId);
const playersOrder = [
...newState.players.slice(initiatorIndex),
...newState.players.slice(0, initiatorIndex)
];
playersOrder.forEach(p => {
if (newState.pendingRightsIssue!.decisions[p.id]) {
const currentShares = p.portfolio[stockId] || 0;
const requestedShares = Math.floor(currentShares / 2000) * 1000; // Round down (e.g. 13,000 -> 6,000)
const actualShares = Math.min(requestedShares, stock.availableShares);
const cost = actualShares * 10;
if (p.cash >= cost && actualShares > 0) {
p.cash -= cost;
p.portfolio[stockId] = currentShares + actualShares;
stock.availableShares -= actualShares;
}
}
});
newState.pendingRightsIssue = undefined;
}
return newState;
}
// Move to next player
newState.currentPlayerIndex = (newState.currentPlayerIndex + 1) % newState.players.length;
newState.turnActionsCount += 1;
// Check if turn is over
if (newState.turnActionsCount >= newState.players.length) {
if (newState.turn < TURNS_PER_ROUND) {
// Move to next turn within the same round
newState.turn += 1;
newState.turnActionsCount = 0;
newState.currentPlayerIndex = 0;
} else {
// End of round: reveal prices
newState.status = 'reveal';
}
}
return newState;
};
const calculateReveal = (state: GameState): GameState => {
const newState = JSON.parse(JSON.stringify(state)) as GameState;
const revealSteps: RevealStep[] = [];
newState.stocks.forEach(stock => {
const originalCards: { playerId: string, value: number }[] = [];
newState.players.forEach(p => {
const cardsToReveal = p.cards;
cardsToReveal.filter(c => c.stockId === stock.id).forEach(c => {
originalCards.push({ playerId: p.id, value: c.value! });
});
});
let cardsToSum = [...originalCards];
let vetoedCard: { playerId: string, value: number } | undefined;
let directorDiscarded: { playerId: string, value: number } | undefined;
// 1. Chairman Privilege (Priority)
if (stock.chairmanId) {
const negativeCards = cardsToSum.filter(c => c.value < 0).sort((a, b) => a.value - b.value);
if (negativeCards.length > 0) {
vetoedCard = negativeCards[0];
const index = cardsToSum.findIndex(c => c === vetoedCard);
if (index !== -1) cardsToSum.splice(index, 1);
}
}
// 2. Director Privilege
const directors = newState.players.filter(p => {
const shares = p.portfolio[stock.id] || 0;
return shares >= 50000 && shares < 100000 && p.id !== stock.chairmanId;
});
directors.forEach(director => {
const directorCards = cardsToSum.filter(c => c.playerId === director.id);
if (directorCards.length > 0) {
const worstCard = directorCards.sort((a, b) => a.value - b.value)[0];
directorDiscarded = worstCard;
const index = cardsToSum.findIndex(c => c === worstCard);
if (index !== -1) cardsToSum.splice(index, 1);
}
});
const totalChange = cardsToSum.reduce((sum, c) => sum + c.value, 0);
const oldPrice = stock.price;
let newPrice = stock.price + totalChange;
let recovered = false;
let becameInsolvent = false;
if (stock.isInsolvent) {
if (totalChange > 0) {
newPrice = 1;
stock.isInsolvent = false;
recovered = true;
} else {
newPrice = 0;
}
} else {
if (newPrice <= 0) {
newPrice = 0;
stock.isInsolvent = true;
becameInsolvent = true;
}
}
stock.price = newPrice;
stock.history.push(stock.price);
revealSteps.push({
stockId: stock.id,
originalCards,
vetoedCard,
directorDiscarded,
finalChange: totalChange,
newPrice,
recovered,
becameInsolvent
});
});
newState.revealSteps = revealSteps;
return newState;
};
const startNextTurn = (state: GameState): GameState => {
const newState = JSON.parse(JSON.stringify(state)) as GameState;
// Reset for next round
newState.turnActionsCount = 0;
newState.currentPlayerIndex = 0;
newState.suspendedStockId = undefined; // Clear suspension for next turn
newState.revealSteps = undefined; // Clear previous reveal
newState.players.forEach(p => {
p.lastAction = undefined;
p.playedCards = []; // Clear accumulated cards
p.cards = generateCards(newState.windfallDeck);
});
newState.turn = 1;
newState.round += 1;
if (newState.round > (newState.maxRounds || ROUNDS_COUNT)) {
newState.status = 'ended';
} else {
newState.status = 'playing';
}
return newState;
};
// --- Helper Components ---
const TickerBackground = () => {
const tickerItems = useMemo(() => {
return [...STOCKS, ...STOCKS].map((stock, i) => ({
...stock,
price: 100 + Math.floor(Math.random() * 500),
change: (Math.random() * 10 - 5).toFixed(2)
}));
}, []);
return (
<div className="fixed inset-0 overflow-hidden pointer-events-none z-0 opacity-20">
<div className="absolute top-0 left-0 w-full h-full flex flex-col justify-around py-10">
{[0, 1, 2].map((row) => (
<div key={row} className="flex whitespace-nowrap overflow-hidden">
<motion.div
animate={{ x: row % 2 === 0 ? [0, -1000] : [-1000, 0] }}
transition={{
duration: 30 + row * 5,
repeat: Infinity,
ease: "linear"
}}
className="flex gap-12 items-center"
>
{tickerItems.map((item, i) => (
<div key={`${row}-${i}`} className="flex items-center gap-3 font-mono">
<span className="text-zinc-700 font-black text-4xl">{item.id}</span>
<span className="text-zinc-800 text-2xl">₹{item.price}</span>
<span className={`text-xl font-bold ${parseFloat(item.change) >= 0 ? 'text-emerald-900' : 'text-rose-900'}`}>
{parseFloat(item.change) >= 0 ? '▲' : '▼'} {Math.abs(parseFloat(item.change))}%
</span>
</div>
))}
</motion.div>
</div>
))}
</div>
</div>
);
};
const STOCK_CARD_COLORS: Record<string, string> = {
WOCKHARDT: 'from-pink-600 to-pink-900 border-pink-400/30',
HDFC: 'from-rose-600 to-rose-900 border-rose-400/30',
TATA: 'from-amber-600 to-amber-900 border-amber-400/30',
ITC: 'from-emerald-600 to-emerald-900 border-emerald-400/30',
ONGC: 'from-orange-600 to-orange-900 border-orange-400/30',
SBI: 'from-violet-600 to-violet-900 border-violet-400/30',
REL: 'from-blue-600 to-blue-900 border-blue-400/30',
INFOSYS: 'from-emerald-600 to-emerald-900 border-emerald-400/30',
};
const GameCardUI: React.FC<{
card: GameCard,
index: number,
total: number,
isHovered: boolean,
onHover: (index: number | null) => void,
isPlayable?: boolean,
onPlay?: (stockId?: string) => void,
gameState?: GameState
}> = ({ card, index, total, isHovered, onHover, isPlayable, onPlay, gameState }) => {
const [showTargetSelector, setShowTargetSelector] = useState(false);
const isWindfall = !!card.windfallType;
const stock = !isWindfall ? STOCKS.find(s => s.id === card.stockId) : null;
const Icon = isWindfall
? Zap
: (STOCK_ICONS[stock?.icon || 'Activity'] || Activity);
const windfallDetail = isWindfall ? WINDFALL_DETAILS[card.windfallType!] : null;
const cardColorClass = isWindfall
? 'from-amber-500 to-amber-900 border-amber-400/30'
: (stock?.cardGradient || 'from-zinc-600 to-zinc-900 border-zinc-400/30');
return (
<div
className="relative group"
onMouseEnter={() => onHover(index)}
onMouseLeave={() => {
onHover(null);
setShowTargetSelector(false);
}}
>
<motion.div
layout
initial={{ y: 50, opacity: 0 }}
animate={{
y: isHovered ? -15 : 0,
opacity: 1,
scale: isHovered ? 1.1 : 1,
zIndex: isHovered ? 100 : index,
}}
transition={{
type: 'spring',
stiffness: 300,
damping: 20,
delay: index * 0.02
}}
whileTap={{ scale: 1.2 }}
onClick={() => {
// On mobile, first click shows info (via isHovered in CardHand)
// If already hovered/showing info, we don't need to do anything special here
// as the Play button will be visible in the tooltip
}}
className={`relative w-16 h-24 md:w-24 md:h-36 rounded-xl md:rounded-2xl border-2 shadow-2xl flex flex-col items-center justify-center p-1.5 md:p-3 cursor-pointer overflow-hidden bg-gradient-to-br ${cardColorClass}`}
style={{
transformOrigin: 'center center',
touchAction: 'none'
}}
>
{/* Uno-style oval background */}
<div className="absolute inset-0 flex items-center justify-center opacity-20 pointer-events-none">
<div className="w-[120%] h-[70%] bg-white rounded-[100%] rotate-[-45deg]" />
</div>
<div className="flex flex-col items-center gap-0.5 md:gap-1 relative z-10 text-center">
<div className="w-8 h-8 md:w-14 md:h-14 rounded-full flex items-center justify-center bg-white shadow-xl border-2 border-black/5">
{isWindfall ? (
<span className="text-xs md:text-2xl">{windfallDetail?.icon}</span>
) : (
<span className={`text-xs md:text-2xl font-black font-mono ${card.value! >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
{card.value! > 0 ? '+' : ''}{card.value}
</span>
)}
</div>
<p className="text-[7px] md:text-[9px] font-black text-white uppercase tracking-tighter drop-shadow-md mt-0.5 md:mt-1">
{isWindfall ? windfallDetail?.name : stock?.id}
</p>
<Icon size={10} className="text-white/70 mt-0.5 md:mt-1" />
</div>
{/* Inner border */}
<div className="absolute inset-2 border border-white/20 rounded-xl pointer-events-none" />
</motion.div>
{/* Info Tooltip / Action Overlay */}
<AnimatePresence>
{isHovered && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.9, x: '-50%' }}
animate={{ opacity: 1, y: 0, scale: 1, x: '-50%' }}
exit={{ opacity: 0, y: 10, scale: 0.9, x: '-50%' }}
className="absolute bottom-full left-1/2 mb-4 w-56 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl p-4 shadow-2xl z-[200] pointer-events-auto text-center"
onClick={(e) => e.stopPropagation()}
>
<div className="space-y-3">
<div className="flex items-center justify-center gap-2">
{isWindfall ? (
<Zap size={14} className="text-amber-500" />
) : (
<Info size={14} className="text-zinc-400" />
)}
<p className={`text-[10px] font-black uppercase tracking-widest ${isWindfall ? 'text-amber-500' : 'text-zinc-400'}`}>
{isWindfall ? 'Windfall Card' : 'Market Intel'}
</p>
</div>
<h4 className="text-sm font-black text-white uppercase tracking-tight">
{isWindfall ? windfallDetail?.name : `${stock?.name} Intel`}
</h4>
<p className="text-[10px] text-zinc-400 leading-relaxed font-medium">
{isWindfall
? windfallDetail?.description
: `This card will shift ${stock?.name}'s price by ${card.value! > 0 ? '+' : ''}${card.value} at the end of the turn.`}
</p>
{isWindfall && isPlayable && (
<div className="pt-2 border-t border-white/5 space-y-2">
{!showTargetSelector ? (
<button
onClick={() => {
if (card.windfallType === 'SHARE_SUSPENDED') {
setShowTargetSelector(true);
} else {
onPlay?.();
}
}}
className="w-full py-2.5 rounded-xl bg-amber-500 text-zinc-950 text-[10px] font-black uppercase tracking-widest hover:bg-amber-400 transition-colors shadow-lg shadow-amber-500/20"
>
Play Card
</button>
) : (
<div className="grid grid-cols-2 gap-1.5">
{gameState?.stocks.map(s => (
<button
key={s.id}
onClick={() => onPlay?.(s.id)}
className="py-1.5 px-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-[8px] font-black text-white uppercase tracking-tighter transition-all"
>
{s.id}
</button>
))}
<button
onClick={() => setShowTargetSelector(false)}
className="col-span-2 py-1.5 rounded-lg bg-rose-500/10 text-rose-500 text-[8px] font-black uppercase tracking-widest mt-1"
>
Cancel
</button>
</div>
)}
</div>
)}
</div>
{/* Tooltip Arrow */}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-8 border-transparent border-t-zinc-900/95" />
</motion.div>
)}
</AnimatePresence>
</div>
);
};
const CardHand = ({
cards,
isMyTurn,
gameState,
onPlayWindfall,
selectedStockId,
status,
mePortfolio
}: {
cards: GameCard[],
isMyTurn?: boolean,
gameState?: GameState,
onPlayWindfall?: (type: WindfallType, stockId?: string) => void,
selectedStockId?: string,
status?: string,
mePortfolio?: Record<string, number>
}) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [stickyIndex, setStickyIndex] = useState<number | null>(null);
useEffect(() => {
const handleClickOutside = () => setStickyIndex(null);
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, []);
if (!Array.isArray(cards)) return null;
// Sort cards: stock cards by stockId, windfall cards at the end
const sortedCards = [...cards].sort((a, b) => {
if (a.windfallType && !b.windfallType) return 1;
if (!a.windfallType && b.windfallType) return -1;
if (a.windfallType && b.windfallType) return a.windfallType.localeCompare(b.windfallType);
return (a.stockId || '').localeCompare(b.stockId || '');
});
return (
<div className="flex flex-wrap justify-center items-center gap-1.5 md:gap-3 px-1 md:px-2 mt-4 md:mt-8 mb-2 md:mb-4">
<AnimatePresence mode="popLayout">
{sortedCards.map((card, i) => {
const isPlayable = (isMyTurn && (
(card.windfallType === 'LOAN_STOCK_MATURED') ||
(card.windfallType === 'DEBENTURE' && gameState?.stocks.some(s => s.isInsolvent && (mePortfolio?.[s.id] || 0) > 0)) ||
(card.windfallType === 'RIGHTS_ISSUE' && selectedStockId)
)) || (status === 'reveal' && card.windfallType === 'SHARE_SUSPENDED');
return (
<div
key={`${card.stockId}-${card.value}-${card.windfallType}-${i}`}
onClick={(e) => {
e.stopPropagation();
if (stickyIndex === i) setStickyIndex(null);
else setStickyIndex(i);
}}
>
<GameCardUI
card={card}
index={i}
total={sortedCards.length}
isHovered={hoveredIndex === i || stickyIndex === i}
onHover={setHoveredIndex}
isPlayable={isPlayable}
gameState={gameState}
onPlay={(targetId) => {
if (card.windfallType) {
onPlayWindfall?.(card.windfallType, targetId || selectedStockId);
setStickyIndex(null);
setHoveredIndex(null);
}
}}
/>
</div>
);
})}
</AnimatePresence>
</div>
);
};
// --- Main Component ---
export default function App() {
const [username, setUsername] = useState('');
const [roomId, setRoomId] = useState('');
const [maxPlayers, setMaxPlayers] = useState(10);
const [maxRounds, setMaxRounds] = useState(5);
const [socket, setSocket] = useState<Socket | null>(null);
const [gameState, setGameState] = useState<GameState | null>(null);
const gameStateRef = useRef<GameState | null>(null);
const [myId, setMyId] = useState('');
const [showPrivacy, setShowPrivacy] = useState(false);
const [persistentPlayerId] = useState(() => {
const saved = localStorage.getItem('stock_rivals_player_id');
if (saved) return saved;
const newId = Math.random().toString(36).substring(2, 15);
localStorage.setItem('stock_rivals_player_id', newId);
return newId;
});
const [error, setError] = useState('');
// Sync ref with state
useEffect(() => {
gameStateRef.current = gameState;
}, [gameState]);
// Local state for trading
const [selectedStockId, setSelectedStockId] = useState(STOCKS[0].id);
const [tradeAmount, setTradeAmount] = useState(1000);
const roomRef = useRef('');
const usernameRef = useRef('');
useEffect(() => {
roomRef.current = roomId;
}, [roomId]);
useEffect(() => {
usernameRef.current = username;
}, [username]);
const isHost = gameState?.hostId === myId;
const me = gameState?.players.find(p => p.playerId === persistentPlayerId);
const isMyTurn = gameState?.status === 'playing' && gameState.players[gameState.currentPlayerIndex]?.playerId === persistentPlayerId;
const totalPortfolioValue = useMemo(() => {
if (!me || !gameState) return 0;
return Object.entries(me.portfolio).reduce((sum: number, [id, amt]: [string, number]) => {
const stock = gameState.stocks.find(s => s.id === id);
return sum + (stock ? stock.price * amt : 0);
}, 0);
}, [me, gameState?.stocks]);
// --- Socket Connection ---
useEffect(() => {
const newSocket = io({
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
timeout: 60000,
});
setSocket(newSocket);
setMyId(newSocket.id || '');
newSocket.on('connect', () => {
setMyId(newSocket.id || '');
// If we were in a room, re-join
if (roomRef.current && usernameRef.current) {
newSocket.emit('join', {
roomId: roomRef.current,
username: usernameRef.current,
playerId: persistentPlayerId
});
}
});
newSocket.on('disconnect', (reason) => {
console.log('Disconnected:', reason);
if (reason === 'io server disconnect') {
newSocket.connect(); // manually reconnect
}
});
newSocket.on('reconnect', (attemptNumber) => {
console.log('Reconnected after', attemptNumber, 'attempts');
});
newSocket.on('lobby_update', ({ roomId: serverRoomId, players, hostId, maxPlayers: serverMaxPlayers }) => {
setGameState(prev => ({
...(prev || {
status: 'lobby',
players: [],
stocks: [],
round: 1,
turn: 1,
currentPlayerIndex: 0,
hostId: '',
roomId: serverRoomId,
turnActionsCount: 0
}),
roomId: serverRoomId,
maxPlayers: serverMaxPlayers,
players: players.map((p: any) => ({
...p,
cash: INITIAL_CASH,
portfolio: {},
cards: [],
playedCards: [],
isHost: p.id === hostId
})),
hostId
}));
});
newSocket.on('start_game', (state) => {
setGameState(state);
});
newSocket.on('state_update', (state) => {
setGameState(state);
});
newSocket.on('error_message', (msg) => {
setError(msg);
setTimeout(() => setError(''), 3000);
});
return () => {
newSocket.disconnect();
};
}, []);
// --- Host Logic: Process Actions ---
useEffect(() => {
if (!isHost || !socket) return;
const handleAction = ({ playerId, action }: { playerId: string, action: any }) => {
if (!gameStateRef.current) return;
try {
const nextState = processAction(gameStateRef.current, playerId, action);
socket.emit('state_update', { roomId: gameStateRef.current.roomId, state: nextState });
} catch (err) {
console.error("Error processing action:", err);
}
};
socket.on('action_received', handleAction);
return () => {
socket.off('action_received', handleAction);
};
}, [isHost, socket]);
// --- Handlers ---
const handleHost = () => {
if (!username) return setError('Enter username');
const id = Math.random().toString(36).substring(2, 7).toUpperCase();
setRoomId(id);
socket?.emit('join', { roomId: id, username, maxPlayers, playerId: persistentPlayerId });
};
const handleJoin = () => {
if (!username || !roomId) return setError('Enter username and room ID');
socket?.emit('join', { roomId, username, playerId: persistentPlayerId });
};
const handleStartGame = () => {
if (!isHost || !gameState) return;
const initialWindfallDeck = shuffle([
'SHARE_SUSPENDED', 'SHARE_SUSPENDED',
'LOAN_STOCK_MATURED', 'LOAN_STOCK_MATURED',
'DEBENTURE', 'DEBENTURE',
'RIGHTS_ISSUE', 'RIGHTS_ISSUE'
] as WindfallType[]);
const players = gameState.players.map(p => ({
...p,
cash: INITIAL_CASH,
portfolio: {},
playedCards: [],
cards: generateCards(initialWindfallDeck)
}));
const initialState: GameState = {
...gameState,
status: 'playing',
roomId,
maxRounds,
windfallDeck: initialWindfallDeck,
stocks: STOCKS.map(s => ({
id: s.id,
name: s.name,
icon: s.icon,
price: s.initialPrice,
history: [s.initialPrice],
availableShares: MARKET_CAP_PER_STOCK,
color: s.color,
bgColor: s.bgColor,
borderColor: s.borderColor,
isInsolvent: false
})),
players,
round: 1,
turn: 1,
currentPlayerIndex: 0,
turnActionsCount: 0
};
socket?.emit('start_game', { roomId, initialState });
};
const sendAction = (action: any) => {
const isSpecialAction = action.type === 'rights_issue_decision' || (action.type === 'play_windfall' && action.cardType === 'SHARE_SUSPENDED');
if (!isMyTurn && !isSpecialAction) return;
socket?.emit('action', { roomId: gameState?.roomId, action });
};
const handleRevealNext = () => {
if (!isHost || !gameState) return;
let nextState;
if (!gameState.revealSteps || gameState.revealSteps.length === 0) {
nextState = calculateReveal(gameState);
} else {
nextState = startNextTurn(gameState);
}
socket?.emit('state_update', { roomId: gameState.roomId, state: nextState });
};
// --- UI Components ---
if (!gameState || gameState.status === 'setup') {
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex items-center justify-center p-6 font-sans selection:bg-orange-500/30 overflow-hidden">
<TickerBackground />
<div className="fixed inset-0 overflow-hidden pointer-events-none opacity-20">
<div className="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-orange-600 rounded-full blur-[120px]" />
<div className="absolute -bottom-[10%] -right-[10%] w-[40%] h-[40%] bg-zinc-800 rounded-full blur-[120px]" />
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-full max-w-md space-y-12 relative z-10"
>
<div className="text-center space-y-4">
<h1 className="text-7xl font-black tracking-tighter italic text-white uppercase leading-[0.8] font-display">
STOCK<br />
<span className="text-orange-500">RIVALS</span>
</h1>
<p className="text-zinc-500 text-xs font-mono uppercase tracking-[0.4em] pt-2">The Ultimate Trading Floor</p>
</div>
<div className="space-y-6 bg-zinc-900/40 backdrop-blur-xl p-6 md:p-8 rounded-3xl md:rounded-[2.5rem] border border-white/5 shadow-2xl">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-black ml-1">Identity</label>
<input
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="CALLSIGN"
className="w-full bg-white/5 border border-white/5 rounded-2xl p-3 md:p-4 text-zinc-100 focus:ring-2 focus:ring-orange-500/50 transition-all font-mono placeholder:text-zinc-700 outline-none"
/>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-black ml-1">Max Players</label>
<select
value={maxPlayers}
onChange={e => setMaxPlayers(parseInt(e.target.value))}
className="w-full bg-white/5 border border-white/5 rounded-2xl p-3 md:p-4 text-zinc-100 focus:ring-2 focus:ring-orange-500/50 transition-all font-mono outline-none appearance-none cursor-pointer"
>
{[...Array(11)].map((_, i) => (
<option key={i + 2} value={i + 2} className="bg-zinc-900">{i + 2}</option>
))}
</select>
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-black ml-1">Number of Rounds</label>
<select
value={maxRounds}
onChange={e => setMaxRounds(parseInt(e.target.value))}
className="w-full bg-white/5 border border-white/5 rounded-2xl p-3 md:p-4 text-zinc-100 focus:ring-2 focus:ring-orange-500/50 transition-all font-mono outline-none appearance-none cursor-pointer"
>
{[3, 5, 7, 10, 12, 15, 20].map((r) => (
<option key={r} value={r} className="bg-zinc-900">{r} Rounds</option>
))}
</select>
</div>
<div className="pt-4 space-y-4">
<button
onClick={handleHost}
className="w-full bg-orange-600 hover:bg-orange-500 text-white font-black py-3 md:py-4 rounded-2xl transition-all flex items-center justify-center gap-3 group shadow-lg shadow-orange-900/20"
>
HOST SESSION <Play size={18} fill="currentColor" className="group-hover:translate-x-1 transition-transform" />
</button>
<div className="relative py-2 flex items-center">
<div className="flex-grow border-t border-white/5"></div>
<span className="flex-shrink mx-4 text-[9px] text-zinc-600 font-black uppercase tracking-[0.3em]">Network Join</span>
<div className="flex-grow border-t border-white/5"></div>
</div>
<div className="flex gap-2">
<input
value={roomId}
onChange={e => setRoomId(e.target.value.toUpperCase())}
placeholder="ROOM_ID"
className="flex-1 min-w-0 bg-white/5 border border-white/5 rounded-2xl p-3 md:p-4 text-zinc-100 focus:ring-2 focus:ring-orange-500/50 transition-all font-mono text-center placeholder:text-zinc-700 outline-none text-sm"
/>
<button
onClick={handleJoin}
className="w-20 md:w-24 flex-none bg-zinc-100 hover:bg-white text-zinc-950 font-black rounded-2xl transition-all uppercase tracking-widest text-[10px]"
>
Join
</button>
</div>
</div>
{error && <p className="text-red-500 text-[10px] text-center font-mono font-bold uppercase tracking-widest animate-pulse">{error}</p>}
</div>
<div className="text-center">
<button
onClick={() => setShowPrivacy(true)}
className="text-[10px] text-zinc-600 hover:text-orange-500 transition-colors font-black uppercase tracking-[0.3em] flex items-center justify-center gap-2 mx-auto"
>
<Shield size={12} /> Privacy Policy
</button>
</div>
</motion.div>
<AnimatePresence>
{showPrivacy && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] bg-zinc-950/90 backdrop-blur-xl flex items-center justify-center p-6"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="bg-zinc-900 border border-white/10 rounded-[2.5rem] max-w-2xl w-full max-h-[80vh] overflow-hidden flex flex-col shadow-2xl"
>
<div className="p-8 border-b border-white/5 flex justify-between items-center bg-white/5">
<div className="flex items-center gap-3">
<Shield className="text-orange-500" size={24} />
<h2 className="text-2xl font-black italic uppercase tracking-tighter text-white">Privacy Policy</h2>
</div>
<button onClick={() => setShowPrivacy(false)} className="text-zinc-500 hover:text-white transition-colors">
<X size={24} />
</button>
</div>
<div className="p-8 overflow-y-auto scrollbar-hide space-y-6 text-zinc-400 font-sans text-sm leading-relaxed">
<section>
<h3 className="text-white font-black uppercase tracking-widest text-xs mb-2">1. Data Collection</h3>
<p>Stock Rivals is a real-time multiplayer game. We collect minimal data required for gameplay, including your chosen callsign and game-related actions. We do not collect personal identifiable information (PII) like your real name, address, or phone number unless explicitly provided.</p>
</section>
<section>
<h3 className="text-white font-black uppercase tracking-widest text-xs mb-2">2. Cookies & Local Storage</h3>
<p>We use local storage and cookies to maintain your session, remember your player identity across reconnections, and store basic game preferences. These are essential for the technical operation of the game.</p>
</section>
<section>
<h3 className="text-white font-black uppercase tracking-widest text-xs mb-2">3. Third-Party Services</h3>
<p>We use Socket.IO for real-time communication. In the future, we may integrate third-party advertising services (like Google AdSense) or analytics tools. These services may collect data such as your IP address and browser information to serve relevant ads or improve game performance.</p>
</section>
<section>
<h3 className="text-white font-black uppercase tracking-widest text-xs mb-2">4. Data Security</h3>
<p>While we strive to protect your game data, no method of transmission over the internet is 100% secure. By using Stock Rivals, you acknowledge that you provide your data at your own risk.</p>
</section>
<section>
<h3 className="text-white font-black uppercase tracking-widest text-xs mb-2">5. Updates</h3>
<p>We may update this policy from time to time. Continued use of the game constitutes acceptance of the updated terms.</p>
</section>
<div className="pt-4 border-t border-white/5">
<p className="text-[10px] text-zinc-600 font-black uppercase tracking-widest">Last Updated: April 2026</p>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
if (gameState.status === 'lobby') {
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 p-6 flex flex-col items-center justify-center font-sans overflow-hidden">
<TickerBackground />
<div className="fixed inset-0 overflow-hidden pointer-events-none opacity-10">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full h-full bg-orange-500 rounded-full blur-[200px]" />
</div>
<div className="w-full max-w-md space-y-10 relative z-10">
<div className="flex justify-between items-end">
<div>
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse" />
<p className="text-[10px] text-orange-500 font-black uppercase tracking-[0.3em]">Session Ready</p>
</div>
<h2 className="text-5xl font-black italic uppercase tracking-tighter font-display">ID: {gameState.roomId}</h2>
</div>
<div className="bg-white/5 px-4 py-2 rounded-2xl border border-white/5 flex items-center gap-3 backdrop-blur-md">
<Users size={16} className="text-zinc-500" />
<span className="text-sm font-mono font-black">{gameState.players.length}<span className="text-zinc-600">/{gameState.maxPlayers || 10}</span></span>
</div>
</div>
<div className="bg-zinc-900/40 backdrop-blur-xl rounded-[2.5rem] border border-white/5 overflow-hidden shadow-2xl">
<div className="p-6 border-b border-white/5 bg-white/5">
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.2em]">Manifest / Active Players</p>
</div>
<div className="divide-y divide-white/5 max-h-[40vh] overflow-y-auto scrollbar-hide">
{gameState.players.map((p, i) => (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.1 }}
key={p.id}
className="p-6 flex justify-between items-center group hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-zinc-800 border border-white/5 flex items-center justify-center text-lg font-black text-zinc-400 group-hover:border-orange-500/30 transition-colors">
{p.name[0].toUpperCase()}
</div>
<div>
<span className={`text-lg font-black italic uppercase tracking-tight ${p.id === myId ? 'text-orange-500' : 'text-zinc-200'}`}>
{p.name}
</span>
{p.id === myId && <p className="text-[8px] font-black text-zinc-600 uppercase tracking-widest mt-0.5">Local Client</p>}
</div>
</div>
{p.isHost && (
<div className="flex items-center gap-2 bg-orange-500/10 px-3 py-1.5 rounded-xl border border-orange-500/20">
<div className="w-1.5 h-1.5 rounded-full bg-orange-500" />
<span className="text-[9px] text-orange-500 font-black uppercase tracking-widest">Host</span>
</div>
)}
</motion.div>
))}
</div>
</div>
{isHost ? (
<button
onClick={handleStartGame}
disabled={gameState.players.length < 2}
className={`w-full py-6 rounded-[2rem] font-black uppercase tracking-[0.2em] transition-all shadow-2xl ${
gameState.players.length >= 2
? 'bg-orange-600 hover:bg-orange-500 text-white scale-100 hover:scale-[1.02] active:scale-95'
: 'bg-zinc-800 text-zinc-600 cursor-not-allowed opacity-50'
}`}
>
Open Market
</button>
) : (
<div className="text-center p-8 bg-white/5 rounded-[2rem] border border-white/5 border-dashed">
<div className="flex flex-col items-center gap-4">
<RefreshCw size={24} className="animate-spin text-orange-500/50" />
<p className="text-xs text-zinc-500 font-mono font-bold uppercase tracking-[0.2em]">Synchronizing with Host...</p>
</div>
</div>
)}
</div>
</div>
);
}
if (gameState.status === 'playing' || gameState.status === 'reveal') {
const currentStock = gameState.stocks.find(s => s.id === selectedStockId)!;
const myPortfolio = me?.portfolio[selectedStockId] || 0;
const currentPlayer = gameState.players[gameState.currentPlayerIndex];
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 font-sans flex flex-col selection:bg-orange-500/30 overflow-hidden relative">
<TickerBackground />
{/* Rights Issue Participation Prompt */}
{gameState.pendingRightsIssue && gameState.pendingRightsIssue.decisions[myId] === null && (
<div className="fixed inset-0 z-[200] bg-zinc-950/80 backdrop-blur-md flex items-center justify-center p-6">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="bg-zinc-900 border border-white/10 p-8 rounded-[2.5rem] max-w-md w-full shadow-2xl text-center space-y-6"
>
<div className="w-16 h-16 bg-emerald-500/20 rounded-2xl flex items-center justify-center mx-auto">
<Plus size={32} className="text-emerald-500" />
</div>
<div>
<h3 className="text-2xl font-black italic uppercase tracking-tighter text-white">Rights Issue Opportunity</h3>
<p className="text-zinc-500 text-xs font-mono mt-2">
A Rights Issue has been initiated for <span className="text-white font-bold">{gameState.pendingRightsIssue.stockId}</span>.
You can buy 1 additional share for every 2 you hold at <span className="text-emerald-500 font-bold">₹10/share</span>.
</p>
</div>
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-widest mb-1">Your Current Holding</p>
<p className="text-xl font-black font-mono">{(me?.portfolio[gameState.pendingRightsIssue.stockId] || 0).toLocaleString()} Shares</p>
<p className="text-[10px] text-emerald-500 font-bold mt-1">
Potential: +{(Math.floor((me?.portfolio[gameState.pendingRightsIssue.stockId] || 0) / 2000) * 1000).toLocaleString()} @ ₹10
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => sendAction({ type: 'rights_issue_decision', participate: true })}
className="bg-emerald-600 hover:bg-emerald-500 text-white font-black py-4 rounded-2xl transition-all uppercase text-xs"
>
Participate
</button>
<button
onClick={() => sendAction({ type: 'rights_issue_decision', participate: false })}
className="bg-zinc-800 hover:bg-zinc-700 text-zinc-400 font-black py-4 rounded-2xl transition-all uppercase text-xs"
>
Decline
</button>
</div>
</motion.div>
</div>
)}
{/* Header */}
<div className="p-4 bg-zinc-900/40 border-b border-white/5 sticky top-0 z-20 backdrop-blur-xl">
<div className="max-w-6xl mx-auto flex justify-between items-center">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<div className="bg-zinc-800/50 border border-white/5 px-4 py-2 rounded-2xl">
<p className="text-[8px] text-zinc-500 font-black uppercase tracking-[0.2em] mb-0.5">Round</p>
<p className="text-xl font-black italic leading-none font-display text-orange-500">{gameState.round}<span className="text-zinc-600 text-sm not-italic ml-1">/ {ROUNDS_COUNT}</span></p>
</div>
<div className="bg-zinc-800/50 border border-white/5 px-4 py-2 rounded-2xl">
<p className="text-[8px] text-zinc-500 font-black uppercase tracking-[0.2em] mb-0.5">Turn</p>
<p className="text-xl font-black italic leading-none font-display text-white">{gameState.turn}<span className="text-zinc-600 text-sm not-italic ml-1">/ {TURNS_PER_ROUND}</span></p>
</div>
</div>
</div>
<div className="hidden md:block text-center">
<h1 className="text-xl font-black italic tracking-tighter uppercase font-display">
STOCK<span className="text-orange-500">RIVALS</span>
</h1>
</div>
<div className="flex items-center gap-3">
<div className="text-right hidden sm:block bg-white/5 border border-white/5 px-5 py-2 rounded-2xl">
<p className="text-[9px] text-zinc-500 font-black uppercase tracking-[0.2em] mb-0.5">Portfolio Value</p>
<p className="text-xl font-black font-mono">₹{totalPortfolioValue.toLocaleString()}</p>
</div>
<div className="text-right bg-orange-500/10 border border-orange-500/20 px-5 py-2 rounded-2xl">
<p className="text-[9px] text-orange-500/70 font-black uppercase tracking-[0.2em] mb-0.5">Liquid Capital</p>
<p className="text-xl font-black font-mono">₹{me?.cash.toLocaleString()}</p>
</div>
</div>
</div>
</div>
<div className="flex-1 max-w-6xl w-full mx-auto p-4 md:p-8 space-y-8">
{/* Recent Activity Feed */}
<div className="bg-zinc-900/40 backdrop-blur-xl rounded-[2rem] p-4 border border-white/5 shadow-xl overflow-hidden">
<div className="flex items-center justify-between mb-3 px-2">
<div className="flex items-center gap-3">
<Radio size={14} className="text-orange-500 animate-pulse" />
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.3em]">Live Transaction Feed</p>
</div>
<div className="flex items-center gap-2 bg-orange-500/10 px-3 py-1 rounded-full border border-orange-500/20">
<p className="text-[10px] text-orange-500 font-black uppercase tracking-widest">
Turn {currentPlayer.name}
</p>
</div>
</div>
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide px-2">
{gameState.players.map(p => (
<div key={p.id} className="flex-none bg-white/5 border border-white/5 rounded-xl px-4 py-2 flex items-center gap-3 min-w-[200px]">
<div className="w-8 h-8 rounded-lg bg-zinc-800 flex items-center justify-center text-xs font-black text-zinc-400">
{p.name[0]}
</div>
<div>
<p className="text-[10px] font-black text-white uppercase tracking-tight">{p.name}</p>
<p className={`text-[9px] font-bold uppercase truncate ${p.lastAction?.includes('Failed') ? 'text-rose-500' : 'text-emerald-500'}`}>
{p.lastAction || 'Waiting for move...'}
</p>
</div>
</div>
))}
</div>
</div>
{gameState.status === 'playing' ? (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Stock List - Tabular Format */}
<div className="lg:col-span-8 space-y-4">
<div className="flex flex-col items-center">
<div className="flex items-center gap-2 mb-4">
<span className="text-zinc-500 text-[10px]"></span>
<h3 className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.4em]">Market Board</h3>
</div>
<div className="w-full bg-zinc-900/40 backdrop-blur-xl rounded-[1.5rem] p-2 md:p-6 border border-white/5 shadow-xl overflow-x-auto scrollbar-hide">
<table className="w-full text-left border-collapse table-fixed">
<thead>
<tr className="border-b border-white/5">
<th className="py-2 px-1 text-[7px] md:text-[10px] text-zinc-500 font-black uppercase tracking-tighter w-14 md:w-32">Metric</th>
{gameState.stocks.map(stock => (
<th key={stock.id} className="py-2 px-0.5 text-center">
<button
onClick={() => setSelectedStockId(stock.id)}
className={`text-[7px] md:text-[10px] font-black uppercase tracking-tighter transition-all truncate w-full ${selectedStockId === stock.id ? 'text-orange-500 scale-110' : 'text-zinc-400 hover:text-white'}`}
>
{stock.name}
</button>
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/5">
<tr>
<td className="py-2 px-1 text-[7px] md:text-[10px] text-zinc-500 font-black uppercase tracking-tighter">Start</td>
{gameState.stocks.map(stock => {
const initialStock = STOCKS.find(s => s.id === stock.id);
return (
<td key={stock.id} className="py-2 px-0.5 text-center font-mono text-[8px] md:text-sm text-zinc-400">
₹{initialStock?.initialPrice}
</td>
);
})}
</tr>
<tr>
<td className="py-2 px-1 text-[7px] md:text-[10px] text-zinc-500 font-black uppercase tracking-tighter">Value</td>
{gameState.stocks.map(stock => {
const diff = stock.history.length > 1 ? stock.price - stock.history[stock.history.length - 2] : 0;
return (
<td key={stock.id} className="py-2 px-0.5 text-center">
<div className="flex flex-col items-center">
<span className={`text-[9px] md:text-lg font-black font-mono ${stock.isInsolvent ? 'text-rose-500 line-through' : 'text-white'}`}>
₹{stock.price}
</span>
{diff !== 0 && (
<span className={`text-[7px] md:text-[10px] font-black font-mono ${diff > 0 ? 'text-emerald-500' : 'text-rose-500'}`}>
{diff > 0 ? '+' : ''}{diff}
</span>
)}
</div>
</td>
);
})}
</tr>
</tbody>
</table>
</div>
</div>
{/* Insider Intel / Your Hand */}
<div className="bg-zinc-900/40 backdrop-blur-xl rounded-[1.5rem] md:rounded-[2.5rem] p-3 md:p-6 border border-white/5 shadow-2xl">
<div className="flex items-center justify-between mb-2 md:mb-4">
<div className="flex items-center gap-1.5 md:gap-2">
<div className="w-6 h-6 md:w-8 md:h-8 rounded-lg md:rounded-xl bg-orange-500/20 flex items-center justify-center">
<Info size={14} className="text-orange-500" />
</div>
<div>
<p className="text-[8px] md:text-[10px] text-zinc-500 font-black uppercase tracking-[0.2em] mb-0.5">Your Cards</p>
<p className="text-xs md:text-sm font-black uppercase tracking-tight font-display">Market Intel</p>
</div>
</div>
<span className="text-[7px] md:text-[8px] bg-orange-500/10 text-orange-500 px-2 md:px-3 py-0.5 md:py-1 rounded-full font-black uppercase tracking-widest border border-orange-500/20">Confidential</span>
</div>
<CardHand
cards={me?.cards || []}
isMyTurn={isMyTurn}
gameState={gameState}
onPlayWindfall={(type, stockId) => sendAction({ type: 'play_windfall', cardType: type, stockId })}
selectedStockId={selectedStockId}
status={gameState.status}
mePortfolio={me?.portfolio}
/>
<div className="text-center mt-4">
<p className="text-[8px] text-zinc-600 font-black uppercase tracking-[0.3em]">Hover to inspect cards • Values aggregate at reveal</p>
</div>
</div>
{/* Trading Actions - Moved here for better mobile flow */}
<div className="bg-zinc-900/40 backdrop-blur-xl rounded-[2.5rem] p-6 md:p-8 border border-white/5 shadow-2xl">
<div className="mb-8">
<div className="flex justify-between items-start mb-4">
<div>
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.2em] mb-1">Asset Focus</p>
<h4 className="text-3xl font-black italic font-display text-white">{currentStock.name}</h4>
</div>
<div className="bg-white/5 p-3 rounded-2xl border border-white/5">
<TrendingUp size={20} className="text-orange-500/50" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<p className="text-[8px] text-zinc-500 font-black uppercase tracking-widest mb-1">Position</p>
<p className="text-lg font-black font-mono">{myPortfolio.toLocaleString()}<span className="text-[10px] text-zinc-600 ml-1">SHRS</span></p>
</div>
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<p className="text-[8px] text-zinc-500 font-black uppercase tracking-widest mb-1">Valuation</p>
<p className="text-lg font-black font-mono text-orange-500">₹{currentStock.price}</p>
</div>
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<p className="text-[8px] text-zinc-500 font-black uppercase tracking-widest mb-1">Market Supply</p>
<p className="text-lg font-black font-mono text-zinc-400">{currentStock.availableShares.toLocaleString()}</p>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-1 md:gap-2 bg-zinc-800/50 rounded-2xl p-1.5 md:p-2 border border-zinc-700/50">
<button
onClick={() => setTradeAmount(Math.max(MIN_BUY_AMOUNT, tradeAmount - 1000))}
className="p-2 md:p-3 hover:bg-zinc-700/50 rounded-xl transition-colors text-zinc-400 hover:text-white flex-none"
>
<Minus size={16} className="md:w-[18px] md:h-[18px]"/>
</button>
<input
type="number"
step="1000"
value={tradeAmount}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val)) setTradeAmount(Math.max(0, val));
else if (e.target.value === '') setTradeAmount(0);
}}
className="flex-1 min-w-0 bg-transparent border-none text-center font-mono font-black text-lg md:text-xl focus:ring-0 text-white p-0"
/>
<button
onClick={() => setTradeAmount(tradeAmount + 1000)}
className="p-2 md:p-3 hover:bg-zinc-700/50 rounded-xl transition-colors text-zinc-400 hover:text-white flex-none"
>
<Plus size={16} className="md:w-[18px] md:h-[18px]"/>
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => {
if (me) {
const maxAffordable = Math.floor(me.cash / currentStock.price);
const maxAvailable = currentStock.availableShares;
const maxPossible = Math.min(maxAffordable, maxAvailable);
const roundedMax = Math.floor(maxPossible / 1000) * 1000;
setTradeAmount(Math.max(MIN_BUY_AMOUNT, roundedMax));
}
}}
className="flex-1 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 text-[9px] font-black uppercase tracking-[0.2em] text-zinc-400 transition-all"
>
Max Buy
</button>
<button
onClick={() => {
if (me) setTradeAmount(myPortfolio);
}}
className="flex-1 py-3 rounded-xl bg-white/5 hover:bg-white/10 border border-white/5 text-[9px] font-black uppercase tracking-[0.2em] text-zinc-400 transition-all"
>
Max Sell
</button>
</div>
<div className="grid grid-cols-2 gap-4 pt-2">
<button
disabled={!isMyTurn || me!.cash < currentStock.price * tradeAmount || tradeAmount < MIN_BUY_AMOUNT || tradeAmount % 1000 !== 0 || currentStock.availableShares < tradeAmount || currentStock.isInsolvent}
onClick={() => sendAction({ type: 'buy', stockId: selectedStockId, amount: tradeAmount })}
className="bg-emerald-600 hover:bg-emerald-500 disabled:opacity-10 disabled:grayscale text-white font-black py-5 rounded-2xl transition-all uppercase text-xs shadow-xl shadow-emerald-900/20 active:scale-95"
>
{currentStock.isInsolvent ? 'Insolvent' : 'Execute Buy'}
</button>
<button
disabled={!isMyTurn || myPortfolio < tradeAmount || tradeAmount <= 0 || tradeAmount % 1000 !== 0 || currentStock.isInsolvent}
onClick={() => sendAction({ type: 'sell', stockId: selectedStockId, amount: tradeAmount })}
className="bg-rose-600 hover:bg-rose-500 disabled:opacity-10 disabled:grayscale text-white font-black py-5 rounded-2xl transition-all uppercase text-xs shadow-xl shadow-rose-900/20 active:scale-95"
>
{currentStock.isInsolvent ? 'Insolvent' : 'Execute Sell'}
</button>
</div>
{currentStock.availableShares < tradeAmount && (
<p className="text-[10px] text-rose-500 font-bold text-center uppercase tracking-widest animate-pulse">
Market Cap Reached (Max 2,00,000 Shares)
</p>
)}
<button
disabled={!isMyTurn}
onClick={() => sendAction({ type: 'pass' })}
className="w-full bg-zinc-800 hover:bg-zinc-700 disabled:opacity-30 text-zinc-400 font-black py-4 rounded-2xl transition-all uppercase text-[9px] tracking-[0.3em] border border-white/5"
>
Hold Position / Pass
</button>
</div>
</div>
</div>
{/* Sidebar Info */}
<div className="lg:col-span-4 space-y-6">
<div className="bg-zinc-900/40 backdrop-blur-xl rounded-[2.5rem] p-6 md:p-8 border border-white/5 shadow-2xl sticky top-28 space-y-6">
<div className="bg-white/5 p-5 rounded-2xl border border-white/5">
<div className="flex justify-between items-center mb-1">
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.2em]">Portfolio Value</p>
<Wallet size={14} className="text-orange-500/50" />
</div>
<p className="text-3xl font-black font-mono text-white">₹{totalPortfolioValue.toLocaleString()}</p>
<div className="flex justify-between items-center mt-2 pt-2 border-t border-white/5">
<p className="text-[8px] text-zinc-600 font-black uppercase tracking-widest">Net Worth</p>
<p className="text-xs font-black font-mono text-orange-500">₹{((me?.cash || 0) + totalPortfolioValue).toLocaleString()}</p>
</div>
</div>
{/* Your Holdings Section */}
<div className="space-y-4">
<div className="flex items-center gap-2 px-2">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.2em]">Your Holdings</p>
</div>
<div className="space-y-2">
{me && Object.entries(me.portfolio as Record<string, number>).filter(([_, amt]) => (amt as number) > 0).length > 0 ? (
(Object.entries(me.portfolio as Record<string, number>) as [string, number][])
.filter(([_, amt]) => amt > 0)
.map(([stockId, amt]) => {
const stock = gameState.stocks.find(s => s.id === stockId);
if (!stock) return null;
const Icon = STOCK_ICONS[stock.icon] || Activity;
return (
<div key={stockId} className="bg-white/5 border border-white/5 rounded-2xl p-4 flex items-center justify-between group hover:bg-white/10 transition-all">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center bg-white/5 border border-white/5 ${stock.color}`}>
<Icon size={18} />
</div>
<div>
<p className="text-[10px] font-black text-white uppercase tracking-tight">{stock.name}</p>
<p className="text-[9px] font-bold text-zinc-500 uppercase tracking-widest">{amt.toLocaleString()} Shares</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-black font-mono text-white">₹{(amt * stock.price).toLocaleString()}</p>
<p className="text-[8px] font-bold text-zinc-600 uppercase tracking-widest">₹{stock.price}/ea</p>
</div>
</div>
);
})
) : (
<div className="text-center py-8 bg-white/5 rounded-2xl border border-white/5 border-dashed">
<p className="text-[9px] text-zinc-600 font-black uppercase tracking-widest">No active positions</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
) : (
/* Reveal Phase */
<div className="space-y-12 py-12">
<div className="text-center space-y-4">
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="inline-block bg-orange-500/10 border border-orange-500/20 px-6 py-2 rounded-full mb-2"
>
<span className="text-xs font-black uppercase tracking-[0.4em] text-orange-500">Market Correction Phase</span>
</motion.div>
<h2 className="text-7xl font-black italic text-white uppercase tracking-tighter font-display leading-none">THE REVEAL</h2>
<p className="text-zinc-500 font-mono text-xs uppercase tracking-[0.5em]">Aggregating Global Insider Data</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{gameState.revealSteps?.map((step, i) => {
const stock = STOCKS.find(s => s.id === step.stockId)!;
const Icon = STOCK_ICONS[stock.icon] || Activity;
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
key={step.stockId}
className={`relative rounded-[2.5rem] border-2 p-6 transition-all text-left flex flex-col justify-between overflow-hidden bg-gradient-to-br ${stock.cardGradient} border-white/10 shadow-2xl`}
>
{/* Card Aesthetic Elements */}
<div className="absolute inset-0 flex items-center justify-center opacity-10 pointer-events-none">
<div className="w-[120%] h-[70%] bg-white rounded-[100%] rotate-[-45deg]" />
</div>
<div className="absolute inset-2 border border-white/10 rounded-[1.5rem] pointer-events-none" />
<div className="flex items-center gap-3 mb-6 relative z-10">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-white/20 border border-white/20">
<Icon size={20} className="text-white" />
</div>
<div>
<p className="text-[10px] font-black uppercase tracking-widest leading-none mb-1 text-white/70">{stock.id}</p>
<h4 className="text-lg font-black italic font-display leading-none text-white">{stock.name}</h4>
</div>
</div>
<div className="space-y-2 relative z-10">
{step.originalCards.map((card, idx) => {
const player = gameState.players.find(p => p.id === card.playerId);
const isVetoed = step.vetoedCard === card;
const isDiscarded = step.directorDiscarded === card;
return (
<div key={idx} className={`flex justify-between items-center text-[9px] font-mono p-1.5 rounded-lg border ${
isVetoed ? 'bg-rose-500/40 border-rose-500/60 line-through opacity-50' :
isDiscarded ? 'bg-amber-500/40 border-amber-500/60 line-through opacity-50' :
'bg-black/20 border-white/5'
}`}>
<span className="text-white/70 font-bold uppercase tracking-tighter truncate max-w-[80px]">
{player?.name}
</span>
<span className={`font-black ${card.value >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
{card.value > 0 ? '+' : ''}{card.value}
</span>
</div>
);
})}
{step.recovered && (
<div className="bg-emerald-500/40 border border-emerald-500/60 p-1.5 rounded-lg text-center mt-2">
<p className="text-[8px] font-black text-white uppercase tracking-widest">RECOVERED</p>
</div>
)}
{step.becameInsolvent && (
<div className="bg-rose-500/40 border border-rose-500/60 p-1.5 rounded-lg text-center mt-2">
<p className="text-[8px] font-black text-white uppercase tracking-widest">INSOLVENT</p>
</div>
)}
<div className="pt-4 mt-4 border-t border-white/20 flex justify-between items-end">
<div>
<p className="text-[7px] text-white/50 font-black uppercase tracking-widest mb-1">Shift</p>
<span className={`text-3xl font-black font-display italic ${step.finalChange >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
{step.finalChange > 0 ? '+' : ''}{step.finalChange}
</span>
</div>
<div className="text-right">
<p className="text-[7px] text-white/50 font-black uppercase tracking-widest mb-1">Price</p>
<p className="text-lg font-black font-mono text-white">₹{step.newPrice}</p>
</div>
</div>
</div>
</motion.div>
);
})}
</div>
{/* Player Hand in Reveal Phase */}
<div className="max-w-4xl mx-auto pt-12 border-t border-white/5">
<div className="text-center mb-6">
<p className="text-[10px] text-zinc-500 font-black uppercase tracking-[0.3em] mb-1">Your Portfolio & Intel</p>
<h3 className="text-xl font-black italic uppercase tracking-tighter text-white">Strategic Assets</h3>
</div>
<CardHand
cards={me?.cards || []}
gameState={gameState}
onPlayWindfall={(type, stockId) => sendAction({ type: 'play_windfall', cardType: type, stockId })}
status={gameState.status}
mePortfolio={me?.portfolio}
/>
</div>
{isHost && (
<div className="flex justify-center pt-12">
<button
onClick={handleRevealNext}
className="bg-zinc-100 hover:bg-white text-zinc-950 font-black px-16 py-6 rounded-[2rem] shadow-2xl transition-all flex items-center gap-4 group scale-100 hover:scale-105 active:scale-95"
>
{(!gameState.revealSteps || gameState.revealSteps.length === 0) ? (
<>REVEAL MARKET <Zap size={18} fill="currentColor" /></>
) : (
<>NEXT TRADING CYCLE <ArrowRight className="group-hover:translate-x-2 transition-transform" /></>
)}
</button>
</div>
)}
</div>
)}
</div>
{/* Footer Leaderboard */}
<div className="p-4 bg-zinc-900/40 border-t border-white/5 backdrop-blur-xl">
<div className="max-w-6xl mx-auto">
<div className="flex items-center gap-2 mb-4 px-1">
<div className="w-1.5 h-1.5 rounded-full bg-orange-500" />
<p className="text-[9px] text-zinc-500 font-black uppercase tracking-[0.3em]">Live Standing / Net Worth Valuation</p>
</div>
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide">
{gameState.players
.map(p => {
const portfolioValue = Object.entries(p.portfolio).reduce((sum: number, [id, amt]) => {
const price = gameState.stocks.find(s => s.id === id)?.price || 0;
return sum + (price * (amt as number));
}, 0);
return { ...p, netWorth: p.cash + portfolioValue };
})
.sort((a, b) => b.netWorth - a.netWorth)
.map((p, i) => (
<motion.div
layout
key={p.id}
className={`flex-shrink-0 px-6 py-3 rounded-2xl border flex items-center gap-4 transition-all ${
i === 0
? 'bg-orange-500/10 border-orange-500/30'
: 'bg-white/5 border-white/5'
}`}
>
<span className={`text-sm font-black italic font-display ${i === 0 ? 'text-orange-500' : 'text-zinc-600'}`}>#{i + 1}</span>
<div>
<p className="text-[10px] font-black uppercase tracking-tight leading-none mb-1">{p.name}</p>
<p className="text-sm font-black font-mono text-zinc-100">₹{p.netWorth.toLocaleString()}</p>
</div>
</motion.div>
))}
</div>
</div>
</div>
</div>
);
}
if (gameState.status === 'ended') {
const leaderboard = gameState.players
.map(p => {
const portfolioValue = Object.entries(p.portfolio).reduce((sum: number, [id, amt]) => {
const price = gameState.stocks.find(s => s.id === id)?.price || 0;
return sum + (price * (amt as number));
}, 0);
return { ...p, netWorth: p.cash + portfolioValue };
})
.sort((a, b) => b.netWorth - a.netWorth);
return (
<div className="min-h-screen bg-zinc-950 text-zinc-100 p-6 flex flex-col items-center justify-center font-sans">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-lg space-y-8"
>
<div className="text-center space-y-4">
<Trophy size={80} className="mx-auto text-orange-500 drop-shadow-[0_0_20px_rgba(249,115,22,0.4)]" />
<h1 className="text-6xl font-black italic uppercase tracking-tighter">Game Over</h1>
<p className="text-zinc-500 font-mono tracking-[0.3em] uppercase">Final Standings</p>
</div>
<div className="bg-zinc-900 rounded-3xl border border-zinc-800 overflow-hidden shadow-2xl">
{leaderboard.map((p, i) => (
<div key={p.id} className={`p-6 flex justify-between items-center ${i === 0 ? 'bg-orange-500/10 border-b border-orange-500/20' : 'border-b border-zinc-800/50'}`}>
<div className="flex items-center gap-4">
<span className={`text-2xl font-black italic ${i === 0 ? 'text-orange-500' : 'text-zinc-600'}`}>0{i + 1}</span>
<div>
<h3 className="text-xl font-black italic">{p.name}</h3>
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest">Portfolio King</p>
</div>
</div>
<div className="text-right">
<p className="text-2xl font-black text-zinc-100">₹{p.netWorth.toLocaleString()}</p>
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest">Net Worth</p>
</div>
</div>
))}
</div>
<button
onClick={() => window.location.reload()}
className="w-full bg-zinc-100 hover:bg-white text-zinc-950 font-black py-5 rounded-2xl transition-all uppercase tracking-widest shadow-xl"
>
Play Again
</button>
</motion.div>
</div>
);
}
return null;
}