Comprehensive UX polish: fix 19 friction points across all pages
CI / build-and-push (push) Successful in 33s
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:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)} />}
|
||||
|
||||
Reference in New Issue
Block a user