Comprehensive UX polish: fix 19 friction points across all pages
CI / build-and-push (push) Successful in 33s

Addresses broken interactions (notification bell, browser dialogs),
missing feedback states (disabled buttons, pricing changes, paused
indicator), unclear affordances (research queue, model tuning, funding
requirements), and navigation gaps (hash routing, keyboard shortcuts,
clickable dashboard cards, sidebar grouping, tutorial hints).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 21:44:18 -04:00
parent d25dfe0435
commit f9f6233b69
19 changed files with 540 additions and 121 deletions
@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import { AlertTriangle } from 'lucide-react';
export function ConfirmModal({ title, message, confirmLabel = 'Confirm', danger = false, onConfirm, onCancel }: {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const cancelRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelRef.current?.focus();
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onCancel]);
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onCancel}>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-6 max-w-sm w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-3">
{danger && <AlertTriangle size={20} className="text-danger shrink-0" />}
<h3 className="text-lg font-bold">{title}</h3>
</div>
<p className="text-sm text-surface-400 mb-6">{message}</p>
<div className="flex justify-end gap-2">
<button
ref={cancelRef}
onClick={onCancel}
className="px-4 py-2 rounded text-sm text-surface-400 hover:text-surface-200 hover:bg-surface-800"
>
Cancel
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded text-sm font-medium ${
danger
? 'bg-danger/20 hover:bg-danger/30 border border-danger/50 text-danger'
: 'bg-accent hover:bg-accent-dark text-white'
}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,78 @@
import { useEffect, useRef } from 'react';
import { X, CheckCircle, AlertTriangle, AlertCircle, Info, Bell } from 'lucide-react';
import { useGameStore, type GameNotification } from '@/store';
import { formatDuration } from '@ai-tycoon/shared';
const ICON_MAP = {
success: { icon: CheckCircle, color: 'text-success' },
warning: { icon: AlertTriangle, color: 'text-warning' },
danger: { icon: AlertCircle, color: 'text-danger' },
info: { icon: Info, color: 'text-accent-light' },
} as const;
export function NotificationPanel({ onClose }: { onClose: () => void }) {
const notifications = useGameStore((s) => s.notifications);
const markAllRead = useGameStore((s) => s.markAllNotificationsRead);
const currentTick = useGameStore((s) => s.meta.tickCount);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
markAllRead();
}, [markAllRead]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
onClose();
}
};
const keyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handler);
document.addEventListener('keydown', keyHandler);
return () => {
document.removeEventListener('mousedown', handler);
document.removeEventListener('keydown', keyHandler);
};
}, [onClose]);
return (
<div
ref={panelRef}
className="absolute top-12 right-0 w-80 max-h-96 bg-surface-900 border border-surface-700 rounded-xl shadow-2xl z-50 overflow-hidden flex flex-col"
>
<div className="px-4 py-3 border-b border-surface-700 flex items-center justify-between shrink-0">
<h3 className="text-sm font-semibold">Notifications</h3>
<button onClick={onClose} className="text-surface-400 hover:text-surface-200">
<X size={14} />
</button>
</div>
<div className="overflow-y-auto flex-1">
{notifications.length === 0 ? (
<div className="p-6 text-center text-surface-500">
<Bell size={24} className="mx-auto mb-2 opacity-50" />
<p className="text-sm">No notifications yet</p>
</div>
) : (
notifications.map((n: GameNotification) => {
const { icon: Icon, color } = ICON_MAP[n.type] ?? ICON_MAP.info;
const ticksAgo = currentTick - n.tick;
return (
<div key={n.id} className="px-4 py-3 border-b border-surface-800 last:border-0 hover:bg-surface-800/50">
<div className="flex items-start gap-2">
<Icon size={14} className={`${color} mt-0.5 shrink-0`} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{n.title}</div>
<div className="text-xs text-surface-400">{n.message}</div>
<div className="text-xs text-surface-600 mt-1">{formatDuration(ticksAgo)} ago</div>
</div>
</div>
</div>
);
})
)}
</div>
</div>
);
}
@@ -2,6 +2,8 @@ import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
import { ToastContainer } from '@/components/common/ToastContainer';
import { useGameStore } from '@/store';
import { useHashRouter } from '@/hooks/useHashRouter';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { DashboardPage } from '@/pages/DashboardPage';
import { InfrastructurePage } from '@/pages/InfrastructurePage';
import { ResearchPage } from '@/pages/ResearchPage';
@@ -16,6 +18,8 @@ import { AchievementsPage } from '@/pages/AchievementsPage';
import { LeaderboardPage } from '@/pages/LeaderboardPage';
export function MainLayout() {
useHashRouter();
useKeyboardShortcuts();
const activePage = useGameStore((s) => s.activePage);
return (
+18 -15
View File
@@ -70,22 +70,25 @@ export function Sidebar() {
const isActive = activePage === page;
const isNew = newPages.has(page);
const showDivider = page === 'talent' || page === 'achievements';
return (
<button
key={page}
onClick={() => handleNavClick(page)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive
? 'bg-accent/10 text-accent-light border-r-2 border-accent'
: 'text-surface-300 hover:bg-surface-800 hover:text-surface-100'
}`}
>
<Icon size={18} />
{label}
{isNew && (
<span className="ml-auto text-[10px] font-bold bg-accent text-white px-1.5 py-0.5 rounded">NEW</span>
)}
</button>
<div key={page}>
{showDivider && <div className="border-t border-surface-700 my-1 mx-4" />}
<button
onClick={() => handleNavClick(page)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive
? 'bg-accent/10 text-accent-light border-r-2 border-accent'
: 'text-surface-300 hover:bg-surface-800 hover:text-surface-100'
}`}
>
<Icon size={18} />
{label}
{isNew && (
<span className="ml-auto text-[10px] font-bold bg-accent text-white px-1.5 py-0.5 rounded">NEW</span>
)}
</button>
</div>
);
})}
</nav>
+22 -9
View File
@@ -1,6 +1,7 @@
import { type ReactNode, useState } from 'react';
import { Pause, Play, Bell, Share2 } from 'lucide-react';
import { CompanyStatsCard } from '@/components/game/CompanyStatsCard';
import { NotificationPanel } from '@/components/common/NotificationPanel';
import { useGameStore } from '@/store';
import { formatMoney, formatNumber, formatDuration, formatPercent } from '@ai-tycoon/shared';
import type { GameSpeed } from '@ai-tycoon/shared';
@@ -23,6 +24,7 @@ export function TopBar() {
const notifications = useGameStore((s) => s.notifications);
const unreadCount = notifications.filter(n => !n.read).length;
const [showStats, setShowStats] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const netIncome = revenuePerTick - expensesPerTick;
@@ -65,11 +67,16 @@ export function TopBar() {
</div>
<div className="flex items-center gap-3">
{isPaused && (
<span className="text-xs font-semibold text-warning bg-warning/10 border border-warning/30 px-2.5 py-1 rounded-lg animate-pulse">
PAUSED
</span>
)}
<div className="flex items-center gap-1 bg-surface-800 rounded-lg p-1">
<button
onClick={togglePause}
className="p-1.5 rounded hover:bg-surface-700 transition-colors"
title={isPaused ? 'Resume' : 'Pause'}
title={isPaused ? 'Resume (Space)' : 'Pause (Space)'}
>
{isPaused ? <Play size={16} /> : <Pause size={16} />}
</button>
@@ -96,14 +103,20 @@ export function TopBar() {
<Share2 size={18} />
</button>
<button className="relative p-2 rounded hover:bg-surface-800 transition-colors">
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-danger text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{unreadCount}
</span>
)}
</button>
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="relative p-2 rounded hover:bg-surface-800 transition-colors"
>
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 bg-danger text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{unreadCount}
</span>
)}
</button>
{showNotifications && <NotificationPanel onClose={() => setShowNotifications(false)} />}
</div>
</div>
{showStats && <CompanyStatsCard onClose={() => setShowStats(false)} />}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { useGameStore, type ActivePage } from '@/store';
const VALID_PAGES = new Set<ActivePage>([
'dashboard', 'infrastructure', 'research', 'models',
'market', 'talent', 'data', 'competitors',
'finance', 'achievements', 'leaderboard', 'settings',
]);
export function useHashRouter() {
const activePage = useGameStore((s) => s.activePage);
const setActivePage = useGameStore((s) => s.setActivePage);
useEffect(() => {
const hash = window.location.hash.slice(1) as ActivePage;
if (hash && VALID_PAGES.has(hash) && hash !== activePage) {
setActivePage(hash);
}
}, []);
useEffect(() => {
const current = window.location.hash.slice(1);
if (current !== activePage) {
window.history.pushState(null, '', `#${activePage}`);
}
}, [activePage]);
useEffect(() => {
const handler = () => {
const hash = window.location.hash.slice(1) as ActivePage;
if (hash && VALID_PAGES.has(hash)) {
setActivePage(hash);
}
};
window.addEventListener('hashchange', handler);
return () => window.removeEventListener('hashchange', handler);
}, [setActivePage]);
}
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { useGameStore } from '@/store';
import type { GameSpeed } from '@ai-tycoon/shared';
export function useKeyboardShortcuts() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
const store = useGameStore.getState();
switch (e.key) {
case ' ':
e.preventDefault();
store.togglePause();
break;
case '1':
store.setGameSpeed(1 as GameSpeed);
break;
case '2':
store.setGameSpeed(2 as GameSpeed);
break;
case '3':
store.setGameSpeed(5 as GameSpeed);
break;
case 'Escape':
break;
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
}
+18 -11
View File
@@ -81,17 +81,24 @@ export function CompetitorsPage() {
</span>
</div>
<div className="flex items-center gap-2">
{canAcquire(era) && rival.status === 'active' && (
<button
onClick={() => acquireCompetitor(rival.id)}
disabled={money < rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000}
className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed"
title={`Cost: ${formatMoney(rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000)}`}
>
<ShoppingCart size={12} />
Acquire
</button>
)}
{canAcquire(era) && rival.status === 'active' && (() => {
const cost = rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000;
return (
<div className="text-right">
<button
onClick={() => acquireCompetitor(rival.id)}
disabled={money < cost}
className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed"
>
<ShoppingCart size={12} />
Acquire ({formatMoney(cost)})
</button>
{money < cost && (
<div className="text-[10px] text-warning mt-0.5">Need {formatMoney(cost - money)} more</div>
)}
</div>
);
})()}
<span className={`text-xs px-2 py-1 rounded-full ${
rival.status === 'active' ? 'bg-success/20 text-success' :
rival.status === 'acquired' ? 'bg-blue-500/20 text-blue-400' :
+10 -2
View File
@@ -53,6 +53,7 @@ export function DashboardPage() {
subValue={`${netIncome >= 0 ? '+' : ''}${formatMoney(netIncome)}/s`}
trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'}
color="text-green-400"
onClick={() => useGameStore.getState().setActivePage('finance')}
/>
<StatCard
icon={Server}
@@ -60,6 +61,7 @@ export function DashboardPage() {
value={dataCenters.length.toString()}
subValue={`${formatNumber(totalFlops)} FLOPS`}
color="text-blue-400"
onClick={() => useGameStore.getState().setActivePage('infrastructure')}
/>
<StatCard
icon={Brain}
@@ -67,6 +69,7 @@ export function DashboardPage() {
value={trainedModels.length.toString()}
subValue={activeTraining ? `Training: ${Math.floor((activeTraining.progressTicks / activeTraining.totalTicks) * 100)}%` : 'Idle'}
color="text-purple-400"
onClick={() => useGameStore.getState().setActivePage('models')}
/>
<StatCard
icon={Users}
@@ -74,6 +77,7 @@ export function DashboardPage() {
value={formatNumber(subscribers)}
subValue={`Satisfaction: ${formatPercent(useGameStore.getState().market.consumers.satisfaction)}`}
color="text-orange-400"
onClick={() => useGameStore.getState().setActivePage('market')}
/>
</div>
@@ -209,7 +213,7 @@ export function DashboardPage() {
}
function StatCard({
icon: Icon, label, value, subValue, trend, color,
icon: Icon, label, value, subValue, trend, color, onClick,
}: {
icon: typeof DollarSign;
label: string;
@@ -217,9 +221,13 @@ function StatCard({
subValue?: string;
trend?: 'up' | 'down' | 'neutral';
color?: string;
onClick?: () => void;
}) {
return (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div
className={`bg-surface-900 border border-surface-700 rounded-xl p-4 ${onClick ? 'cursor-pointer hover:border-accent/30 transition-colors' : ''}`}
onClick={onClick}
>
<div className="flex items-center gap-2 mb-2">
<Icon size={16} className={color ?? 'text-surface-400'} />
<span className="text-xs text-surface-400 uppercase tracking-wider">{label}</span>
+12 -7
View File
@@ -145,13 +145,18 @@ export function DataPage() {
{owned ? (
<span className="text-xs text-surface-500">Owned</span>
) : (
<button
onClick={() => handlePurchase(item)}
disabled={money < item.price}
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs disabled:opacity-40"
>
{formatMoney(item.price)}
</button>
<div className="text-right">
<button
onClick={() => handlePurchase(item)}
disabled={money < item.price}
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs disabled:opacity-40"
>
{formatMoney(item.price)}
</button>
{money < item.price && (
<div className="text-[10px] text-warning mt-0.5">Need {formatMoney(item.price - money)} more</div>
)}
</div>
)}
</div>
</div>
+52 -22
View File
@@ -1,8 +1,8 @@
import { useGameStore } from '@/store';
import { formatMoney, formatPercent, FUNDING_ROUNDS } from '@ai-tycoon/shared';
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@ai-tycoon/shared';
import type { FundingRoundType } from '@ai-tycoon/shared';
import { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket } from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line } from 'recharts';
import { TrendingUp, DollarSign, PiggyBank, BarChart3, Rocket, Check, X as XIcon } from 'lucide-react';
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, LineChart, Line, Tooltip } from 'recharts';
import { canRaiseFunding } from '@ai-tycoon/game-engine';
import type { GameState } from '@ai-tycoon/shared';
@@ -15,6 +15,9 @@ export function FinancePage() {
const infrastructure = useGameStore((s) => s.infrastructure);
const talent = useGameStore((s) => s.talent);
const raiseFunding = useGameStore((s) => s.raiseFunding);
const totalRevenue = useGameStore((s) => s.economy.totalRevenue);
const subscribers = useGameStore((s) => s.market.consumers.totalSubscribers);
const reputationScore = useGameStore((s) => s.reputation.score);
const state = useGameStore.getState();
const gameStateForFunding: GameState = {
@@ -81,12 +84,22 @@ export function FinancePage() {
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-surface-400 mb-4">Revenue vs Expenses</h3>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-surface-400">Revenue vs Expenses</h3>
<div className="flex items-center gap-4 text-xs">
<span className="flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full bg-success inline-block" />Revenue</span>
<span className="flex items-center gap-1"><span className="w-2.5 h-2.5 rounded-full bg-danger inline-block" />Expenses</span>
</div>
</div>
{history.length > 1 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={history}>
<XAxis dataKey="tick" hide />
<YAxis hide />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
formatter={(value: number, name: string) => [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']}
/>
<Line type="monotone" dataKey="revenue" stroke="#22c55e" dot={false} strokeWidth={2} />
<Line type="monotone" dataKey="expenses" stroke="#ef4444" dot={false} strokeWidth={2} />
</LineChart>
@@ -143,26 +156,43 @@ export function FinancePage() {
)}
</div>
{fundingStatus.nextRound && (
<div className="bg-surface-800 rounded-lg p-4 mb-4 border border-surface-600">
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-sm capitalize">
{fundingStatus.nextRound === 'ipo' ? 'IPO' : fundingStatus.nextRound.replace('series', 'Series ')}
</h4>
{fundingStatus.canRaise ? (
<button
onClick={() => raiseFunding(fundingStatus.nextRound!)}
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium"
>
<Rocket size={14} />
Raise {formatMoney(FUNDING_ROUNDS[fundingStatus.nextRound! as FundingRoundType].amount)}
</button>
) : (
<span className="text-xs text-warning">{String(fundingStatus.reason ?? '')}</span>
{fundingStatus.nextRound && (() => {
const roundConfig = FUNDING_ROUNDS[fundingStatus.nextRound as FundingRoundType];
const reqs = roundConfig.requirements;
const checks = [
...(reqs.minRevenue ? [{ label: `Total Revenue: ${formatMoney(totalRevenue)} / ${formatMoney(reqs.minRevenue)}`, met: totalRevenue >= reqs.minRevenue }] : []),
...(reqs.minUsers ? [{ label: `Subscribers: ${formatNumber(subscribers)} / ${formatNumber(reqs.minUsers)}`, met: subscribers >= reqs.minUsers }] : []),
...(reqs.minReputation ? [{ label: `Reputation: ${reputationScore} / ${reqs.minReputation}`, met: reputationScore >= reqs.minReputation }] : []),
];
return (
<div className="bg-surface-800 rounded-lg p-4 mb-4 border border-surface-600">
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-sm capitalize">
{fundingStatus.nextRound === 'ipo' ? 'IPO' : fundingStatus.nextRound.replace('series', 'Series ')}
</h4>
{fundingStatus.canRaise && (
<button
onClick={() => raiseFunding(fundingStatus.nextRound!)}
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium"
>
<Rocket size={14} />
Raise {formatMoney(roundConfig.amount)}
</button>
)}
</div>
{checks.length > 0 && (
<div className="space-y-1.5 mt-2">
{checks.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
{c.met ? <Check size={12} className="text-success shrink-0" /> : <XIcon size={12} className="text-danger shrink-0" />}
<span className={c.met ? 'text-surface-400' : 'text-surface-200'}>{c.label}</span>
</div>
))}
</div>
)}
</div>
</div>
)}
);
})()}
{funding.completedRounds.length === 0 ? (
<p className="text-sm text-surface-500">No funding rounds completed yet.</p>
+52 -13
View File
@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { Plus, Server, MapPin, Zap, HardDrive, Wrench, ChevronDown, ChevronUp, Thermometer, Shield, X } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
import { useGameStore } from '@/store';
import { useShallow } from 'zustand/shallow';
import {
@@ -9,6 +10,7 @@ import {
import type { DCTier, RackSkuId, LocationId, RackOrder, PipelineStage, Era } from '@ai-tycoon/shared';
const ERA_ORDER: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
const collapsedDCs = new Set<string>();
const STAGE_LABELS: Record<PipelineStage, string> = {
ordered: 'Ordered',
@@ -113,7 +115,17 @@ function DataCenterCard({ dcId }: { dcId: string }) {
const orderRack = useGameStore((s) => s.orderRack);
const decommissionRack = useGameStore((s) => s.decommissionRack);
const upgradeDataCenter = useGameStore((s) => s.upgradeDataCenter);
const [expanded, setExpanded] = useState(true);
const [expanded, setExpanded] = useState(!collapsedDCs.has(dcId));
const [confirmDecom, setConfirmDecom] = useState<string | null>(null);
const toggleExpanded = useCallback(() => {
setExpanded(prev => {
const next = !prev;
if (next) collapsedDCs.delete(dcId);
else collapsedDCs.add(dcId);
return next;
});
}, [dcId]);
const tierConfig = DC_TIER_CONFIGS[dc.tier];
const currentEraIdx = ERA_ORDER.indexOf(era);
@@ -166,7 +178,7 @@ function DataCenterCard({ dcId }: { dcId: string }) {
<span className="text-danger">Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s</span>
</div>
</div>
<button onClick={() => setExpanded(!expanded)} className="text-surface-400 hover:text-surface-200">
<button onClick={toggleExpanded} className="text-surface-400 hover:text-surface-200">
{expanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</button>
</div>
@@ -190,15 +202,37 @@ function DataCenterCard({ dcId }: { dcId: string }) {
? 'bg-surface-800 border-surface-600'
: 'bg-danger/10 border-danger/30'
}`}>
<button
onClick={() => decommissionRack(dc.id, rack.id)}
className="absolute top-0.5 right-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-danger/20 text-surface-400 hover:text-danger transition-all"
title="Decommission rack"
>
<X size={10} />
</button>
<div className="font-medium truncate">{sku.name}</div>
<div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS</div>
{confirmDecom === rack.id ? (
<div className="flex items-center justify-between gap-1">
<span className="text-danger text-[10px]">Remove?</span>
<div className="flex gap-0.5">
<button
onClick={() => { decommissionRack(dc.id, rack.id); setConfirmDecom(null); }}
className="px-1 py-0.5 rounded bg-danger/20 text-danger hover:bg-danger/30 text-[10px]"
>
Yes
</button>
<button
onClick={() => setConfirmDecom(null)}
className="px-1 py-0.5 rounded hover:bg-surface-700 text-surface-400 text-[10px]"
>
No
</button>
</div>
</div>
) : (
<>
<button
onClick={() => setConfirmDecom(rack.id)}
className="absolute top-0.5 right-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-danger/20 text-surface-400 hover:text-danger transition-all"
title="Decommission rack"
>
<X size={10} />
</button>
<div className="font-medium truncate">{sku.name}</div>
<div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS</div>
</>
)}
</div>
);
})}
@@ -214,6 +248,7 @@ function DataCenterCard({ dcId }: { dcId: string }) {
const hasSlot = liveUsedSlots < tierConfig.rackSlots;
const hasPower = liveUsedPower + sku.powerDrawKW <= tierConfig.powerBudgetKW;
const disabled = !canAfford || !hasSlot || !hasPower;
const reason = !canAfford ? `Need ${formatMoney(sku.baseCost)}` : !hasSlot ? 'No slots available' : !hasPower ? 'Exceeds power budget' : '';
return (
<button
@@ -221,10 +256,10 @@ function DataCenterCard({ dcId }: { dcId: string }) {
onClick={() => orderRack(dc.id, sku.id)}
disabled={disabled}
className="bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded-lg px-2.5 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed transition-colors text-left"
title={!hasSlot ? 'No slots available' : !hasPower ? 'Exceeds power budget' : ''}
>
<div className="font-medium">{sku.name}</div>
<div className="text-surface-400">{formatNumber(sku.flopsPerRack)} FLOPS · {sku.powerDrawKW}kW · {formatMoney(sku.baseCost)}</div>
{disabled && reason && <div className="text-warning mt-0.5">{reason}</div>}
</button>
);
})}
@@ -364,6 +399,10 @@ export function InfrastructurePage() {
</button>
</div>
<TutorialHint id="infra-intro">
Choose a location and tier for your data center, then order GPU racks to add compute capacity. Racks go through a build pipeline before they come online.
</TutorialHint>
{showNewDC && <BuildDCPanel onClose={() => setShowNewDC(false)} />}
<PipelineKanban />
+10 -1
View File
@@ -88,7 +88,16 @@ export function LeaderboardPage() {
<div className="bg-surface-900 border border-surface-700 rounded-xl overflow-hidden">
{loading ? (
<div className="p-8 text-center text-surface-500">Loading...</div>
<div className="p-4 space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-4 animate-pulse">
<div className="w-8 h-4 bg-surface-800 rounded" />
<div className="flex-1 h-4 bg-surface-800 rounded" />
<div className="w-16 h-4 bg-surface-800 rounded" />
<div className="w-12 h-4 bg-surface-800 rounded" />
</div>
))}
</div>
) : entries.length === 0 ? (
<div className="p-8 text-center text-surface-500">
<Trophy size={48} className="mx-auto mb-4 opacity-50" />
+46 -10
View File
@@ -1,9 +1,32 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useGameStore } from '@/store';
import {
formatNumber, formatMoney, formatPercent,
MARKET_SIZE_CAP, MARKET_CAP_QUALITY_BONUS, MARKET_CAP_REPUTATION_BONUS,
} from '@ai-tycoon/shared';
import { Users, Zap, Shield, TrendingUp, Settings2 } from 'lucide-react';
import { Users, Zap, Shield, TrendingUp, Settings2, Check } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
function useAppliedFeedback() {
const [show, setShow] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const trigger = useCallback(() => {
setShow(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setShow(false), 1200);
}, []);
useEffect(() => () => clearTimeout(timerRef.current), []);
return { show, trigger };
}
function AppliedBadge({ visible }: { visible: boolean }) {
if (!visible) return null;
return (
<span className="inline-flex items-center gap-1 text-[10px] text-success ml-2 animate-pulse">
<Check size={10} /> Applied
</span>
);
}
export function MarketPage() {
const consumers = useGameStore((s) => s.market.consumers);
@@ -21,6 +44,8 @@ export function MarketPage() {
});
const setProductPricing = useGameStore((s) => s.setProductPricing);
const setOverloadPolicy = useGameStore((s) => s.setOverloadPolicy);
const pricingFeedback = useAppliedFeedback();
const policyFeedback = useAppliedFeedback();
const eraCapBase = MARKET_SIZE_CAP[currentEra] ?? 100_000_000;
const effectiveCap = eraCapBase
* (1 + bestQuality * MARKET_CAP_QUALITY_BONUS)
@@ -34,6 +59,10 @@ export function MarketPage() {
<div className="space-y-6">
<h2 className="text-2xl font-bold">Market</h2>
<TutorialHint id="market-intro">
Adjust pricing to balance growth and revenue. Watch customer satisfaction low scores increase churn. High system load means you need more inference capacity.
</TutorialHint>
<div className="grid grid-cols-4 gap-4">
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
@@ -97,13 +126,15 @@ export function MarketPage() {
</span>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">Monthly Subscription Price</label>
<label className="block text-xs text-surface-400 mb-1">
Monthly Subscription Price <AppliedBadge visible={pricingFeedback.show} />
</label>
<div className="flex items-center gap-2">
<span className="text-sm">$</span>
<input
type="number"
value={chatProduct.pricing.subscriptionPrice}
onChange={(e) => setProductPricing(chatProduct.id, 'subscriptionPrice', Number(e.target.value))}
onChange={(e) => { setProductPricing(chatProduct.id, 'subscriptionPrice', Number(e.target.value)); pricingFeedback.trigger(); }}
className="w-24 bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={0}
step={5}
@@ -124,11 +155,11 @@ export function MarketPage() {
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-surface-400 mb-1">Input ($/M tokens)</label>
<label className="block text-xs text-surface-400 mb-1">Input ($/M tokens) <AppliedBadge visible={pricingFeedback.show} /></label>
<input
type="number"
value={textApi.pricing.inputTokenPrice}
onChange={(e) => setProductPricing(textApi.id, 'inputTokenPrice', Number(e.target.value))}
onChange={(e) => { setProductPricing(textApi.id, 'inputTokenPrice', Number(e.target.value)); pricingFeedback.trigger(); }}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={0}
step={0.5}
@@ -139,7 +170,7 @@ export function MarketPage() {
<input
type="number"
value={textApi.pricing.outputTokenPrice}
onChange={(e) => setProductPricing(textApi.id, 'outputTokenPrice', Number(e.target.value))}
onChange={(e) => { setProductPricing(textApi.id, 'outputTokenPrice', Number(e.target.value)); pricingFeedback.trigger(); }}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={0}
step={0.5}
@@ -154,6 +185,7 @@ export function MarketPage() {
<h3 className="font-semibold flex items-center gap-2">
<Settings2 size={16} />
Overload Policy
<AppliedBadge visible={policyFeedback.show} />
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
@@ -161,22 +193,24 @@ export function MarketPage() {
<input
type="number"
value={overloadPolicy.maxQueueDepth}
onChange={(e) => setOverloadPolicy({ maxQueueDepth: Number(e.target.value) })}
onChange={(e) => { setOverloadPolicy({ maxQueueDepth: Number(e.target.value) }); policyFeedback.trigger(); }}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={10}
step={10}
/>
<p className="text-[10px] text-surface-500 mt-0.5">Higher = more latency tolerance, lower satisfaction</p>
</div>
<div>
<label className="block text-xs text-surface-400 mb-1">Rate Limit / Customer (tok/s)</label>
<input
type="number"
value={overloadPolicy.rateLimitPerCustomer}
onChange={(e) => setOverloadPolicy({ rateLimitPerCustomer: Number(e.target.value) })}
onChange={(e) => { setOverloadPolicy({ rateLimitPerCustomer: Number(e.target.value) }); policyFeedback.trigger(); }}
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
min={100}
step={100}
/>
<p className="text-[10px] text-surface-500 mt-0.5">Lower = less compute per user, serves more customers</p>
</div>
</div>
<div className="flex items-center gap-6">
@@ -184,19 +218,21 @@ export function MarketPage() {
<input
type="checkbox"
checked={overloadPolicy.degradeQualityUnderLoad}
onChange={(e) => setOverloadPolicy({ degradeQualityUnderLoad: e.target.checked })}
onChange={(e) => { setOverloadPolicy({ degradeQualityUnderLoad: e.target.checked }); policyFeedback.trigger(); }}
className="accent-accent"
/>
<span className="text-surface-300">Degrade quality under load</span>
<span className="text-[10px] text-surface-500">Reduces quality to maintain throughput</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={overloadPolicy.prioritizeEnterprise}
onChange={(e) => setOverloadPolicy({ prioritizeEnterprise: e.target.checked })}
onChange={(e) => { setOverloadPolicy({ prioritizeEnterprise: e.target.checked }); policyFeedback.trigger(); }}
className="accent-accent"
/>
<span className="text-surface-300">Prioritize enterprise</span>
<span className="text-[10px] text-surface-500">Enterprise SLAs before consumer traffic</span>
</label>
</div>
</div>
+28 -10
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { Brain, Play, Rocket, Globe, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
import { useGameStore } from '@/store';
import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
import type { TuningPreset } from '@ai-tycoon/shared';
@@ -45,6 +46,10 @@ export function ModelsPage() {
<div className="space-y-6">
<h2 className="text-2xl font-bold">Models</h2>
<TutorialHint id="models-intro">
Split compute between training (building new models) and inference (serving customers). Deploy trained models to start earning revenue.
</TutorialHint>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="font-semibold mb-3">Compute Allocation</h3>
<div className="flex items-center gap-4">
@@ -114,14 +119,22 @@ export function ModelsPage() {
<div className="text-sm text-surface-400">
Estimated capability score: <span className="text-accent-light font-mono">{estimatedCapability.toFixed(1)}/100</span>
</div>
<button
onClick={handleStartTraining}
disabled={trainingFlops === 0}
className="flex items-center gap-2 bg-accent hover:bg-accent-dark text-white px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play size={16} />
Start Training
</button>
<div>
<button
onClick={handleStartTraining}
disabled={trainingFlops === 0}
className="flex items-center gap-2 bg-accent hover:bg-accent-dark text-white px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play size={16} />
Start Training
</button>
{trainingFlops === 0 && totalFlops === 0 && (
<p className="text-xs text-warning mt-1">Build a data center and order racks first</p>
)}
{trainingFlops === 0 && totalFlops > 0 && (
<p className="text-xs text-warning mt-1">Allocate compute to training above</p>
)}
</div>
</div>
)}
</div>
@@ -137,8 +150,13 @@ export function ModelsPage() {
<div key={model.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button onClick={() => setExpandedModel(isExpanded ? null : model.id)} className="text-surface-400 hover:text-surface-200">
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
<button
onClick={() => setExpandedModel(isExpanded ? null : model.id)}
className="flex items-center gap-1 text-xs text-surface-400 hover:text-surface-200"
>
<SlidersHorizontal size={12} />
<span>{isExpanded ? 'Hide' : 'Tune'}</span>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
<div>
<h4 className="font-medium">{model.name}</h4>
+8
View File
@@ -1,4 +1,5 @@
import { FlaskConical, Lock, Check, Play } from 'lucide-react';
import { TutorialHint } from '@/components/game/TutorialHint';
import { useGameStore } from '@/store';
import { formatDuration, formatPercent, formatNumber } from '@ai-tycoon/shared';
import { TECH_TREE, getAvailableResearch } from '@ai-tycoon/game-engine';
@@ -58,6 +59,10 @@ export function ResearchPage() {
</div>
</div>
<TutorialHint id="research-intro">
Only one research project can run at a time. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure.
</TutorialHint>
{activeResearch && (
<div className="bg-surface-900 border border-accent/30 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
@@ -127,6 +132,9 @@ export function ResearchPage() {
Start
</button>
)}
{isAvailable && activeResearch && (
<span className="text-xs text-surface-500 italic">Queue after current</span>
)}
</div>
{node.prerequisites.length > 0 && isLocked && (
<div className="text-xs text-surface-600 mt-2">
+38 -13
View File
@@ -1,11 +1,15 @@
import { useRef } from 'react';
import { useRef, useState } from 'react';
import { useGameStore } from '@/store';
import { ConfirmModal } from '@/components/common/ConfirmModal';
export function SettingsPage() {
const settings = useGameStore((s) => s.meta.settings);
const companyName = useGameStore((s) => s.meta.companyName);
const updateState = useGameStore((s) => s.updateState);
const addNotification = useGameStore((s) => s.addNotification);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [importData, setImportData] = useState<{ data: unknown; name: string } | null>(null);
const toggleSound = () => {
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } });
@@ -16,10 +20,8 @@ export function SettingsPage() {
};
const handleReset = () => {
if (confirm('Are you sure you want to reset all progress? This cannot be undone.')) {
localStorage.removeItem('ai-tycoon-save');
window.location.reload();
}
localStorage.removeItem('ai-tycoon-save');
window.location.reload();
};
const handleExport = () => {
@@ -43,22 +45,24 @@ export function SettingsPage() {
try {
const data = JSON.parse(event.target?.result as string);
if (!data.meta?.companyName) {
alert('Invalid save file: missing company data.');
addNotification({ title: 'Import Failed', message: 'Invalid save file: missing company data.', type: 'danger', tick: useGameStore.getState().meta.tickCount });
return;
}
if (!confirm(`Import save for "${data.meta.companyName}"? This will replace your current game.`)) {
return;
}
localStorage.setItem('ai-tycoon-save', JSON.stringify({ state: data }));
window.location.reload();
setImportData({ data, name: data.meta.companyName });
} catch {
alert('Failed to read save file. Make sure it is a valid AI Tycoon export.');
addNotification({ title: 'Import Failed', message: 'Could not read save file. Make sure it is a valid AI Tycoon export.', type: 'danger', tick: useGameStore.getState().meta.tickCount });
}
};
reader.readAsText(file);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const confirmImport = () => {
if (!importData) return;
localStorage.setItem('ai-tycoon-save', JSON.stringify({ state: importData.data }));
window.location.reload();
};
return (
<div className="space-y-6 max-w-2xl">
<h2 className="text-2xl font-bold">Settings</h2>
@@ -113,13 +117,34 @@ export function SettingsPage() {
className="hidden"
/>
<button
onClick={handleReset}
onClick={() => setShowResetConfirm(true)}
className="px-4 py-2 rounded bg-danger/20 hover:bg-danger/30 border border-danger/50 text-danger text-sm"
>
Reset Progress
</button>
</div>
</div>
{showResetConfirm && (
<ConfirmModal
title="Reset All Progress"
message="This will permanently delete your save data and start a new game. This cannot be undone."
confirmLabel="Reset Everything"
danger
onConfirm={handleReset}
onCancel={() => setShowResetConfirm(false)}
/>
)}
{importData && (
<ConfirmModal
title="Import Save"
message={`Import save for "${importData.name}"? This will replace your current game.`}
confirmLabel="Import"
onConfirm={confirmImport}
onCancel={() => setImportData(null)}
/>
)}
</div>
);
}
+11 -8
View File
@@ -72,14 +72,17 @@ export function TalentPage() {
<span className="text-xs text-surface-400">
Budget: {formatMoney(dept.budget)}/mo
</span>
<button
onClick={() => hireDepartment(id, 1)}
disabled={!canHire}
className="flex items-center gap-1 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded px-3 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus size={12} />
Hire ({formatMoney(hiringCost)})
</button>
<div className="text-right">
<button
onClick={() => hireDepartment(id, 1)}
disabled={!canHire}
className="flex items-center gap-1 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded px-3 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus size={12} />
Hire ({formatMoney(hiringCost)})
</button>
{!canHire && <div className="text-[10px] text-warning mt-0.5">Insufficient funds</div>}
</div>
</div>
</div>
))}
+5
View File
@@ -45,6 +45,7 @@ interface Actions {
setActivePage: (page: ActivePage) => void;
addNotification: (n: Omit<GameNotification, 'id' | 'read'>) => void;
dismissNotification: (id: string) => void;
markAllNotificationsRead: () => void;
startNewGame: (companyName: string) => void;
setGameSpeed: (speed: GameSpeed) => void;
togglePause: () => void;
@@ -118,6 +119,10 @@ export const useGameStore = create<Store>()(
),
})),
markAllNotificationsRead: () => set((s) => ({
notifications: s.notifications.map(n => ({ ...n, read: true })),
})),
startNewGame: (companyName) => set({
...initialGameState,
meta: {