feat: new home web
This commit is contained in:
@@ -5,6 +5,7 @@ import { useAuth } from '../lib/auth';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import ThemeToggle from '../components/ThemeToggle';
|
||||
import SettingsDialog from '../components/SettingsDialog';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
|
||||
type LayoutContext = { onOpenSettings: () => void };
|
||||
export function useLayoutContext() { return useOutletContext<LayoutContext>(); }
|
||||
@@ -16,6 +17,7 @@ type ProjectSummary = {
|
||||
|
||||
function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string; email: string }; logout: () => void; onOpenSettings: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmLogout, setConfirmLogout] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
|
||||
@@ -74,7 +76,7 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setOpen(false); logout(); }}
|
||||
onClick={() => { setOpen(false); setConfirmLogout(true); }}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mx-1"
|
||||
style={{ width: 'calc(100% - 8px)' }}
|
||||
>
|
||||
@@ -86,6 +88,15 @@ function UserDropdown({ user, logout, onOpenSettings }: { user: { name: string;
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmLogout}
|
||||
onConfirm={() => { setConfirmLogout(false); logout(); }}
|
||||
onCancel={() => setConfirmLogout(false)}
|
||||
title="Sign Out"
|
||||
description="Are you sure you want to sign out?"
|
||||
confirmText="Sign Out"
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -100,7 +111,7 @@ function ProjectSidebar() {
|
||||
queryFn: () => apiFetch<ProjectSummary[]>('/projects'),
|
||||
});
|
||||
|
||||
const isProjectsRoot = location.pathname === '/';
|
||||
const isProjectsRoot = location.pathname === '/dashboard';
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex w-[240px] shrink-0 flex-col border-r border-border-default bg-bg-sidebar">
|
||||
@@ -112,7 +123,7 @@ function ProjectSidebar() {
|
||||
{/* Project list */}
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
||||
<NavLink
|
||||
to="/"
|
||||
to="/dashboard"
|
||||
end
|
||||
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] font-medium transition-all duration-150 ${
|
||||
isProjectsRoot
|
||||
@@ -141,7 +152,7 @@ function ProjectSidebar() {
|
||||
{projects?.map((p) => (
|
||||
<NavLink
|
||||
key={p.id}
|
||||
to={`/projects/${p.id}`}
|
||||
to={`/dashboard/projects/${p.id}`}
|
||||
className={`flex items-center gap-2 px-2.5 py-[7px] rounded-lg text-[13px] transition-all duration-150 group ${
|
||||
activeProjectId === p.id
|
||||
? 'bg-accent-muted text-accent font-medium'
|
||||
@@ -222,12 +233,13 @@ export default function Layout() {
|
||||
</svg>
|
||||
</button>
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center shadow-sm"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
||||
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
|
||||
<span className="font-bold text-lg text-text-primary tracking-tight" style={{ fontFamily: 'var(--font-heading)' }}>AgentFox</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -248,17 +260,18 @@ export default function Layout() {
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
<aside className={`fixed inset-y-0 left-0 z-50 w-[260px] bg-bg-sidebar border-r border-border-default flex flex-col transition-transform duration-200 lg:hidden ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<div className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
|
||||
<div className="w-7 h-7 rounded-lg bg-accent flex items-center justify-center shadow-sm">
|
||||
<svg className="w-[14px] h-[14px] text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
<Link to="/" className="px-4 h-14 flex items-center gap-2.5 border-b border-border-default">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center shadow-sm"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
||||
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-semibold text-[15px] text-text-primary tracking-[-0.01em]">Agent Fox</span>
|
||||
</div>
|
||||
<span className="font-bold text-lg text-text-primary tracking-tight" style={{ fontFamily: 'var(--font-heading)' }}>AgentFox</span>
|
||||
</Link>
|
||||
<nav className="flex-1 overflow-y-auto px-2.5 py-3 space-y-0.5">
|
||||
<NavLink
|
||||
to="/"
|
||||
to="/dashboard"
|
||||
end
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../lib/auth';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirectTo = searchParams.get('redirect') || '/';
|
||||
|
||||
const validate = () => {
|
||||
const errors: { email?: string; password?: string } = {};
|
||||
if (!email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
}
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!validate()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
navigate(redirectTo);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
@@ -44,7 +62,7 @@ export default function Login() {
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Sign in to Agent Fox</h1>
|
||||
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Sign in to AgentFox</h1>
|
||||
<p className="text-[13px] text-text-muted mt-1">API documentation for LLMs</p>
|
||||
</div>
|
||||
|
||||
@@ -56,14 +74,38 @@ export default function Login() {
|
||||
<span className="text-danger text-[13px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input-base" placeholder="you@example.com" required />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); if (fieldErrors.email) setFieldErrors(prev => ({ ...prev, email: undefined })); }}
|
||||
className={`input-base ${fieldErrors.email ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Password</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input-base" placeholder="Enter your password" required />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); if (fieldErrors.password) setFieldErrors(prev => ({ ...prev, password: undefined })); }}
|
||||
className={`input-base ${fieldErrors.password ? 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!' : ''}`}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{loading ? (
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function ProjectDetail() {
|
||||
<div className="text-center py-20">
|
||||
<svg className="w-10 h-10 mx-auto text-text-muted mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
<p className="text-text-muted text-sm">Project not found</p>
|
||||
<Link to="/" className="text-accent hover:underline text-sm mt-2 inline-block">Back to projects</Link>
|
||||
<Link to="/dashboard" className="text-accent hover:underline text-sm mt-2 inline-block">Back to projects</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export default function ProjectDetail() {
|
||||
<div>
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-1.5 text-[13px] text-text-muted mb-5">
|
||||
<Link to="/" className="hover:text-text-primary transition-colors">Projects</Link>
|
||||
<Link to="/dashboard" className="hover:text-text-primary transition-colors">Projects</Link>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M9 5l7 7-7 7" /></svg>
|
||||
<span className="text-text-secondary font-medium">{project.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -7,17 +7,44 @@ export default function Register() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string; password?: string }>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const clearFieldError = (field: string) => {
|
||||
if (fieldErrors[field as keyof typeof fieldErrors]) {
|
||||
setFieldErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const errors: { name?: string; email?: string; password?: string } = {};
|
||||
if (!name.trim()) {
|
||||
errors.name = 'Name is required';
|
||||
}
|
||||
if (!email.trim()) {
|
||||
errors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errors.email = 'Please enter a valid email address';
|
||||
}
|
||||
if (!password) {
|
||||
errors.password = 'Password is required';
|
||||
} else if (password.length < 8) {
|
||||
errors.password = 'Password must be at least 8 characters';
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!validate()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(email, password, name);
|
||||
navigate('/');
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
} finally {
|
||||
@@ -25,6 +52,8 @@ export default function Register() {
|
||||
}
|
||||
};
|
||||
|
||||
const errorInputClass = 'border-danger! focus:border-danger! focus:shadow-[0_0_0_3px_var(--danger-muted)]!';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
|
||||
<div className="absolute inset-0" style={{
|
||||
@@ -43,7 +72,7 @@ export default function Register() {
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-text-primary tracking-[-0.01em]">Create your account</h1>
|
||||
<p className="text-[13px] text-text-muted mt-1">Get started with Agent Fox</p>
|
||||
<p className="text-[13px] text-text-muted mt-1">Get started with AgentFox</p>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 shadow-md">
|
||||
@@ -53,18 +82,54 @@ export default function Register() {
|
||||
<span className="text-danger text-[13px]">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Name</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" placeholder="Your name" required />
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); clearFieldError('name'); }}
|
||||
className={`input-base ${fieldErrors.name ? errorInputClass : ''}`}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
{fieldErrors.name && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Email</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input-base" placeholder="you@example.com" required />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); clearFieldError('email'); }}
|
||||
className={`input-base ${fieldErrors.email ? errorInputClass : ''}`}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
{fieldErrors.email && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] font-medium text-text-secondary mb-1.5">Password</label>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} required />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); clearFieldError('password'); }}
|
||||
className={`input-base ${fieldErrors.password ? errorInputClass : ''}`}
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
{fieldErrors.password && (
|
||||
<p className="mt-1.5 text-[12px] text-danger flex items-center gap-1">
|
||||
<svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><circle cx="12" cy="12" r="10" /><path d="M12 8v4m0 4h.01" /></svg>
|
||||
{fieldErrors.password}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn-primary w-full">
|
||||
{loading ? (
|
||||
|
||||
77
packages/web/src/pages/landing/FAQSection.tsx
Normal file
77
packages/web/src/pages/landing/FAQSection.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||
|
||||
const faqKeys = [1, 2, 3, 4, 5, 6] as const;
|
||||
|
||||
function FAQItem({ q, a, isOpen, onToggle }: { q: string; a: string; isOpen: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl transition-all duration-200"
|
||||
style={{
|
||||
background: isOpen ? 'var(--bg-elevated)' : 'transparent',
|
||||
border: `1px solid ${isOpen ? 'var(--border-default)' : 'var(--border-muted)'}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between text-left px-6 py-4 cursor-pointer group"
|
||||
>
|
||||
<span className="text-sm font-medium pr-4 transition-colors" style={{ color: isOpen ? 'var(--text-primary)' : 'var(--text-secondary)' }}>
|
||||
{q}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-45' : ''}`}
|
||||
style={{ color: isOpen ? 'var(--fox-amber)' : 'var(--text-muted)' }}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-300"
|
||||
style={{ maxHeight: isOpen ? '500px' : '0' }}
|
||||
>
|
||||
<p className="px-6 pb-5 text-sm leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
||||
{a}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FAQSection() {
|
||||
const { t } = useI18n();
|
||||
const { ref, isVisible } = useScrollReveal();
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<div className="w-full py-20">
|
||||
<div ref={ref} className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className={`text-center mb-12 transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
<span className="inline-block text-xs font-semibold uppercase tracking-[0.15em] mb-3 px-3 py-1 rounded-full"
|
||||
style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)' }}>
|
||||
{t('faq.label')}
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
|
||||
{t('faq.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* FAQ items */}
|
||||
<div className={`space-y-2 transition-all duration-700 delay-200 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
{faqKeys.map((num, i) => (
|
||||
<FAQItem
|
||||
key={num}
|
||||
q={t(`faq.${num}.q`)}
|
||||
a={t(`faq.${num}.a`)}
|
||||
isOpen={openIndex === i}
|
||||
onToggle={() => setOpenIndex(openIndex === i ? null : i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
packages/web/src/pages/landing/FeaturesSection.tsx
Normal file
105
packages/web/src/pages/landing/FeaturesSection.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||
|
||||
const featureKeys = [
|
||||
{ key: 'progressive', icon: 'layers' },
|
||||
{ key: 'token', icon: 'zap' },
|
||||
{ key: 'spec', icon: 'file' },
|
||||
{ key: 'import', icon: 'upload' },
|
||||
{ key: 'projects', icon: 'grid' },
|
||||
{ key: 'security', icon: 'shield' },
|
||||
] as const;
|
||||
|
||||
function FeatureIcon({ type }: { type: string }) {
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
layers: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
),
|
||||
zap: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
),
|
||||
file: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" />
|
||||
</svg>
|
||||
),
|
||||
upload: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" />
|
||||
</svg>
|
||||
),
|
||||
grid: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
shield: (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
return icons[type] || null;
|
||||
}
|
||||
|
||||
export default function FeaturesSection() {
|
||||
const { t } = useI18n();
|
||||
const { ref, isVisible } = useScrollReveal();
|
||||
|
||||
return (
|
||||
<div className="w-full py-20">
|
||||
<div ref={ref} className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className={`text-center mb-16 transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
<span className="inline-block text-xs font-semibold uppercase tracking-[0.15em] mb-3 px-3 py-1 rounded-full"
|
||||
style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)' }}>
|
||||
{t('features.label')}
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
|
||||
{t('features.title')}
|
||||
</h2>
|
||||
<p className="text-lg max-w-2xl mx-auto" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('features.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{featureKeys.map(({ key, icon }, i) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`group p-6 rounded-2xl transition-all duration-700 hover:shadow-lg hover:-translate-y-1 ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
|
||||
}`}
|
||||
style={{
|
||||
transitionDelay: isVisible ? `${150 + i * 100}ms` : '0ms',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-default)',
|
||||
}}
|
||||
>
|
||||
<div className="w-11 h-11 rounded-xl flex items-center justify-center mb-4 transition-transform duration-300 group-hover:scale-110"
|
||||
style={{ background: 'var(--fox-glow)', color: 'var(--fox-amber)' }}>
|
||||
<FeatureIcon type={icon} />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
||||
{t(`features.${key}.title`)}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t(`features.${key}.desc`)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
packages/web/src/pages/landing/FooterSection.tsx
Normal file
82
packages/web/src/pages/landing/FooterSection.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
|
||||
export default function FooterSection() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('footer.product'),
|
||||
links: [
|
||||
{ label: t('footer.features'), href: '#features' },
|
||||
{ label: t('footer.pricing'), href: '#pricing' },
|
||||
{ label: t('footer.docs'), href: '#' },
|
||||
{ label: t('footer.changelog'), href: '#' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('footer.resources'),
|
||||
links: [
|
||||
{ label: t('footer.github'), href: 'https://github.com' },
|
||||
{ label: t('footer.community'), href: '#' },
|
||||
{ label: t('footer.blog'), href: '#' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('footer.legal'),
|
||||
links: [
|
||||
{ label: t('footer.privacy'), href: '#' },
|
||||
{ label: t('footer.terms'), href: '#' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<footer className="w-full py-16 border-t" style={{ borderColor: 'var(--border-muted)', background: 'var(--bg-secondary)' }}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-12">
|
||||
{/* Brand column */}
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<Link to="/" className="flex items-center gap-2.5 mb-4">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
||||
<svg className="w-3.5 h-3.5 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-text-primary" style={{ fontFamily: 'var(--font-heading)' }}>AgentFox</span>
|
||||
</Link>
|
||||
<p className="text-[13px] leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('footer.tagline')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Link columns */}
|
||||
{columns.map(col => (
|
||||
<div key={col.title}>
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-[0.1em] mb-3" style={{ color: 'var(--text-muted)' }}>
|
||||
{col.title}
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{col.links.map(link => (
|
||||
<li key={link.label}>
|
||||
<a href={link.href} className="text-sm transition-colors hover:underline" style={{ color: 'var(--text-secondary)' }}>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="pt-6 border-t flex flex-col sm:flex-row items-center justify-between gap-4" style={{ borderColor: 'var(--border-muted)' }}>
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-muted)' }}>
|
||||
{t('footer.copyright')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
154
packages/web/src/pages/landing/HeroSection.tsx
Normal file
154
packages/web/src/pages/landing/HeroSection.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||
|
||||
function TerminalDemo() {
|
||||
const { t } = useI18n();
|
||||
const [visibleLines, setVisibleLines] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const delays = [300, 800, 1400, 2200, 2800, 3600, 4400];
|
||||
delays.forEach((delay, i) => {
|
||||
timers.push(setTimeout(() => setVisibleLines(i + 1), delay));
|
||||
});
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, []);
|
||||
|
||||
const lines = [
|
||||
{ type: 'comment', text: t('hero.terminal.comment') },
|
||||
{ type: 'cmd', text: `> ${t('hero.terminal.cmd1')}` },
|
||||
{ type: 'result', text: t('hero.terminal.res1') },
|
||||
{ type: 'cmd', text: `> ${t('hero.terminal.cmd2')}` },
|
||||
{ type: 'result', text: t('hero.terminal.res2') },
|
||||
{ type: 'cmd', text: `> ${t('hero.terminal.cmd3')}` },
|
||||
{ type: 'result', text: t('hero.terminal.res3') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto">
|
||||
<div className="rounded-xl overflow-hidden shadow-2xl" style={{ border: '1px solid var(--border-default)' }}>
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center gap-2 px-4 py-3" style={{ background: 'var(--bg-tertiary)', borderBottom: '1px solid var(--border-default)' }}>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: '#ff5f57' }} />
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: '#febc2e' }} />
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: '#28c840' }} />
|
||||
</div>
|
||||
<span className="text-[11px] font-mono ml-2" style={{ color: 'var(--text-muted)' }}>mcp-client</span>
|
||||
</div>
|
||||
{/* Terminal body */}
|
||||
<div className="p-5 font-mono text-[13px] leading-relaxed space-y-1 min-h-[220px]" style={{ background: 'var(--code-bg)' }}>
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`transition-all duration-500 ${i < visibleLines ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'}`}
|
||||
style={{
|
||||
color: line.type === 'comment'
|
||||
? 'var(--code-comment)'
|
||||
: line.type === 'cmd'
|
||||
? 'var(--fox-amber)'
|
||||
: 'var(--code-text)',
|
||||
}}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
{visibleLines >= lines.length && (
|
||||
<div className="flex items-center gap-0.5 mt-1">
|
||||
<span style={{ color: 'var(--fox-amber)' }}>{'> '}</span>
|
||||
<div className="w-2 h-4 animate-pulse-soft" style={{ background: 'var(--fox-amber)' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HeroSection() {
|
||||
const { user } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const { ref, isVisible } = useScrollReveal({ threshold: 0.1 });
|
||||
|
||||
return (
|
||||
<div className="w-full relative overflow-hidden">
|
||||
{/* Background effects */}
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `linear-gradient(var(--border-muted) 1px, transparent 1px), linear-gradient(90deg, var(--border-muted) 1px, transparent 1px)`,
|
||||
backgroundSize: '64px 64px',
|
||||
opacity: 0.5,
|
||||
}} />
|
||||
<div className="absolute inset-0" style={{
|
||||
background: `radial-gradient(ellipse at center, transparent 0%, var(--bg-primary) 75%)`,
|
||||
}} />
|
||||
|
||||
{/* Gradient orbs */}
|
||||
<div className="absolute top-1/4 -left-32 w-96 h-96 rounded-full blur-3xl animate-gradient-shift" style={{ background: 'var(--fox-glow)' }} />
|
||||
<div className="absolute bottom-1/4 -right-32 w-80 h-80 rounded-full blur-3xl animate-gradient-shift" style={{ background: 'var(--fox-glow)', animationDelay: '3s' }} />
|
||||
|
||||
<div ref={ref} className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-28 pb-16">
|
||||
<div className={`text-center transition-all duration-1000 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
{/* Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full mb-8"
|
||||
style={{ background: 'var(--fox-glow)', border: '1px solid rgba(245, 158, 11, 0.2)' }}>
|
||||
<div className="w-1.5 h-1.5 rounded-full animate-pulse-soft" style={{ background: 'var(--fox-amber)' }} />
|
||||
<span className="text-xs font-medium tracking-wide" style={{ color: 'var(--fox-amber)' }}>
|
||||
{t('hero.badge')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-extrabold tracking-tight leading-[1.1] mb-6" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
<span style={{ color: 'var(--text-primary)' }}>{t('hero.title')}</span>
|
||||
<br />
|
||||
<span style={{
|
||||
background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}>
|
||||
{t('hero.titleHighlight')}
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className="max-w-2xl mx-auto text-lg sm:text-xl leading-relaxed mb-10" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('hero.subtitle')}
|
||||
</p>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
to={user ? '/dashboard' : '/login?redirect=/dashboard'}
|
||||
className="px-7 py-3.5 rounded-xl text-base font-semibold text-white transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 active:translate-y-0"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))',
|
||||
boxShadow: '0 4px 20px var(--fox-glow)',
|
||||
}}
|
||||
>
|
||||
{t('hero.cta')}
|
||||
</Link>
|
||||
<a
|
||||
href="#features"
|
||||
className="px-7 py-3.5 rounded-xl text-base font-medium transition-all duration-200 hover:-translate-y-0.5"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border-default)',
|
||||
background: 'var(--bg-elevated)',
|
||||
}}
|
||||
>
|
||||
{t('hero.ctaSecondary')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal demo */}
|
||||
<div className={`mt-16 transition-all duration-1000 delay-300 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-12'}`}>
|
||||
<TerminalDemo />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
packages/web/src/pages/landing/LandingNav.tsx
Normal file
274
packages/web/src/pages/landing/LandingNav.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import ThemeToggle from '../../components/ThemeToggle';
|
||||
import LanguageToggle from '../../components/LanguageToggle';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
|
||||
function LandingUserDropdown({ user, logout }: { user: { name: string; email: string }; logout: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmLogout, setConfirmLogout] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const initials = user.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-bg-tertiary transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold tracking-wide">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="hidden md:block text-left">
|
||||
<div className="text-[13px] font-medium text-text-primary leading-tight">{user.name}</div>
|
||||
</div>
|
||||
<svg className="w-3.5 h-3.5 text-text-muted hidden md:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="user-dropdown">
|
||||
{/* User info */}
|
||||
<div className="px-3 py-2.5 border-b border-border-muted">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[11px] font-bold tracking-wide shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium text-text-primary truncate">{user.name}</div>
|
||||
<div className="text-[11px] text-text-muted truncate">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => { setOpen(false); setConfirmLogout(true); }}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2 rounded-lg text-[13px] text-text-muted hover:text-danger hover:bg-danger-muted transition-colors mx-1"
|
||||
style={{ width: 'calc(100% - 8px)' }}
|
||||
>
|
||||
<svg className="w-[15px] h-[15px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmLogout}
|
||||
onConfirm={() => { setConfirmLogout(false); logout(); }}
|
||||
onCancel={() => setConfirmLogout(false)}
|
||||
title="Sign Out"
|
||||
description="Are you sure you want to sign out?"
|
||||
confirmText="Sign Out"
|
||||
variant="warning"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LandingNav({ scrollRef }: { scrollRef: React.RefObject<HTMLDivElement | null> }) {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [confirmLogout, setConfirmLogout] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => setScrolled(el.scrollTop > 50);
|
||||
el.addEventListener('scroll', handler, { passive: true });
|
||||
return () => el.removeEventListener('scroll', handler);
|
||||
}, [scrollRef]);
|
||||
|
||||
const scrollToSection = (id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
el?.scrollIntoView({ behavior: 'smooth' });
|
||||
setMobileOpen(false);
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ label: t('nav.features'), id: 'features' },
|
||||
{ label: t('nav.tools'), id: 'tools' },
|
||||
{ label: t('nav.testimonials'), id: 'testimonials' },
|
||||
{ label: t('nav.pricing'), id: 'pricing' },
|
||||
{ label: t('nav.faq'), id: 'faq' },
|
||||
];
|
||||
|
||||
const initials = user?.name?.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2);
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
scrolled
|
||||
? 'bg-bg-primary/80 backdrop-blur-xl border-b border-border-muted shadow-sm'
|
||||
: 'bg-transparent'
|
||||
}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center h-16">
|
||||
{/* Left: Logo */}
|
||||
<div className="flex-1 flex items-center">
|
||||
<Link to="/" className="flex items-center gap-2.5 shrink-0">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center shadow-sm"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
||||
<svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-lg text-text-primary tracking-tight" style={{ fontFamily: 'var(--font-heading)' }}>
|
||||
AgentFox
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Center: Desktop nav links */}
|
||||
<div className="hidden md:flex items-center gap-1 shrink-0">
|
||||
{navLinks.map(link => (
|
||||
<button
|
||||
key={link.id}
|
||||
onClick={() => scrollToSection(link.id)}
|
||||
className="px-3 py-2 rounded-lg text-sm text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-150 cursor-pointer"
|
||||
>
|
||||
{link.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex-1 flex items-center justify-end gap-2">
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border-default mx-1 hidden sm:block" />
|
||||
|
||||
{/* Logged out: Sign In + Get Started */}
|
||||
{!loading && !user && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<Link to="/login" className="px-3 py-1.5 rounded-lg text-sm text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all duration-150">
|
||||
{t('nav.signIn')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/login?redirect=/dashboard"
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white transition-all duration-150 hover:opacity-90 shadow-md"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}
|
||||
>
|
||||
{t('nav.getStarted')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logged in: Dashboard button + User dropdown */}
|
||||
{!loading && user && (
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="px-3.5 py-1.5 rounded-lg text-sm font-medium text-white transition-all duration-150 hover:opacity-90 shadow-sm"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}
|
||||
>
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
<LandingUserDropdown user={user} logout={logout} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="md:hidden p-2 rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={mobileOpen}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
{mobileOpen
|
||||
? <path d="M6 18L18 6M6 6l12 12" />
|
||||
: <path d="M4 6h16M4 12h16M4 18h16" />
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu overlay */}
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-40 bg-bg-primary/95 backdrop-blur-xl md:hidden">
|
||||
<div className="pt-20 px-6 space-y-2">
|
||||
{navLinks.map(link => (
|
||||
<button
|
||||
key={link.id}
|
||||
onClick={() => scrollToSection(link.id)}
|
||||
className="block w-full text-left px-4 py-3 rounded-xl text-lg text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all"
|
||||
>
|
||||
{link.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-border-muted my-4!" />
|
||||
<div className="flex items-center gap-3 px-4 py-2">
|
||||
<LanguageToggle />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="border-t border-border-muted my-4!" />
|
||||
{!loading && !user && (
|
||||
<div className="space-y-2 px-4">
|
||||
<Link to="/login" onClick={() => setMobileOpen(false)} className="block w-full text-center px-4 py-3 rounded-xl text-text-secondary hover:text-text-primary hover:bg-bg-tertiary/50 transition-all text-lg">
|
||||
{t('nav.signIn')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/login?redirect=/dashboard"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block w-full text-center px-4 py-3 rounded-xl text-white font-medium text-lg shadow-md"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}
|
||||
>
|
||||
{t('nav.getStarted')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!loading && user && (
|
||||
<div className="space-y-2 px-4">
|
||||
<Link to="/dashboard" onClick={() => setMobileOpen(false)} className="block w-full text-center px-4 py-3 rounded-xl text-white font-medium text-lg shadow-md"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
||||
{t('nav.dashboard')}
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl">
|
||||
<div className="w-8 h-8 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<span className="text-lg text-text-primary font-medium flex-1">{user.name}</span>
|
||||
<button onClick={() => { setMobileOpen(false); setConfirmLogout(true); }} className="text-sm text-text-muted hover:text-danger transition-colors">
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmLogout}
|
||||
onConfirm={() => { setConfirmLogout(false); logout(); }}
|
||||
onCancel={() => setConfirmLogout(false)}
|
||||
title="Sign Out"
|
||||
description="Are you sure you want to sign out?"
|
||||
confirmText="Sign Out"
|
||||
variant="warning"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
packages/web/src/pages/landing/LandingPage.tsx
Normal file
51
packages/web/src/pages/landing/LandingPage.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useRef } from 'react';
|
||||
import LandingNav from './LandingNav';
|
||||
import HeroSection from './HeroSection';
|
||||
import FeaturesSection from './FeaturesSection';
|
||||
import ToolsSection from './ToolsSection';
|
||||
import TestimonialsSection from './TestimonialsSection';
|
||||
import PricingSection from './PricingSection';
|
||||
import FAQSection from './FAQSection';
|
||||
import FooterSection from './FooterSection';
|
||||
|
||||
export default function LandingPage() {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-screen overflow-y-auto snap-y snap-proximity md:snap-mandatory scroll-smooth"
|
||||
style={{ background: 'var(--bg-primary)' }}
|
||||
>
|
||||
<LandingNav scrollRef={scrollRef} />
|
||||
|
||||
<section id="hero" className="min-h-screen snap-start snap-always flex items-center">
|
||||
<HeroSection />
|
||||
</section>
|
||||
|
||||
<section id="features" className="min-h-screen snap-start snap-always flex items-center">
|
||||
<FeaturesSection />
|
||||
</section>
|
||||
|
||||
<section id="tools" className="min-h-screen snap-start snap-always flex items-center">
|
||||
<ToolsSection />
|
||||
</section>
|
||||
|
||||
<section id="testimonials" className="min-h-screen snap-start snap-always flex items-center">
|
||||
<TestimonialsSection />
|
||||
</section>
|
||||
|
||||
<section id="pricing" className="min-h-screen snap-start snap-always flex items-center">
|
||||
<PricingSection />
|
||||
</section>
|
||||
|
||||
<section id="faq" className="min-h-screen snap-start snap-always flex items-center">
|
||||
<FAQSection />
|
||||
</section>
|
||||
|
||||
<section className="snap-start">
|
||||
<FooterSection />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
packages/web/src/pages/landing/PricingSection.tsx
Normal file
118
packages/web/src/pages/landing/PricingSection.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||
|
||||
type PlanKey = 'free' | 'pro' | 'enterprise';
|
||||
|
||||
const plans: { key: PlanKey; featured: boolean; featureCount: number }[] = [
|
||||
{ key: 'free', featured: false, featureCount: 4 },
|
||||
{ key: 'pro', featured: true, featureCount: 5 },
|
||||
{ key: 'enterprise', featured: false, featureCount: 5 },
|
||||
];
|
||||
|
||||
export default function PricingSection() {
|
||||
const { t } = useI18n();
|
||||
const { ref, isVisible } = useScrollReveal();
|
||||
|
||||
return (
|
||||
<div className="w-full py-20">
|
||||
<div ref={ref} className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className={`text-center mb-16 transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
<span className="inline-block text-xs font-semibold uppercase tracking-[0.15em] mb-3 px-3 py-1 rounded-full"
|
||||
style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)' }}>
|
||||
{t('pricing.label')}
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
|
||||
{t('pricing.title')}
|
||||
</h2>
|
||||
<p className="text-lg" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('pricing.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pricing cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 max-w-5xl mx-auto">
|
||||
{plans.map(({ key, featured, featureCount }, i) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`relative p-7 rounded-2xl transition-all duration-700 hover:-translate-y-1 ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
|
||||
} ${featured ? 'shadow-lg' : ''}`}
|
||||
style={{
|
||||
transitionDelay: isVisible ? `${200 + i * 150}ms` : '0ms',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: featured
|
||||
? '2px solid var(--fox-amber)'
|
||||
: '1px solid var(--border-default)',
|
||||
}}
|
||||
>
|
||||
{/* Popular badge */}
|
||||
{featured && t(`pricing.${key}.badge`) && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 rounded-full text-[11px] font-semibold text-white"
|
||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
||||
{t(`pricing.${key}.badge`)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan name + price */}
|
||||
<div className="mb-5">
|
||||
<h3 className="text-base font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
|
||||
{t(`pricing.${key}.name`)}
|
||||
</h3>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold tracking-tight" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
|
||||
{t(`pricing.${key}.price`)}
|
||||
</span>
|
||||
{t(`pricing.${key}.period`) && (
|
||||
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
{t(`pricing.${key}.period`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t(`pricing.${key}.desc`)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Features list */}
|
||||
<ul className="space-y-3 mb-7">
|
||||
{Array.from({ length: featureCount }, (_, j) => (
|
||||
<li key={j} className="flex items-center gap-2.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
<svg className="w-4 h-4 shrink-0" style={{ color: 'var(--fox-amber)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{t(`pricing.${key}.f${j + 1}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
{key === 'enterprise' ? (
|
||||
<button
|
||||
className={`block w-full text-center py-2.5 rounded-xl text-sm font-medium transition-all duration-200 hover:-translate-y-0.5 cursor-pointer`}
|
||||
style={{ background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }}
|
||||
>
|
||||
{t(`pricing.${key}.cta`)}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/register"
|
||||
className={`block w-full text-center py-2.5 rounded-xl text-sm font-medium transition-all duration-200 hover:-translate-y-0.5 ${
|
||||
featured ? 'text-white shadow-md' : ''
|
||||
}`}
|
||||
style={featured
|
||||
? { background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }
|
||||
: { background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }
|
||||
}
|
||||
>
|
||||
{t(`pricing.${key}.cta`)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
packages/web/src/pages/landing/TestimonialsSection.tsx
Normal file
71
packages/web/src/pages/landing/TestimonialsSection.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||
|
||||
const testimonials = [1, 2, 3] as const;
|
||||
|
||||
export default function TestimonialsSection() {
|
||||
const { t } = useI18n();
|
||||
const { ref, isVisible } = useScrollReveal();
|
||||
|
||||
const colors = ['#f59e0b', '#6366f1', '#30a46c'];
|
||||
|
||||
return (
|
||||
<div className="w-full py-20">
|
||||
<div ref={ref} className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className={`text-center mb-16 transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
<span className="inline-block text-xs font-semibold uppercase tracking-[0.15em] mb-3 px-3 py-1 rounded-full"
|
||||
style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)' }}>
|
||||
{t('testimonials.label')}
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
|
||||
{t('testimonials.title')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{testimonials.map((num, i) => (
|
||||
<div
|
||||
key={num}
|
||||
className={`relative p-7 rounded-2xl transition-all duration-700 hover:-translate-y-1 hover:shadow-lg ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
|
||||
}`}
|
||||
style={{
|
||||
transitionDelay: isVisible ? `${200 + i * 150}ms` : '0ms',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-default)',
|
||||
}}
|
||||
>
|
||||
{/* Quote mark */}
|
||||
<div className="absolute top-5 right-6 text-5xl font-serif leading-none" style={{ color: 'var(--border-default)' }}>
|
||||
"
|
||||
</div>
|
||||
|
||||
{/* Quote text */}
|
||||
<p className="text-sm leading-relaxed mb-6 relative z-10" style={{ color: 'var(--text-secondary)' }}>
|
||||
"{t(`testimonials.${num}.quote`)}"
|
||||
</p>
|
||||
|
||||
{/* Author */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center text-[11px] font-bold text-white shrink-0"
|
||||
style={{ background: colors[i] }}>
|
||||
{t(`testimonials.${num}.name`).split(' ').map(w => w[0]).join('')}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{t(`testimonials.${num}.name`)}
|
||||
</div>
|
||||
<div className="text-[12px]" style={{ color: 'var(--text-muted)' }}>
|
||||
{t(`testimonials.${num}.role`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
packages/web/src/pages/landing/ToolsSection.tsx
Normal file
90
packages/web/src/pages/landing/ToolsSection.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useI18n } from '../../lib/i18n';
|
||||
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||
|
||||
import claudeCodeIcon from '../../assets/icons/tools/claude-code.svg';
|
||||
import codexIcon from '../../assets/icons/tools/codex.svg';
|
||||
import cursorIcon from '../../assets/icons/tools/cursor.svg';
|
||||
import copilotIcon from '../../assets/icons/tools/github-copilot.svg';
|
||||
import geminiIcon from '../../assets/icons/tools/gemini.svg';
|
||||
import antigravityIcon from '../../assets/icons/tools/antigravity.svg';
|
||||
import openclawIcon from '../../assets/icons/tools/openclaw.svg';
|
||||
|
||||
const tools = [
|
||||
{ key: 'claude', icon: claudeCodeIcon, bg: '#584438' },
|
||||
{ key: 'codex', icon: codexIcon, bg: '#3e3e5c' },
|
||||
{ key: 'openclaw', icon: openclawIcon, bg: '#583030' },
|
||||
{ key: 'gemini', icon: geminiIcon, bg: '#2e3e5c' },
|
||||
{ key: 'cursor', icon: cursorIcon, bg: '#2e4a4a' },
|
||||
{ key: 'copilot', icon: copilotIcon, bg: '#40345a' },
|
||||
{ key: 'antigravity', icon: antigravityIcon, bg: '#3c3c44' },
|
||||
] as const;
|
||||
|
||||
export default function ToolsSection() {
|
||||
const { t } = useI18n();
|
||||
const { ref, isVisible } = useScrollReveal();
|
||||
|
||||
return (
|
||||
<div className="w-full py-20">
|
||||
<div ref={ref} className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className={`text-center mb-16 transition-all duration-700 ${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}`}>
|
||||
<span className="inline-block text-xs font-semibold uppercase tracking-[0.15em] mb-3 px-3 py-1 rounded-full"
|
||||
style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)' }}>
|
||||
{t('tools.label')}
|
||||
</span>
|
||||
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight mb-4" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
|
||||
{t('tools.title')}
|
||||
</h2>
|
||||
<p className="text-lg max-w-2xl mx-auto" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t('tools.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tool grid — 7 items: 4+3 on tablet, single row on desktop */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-7 gap-4">
|
||||
{tools.map(({ key, icon, bg }, i) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`group flex flex-col items-center text-center p-5 rounded-2xl transition-all duration-700 hover:-translate-y-2 cursor-default ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'
|
||||
}`}
|
||||
style={{
|
||||
transitionDelay: isVisible ? `${200 + i * 80}ms` : '0ms',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border-default)',
|
||||
}}
|
||||
>
|
||||
{/* Icon container with dark bg for visibility */}
|
||||
<div
|
||||
className="w-16 h-16 flex items-center justify-center rounded-2xl mb-3 transition-all duration-500 group-hover:shadow-lg group-hover:scale-110"
|
||||
style={{ background: bg }}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt={t(`tools.${key}.name`)}
|
||||
className="w-9 h-9"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold mb-0.5 transition-colors duration-300 group-hover:text-text-primary" style={{ color: 'var(--text-secondary)' }}>
|
||||
{t(`tools.${key}.name`)}
|
||||
</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-muted)' }}>
|
||||
{t(`tools.${key}.desc`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Connector line visual */}
|
||||
<div className={`mt-12 flex items-center justify-center gap-3 transition-all duration-700 delay-700 ${isVisible ? 'opacity-100' : 'opacity-0'}`}>
|
||||
<div className="h-px flex-1 max-w-[120px]" style={{ background: 'linear-gradient(to right, transparent, var(--border-default))' }} />
|
||||
<span className="text-xs font-mono tracking-wide px-3 py-1 rounded-full" style={{ color: 'var(--fox-amber)', background: 'var(--fox-glow)', border: '1px solid rgba(245,158,11,0.15)' }}>
|
||||
MCP
|
||||
</span>
|
||||
<div className="h-px flex-1 max-w-[120px]" style={{ background: 'linear-gradient(to left, transparent, var(--border-default))' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user