diff --git a/apps/web/src/components/common/ConfirmModal.tsx b/apps/web/src/components/common/ConfirmModal.tsx new file mode 100644 index 0000000..fc4bb73 --- /dev/null +++ b/apps/web/src/components/common/ConfirmModal.tsx @@ -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(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 ( +
+
e.stopPropagation()}> +
+ {danger && } +

{title}

+
+

{message}

+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/common/NotificationPanel.tsx b/apps/web/src/components/common/NotificationPanel.tsx new file mode 100644 index 0000000..4a7e155 --- /dev/null +++ b/apps/web/src/components/common/NotificationPanel.tsx @@ -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(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 ( +
+
+

Notifications

+ +
+
+ {notifications.length === 0 ? ( +
+ +

No notifications yet

+
+ ) : ( + notifications.map((n: GameNotification) => { + const { icon: Icon, color } = ICON_MAP[n.type] ?? ICON_MAP.info; + const ticksAgo = currentTick - n.tick; + return ( +
+
+ +
+
{n.title}
+
{n.message}
+
{formatDuration(ticksAgo)} ago
+
+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 0d46b60..a97a657 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -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 ( diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 3550c4e..04a0a16 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -70,22 +70,25 @@ export function Sidebar() { const isActive = activePage === page; const isNew = newPages.has(page); + const showDivider = page === 'talent' || page === 'achievements'; return ( - +
+ {showDivider &&
} + +
); })} diff --git a/apps/web/src/components/layout/TopBar.tsx b/apps/web/src/components/layout/TopBar.tsx index 78f48e4..c5e2061 100644 --- a/apps/web/src/components/layout/TopBar.tsx +++ b/apps/web/src/components/layout/TopBar.tsx @@ -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() {
+ {isPaused && ( + + PAUSED + + )}
@@ -96,14 +103,20 @@ export function TopBar() { - +
+ + {showNotifications && setShowNotifications(false)} />} +
{showStats && setShowStats(false)} />} diff --git a/apps/web/src/hooks/useHashRouter.ts b/apps/web/src/hooks/useHashRouter.ts new file mode 100644 index 0000000..78aae0d --- /dev/null +++ b/apps/web/src/hooks/useHashRouter.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { useGameStore, type ActivePage } from '@/store'; + +const VALID_PAGES = new Set([ + '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]); +} diff --git a/apps/web/src/hooks/useKeyboardShortcuts.ts b/apps/web/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 0000000..00982a0 --- /dev/null +++ b/apps/web/src/hooks/useKeyboardShortcuts.ts @@ -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); + }, []); +} diff --git a/apps/web/src/pages/CompetitorsPage.tsx b/apps/web/src/pages/CompetitorsPage.tsx index a100593..16a9178 100644 --- a/apps/web/src/pages/CompetitorsPage.tsx +++ b/apps/web/src/pages/CompetitorsPage.tsx @@ -81,17 +81,24 @@ export function CompetitorsPage() {
- {canAcquire(era) && rival.status === 'active' && ( - - )} + {canAcquire(era) && rival.status === 'active' && (() => { + const cost = rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000; + return ( +
+ + {money < cost && ( +
Need {formatMoney(cost - money)} more
+ )} +
+ ); + })()} = 0 ? '+' : ''}${formatMoney(netIncome)}/s`} trend={netIncome > 0 ? 'up' : netIncome < 0 ? 'down' : 'neutral'} color="text-green-400" + onClick={() => useGameStore.getState().setActivePage('finance')} /> useGameStore.getState().setActivePage('infrastructure')} /> useGameStore.getState().setActivePage('models')} /> useGameStore.getState().setActivePage('market')} />
@@ -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 ( -
+
{label} diff --git a/apps/web/src/pages/DataPage.tsx b/apps/web/src/pages/DataPage.tsx index b4add53..32b10e1 100644 --- a/apps/web/src/pages/DataPage.tsx +++ b/apps/web/src/pages/DataPage.tsx @@ -145,13 +145,18 @@ export function DataPage() { {owned ? ( Owned ) : ( - +
+ + {money < item.price && ( +
Need {formatMoney(item.price - money)} more
+ )} +
)}
diff --git a/apps/web/src/pages/FinancePage.tsx b/apps/web/src/pages/FinancePage.tsx index 64394a3..448573b 100644 --- a/apps/web/src/pages/FinancePage.tsx +++ b/apps/web/src/pages/FinancePage.tsx @@ -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() {
-

Revenue vs Expenses

+
+

Revenue vs Expenses

+
+ Revenue + Expenses +
+
{history.length > 1 ? ( + [formatMoney(value), name === 'revenue' ? 'Revenue' : 'Expenses']} + /> @@ -143,26 +156,43 @@ export function FinancePage() { )}
- {fundingStatus.nextRound && ( -
-
-

- {fundingStatus.nextRound === 'ipo' ? 'IPO' : fundingStatus.nextRound.replace('series', 'Series ')} -

- {fundingStatus.canRaise ? ( - - ) : ( - {String(fundingStatus.reason ?? '')} + {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 ( +
+
+

+ {fundingStatus.nextRound === 'ipo' ? 'IPO' : fundingStatus.nextRound.replace('series', 'Series ')} +

+ {fundingStatus.canRaise && ( + + )} +
+ {checks.length > 0 && ( +
+ {checks.map((c, i) => ( +
+ {c.met ? : } + {c.label} +
+ ))} +
)}
-
- )} + ); + })()} {funding.completedRounds.length === 0 ? (

No funding rounds completed yet.

diff --git a/apps/web/src/pages/InfrastructurePage.tsx b/apps/web/src/pages/InfrastructurePage.tsx index 23e3727..34c794f 100644 --- a/apps/web/src/pages/InfrastructurePage.tsx +++ b/apps/web/src/pages/InfrastructurePage.tsx @@ -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(); const STAGE_LABELS: Record = { 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(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 }) { Cost: {formatMoney(dc.energyCostPerTick + dc.maintenanceCostPerTick)}/s
- @@ -190,15 +202,37 @@ function DataCenterCard({ dcId }: { dcId: string }) { ? 'bg-surface-800 border-surface-600' : 'bg-danger/10 border-danger/30' }`}> - -
{sku.name}
-
{formatNumber(sku.flopsPerRack)} FLOPS
+ {confirmDecom === rack.id ? ( +
+ Remove? +
+ + +
+
+ ) : ( + <> + +
{sku.name}
+
{formatNumber(sku.flopsPerRack)} FLOPS
+ + )} ); })} @@ -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 ( ); })} @@ -364,6 +399,10 @@ export function InfrastructurePage() { + + 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. + + {showNewDC && setShowNewDC(false)} />} diff --git a/apps/web/src/pages/LeaderboardPage.tsx b/apps/web/src/pages/LeaderboardPage.tsx index aa6f353..7ec6401 100644 --- a/apps/web/src/pages/LeaderboardPage.tsx +++ b/apps/web/src/pages/LeaderboardPage.tsx @@ -88,7 +88,16 @@ export function LeaderboardPage() {
{loading ? ( -
Loading...
+
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+ ))} +
) : entries.length === 0 ? (
diff --git a/apps/web/src/pages/MarketPage.tsx b/apps/web/src/pages/MarketPage.tsx index 43fda68..ad142e1 100644 --- a/apps/web/src/pages/MarketPage.tsx +++ b/apps/web/src/pages/MarketPage.tsx @@ -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>(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 ( + + Applied + + ); +} 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() {

Market

+ + Adjust pricing to balance growth and revenue. Watch customer satisfaction — low scores increase churn. High system load means you need more inference capacity. + +
@@ -97,13 +126,15 @@ export function MarketPage() {
- +
$ 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() {
- + 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() { 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() {

Overload Policy +

@@ -161,22 +193,24 @@ export function MarketPage() { 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} /> +

Higher = more latency tolerance, lower satisfaction

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} /> +

Lower = less compute per user, serves more customers

@@ -184,19 +218,21 @@ export function MarketPage() { setOverloadPolicy({ degradeQualityUnderLoad: e.target.checked })} + onChange={(e) => { setOverloadPolicy({ degradeQualityUnderLoad: e.target.checked }); policyFeedback.trigger(); }} className="accent-accent" /> Degrade quality under load + Reduces quality to maintain throughput
diff --git a/apps/web/src/pages/ModelsPage.tsx b/apps/web/src/pages/ModelsPage.tsx index 3541c78..767a43b 100644 --- a/apps/web/src/pages/ModelsPage.tsx +++ b/apps/web/src/pages/ModelsPage.tsx @@ -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() {

Models

+ + Split compute between training (building new models) and inference (serving customers). Deploy trained models to start earning revenue. + +

Compute Allocation

@@ -114,14 +119,22 @@ export function ModelsPage() {
Estimated capability score: {estimatedCapability.toFixed(1)}/100
- +
+ + {trainingFlops === 0 && totalFlops === 0 && ( +

Build a data center and order racks first

+ )} + {trainingFlops === 0 && totalFlops > 0 && ( +

Allocate compute to training above

+ )} +
)}
@@ -137,8 +150,13 @@ export function ModelsPage() {
-

{model.name}

diff --git a/apps/web/src/pages/ResearchPage.tsx b/apps/web/src/pages/ResearchPage.tsx index 4bc5bc2..9c77661 100644 --- a/apps/web/src/pages/ResearchPage.tsx +++ b/apps/web/src/pages/ResearchPage.tsx @@ -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() {
+ + Only one research project can run at a time. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure. + + {activeResearch && (
@@ -127,6 +132,9 @@ export function ResearchPage() { Start )} + {isAvailable && activeResearch && ( + Queue after current + )}
{node.prerequisites.length > 0 && isLocked && (
diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index e093b0d..d6eee89 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -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(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 (

Settings

@@ -113,13 +117,34 @@ export function SettingsPage() { className="hidden" />
+ + {showResetConfirm && ( + setShowResetConfirm(false)} + /> + )} + + {importData && ( + setImportData(null)} + /> + )}
); } diff --git a/apps/web/src/pages/TalentPage.tsx b/apps/web/src/pages/TalentPage.tsx index 109cfcb..f6863ff 100644 --- a/apps/web/src/pages/TalentPage.tsx +++ b/apps/web/src/pages/TalentPage.tsx @@ -72,14 +72,17 @@ export function TalentPage() { Budget: {formatMoney(dept.budget)}/mo - +
+ + {!canHire &&
Insufficient funds
} +
))} diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index 2bed087..d908d41 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -45,6 +45,7 @@ interface Actions { setActivePage: (page: ActivePage) => void; addNotification: (n: Omit) => 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()( ), })), + markAllNotificationsRead: () => set((s) => ({ + notifications: s.notifications.map(n => ({ ...n, read: true })), + })), + startNewGame: (companyName) => set({ ...initialGameState, meta: {