This commit is contained in:
396
pages/AdminDashboard.tsx
Normal file
396
pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { Users, PlusCircle, Shield, ArrowLeft, RefreshCw, Key, DollarSign, BarChart2, Save, Activity } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tooltip } from '../components/Tooltip';
|
||||
import { Layout } from '../components/Layout';
|
||||
|
||||
// Simple SVG Bar Chart Component
|
||||
const AnalyticsChart = ({ data }: { data: any[] }) => {
|
||||
if (!data || data.length === 0) return <div className="text-center text-stone-400 py-10">No data available for this period.</div>;
|
||||
|
||||
const maxVal = Math.max(...data.map(d => Math.max(d.revenue, d.usage)), 10); // Prevent zero division
|
||||
const height = 200;
|
||||
const barWidth = 40;
|
||||
const gap = 20;
|
||||
const width = data.length * (barWidth + gap);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto pb-4">
|
||||
<svg width={Math.max(width, 600)} height={height + 50} className="font-mono text-[10px]">
|
||||
{/* Y-Axis Grid */}
|
||||
{[0, 0.5, 1].map(t => (
|
||||
<line key={t} x1={0} y1={height * (1 - t)} x2="100%" y2={height * (1 - t)} stroke="#e5e5e5" strokeDasharray="4" />
|
||||
))}
|
||||
|
||||
{data.map((d, i) => {
|
||||
const x = i * (barWidth + gap);
|
||||
const hRev = (d.revenue / maxVal) * height;
|
||||
const hCost = (d.usage / maxVal) * height;
|
||||
|
||||
return (
|
||||
<g key={i} transform={`translate(${x}, 0)`}>
|
||||
{/* Revenue Bar (Green) */}
|
||||
<rect x={0} y={height - hRev} width={barWidth / 2} height={hRev} fill="#22c55e" rx="2" />
|
||||
{/* Cost Bar (Red) */}
|
||||
<rect x={barWidth / 2} y={height - hCost} width={barWidth / 2} height={hCost} fill="#ef4444" rx="2" />
|
||||
|
||||
{/* Label */}
|
||||
<text x={barWidth / 2} y={height + 15} textAnchor="middle" fill="#78716c">{d.date.slice(5)}</text>
|
||||
{/* Values */}
|
||||
<text x={0} y={height - hRev - 5} className="fill-green-600 font-bold" fontSize="8">${d.revenue.toFixed(1)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex gap-4 justify-center mt-4 text-xs font-bold">
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-3 bg-green-500 rounded"></div> Revenue</div>
|
||||
<div className="flex items-center gap-1"><div className="w-3 h-3 bg-red-500 rounded"></div> Usage Cost</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Tab State
|
||||
const [activeTab, setActiveTab] = useState<'users' | 'pricing' | 'analytics'>('users');
|
||||
|
||||
// Data State
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [configs, setConfigs] = useState<any>({});
|
||||
const [analytics, setAnalytics] = useState<any[]>([]);
|
||||
|
||||
// Loading State
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<string | null>(null);
|
||||
const [configValue, setConfigValue] = useState("");
|
||||
const [analyticsRange, setAnalyticsRange] = useState("30d");
|
||||
|
||||
// NEW: User Management State
|
||||
const [selectedUser, setSelectedUser] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.role !== 'ADMIN') {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
fetchData();
|
||||
}, [user, activeTab, analyticsRange]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
|
||||
try {
|
||||
if (activeTab === 'users') {
|
||||
const res = await axios.get('/api/admin/users', { headers });
|
||||
setUsers(res.data.users);
|
||||
} else if (activeTab === 'pricing') {
|
||||
const res = await axios.get('/api/admin/config', { headers });
|
||||
setConfigs(res.data);
|
||||
} else if (activeTab === 'analytics') {
|
||||
const res = await axios.get(`/api/admin/analytics?range=${analyticsRange}`, { headers });
|
||||
const data = Array.isArray(res.data.analytics) ? res.data.analytics : [];
|
||||
setAnalytics(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fetch Error:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateConfig = async (key: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('/api/admin/config', { key, value: configValue }, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setConfigs({ ...configs, [key]: configValue });
|
||||
setEditingConfig(null);
|
||||
} catch (e) {
|
||||
alert("Failed to update config");
|
||||
}
|
||||
};
|
||||
|
||||
const addCredits = async (userId: string, amount: number) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('/api/admin/credits', { userId, amount }, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
fetchData(); // Refresh list to show new balance
|
||||
if (selectedUser) {
|
||||
// Update local selected user state immediately for better UX
|
||||
setSelectedUser((prev: any) => ({ ...prev, credits: prev.credits + amount }));
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Failed to add credits");
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserRole = async (userId: string, role: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('/api/admin/role', { userId, role }, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
fetchData(); // Refresh list
|
||||
if (selectedUser) {
|
||||
setSelectedUser((prev: any) => ({ ...prev, role }));
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || "Failed to update role");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-7xl mx-auto p-6 pt-12 space-y-8">
|
||||
{/* Admin Sub-Header */}
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6 mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="p-3 bg-white hover:bg-stone-100 border border-stone-200 rounded-2xl transition-all shadow-sm group"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-stone-600 group-hover:-translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tighter text-stone-900 flex items-center gap-3">
|
||||
<Shield className="w-8 h-8 text-purple-600" />
|
||||
Admin Command Center
|
||||
</h1>
|
||||
<p className="text-stone-500 font-medium text-sm">System management and financial oversight.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex bg-stone-200 p-1 rounded-xl">
|
||||
<button onClick={() => setActiveTab('users')} className={`px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'users' ? 'bg-white text-stone-900 shadow-sm' : 'text-stone-500 hover:text-stone-700'}`}>Users</button>
|
||||
<button onClick={() => setActiveTab('pricing')} className={`px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'pricing' ? 'bg-white text-stone-900 shadow-sm' : 'text-stone-500 hover:text-stone-700'}`}>Pricing</button>
|
||||
<button onClick={() => setActiveTab('analytics')} className={`px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all ${activeTab === 'analytics' ? 'bg-white text-stone-900 shadow-sm' : 'text-stone-500 hover:text-stone-700'}`}>Analytics</button>
|
||||
</div>
|
||||
|
||||
<button onClick={fetchData} className="p-3 bg-white hover:bg-stone-50 border border-stone-200 rounded-xl transition-all shadow-sm">
|
||||
<RefreshCw className={`w-4 h-4 text-stone-600 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- USERS TAB --- */}
|
||||
{activeTab === 'users' && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-200 overflow-hidden">
|
||||
<div className="p-6 border-b border-stone-100 flex items-center gap-3">
|
||||
<Users className="w-5 h-5 text-stone-400" />
|
||||
<h3 className="font-bold text-lg">User Management</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-stone-50 border-b border-stone-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-xs font-black text-stone-400 uppercase tracking-wider">User</th>
|
||||
<th className="px-6 py-3 text-xs font-black text-stone-400 uppercase tracking-wider">Credits</th>
|
||||
<th className="px-6 py-3 text-xs font-black text-stone-400 uppercase tracking-wider">P&L (Rev/Cost)</th>
|
||||
<th className="px-6 py-3 text-xs font-black text-stone-400 uppercase tracking-wider">API Key</th>
|
||||
<th className="px-6 py-3 text-xs font-black text-stone-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{users.map(u => (
|
||||
<tr key={u.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-bold">{u.email}</div>
|
||||
<div className="text-xs text-stone-400 font-mono">{u.id}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-mono font-bold text-stone-700">{u.credits}</td>
|
||||
<td className="px-6 py-4 font-mono text-xs">
|
||||
<span className="text-green-600 block">+${(u.totalRevenue || 0).toFixed(2)}</span>
|
||||
<span className="text-red-500 block">-${(u.totalCost || 0).toFixed(4)}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{u.apiKey ? <span className="bg-green-100 text-green-700 px-2 py-1 rounded text-xs font-bold">Key Active</span> : <span className="text-stone-400 text-xs">No Key</span>}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => setSelectedUser(u)}
|
||||
className="px-3 py-1 bg-purple-600 text-white rounded-lg text-xs font-bold hover:bg-purple-500 shadow-sm flex items-center gap-1"
|
||||
>
|
||||
<Key className="w-3 h-3" /> Manage
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- USER MANAGEMENT MODAL --- */}
|
||||
{selectedUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden border border-stone-100">
|
||||
{/* Header */}
|
||||
<div className="bg-stone-50 px-6 py-4 border-b border-stone-200 flex justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-black text-stone-800">Manage User</h3>
|
||||
<p className="text-xs text-stone-500 font-mono">{selectedUser.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedUser(null)}
|
||||
className="p-1 hover:bg-stone-200 rounded-full transition-colors text-stone-400 hover:text-stone-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Role Management */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-stone-400">User Role</label>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedUser.role}
|
||||
onChange={(e) => updateUserRole(selectedUser.id, e.target.value)}
|
||||
className="flex-1 bg-stone-50 border border-stone-200 rounded-xl px-4 py-2 text-sm font-bold focus:ring-2 focus:ring-purple-500 outline-none"
|
||||
>
|
||||
<option value="USER">USER (Standard)</option>
|
||||
<option value="VIP">VIP (High Priority)</option>
|
||||
<option value="MODERATOR">MODERATOR (Team)</option>
|
||||
<option value="ADMIN">ADMIN (God Mode)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-[10px] text-stone-400">
|
||||
⚠️ Admins have full access. VIPs get priority queue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<hr className="border-stone-100" />
|
||||
|
||||
{/* Credit Management */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-end">
|
||||
<label className="text-xs font-black uppercase tracking-widest text-stone-400">Credit Balance</label>
|
||||
<span className="text-2xl font-black text-stone-800 font-mono">{selectedUser.credits}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-green-600 text-center">Add Credits</p>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => addCredits(selectedUser.id, 50)} className="flex-1 py-2 bg-green-50 text-green-600 rounded-lg text-xs font-bold hover:bg-green-100 transition-colors">+50</button>
|
||||
<button onClick={() => addCredits(selectedUser.id, 500)} className="flex-1 py-2 bg-green-50 text-green-600 rounded-lg text-xs font-bold hover:bg-green-100 transition-colors">+500</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-red-500 text-center">Deduct</p>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => addCredits(selectedUser.id, -50)} className="flex-1 py-2 bg-red-50 text-red-500 rounded-lg text-xs font-bold hover:bg-red-100 transition-colors">-50</button>
|
||||
<button onClick={() => addCredits(selectedUser.id, -500)} className="flex-1 py-2 bg-red-50 text-red-500 rounded-lg text-xs font-bold hover:bg-red-100 transition-colors">-500</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual Input */}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const val = Number((e.target as any).amount.value);
|
||||
if (val) addCredits(selectedUser.id, val);
|
||||
(e.target as any).reset();
|
||||
}}
|
||||
className="flex gap-2 mt-2"
|
||||
>
|
||||
<input
|
||||
name="amount"
|
||||
type="number"
|
||||
placeholder="Custom Amount (+/-)"
|
||||
className="flex-1 bg-stone-50 border border-stone-200 rounded-xl px-4 py-2 text-sm font-mono focus:ring-2 focus:ring-stone-500 outline-none"
|
||||
/>
|
||||
<button type="submit" className="px-4 py-2 bg-stone-800 text-white rounded-xl text-xs font-bold hover:bg-stone-700">Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- PRICING TAB --- */}
|
||||
{activeTab === 'pricing' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{Object.entries(configs).map(([key, value]) => (
|
||||
<div key={key} className="bg-white p-4 rounded-xl border border-stone-200 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h4 className="font-bold text-sm text-stone-800 break-all">{key}</h4>
|
||||
<p className="text-xs text-stone-400">System Configuration Key</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEditingConfig(key); setConfigValue(String(value)); }}
|
||||
className="text-blue-500 hover:text-blue-600 text-xs font-bold"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
{editingConfig === key ? (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
value={configValue}
|
||||
onChange={(e) => setConfigValue(e.target.value)}
|
||||
className="flex-1 bg-stone-50 border border-stone-300 rounded px-2 py-1 text-sm font-mono"
|
||||
/>
|
||||
<button onClick={() => updateConfig(key)} className="bg-green-500 text-white px-2 rounded text-xs"><Save className="w-4 h-4" /></button>
|
||||
<button onClick={() => setEditingConfig(null)} className="bg-stone-300 text-stone-700 px-2 rounded text-xs">✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-stone-50 p-2 rounded border border-stone-100 font-mono text-xs text-stone-600 break-all">
|
||||
{String(value)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- ANALYTICS TAB --- */}
|
||||
{activeTab === 'analytics' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-stone-200 p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="w-6 h-6 text-blue-500" />
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">Financial Performance</h3>
|
||||
<p className="text-sm text-stone-500">Revenue (Stripe/Purchase) vs Cost (Gemini API)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex bg-stone-100 rounded p-1">
|
||||
{['24h', '7d', '30d'].map(r => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setAnalyticsRange(r)}
|
||||
className={`px-3 py-1 text-xs font-bold rounded ${analyticsRange === r ? 'bg-white shadow text-black' : 'text-stone-500'}`}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnalyticsChart data={analytics} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
279
pages/AnalyticsPage.tsx
Normal file
279
pages/AnalyticsPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Activity, Users, DollarSign, Server, Cpu,
|
||||
ArrowUpRight, ArrowDownRight, RefreshCw, Layers
|
||||
} from 'lucide-react';
|
||||
import Layout from '../components/Layout';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
// Simple simulated chart component
|
||||
const MicroChart = ({ color, data }: { color: string, data: number[] }) => (
|
||||
<div className="flex items-end gap-1 h-12 w-full mt-2 opacity-50">
|
||||
{data.map((val, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: `${val}%`,
|
||||
backgroundColor: color
|
||||
}}
|
||||
className="flex-1 rounded-sm transition-all duration-500"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Mock Chart Data
|
||||
const [chartData] = useState(() => Array.from({ length: 12 }, () => Math.floor(Math.random() * 60) + 20));
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/admin/analytics');
|
||||
setStats(res.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load analytics", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchStats();
|
||||
|
||||
// Auto-refresh every 30s
|
||||
const interval = setInterval(fetchStats, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshKey]);
|
||||
|
||||
const formatCurrency = (val: number) => {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(val);
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
};
|
||||
|
||||
if (loading && !stats) return (
|
||||
<Layout>
|
||||
<div className="flex h-[80vh] items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 text-stone-300 animate-spin" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-7xl mx-auto p-8 relative overflow-hidden min-h-screen">
|
||||
|
||||
{/* Background Ambient Glow */}
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-purple-500/5 rounded-full blur-[120px] -z-10" />
|
||||
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-[120px] -z-10" />
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="flex justify-between items-end mb-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-stone-900 text-white px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest">
|
||||
v{stats.system.version}
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 text-green-600 text-[10px] font-bold uppercase tracking-widest">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
System Operational
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-black text-stone-900 tracking-tighter">Engine Analytics</h1>
|
||||
<p className="text-stone-500 font-medium">Mission Control & System Health</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setRefreshKey(p => p + 1)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white/50 hover:bg-white border border-stone-200/50 rounded-xl transition-all shadow-sm backdrop-blur-sm text-sm font-bold text-stone-600 hover:text-stone-900"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* KPI Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
|
||||
|
||||
{/* KPI 1: Users */}
|
||||
<div className="bg-white/60 backdrop-blur-xl border border-white/40 shadow-[0_8px_30px_rgb(0,0,0,0.04)] rounded-3xl p-6 relative overflow-hidden group">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-3 bg-indigo-50 rounded-2xl text-indigo-600 group-hover:scale-110 transition-transform">
|
||||
<Users className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="flex items-center text-xs font-bold text-green-600 bg-green-50 px-2 py-1 rounded-lg">
|
||||
+{stats.users.new24h} <ArrowUpRight className="w-3 h-3 ml-1" />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-stone-400 uppercase tracking-widest mb-1">Total Users</p>
|
||||
<h3 className="text-3xl font-black text-stone-900 tracking-tight">{stats.users.total}</h3>
|
||||
</div>
|
||||
<MicroChart color="#6366f1" data={chartData} />
|
||||
</div>
|
||||
|
||||
{/* KPI 2: Projects */}
|
||||
<div className="bg-white/60 backdrop-blur-xl border border-white/40 shadow-[0_8px_30px_rgb(0,0,0,0.04)] rounded-3xl p-6 relative overflow-hidden group">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-3 bg-purple-50 rounded-2xl text-purple-600 group-hover:scale-110 transition-transform">
|
||||
<Layers className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="flex items-center text-xs font-bold text-purple-600 bg-purple-50 px-2 py-1 rounded-lg">
|
||||
{stats.projects.completionRate}% Done
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-stone-400 uppercase tracking-widest mb-1">Total Projects</p>
|
||||
<h3 className="text-3xl font-black text-stone-900 tracking-tight">{stats.projects.total}</h3>
|
||||
</div>
|
||||
<MicroChart color="#a855f7" data={[...chartData].reverse()} />
|
||||
</div>
|
||||
|
||||
{/* KPI 3: Liability */}
|
||||
<div className="bg-white/60 backdrop-blur-xl border border-white/40 shadow-[0_8px_30px_rgb(0,0,0,0.04)] rounded-3xl p-6 relative overflow-hidden group">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-3 bg-amber-50 rounded-2xl text-amber-600 group-hover:scale-110 transition-transform">
|
||||
<DollarSign className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-stone-400 uppercase tracking-widest mb-1">Credit Liability</p>
|
||||
<h3 className="text-3xl font-black text-stone-900 tracking-tight">{stats.financials.creditsLiability.toLocaleString()}</h3>
|
||||
<p className="text-xs text-stone-400 mt-1 font-mono">Value: {formatCurrency(stats.financials.estimatedValue)}</p>
|
||||
</div>
|
||||
<div className="absolute -right-6 -bottom-6 opacity-5">
|
||||
<DollarSign className="w-40 h-40" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 4: System Health */}
|
||||
<div className="bg-stone-900 backdrop-blur-xl border border-stone-800 shadow-[0_8px_30px_rgb(0,0,0,0.04)] rounded-3xl p-6 relative overflow-hidden text-white group">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-3 bg-white/10 rounded-2xl text-white group-hover:scale-110 transition-transform">
|
||||
<Server className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="flex items-center text-xs font-bold text-green-400 bg-green-900/30 px-2 py-1 rounded-lg border border-green-800">
|
||||
{stats.system.cpuLoad}% Load
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-stone-400 uppercase tracking-widest mb-1">Uptime</p>
|
||||
<h3 className="text-3xl font-black tracking-tight">{formatTime(stats.system.uptime)}</h3>
|
||||
<p className="text-xs text-stone-500 mt-1 font-mono">Mem: {stats.system.memory}MB</p>
|
||||
</div>
|
||||
<div className="absolute top-0 right-0 w-full h-full bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-10 mix-blend-overlay"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Sections */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* Live Feed */}
|
||||
<div className="lg:col-span-2 bg-white border border-stone-100 shadow-sm rounded-3xl p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Activity className="w-5 h-5 text-stone-400" />
|
||||
<h3 className="text-lg font-black text-stone-900 tracking-tight">Live Activity Feed</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{stats.activityLog.map((log: any) => (
|
||||
<div key={log.id} className="flex items-center justify-between p-4 rounded-2xl bg-stone-50 border border-stone-100 hover:bg-white hover:shadow-md transition-all group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold text-xs">
|
||||
{log.user.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-stone-900">{log.action.replace('_', ' ')}</p>
|
||||
<p className="text-xs text-stone-500">{log.user}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-mono font-bold text-stone-400">{new Date(log.timestamp).toLocaleTimeString()}</p>
|
||||
<p className="text-[10px] font-bold text-stone-300 uppercase tracking-widest">{log.details}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{stats.activityLog.length === 0 && (
|
||||
<div className="text-center py-10 text-stone-400 text-sm">No recent activity found.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Resources */}
|
||||
<div className="bg-white border border-stone-100 shadow-sm rounded-3xl p-8">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Cpu className="w-5 h-5 text-stone-400" />
|
||||
<h3 className="text-lg font-black text-stone-900 tracking-tight">Resource Monitor</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs font-bold text-stone-500 mb-2 uppercase tracking-widest">
|
||||
<span>CPU Usage</span>
|
||||
<span>{stats.system.cpuLoad}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-stone-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-stone-900 rounded-full transition-all duration-1000 ease-out"
|
||||
style={{ width: `${stats.system.cpuLoad}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-xs font-bold text-stone-500 mb-2 uppercase tracking-widest">
|
||||
<span>Memory Allocation</span>
|
||||
<span>{stats.system.memory} MB</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-stone-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full transition-all duration-1000 ease-out"
|
||||
style={{ width: `${Math.min((stats.system.memory / 512) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-xs font-bold text-stone-500 mb-2 uppercase tracking-widest">
|
||||
<span>Storage (Projects)</span>
|
||||
<span>{stats.projects.total} Items</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-stone-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all duration-1000 ease-out"
|
||||
style={{ width: '45%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-amber-50 rounded-2xl border border-amber-100">
|
||||
<h4 className="flex items-center gap-2 text-xs font-black text-amber-700 uppercase tracking-widest mb-1">
|
||||
<Activity className="w-3 h-3" /> System Status
|
||||
</h4>
|
||||
<p className="text-xs text-amber-600/80 leading-relaxed">
|
||||
All services operate within nominal parameters. No critical warnings detected in the last 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
184
pages/ConfigPage.tsx
Normal file
184
pages/ConfigPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import Layout from '../components/Layout';
|
||||
import BrandKit from '../components/BrandKit';
|
||||
import { Settings, Save, Plus, Trash2, ToggleLeft, ToggleRight, Search } from 'lucide-react';
|
||||
|
||||
interface ConfigItem {
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [configs, setConfigs] = useState<ConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [newItem, setNewItem] = useState({ key: '', value: '', description: '' });
|
||||
const [editingItem, setEditingItem] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs();
|
||||
}, []);
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/admin/config');
|
||||
setConfigs(res.data.configs);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (key: string, value: string, description?: string) => {
|
||||
try {
|
||||
await axios.put('/api/admin/config', { key, value, description });
|
||||
setEditingItem(null);
|
||||
fetchConfigs();
|
||||
if (key === newItem.key) setNewItem({ key: '', value: '', description: '' });
|
||||
} catch (err) {
|
||||
alert('Failed to save config');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredConfigs = (configs || []).filter(c =>
|
||||
c.key.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(c.description || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-5xl mx-auto p-8">
|
||||
|
||||
<div className="flex justify-between items-end mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black text-stone-900 tracking-tight flex items-center gap-3">
|
||||
<Settings className="w-8 h-8 text-stone-400" />
|
||||
System Configuration
|
||||
</h1>
|
||||
<p className="text-stone-500 mt-2">Manage global variables, feature flags, and system limits.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BrandKit />
|
||||
|
||||
{/* New Config Card */}
|
||||
<div className="bg-white border border-stone-200 shadow-sm rounded-2xl p-6 mb-8 group focus-within:ring-2 ring-purple-500/20 transition-all">
|
||||
<h3 className="text-xs font-bold text-stone-400 uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" /> Add New Variable
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<input
|
||||
placeholder="KEY_NAME (e.g. MAX_TOKENS)"
|
||||
className="bg-stone-50 border border-stone-200 rounded-xl px-4 py-2 text-sm font-mono font-bold focus:outline-none focus:border-purple-500 transition-all"
|
||||
value={newItem.key}
|
||||
onChange={e => setNewItem({ ...newItem, key: e.target.value.toUpperCase() })}
|
||||
/>
|
||||
<input
|
||||
placeholder="Value"
|
||||
className="bg-stone-50 border border-stone-200 rounded-xl px-4 py-2 text-sm focus:outline-none focus:border-purple-500 transition-all"
|
||||
value={newItem.value}
|
||||
onChange={e => setNewItem({ ...newItem, value: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
placeholder="Description (Optional)"
|
||||
className="bg-stone-50 border border-stone-200 rounded-xl px-4 py-2 text-sm focus:outline-none focus:border-purple-500 transition-all"
|
||||
value={newItem.description}
|
||||
onChange={e => setNewItem({ ...newItem, description: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
disabled={!newItem.key || !newItem.value}
|
||||
onClick={() => handleSave(newItem.key, newItem.value, newItem.description)}
|
||||
className="bg-stone-900 text-white rounded-xl font-bold text-sm hover:bg-purple-600 disabled:opacity-50 disabled:hover:bg-stone-900 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save className="w-4 h-4" /> Save Variable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative mb-6">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search configuration keys..."
|
||||
className="w-full pl-10 pr-4 py-3 bg-white border border-stone-200 rounded-xl text-sm font-medium focus:outline-none focus:ring-2 focus:ring-stone-100 transition-all"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Config Table */}
|
||||
<div className="bg-white border border-stone-200 shadow-sm rounded-3xl overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-stone-50 border-b border-stone-100">
|
||||
<tr>
|
||||
<th className="text-left py-4 px-6 text-[10px] font-black uppercase tracking-widest text-stone-400">Key Name</th>
|
||||
<th className="text-left py-4 px-6 text-[10px] font-black uppercase tracking-widest text-stone-400">Value</th>
|
||||
<th className="text-left py-4 px-6 text-[10px] font-black uppercase tracking-widest text-stone-400">Description</th>
|
||||
<th className="text-right py-4 px-6 text-[10px] font-black uppercase tracking-widest text-stone-400">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{filteredConfigs.map(config => (
|
||||
<tr key={config.key} className="hover:bg-stone-50/50 transition-colors group">
|
||||
<td className="py-4 px-6 font-mono text-xs font-bold text-purple-600 select-all">
|
||||
{config.key}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
{editingItem === config.key ? (
|
||||
<input
|
||||
defaultValue={config.value}
|
||||
className="w-full bg-white border border-purple-200 rounded-lg px-2 py-1 text-sm focus:outline-none"
|
||||
onBlur={(e) => handleSave(config.key, e.target.value, config.description)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave(config.key, e.currentTarget.value, config.description);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditingItem(config.key)}
|
||||
className="text-sm font-medium text-stone-700 hover:text-purple-600 border-b border-stone-200 border-dashed hover:border-purple-300 transition-colors text-left"
|
||||
>
|
||||
{config.value === 'true' || config.value === 'false' ? (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-[10px] uppercase font-black tracking-wider ${config.value === 'true' ? 'bg-green-100 text-green-700' : 'bg-red-50 text-red-500'}`}>
|
||||
{config.value === 'true' ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
|
||||
{config.value.toUpperCase()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate max-w-[200px] block" title={config.value}>{config.value}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-xs text-stone-400 font-medium">
|
||||
{config.description || '-'}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-right">
|
||||
<button
|
||||
onClick={() => setEditingItem(config.key)}
|
||||
className="p-2 text-stone-300 hover:text-stone-900 transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredConfigs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-10 text-center text-stone-400 text-sm">
|
||||
No configuration keys found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
94
pages/LoginPage.tsx
Normal file
94
pages/LoginPage.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '../components/Tooltip';
|
||||
import { GoogleLogin } from '@react-oauth/google';
|
||||
|
||||
export default function Login() {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const res = await axios.post('/api/auth/login', { email, password });
|
||||
login(res.data.token, res.data.user);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-100">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-lg">
|
||||
<h2 className="mb-6 text-center text-2xl font-bold text-gray-800">{t('buttons.login')}</h2>
|
||||
{error && <div className="mb-4 rounded bg-red-100 p-2 text-red-600">{error}</div>}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="mb-1 block font-semibold text-gray-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded border p-2 focus:border-blue-500 focus:outline-none"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="mb-1 block font-semibold text-gray-700">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded border p-2 focus:border-blue-500 focus:outline-none"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Tooltip content={t('buttons.login')} position="top">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded bg-blue-600 py-2 font-bold text-white hover:bg-blue-700 transition"
|
||||
>
|
||||
{t('buttons.login')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<GoogleLogin
|
||||
onSuccess={async (credentialResponse) => {
|
||||
try {
|
||||
const res = await axios.post('/api/auth/google', { credential: credentialResponse.credential });
|
||||
login(res.data.token, res.data.user);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError('Google Login Failed');
|
||||
}
|
||||
}}
|
||||
onError={() => setError('Google Login Failed')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-gray-600">Don't have an account? <Link to="/signup" className="text-blue-600 hover:underline">Sign up</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
pages/PricingPage.tsx
Normal file
141
pages/PricingPage.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { Check, Shield, ArrowLeft } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Layout } from '../components/Layout';
|
||||
|
||||
const PricingPage: React.FC = () => {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState<string | null>(null);
|
||||
|
||||
const handlePurchase = async (plan: string, credits: number, price: number) => {
|
||||
setLoading(plan);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
// MOCK PURCHASE ENDPOINT
|
||||
await axios.post('/api/user/purchase', { plan, credits, price }, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
await refreshUser();
|
||||
alert(`Successfully purchased ${plan}! ${credits} credits added.`);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert("Purchase failed: " + (err.response?.data?.error || err.message));
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-5xl mx-auto p-8 pt-12">
|
||||
<div className="flex items-center gap-4 mb-12">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="p-3 bg-white hover:bg-stone-100 border border-stone-200 rounded-2xl transition-all shadow-sm group"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-stone-600 group-hover:-translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tighter text-stone-900">Upgrade Your Engine</h1>
|
||||
<p className="text-stone-500 font-medium text-sm">Unlock higher limits and premium features.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* ... tier content remains same ... */}
|
||||
{/* Free Tier */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-stone-200 p-8 flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-stone-500 uppercase tracking-wider">Starter</h3>
|
||||
<p className="text-4xl font-black mt-2">$0</p>
|
||||
<p className="text-sm text-stone-400">Forever free</p>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-8 flex-1">
|
||||
<li className="flex items-center gap-3 text-sm">
|
||||
<span className="p-1 bg-green-100 text-green-600 rounded-full"><Check className="w-3 h-3" /></span>
|
||||
10 Free Credits / Month
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm">
|
||||
<span className="p-1 bg-green-100 text-green-600 rounded-full"><Check className="w-3 h-3" /></span>
|
||||
Standard Quality
|
||||
</li>
|
||||
</ul>
|
||||
<button disabled className="w-full py-3 bg-stone-100 text-stone-400 font-bold rounded-xl cursor-not-allowed">
|
||||
Current Plan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pro Tier */}
|
||||
<div className="bg-stone-900 text-white rounded-2xl shadow-xl border border-stone-800 p-8 flex flex-col relative transform scale-105">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-gradient-to-r from-purple-500 to-indigo-500 text-white px-4 py-1 rounded-full text-xs font-bold uppercase tracking-widest shadow-lg">
|
||||
Most Popular
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-purple-400 uppercase tracking-wider">Professional</h3>
|
||||
<p className="text-4xl font-black mt-2">$29</p>
|
||||
<p className="text-sm text-stone-400">per month</p>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-8 flex-1">
|
||||
<li className="flex items-center gap-3 text-sm">
|
||||
<span className="p-1 bg-purple-500/20 text-purple-400 rounded-full"><Check className="w-3 h-3" /></span>
|
||||
1,000 Credits / Month
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm">
|
||||
<span className="p-1 bg-purple-500/20 text-purple-400 rounded-full"><Check className="w-3 h-3" /></span>
|
||||
Priority Generation
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm">
|
||||
<span className="p-1 bg-purple-500/20 text-purple-400 rounded-full"><Check className="w-3 h-3" /></span>
|
||||
Access to Beta Features
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => handlePurchase('PRO', 1000, 29)}
|
||||
disabled={loading === 'PRO'}
|
||||
className="w-full py-3 bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg hover:shadow-purple-500/25 disabled:opacity-50"
|
||||
>
|
||||
{loading === 'PRO' ? 'Processing...' : 'Upgrade Now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Enterprise/Bulk */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-stone-200 p-8 flex flex-col">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-bold text-stone-500 uppercase tracking-wider">Credit Pack</h3>
|
||||
<p className="text-4xl font-black mt-2">$10</p>
|
||||
<p className="text-sm text-stone-400">One-time purchase</p>
|
||||
</div>
|
||||
<ul className="space-y-4 mb-8 flex-1">
|
||||
<li className="flex items-center gap-3 text-sm">
|
||||
<span className="p-1 bg-green-100 text-green-600 rounded-full"><Check className="w-3 h-3" /></span>
|
||||
+300 Credits
|
||||
</li>
|
||||
<li className="flex items-center gap-3 text-sm">
|
||||
<span className="p-1 bg-green-100 text-green-600 rounded-full"><Check className="w-3 h-3" /></span>
|
||||
Never Expires
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => handlePurchase('PACK', 300, 10)}
|
||||
disabled={loading === 'PACK'}
|
||||
className="w-full py-3 bg-white border-2 border-stone-200 text-stone-700 hover:border-stone-900 hover:text-stone-900 font-bold rounded-xl transition-all disabled:opacity-50"
|
||||
>
|
||||
{loading === 'PACK' ? 'Processing...' : 'Buy Pack'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center text-xs text-stone-400 bg-stone-100 p-4 rounded-lg inline-flex items-center gap-2">
|
||||
<Shield className="w-4 h-4" />
|
||||
Secure Payment Processing via MockStripe (Test Mode)
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
185
pages/ScorecardPage.tsx
Normal file
185
pages/ScorecardPage.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState } from 'react';
|
||||
import Header from '../components/Header';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Brain, Upload, Image as ImageIcon, AlertCircle } from 'lucide-react';
|
||||
import NeuroScorecard from '../components/NeuroScorecard';
|
||||
import { ApiKeyModal } from '../components/ApiKeyModal';
|
||||
|
||||
const ScorecardPage = () => {
|
||||
const { user, token, logout, refreshUser } = useAuth();
|
||||
const [imageUrl, setImageUrl] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [analysis, setAnalysis] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false);
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImageUrl(reader.result as string);
|
||||
setAnalysis(null); // Reset analysis when new image is uploaded
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const analyzeImage = async () => {
|
||||
if (!imageUrl) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Strip base64 prefix if present for API
|
||||
const base64Data = imageUrl.split(',')[1];
|
||||
|
||||
const response = await fetch('http://localhost:3001/api/neuro-score', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
imageBase64: base64Data,
|
||||
apiKey: localStorage.getItem('gemini_api_key') || user?.apiKey // BYOK support
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || "Analysis failed");
|
||||
}
|
||||
|
||||
setAnalysis(data.data);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-950 text-stone-200 font-sans selection:bg-purple-500/30">
|
||||
<Header
|
||||
user={user}
|
||||
logout={logout}
|
||||
openApiKeyModal={() => setIsApiKeyModalOpen(true)}
|
||||
/>
|
||||
|
||||
<ApiKeyModal
|
||||
isOpen={isApiKeyModalOpen}
|
||||
onClose={() => setIsApiKeyModalOpen(false)}
|
||||
/>
|
||||
|
||||
<main className="max-w-6xl mx-auto px-6 py-12">
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="text-center mb-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-sm font-medium mb-6"
|
||||
>
|
||||
<Brain className="w-4 h-4" />
|
||||
<span>The Internal Critic</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-5xl md:text-6xl font-serif text-white mb-6">
|
||||
Neuro-Scorecard
|
||||
</h1>
|
||||
<p className="text-xl text-stone-400 max-w-2xl mx-auto leading-relaxed">
|
||||
Don't guess what sells. Let our AI predict your conversion rate by analyzing
|
||||
dopamine triggers, cognitive ease, and market fit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
|
||||
|
||||
{/* Input Section */}
|
||||
<div className="space-y-6">
|
||||
<div className="p-8 rounded-3xl bg-stone-900/50 border border-white/5 backdrop-blur-sm relative group overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/5 to-purple-500/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
|
||||
{!imageUrl ? (
|
||||
<label className="flex flex-col items-center justify-center w-full h-80 border-2 border-dashed border-white/10 rounded-xl cursor-pointer hover:border-blue-500/50 hover:bg-white/5 transition-all group-hover:scale-[1.02]">
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<div className="p-4 bg-stone-800 rounded-full mb-4">
|
||||
<Upload className="w-8 h-8 text-stone-400" />
|
||||
</div>
|
||||
<p className="mb-2 text-lg text-stone-300 font-medium">Click to upload an image</p>
|
||||
<p className="text-sm text-stone-500">PNG, JPG up to 10MB</p>
|
||||
</div>
|
||||
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} />
|
||||
</label>
|
||||
) : (
|
||||
<div className="relative rounded-xl overflow-hidden shadow-2xl">
|
||||
<img src={imageUrl} alt="Analysis Target" className="w-full h-auto object-cover" />
|
||||
<button
|
||||
onClick={() => { setImageUrl(''); setAnalysis(null); }}
|
||||
className="absolute top-4 right-4 p-2 bg-black/50 backdrop-blur-md rounded-full text-white hover:bg-red-500/80 transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={analyzeImage}
|
||||
disabled={!imageUrl || loading}
|
||||
className={`w-full py-4 rounded-xl font-bold text-lg shadow-lg flex items-center justify-center gap-2 transition-all ${!imageUrl ? 'bg-stone-800 text-stone-500 cursor-not-allowed' :
|
||||
loading ? 'bg-stone-700 text-stone-400 cursor-wait' :
|
||||
'bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 text-white hover:shadow-blue-500/20 hover:scale-[1.02]'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<>Processing Vision...</>
|
||||
) : (
|
||||
<>
|
||||
<Brain className="w-5 h-5" />
|
||||
Run Neuro-Analysis
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 flex items-center gap-3"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<p>{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results Section */}
|
||||
<div>
|
||||
{!analysis && !loading && (
|
||||
<div className="h-full flex flex-col items-center justify-center text-stone-600 p-12 border border-white/5 rounded-3xl bg-stone-900/30 border-dashed">
|
||||
<ImageIcon className="w-16 h-16 mb-4 opacity-20" />
|
||||
<p className="text-lg font-medium">Ready to Score</p>
|
||||
<p className="text-sm">Upload an image to see its commercial prediction.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(analysis || loading) && (
|
||||
<NeuroScorecard analysis={analysis} loading={loading} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScorecardPage;
|
||||
493
pages/SettingsPage.tsx
Normal file
493
pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { Save, Key, Shield, User, ArrowLeft, CreditCard, Upload, Image as ImageIcon } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Layout } from '../components/Layout';
|
||||
import { ProductType } from '../types';
|
||||
import { Tag } from 'lucide-react';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [savedKeyMasked, setSavedKeyMasked] = useState<string | null>(null);
|
||||
const [etsyShopName, setEtsyShopName] = useState('');
|
||||
const [etsyShopLink, setEtsyShopLink] = useState('');
|
||||
const [etsyShopLogo, setEtsyShopLogo] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [brandingLoading, setBrandingLoading] = useState(false);
|
||||
const [logoUploading, setLogoUploading] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
|
||||
// SKU State
|
||||
const [skuConfig, setSkuConfig] = useState<Record<string, { prefix: string, next: number }>>({});
|
||||
const [skuLoading, setSkuLoading] = useState(false);
|
||||
|
||||
const PRODUCT_TYPES: ProductType[] = ["Wall Art", "Bookmark", "Sticker", "Planner", "Phone Wallpaper", "Social Media Kit", "Label"];
|
||||
|
||||
useEffect(() => {
|
||||
fetchApiKey();
|
||||
fetchBranding();
|
||||
// Check local storage for migration
|
||||
const localKey = localStorage.getItem('gemini_api_key');
|
||||
if (localKey && !apiKey && !savedKeyMasked) {
|
||||
setApiKey(localKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchApiKey = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get('/api/user/apikey', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.data.apiKey) {
|
||||
setSavedKeyMasked(res.data.apiKey);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch key", err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBranding = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get('/api/user/branding', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setEtsyShopName(res.data.etsyShopName || '');
|
||||
setEtsyShopLink(res.data.etsyShopLink || '');
|
||||
setEtsyShopLogo(res.data.etsyShopLogo || null);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch branding", err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setEtsyShopName(user.etsyShopName || '');
|
||||
setEtsyShopLink(user.etsyShopLink || '');
|
||||
setEtsyShopLogo(user.etsyShopLogo || null);
|
||||
if (user.apiKey) setSavedKeyMasked(user.apiKey.replace(/.(?=.{4})/g, '*'));
|
||||
|
||||
try {
|
||||
if (user.skuSettings) {
|
||||
setSkuConfig(JSON.parse(user.skuSettings));
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSaveSku = async () => {
|
||||
setSkuLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('/api/user/sku', { skuSettings: JSON.stringify(skuConfig) }, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setMessage({ type: 'success', text: 'SKU Settings saved successfully.' });
|
||||
await refreshUser();
|
||||
} catch (error: any) {
|
||||
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to save SKU settings.' });
|
||||
} finally {
|
||||
setSkuLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveKey = async () => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('/api/user/apikey', { apiKey }, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
setMessage({ type: 'success', text: 'API Key saved securely to your profile.' });
|
||||
setSavedKeyMasked(`${apiKey.substring(0, 4)}...${apiKey.substring(apiKey.length - 4)}`);
|
||||
setApiKey(''); // Clear input for security
|
||||
|
||||
// Also update local storage for redundancy if needed, or clear it to enforce server side?
|
||||
// Let's keep it in sync for now as a fallback if we revert logic.
|
||||
localStorage.setItem('gemini_api_key', apiKey);
|
||||
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to save key' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBranding = async () => {
|
||||
setBrandingLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
await axios.post('/api/user/branding', { etsyShopName, etsyShopLink }, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setMessage({ type: 'success', text: 'Store branding updated successfully.' });
|
||||
await refreshUser(); // Update global context field
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to update branding' });
|
||||
} finally {
|
||||
setBrandingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGodMode = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.post('http://localhost:3001/api/admin/grant-me-god-mode', {}, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
if (res.data.token) {
|
||||
localStorage.setItem('token', res.data.token);
|
||||
// Force update context if possible, or just reload
|
||||
}
|
||||
|
||||
alert(res.data.message);
|
||||
window.location.reload();
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert(err.response?.data?.error || "Failed to grant God Mode");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="max-w-3xl mx-auto p-6 pt-12 space-y-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="p-3 bg-white hover:bg-stone-100 border border-stone-200 rounded-2xl transition-all shadow-sm group"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-stone-600 group-hover:-translate-x-1 transition-transform" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-black tracking-tighter text-stone-900">Account Settings</h1>
|
||||
<p className="text-stone-500 font-medium text-sm">Manage your profile and API security.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Card */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-stone-200 p-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-full flex items-center justify-center text-purple-600">
|
||||
<User className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{user?.email}</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="px-2 py-0.5 bg-stone-100 border border-stone-200 rounded text-xs text-stone-600 font-medium">
|
||||
{user?.role}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-indigo-50 border border-indigo-100 rounded text-xs text-indigo-600 font-medium">
|
||||
{user?.plan} Plan
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-stone-50 rounded-lg border border-stone-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-100 rounded-lg text-yellow-700">
|
||||
<CreditCard className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-stone-500 font-medium">Available Credits</p>
|
||||
<p className="text-xl font-bold text-stone-800">{user?.credits}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="text-sm font-medium text-purple-600 hover:underline">
|
||||
Top Up (Soon)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Key Management */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-stone-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Key className="w-5 h-5 text-purple-600" />
|
||||
<h3 className="text-lg font-semibold">Gemini API Key</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 p-4 bg-blue-50 text-blue-800 rounded-lg text-sm border border-blue-100 flex gap-3">
|
||||
<Shield className="w-5 h-5 flex-shrink-0" />
|
||||
<p>
|
||||
<strong>Beta Mode Requirement:</strong> Your personal request key is now stored securely in your profile.
|
||||
This allows us to process your requests on our servers without you needing to re-enter it on every device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">
|
||||
{savedKeyMasked ? 'Update API Key' : 'Add API Key'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={savedKeyMasked ? `Current: ${savedKeyMasked}` : "Paste your Google Gemini API Key here"}
|
||||
className="w-full px-4 py-2 border border-stone-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
{savedKeyMasked && !apiKey && (
|
||||
<span className="absolute right-3 top-2.5 text-xs text-green-600 font-medium flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" /> Securely Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-stone-500">
|
||||
Get your key from <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noreferrer" className="text-purple-600 hover:underline">Google AI Studio</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm p-3 rounded-lg ${message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSaveKey}
|
||||
disabled={loading || apiKey.length < 10}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg font-medium text-white transition-all ${loading || apiKey.length < 10
|
||||
? 'bg-stone-300 cursor-not-allowed'
|
||||
: 'bg-stone-900 hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{loading ? 'Saving...' : 'Save Key'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Store Branding */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-stone-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Save className="w-5 h-5 text-indigo-600" />
|
||||
<h3 className="text-lg font-semibold">Store Branding</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">
|
||||
Etsy Shop Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={etsyShopName}
|
||||
onChange={(e) => setEtsyShopName(e.target.value)}
|
||||
placeholder="e.g. MyAmazingStudio"
|
||||
className="w-full px-4 py-2 border border-stone-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1">
|
||||
Etsy Shop Link
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={etsyShopLink}
|
||||
onChange={(e) => setEtsyShopLink(e.target.value)}
|
||||
placeholder="https://www.etsy.com/shop/..."
|
||||
className="w-full px-4 py-2 border border-stone-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo Upload Section */}
|
||||
<div className="mt-4 border-t border-stone-100 pt-4">
|
||||
<label className="block text-sm font-medium text-stone-700 mb-3">
|
||||
Shop Logo
|
||||
</label>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative w-24 h-24 bg-stone-100 rounded-full border border-stone-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||
{etsyShopLogo ? (
|
||||
<img
|
||||
src={`/api/storage/${etsyShopLogo}`}
|
||||
alt="Shop Logo"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<ImageIcon className="w-8 h-8 text-stone-300" />
|
||||
)}
|
||||
{logoUploading && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="cursor-pointer inline-flex items-center gap-2 px-4 py-2 bg-white border border-stone-300 rounded-lg text-sm font-medium text-stone-700 hover:bg-stone-50 transition-colors shadow-sm">
|
||||
<Upload className="w-4 h-4" />
|
||||
{etsyShopLogo ? 'Change Logo' : 'Upload Logo'}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
if (!e.target.files || !e.target.files[0]) return;
|
||||
const file = e.target.files[0];
|
||||
setLogoUploading(true);
|
||||
setMessage(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.post('/api/user/logo', formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
setEtsyShopLogo(res.data.logoPath);
|
||||
setMessage({ type: 'success', text: 'Logo uploaded successfully.' });
|
||||
await refreshUser(); // Update global context field
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to upload logo' });
|
||||
} finally {
|
||||
setLogoUploading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-stone-500">
|
||||
Recommended: 500x500px, PNG or JPG.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSaveBranding}
|
||||
disabled={brandingLoading}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg font-medium text-white transition-all ${brandingLoading
|
||||
? 'bg-stone-300 cursor-not-allowed'
|
||||
: 'bg-stone-900 hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{brandingLoading ? 'Updating...' : 'Save Branding'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SKU MANAGEMENT */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-stone-200 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Tag className="w-5 h-5 text-emerald-600" />
|
||||
<h3 className="text-lg font-semibold">SKU Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-stone-500">
|
||||
Set up prefixes and starting numbers for automatic SKU generation (e.g., WLR105).
|
||||
</p>
|
||||
|
||||
<div className="bg-stone-50 rounded-xl overflow-hidden border border-stone-200">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-stone-100 text-stone-600 font-bold uppercase text-xs">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Product Type</th>
|
||||
<th className="px-4 py-3">Prefix (3-4 Chars)</th>
|
||||
<th className="px-4 py-3">Next Number</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-200">
|
||||
{PRODUCT_TYPES.map((type) => {
|
||||
const config = skuConfig[type] || { prefix: '', next: 1 };
|
||||
return (
|
||||
<tr key={type} className="hover:bg-white transition-colors">
|
||||
<td className="px-4 py-3 font-medium text-stone-700">{type}</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={4}
|
||||
placeholder="e.g. WLR"
|
||||
value={config.prefix}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
|
||||
setSkuConfig(prev => ({
|
||||
...prev,
|
||||
[type]: { ...prev[type], prefix: val, next: prev[type]?.next || 1 }
|
||||
}));
|
||||
}}
|
||||
className="w-24 px-2 py-1 border border-stone-300 rounded font-mono uppercase focus:ring-2 focus:ring-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={config.next}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 1;
|
||||
setSkuConfig(prev => ({
|
||||
...prev,
|
||||
[type]: { ...prev[type], prefix: prev[type]?.prefix || '', next: val }
|
||||
}));
|
||||
}}
|
||||
className="w-24 px-2 py-1 border border-stone-300 rounded font-mono focus:ring-2 focus:ring-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSaveSku}
|
||||
disabled={skuLoading}
|
||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg font-medium text-white transition-all ${skuLoading
|
||||
? 'bg-stone-300 cursor-not-allowed'
|
||||
: 'bg-stone-900 hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{skuLoading ? 'Saving...' : 'Save Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="bg-red-50 rounded-xl shadow-sm border border-red-100 p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Shield className="w-5 h-5 text-red-600" />
|
||||
<h3 className="text-lg font-bold text-red-900">Emergency Admin Access</h3>
|
||||
</div>
|
||||
<p className="text-sm text-red-700 mb-4">
|
||||
If you are unable to access admin features or are stuck with credit limits, use this button to force-upgrade your account to ADMIN status.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleGodMode}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-bold rounded-lg transition-colors shadow-sm"
|
||||
>
|
||||
⚡ Grant Me Admin God Mode
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</Layout >
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
243
pages/SignupPage.tsx
Normal file
243
pages/SignupPage.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import React, { useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '../components/Tooltip';
|
||||
import { LegalModal } from '../components/LegalModal';
|
||||
import { ShieldCheck, FileText, AlertTriangle, Key } from 'lucide-react';
|
||||
import { GoogleLogin } from '@react-oauth/google';
|
||||
|
||||
export default function Signup() {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [etsyShopName, setEtsyShopName] = useState('');
|
||||
const [etsyShopLink, setEtsyShopLink] = useState('');
|
||||
// New Fields for Phase 6
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [kvkkAccepted, setKvkkAccepted] = useState(false);
|
||||
|
||||
// UI State
|
||||
const [error, setError] = useState<{ message: string, code?: string } | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [modalType, setModalType] = useState<'terms' | 'kvkk' | null>(null);
|
||||
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Frontend Verification
|
||||
if (!termsAccepted || !kvkkAccepted) {
|
||||
setError({ message: "You must accept both the User Agreement and KVKK to register." });
|
||||
return;
|
||||
}
|
||||
if (apiKey.length < 20) {
|
||||
setError({ message: "Please enter a valid Gemini API Key." });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await axios.post('/api/auth/register', {
|
||||
email,
|
||||
password,
|
||||
apiKey,
|
||||
etsyShopName,
|
||||
etsyShopLink,
|
||||
termsAccepted: true // Backend checks this
|
||||
});
|
||||
|
||||
login(res.data.token, res.data.user);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
const data = err.response?.data;
|
||||
const code = data?.code || 'UNKNOWN_ERROR';
|
||||
const msg = data?.error || 'Registration failed. Please try again.';
|
||||
setError({ message: msg, code });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-stone-50 p-4">
|
||||
<LegalModal
|
||||
isOpen={!!modalType}
|
||||
onClose={() => setModalType(null)}
|
||||
type={modalType}
|
||||
/>
|
||||
|
||||
<div className="w-full max-w-lg rounded-2xl bg-white p-8 shadow-xl border border-stone-100">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-black text-stone-900 tracking-tight">Create Account</h2>
|
||||
<p className="text-stone-500 mt-2 text-sm">Join the Beta access program</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={`mb-6 rounded-xl p-4 flex gap-3 items-start ${error.code === 'QUOTA_EXCEEDED' ? 'bg-amber-50 text-amber-800 border-amber-200 border' : 'bg-red-50 text-red-800 border-red-200 border'}`}>
|
||||
<AlertTriangle className="w-5 h-5 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-bold text-sm uppercase tracking-wide mb-1">
|
||||
{error.code === 'QUOTA_EXCEEDED' ? 'Quota Exceeded' : 'Registration Failed'}
|
||||
</h4>
|
||||
<p className="text-sm opacity-90">{error.message}</p>
|
||||
{error.code === 'INVALID_KEY' && (
|
||||
<p className="text-xs mt-2 font-mono bg-white/50 p-1 rounded inline-block">
|
||||
Tip: Keys usually start with AIza...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Security Note */}
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg p-3 flex gap-2 items-center text-xs text-blue-700">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Your data is encrypted. We strictly follow KVKK/GDPR.</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-bold text-stone-700">Email Address</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded-lg border border-stone-300 p-3 text-sm focus:border-stone-900 focus:ring-1 focus:ring-stone-900 focus:outline-none transition-all"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-bold text-stone-700">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-lg border border-stone-300 p-3 text-sm focus:border-stone-900 focus:ring-1 focus:ring-stone-900 focus:outline-none transition-all"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-bold text-stone-700">Etsy Shop Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-stone-300 p-3 text-sm focus:border-stone-900 focus:ring-1 focus:ring-stone-900 focus:outline-none transition-all"
|
||||
value={etsyShopName}
|
||||
onChange={(e) => setEtsyShopName(e.target.value)}
|
||||
placeholder="e.g. PixelParadise"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-bold text-stone-700">Etsy Shop Link</label>
|
||||
<input
|
||||
type="url"
|
||||
className="w-full rounded-lg border border-stone-300 p-3 text-sm focus:border-stone-900 focus:ring-1 focus:ring-stone-900 focus:outline-none transition-all"
|
||||
value={etsyShopLink}
|
||||
onChange={(e) => setEtsyShopLink(e.target.value)}
|
||||
placeholder="https://etsy.com/shop/..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label className="block text-sm font-bold text-stone-700">Gemini API Key (Required)</label>
|
||||
<a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noreferrer" className="text-[10px] font-bold text-blue-600 hover:underline uppercase">
|
||||
Get Key ↗
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-3 w-4 h-4 text-stone-400" />
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-lg border border-stone-300 p-3 pl-10 text-sm font-mono focus:border-stone-900 focus:ring-1 focus:ring-stone-900 focus:outline-none transition-all"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
required
|
||||
placeholder="AIzaSy..."
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-stone-400 mt-1">
|
||||
The system will send a test request to validate this key immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Legal Checkboxes */}
|
||||
<div className="space-y-3 pt-2">
|
||||
<label className="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 rounded border-stone-300 text-stone-900 focus:ring-stone-900"
|
||||
checked={termsAccepted}
|
||||
onChange={(e) => setTermsAccepted(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-stone-600 group-hover:text-stone-800 transition-colors">
|
||||
I accept the <button type="button" onClick={(e) => { e.preventDefault(); setModalType('terms'); }} className="font-bold underline">User Agreement & IP Rights</button>.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 rounded border-stone-300 text-stone-900 focus:ring-stone-900"
|
||||
checked={kvkkAccepted}
|
||||
onChange={(e) => setKvkkAccepted(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-stone-600 group-hover:text-stone-800 transition-colors">
|
||||
I have read and accept the <button type="button" onClick={(e) => { e.preventDefault(); setModalType('kvkk'); }} className="font-bold underline">KVKK (PDPL) & Privacy Policy</button>.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !termsAccepted || !kvkkAccepted || !apiKey}
|
||||
className="w-full rounded-xl bg-stone-900 py-3.5 font-bold text-white uppercase tracking-widest hover:bg-stone-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-lg hover:shadow-xl transform active:scale-[0.98]"
|
||||
>
|
||||
{isSubmitting ? 'Verifying Key...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-stone-200"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="bg-white px-2 text-stone-500 font-bold uppercase text-[10px] tracking-widest">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<GoogleLogin
|
||||
onSuccess={async (credentialResponse) => {
|
||||
try {
|
||||
const res = await axios.post('/api/auth/google', { credential: credentialResponse.credential });
|
||||
login(res.data.token, res.data.user);
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
setError({ message: 'Google Signup Failed' });
|
||||
}
|
||||
}}
|
||||
onError={() => setError({ message: 'Google Signup Failed' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center pt-6 border-t border-stone-100">
|
||||
<p className="text-stone-500 text-sm">Already have an account? <Link to="/login" className="font-bold text-stone-900 hover:underline">Log in</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
pages/XRayPage.tsx
Normal file
212
pages/XRayPage.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Sparkles, ArrowRight, Activity, Copy, CheckCircle, AlertTriangle, ScanEye } from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const XRayPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [url, setUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Mock result for dev preview if needed, but we connect to real API
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!url) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/xray', { url }, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
setResult(response.data);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.response?.data?.error || 'Failed to analyze. Please check the URL and try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyPrompt = () => {
|
||||
if (result?.analysis?.superiorPrompt) {
|
||||
navigator.clipboard.writeText(result.analysis.superiorPrompt);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-50 p-8 text-stone-900 font-sans">
|
||||
<div className="max-w-6xl mx-auto space-y-12">
|
||||
|
||||
{/* Header Section */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="inline-flex items-center justify-center p-3 bg-indigo-100 rounded-full mb-4">
|
||||
<ScanEye className="w-8 h-8 text-indigo-600" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-serif font-bold tracking-tight text-stone-900">
|
||||
Competitor X-Ray
|
||||
</h1>
|
||||
<p className="text-lg text-stone-500 max-w-2xl mx-auto">
|
||||
Paste any Etsy or Pinterest product URL. Our AI will deconstruct its success formula and generate a superior prompt for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Input Section */}
|
||||
<div className="bg-white/80 backdrop-blur-xl rounded-2xl shadow-xl border border-stone-200 p-2 flex items-center max-w-3xl mx-auto transition-all focus-within:ring-4 ring-indigo-500/10">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Paste Etsy or Pinterest URL here..."
|
||||
className="flex-1 bg-transparent border-none text-lg px-6 py-4 focus:ring-0 placeholder:text-stone-400"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading || !url}
|
||||
className={`
|
||||
px-8 py-4 rounded-xl font-medium text-white shadow-lg transition-all flex items-center gap-2
|
||||
${loading || !url ? 'bg-stone-300 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700 hover:shadow-indigo-500/30'}
|
||||
`}
|
||||
>
|
||||
{loading ? (
|
||||
<>Running X-Ray...</>
|
||||
) : (
|
||||
<>Analyze <ArrowRight className="w-5 h-5" /></>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="max-w-3xl mx-auto bg-red-50 text-red-600 p-4 rounded-xl border border-red-100 flex items-center gap-3">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Section */}
|
||||
{result && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 animate-in fade-in slide-in-from-bottom-8 duration-700">
|
||||
|
||||
{/* Left: Competitor Analysis */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-stone-100 overflow-hidden">
|
||||
<div className="aspect-[4/3] bg-stone-100 relative overflow-hidden">
|
||||
<img
|
||||
src={result.metadata.image}
|
||||
alt="Competitor"
|
||||
className="w-full h-full object-cover transform hover:scale-105 transition-transform duration-700"
|
||||
/>
|
||||
<div className="absolute top-4 left-4 bg-black/70 backdrop-blur-md text-white px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider">
|
||||
Competitor Asset
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-bold font-serif mb-2 line-clamp-2">{result.metadata.title}</h3>
|
||||
<p className="text-stone-500 text-sm line-clamp-3 mb-4">{result.metadata.description}</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-stone-400 uppercase tracking-wider mb-2">Visual DNA Detected</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.analysis.visualDna?.map((tag: string, i: number) => (
|
||||
<span key={i} className="px-3 py-1 bg-stone-100 text-stone-600 rounded-full text-xs font-medium border border-stone-200">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-red-400 uppercase tracking-wider mb-2">Identified Weaknesses (Sentiment Gap)</h4>
|
||||
<div className="bg-red-50/50 p-4 rounded-xl border border-red-100 text-sm text-stone-700 italic">
|
||||
"{result.analysis.sentimentGap}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: The Solution (Superior Prompt) */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-indigo-900 text-white rounded-2xl shadow-xl overflow-hidden relative">
|
||||
{/* Decorative background glow */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/20 blur-3xl rounded-full translate-x-1/2 -translate-y-1/2"></div>
|
||||
|
||||
<div className="p-8 relative z-10">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2 bg-indigo-500/20 rounded-lg">
|
||||
<Sparkles className="w-6 h-6 text-indigo-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-serif font-bold">Superior Formula</h3>
|
||||
<p className="text-indigo-200 text-sm">Optimized for high-conversion & aesthetics</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-black/30 backdrop-blur-sm p-6 rounded-xl border border-white/10 relative group">
|
||||
<p className="font-mono text-sm text-indigo-100 leading-relaxed">
|
||||
{result.analysis.superiorPrompt}
|
||||
</p>
|
||||
<button
|
||||
onClick={copyPrompt}
|
||||
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors text-white"
|
||||
title="Copy Prompt"
|
||||
>
|
||||
{copied ? <CheckCircle className="w-5 h-5 text-green-400" /> : <Copy className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h4 className="text-xs font-bold text-indigo-300 uppercase tracking-wider mb-3">Why This Wins</h4>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-indigo-100/80 leading-relaxed border-l-2 border-indigo-500 pl-4">
|
||||
{result.analysis.gapAnalysis}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-indigo-950/50 p-4 flex justify-between items-center border-t border-white/10">
|
||||
<span className="text-xs text-indigo-400 font-medium">Ready to dominate?</span>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/', { state: { prompt: result.analysis.superiorPrompt } })} // Assuming Home takes state
|
||||
className="px-4 py-2 bg-white text-indigo-900 rounded-lg text-sm font-bold hover:bg-indigo-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
Generate Asset <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pro Tip */}
|
||||
<div className="bg-yellow-50 border border-yellow-100 rounded-xl p-4 flex items-start gap-3">
|
||||
<Activity className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-bold text-yellow-800 text-sm">Pro Tip</h4>
|
||||
<p className="text-xs text-yellow-700 mt-1">
|
||||
Use this prompt in the "Analyst" mode for best results. Consider generating 4 variations to test different lighting setups.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default XRayPage;
|
||||
Reference in New Issue
Block a user