8d650fefae
CI / build-and-push (push) Successful in 28s
Address 18 issues across high/medium/low impact tiers identified in a full interface review. Key changes: Models page decomposed into tabs, confirmation dialogs for irreversible actions (deploy/open-source/acquire), chart Y-axes made visible, hash router extended for Market tab persistence, collapsible sidebar, keyboard navigation shortcuts (g+key chords), notification bulk actions, achievement progress bars, and ARIA label improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
178 lines
7.9 KiB
TypeScript
178 lines
7.9 KiB
TypeScript
import { useState } from 'react';
|
|
import { Swords, TrendingUp, Shield, Users, Brain, ShoppingCart } from 'lucide-react';
|
|
import { useGameStore } from '@/store';
|
|
import { ConfirmModal } from '@/components/common/ConfirmModal';
|
|
import { Tooltip } from '@/components/common/Tooltip';
|
|
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
|
|
import type { Era } from '@ai-tycoon/shared';
|
|
|
|
const ARCHETYPE_LABELS: Record<string, string> = {
|
|
'safety-first': 'Safety-First Lab',
|
|
'move-fast': 'Move-Fast Startup',
|
|
'big-tech': 'Big Tech Giant',
|
|
'open-source': 'Open Source Maximalist',
|
|
'stealth-startup': 'Stealth Startup',
|
|
};
|
|
|
|
const ARCHETYPE_COLORS: Record<string, string> = {
|
|
'safety-first': 'text-green-400',
|
|
'move-fast': 'text-red-400',
|
|
'big-tech': 'text-blue-400',
|
|
'open-source': 'text-orange-400',
|
|
'stealth-startup': 'text-purple-400',
|
|
};
|
|
|
|
export function CompetitorsPage() {
|
|
const rivals = useGameStore((s) => s.competitors.rivals);
|
|
const industryBenchmark = useGameStore((s) => s.competitors.industryBenchmark);
|
|
const playerBest = useGameStore((s) => s.models.bestDeployedModelScore);
|
|
const era = useGameStore((s) => s.meta.currentEra);
|
|
const money = useGameStore((s) => s.economy.money);
|
|
const acquireCompetitor = useGameStore((s) => s.acquireCompetitor);
|
|
const canAcquire = (era: Era) => era === 'bigtech' || era === 'agi';
|
|
const [acquireConfirm, setAcquireConfirm] = useState<{ id: string; name: string; cost: number } | null>(null);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-bold">Competitors</h2>
|
|
<div className="text-sm text-surface-400">
|
|
Industry Benchmark: <span className="text-surface-100 font-mono">{industryBenchmark.toFixed(1)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
|
<h3 className="text-sm font-medium text-surface-400 mb-3">Your Position</h3>
|
|
<div className="flex items-center gap-6">
|
|
<div>
|
|
<div className="text-xs text-surface-500">Best Model</div>
|
|
<div className="text-lg font-mono font-bold">{playerBest.toFixed(1)}/100</div>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="h-3 bg-surface-800 rounded-full overflow-hidden relative">
|
|
<div
|
|
className="absolute h-full bg-accent rounded-full transition-all"
|
|
style={{ width: `${playerBest}%` }}
|
|
/>
|
|
{rivals.filter(r => r.status === 'active').map(rival => (
|
|
<div
|
|
key={rival.id}
|
|
className="absolute top-0 h-full w-0.5 bg-danger"
|
|
style={{ left: `${rival.estimatedCapability}%` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-4 mt-1.5 text-[10px]">
|
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-accent inline-block" />You: {playerBest.toFixed(1)}</span>
|
|
{rivals.filter(r => r.status === 'active').map(rival => (
|
|
<span key={rival.id} className="flex items-center gap-1"><span className="w-2 h-0.5 bg-danger inline-block" />{rival.name}: {rival.estimatedCapability.toFixed(1)}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{rivals.map(rival => (
|
|
<div
|
|
key={rival.id}
|
|
className={`bg-surface-900 border rounded-xl p-4 ${
|
|
rival.status === 'active' ? 'border-surface-700' : 'border-surface-800 opacity-50'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div>
|
|
<h3 className="font-semibold text-lg">{rival.name}</h3>
|
|
<span className={`text-xs ${ARCHETYPE_COLORS[rival.archetype]}`}>
|
|
{ARCHETYPE_LABELS[rival.archetype]}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{canAcquire(era) && rival.status === 'active' && (() => {
|
|
const cost = rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000;
|
|
return (
|
|
<div className="text-right">
|
|
<button
|
|
onClick={() => setAcquireConfirm({ id: rival.id, name: rival.name, cost })}
|
|
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' :
|
|
'bg-surface-700 text-surface-400'
|
|
}`}>
|
|
{rival.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 gap-4">
|
|
<Stat icon={Brain} label="Capability" value={rival.estimatedCapability.toFixed(1)} sub={rival.latestModelName} />
|
|
<Stat icon={TrendingUp} label="Est. Revenue" value={formatMoney(rival.estimatedRevenue)} sub="/tick" />
|
|
<Stat icon={Users} label="Est. Users" value={formatNumber(rival.estimatedUsers)} />
|
|
<Stat icon={Shield} label="Reputation" value={`${rival.reputation}/100`} />
|
|
</div>
|
|
|
|
<div className="mt-3 grid grid-cols-6 gap-1">
|
|
{Object.entries(rival.personality).map(([key, val]) => (
|
|
<div key={key} className="text-center">
|
|
<div className="h-1 bg-surface-800 rounded-full overflow-hidden mb-1">
|
|
<div className="h-full bg-accent rounded-full" style={{ width: `${(val as number) * 100}%` }} />
|
|
</div>
|
|
<div className="text-[10px] text-surface-500 capitalize">{key.replace(/([A-Z])/g, ' $1').trim()}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{rivals.length === 0 && (
|
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500">
|
|
<Swords size={48} className="mx-auto mb-4 opacity-50" />
|
|
<p>No competitors detected yet.</p>
|
|
</div>
|
|
)}
|
|
|
|
{acquireConfirm && (
|
|
<ConfirmModal
|
|
title="Acquire Competitor"
|
|
message={`Acquire "${acquireConfirm.name}" for ${formatMoney(acquireConfirm.cost)}? This will absorb their users and technology. This cannot be undone.`}
|
|
confirmLabel={`Acquire (${formatMoney(acquireConfirm.cost)})`}
|
|
danger
|
|
onConfirm={() => { acquireCompetitor(acquireConfirm.id); setAcquireConfirm(null); }}
|
|
onCancel={() => setAcquireConfirm(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Stat({ icon: Icon, label, value, sub }: {
|
|
icon: typeof Brain;
|
|
label: string;
|
|
value: string;
|
|
sub?: string;
|
|
}) {
|
|
return (
|
|
<div className="bg-surface-800 rounded-lg p-2">
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<Icon size={12} className="text-surface-400" />
|
|
<span className="text-xs text-surface-400">{label}</span>
|
|
</div>
|
|
<div className="text-sm font-mono font-semibold">{value}</div>
|
|
{sub && <div className="text-[10px] text-surface-500">{sub}</div>}
|
|
</div>
|
|
);
|
|
}
|