test8 / src /app /page.tsx
simikkk's picture
Upload 93 files
eaab0a9 verified
Raw
History Blame Contribute Delete
190 kB
'use client';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAppStore } from '@/stores/app-store';
import type { InvoiceRow, View, ChatMessage } from '@/stores/app-store';
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@/components/ui/tooltip';
import {
Upload,
FileText,
BarChart3,
Bot,
Download,
CreditCard,
Key,
User,
LogOut,
Menu,
X,
ChevronDown,
ChevronUp,
Search,
Trash2,
Send,
Copy,
Eye,
EyeOff,
AlertCircle,
CheckCircle,
Loader2,
ArrowRight,
Sparkles,
Shield,
Zap,
Globe,
Layers,
RefreshCw,
FileDown,
FileJson,
Quote,
ChevronRight,
ScanLine,
Database,
Lock,
Mail,
UserPlus,
Inbox,
MessageSquare,
CircleDollarSign,
CopyCheck,
Terminal,
ArrowLeft,
Crown,
Building2,
Check,
MousePointerClick,
TrendingUp,
Bell,
Clock,
Pencil,
LogIn,
Sun,
Moon,
ArrowUpDown,
Command,
Settings,
} from 'lucide-react';
import { Chrome } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { toast } from 'sonner';
import {
AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell,
XAxis, YAxis, Tooltip as RechartsTooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
import { PLAN_LIMITS, PLAN_LABELS, PLAN_PRICES } from '@/lib/auth';
import { useTheme } from 'next-themes';
// ─── Tooltip helper ───────────────────────────────────────────────────────────────
function Tt({ children, text }: { children: React.ReactNode; text: string }) {
return (
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>{text}</TooltipContent>
</Tooltip>
);
}
// ─── Helpers ────────────────────────────────────────────────────────────────────
const currencyFmt = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
});
function formatCurrency(amount: number | null, currency?: string): string {
if (amount === null || amount === undefined) return '--';
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
}).format(amount);
} catch {
return currency ? `${currency} ${amount.toFixed(2)}` : `$${amount.toFixed(2)}`;
}
}
function getPasswordStrength(pw: string): { label: string; color: string; width: string } {
if (!pw) return { label: '', color: 'bg-zinc-700', width: 'w-0' };
let score = 0;
if (pw.length >= 6) score++;
if (pw.length >= 10) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
if (score <= 2) return { label: 'Weak', color: 'bg-red-500', width: 'w-1/3' };
if (score <= 3) return { label: 'Medium', color: 'bg-amber-500', width: 'w-2/3' };
return { label: 'Strong', color: 'bg-green-500', width: 'w-full' };
}
const DASH_TABS = [
{ id: 'upload', label: 'Upload', icon: Upload },
{ id: 'invoices', label: 'Invoices', icon: FileText },
{ id: 'analytics', label: 'Analytics', icon: BarChart3 },
{ id: 'chat', label: 'AI Chat', icon: Bot },
{ id: 'export', label: 'Export', icon: Download },
{ id: 'upgrade', label: 'Upgrade', icon: Crown },
{ id: 'api', label: 'API Access', icon: Key },
{ id: 'profile', label: 'Profile', icon: User },
{ id: 'activity', label: 'Activity', icon: Clock },
{ id: 'settings', label: 'Settings', icon: Settings },
] as const;
function relativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
// ─── Main Page Component ────────────────────────────────────────────────────────
export default function Home() {
const store = useAppStore();
const { theme, setTheme } = useTheme();
// ── Local Auth State ──
const [authMode, setAuthMode] = useState<'login' | 'signup'>('login');
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [signupName, setSignupName] = useState('');
const [signupEmail, setSignupEmail] = useState('');
const [signupPassword, setSignupPassword] = useState('');
const [signupConfirm, setSignupConfirm] = useState('');
const [signupTerms, setSignupTerms] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
// ── Dashboard Local State ──
const [invoiceSearch, setInvoiceSearch] = useState('');
const [invoiceFilter, setInvoiceFilter] = useState('all');
const [expandedInvoice, setExpandedInvoice] = useState<string | null>(null);
const [selectedInvoices, setSelectedInvoices] = useState<Set<string>>(new Set());
const [chatInput, setChatInput] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
const [profileCurrentPw, setProfileCurrentPw] = useState('');
const [profileNewPw, setProfileNewPw] = useState('');
const [profileConfirmPw, setProfileConfirmPw] = useState('');
const [profilePwLoading, setProfilePwLoading] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
// ── Landing Mobile Nav ──
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [dashMobileMenu, setDashMobileMenu] = useState(false);
const [editingInvoice, setEditingInvoice] = useState<InvoiceRow | null>(null);
const [editForm, setEditForm] = useState<Record<string, string>>({});
const [editSaving, setEditSaving] = useState(false);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [sortField, setSortField] = useState<string>('createdAt');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
// ── Activity Items (real API) ──
const [activityLogs, setActivityLogs] = useState<Array<{id:string;type:string;title:string;description:string;createdAt:string;metadata:string|null}>>([]);
const [activityLoading, setActivityLoading] = useState(false);
const fetchActivity = useCallback(async () => {
setActivityLoading(true);
try {
const res = await fetch('/api/activity');
if (res.ok) {
const data = await res.json();
setActivityLogs(data.activities);
}
} catch { /* silent */ }
finally { setActivityLoading(false); }
}, []);
useEffect(() => {
if (store.view === 'dashboard' && store.activeDashTab === 'activity') {
fetchActivity();
}
}, [store.view, store.activeDashTab, fetchActivity]);
const [showNotifications, setShowNotifications] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [showCommandPalette, setShowCommandPalette] = useState(false);
const [commandSearch, setCommandSearch] = useState('');
// ── Forgot Password State ──
const [forgotStep, setForgotStep] = useState<'idle' | 'enterEmail' | 'enterCode'>('idle');
const [forgotEmail, setForgotEmail] = useState('');
const [forgotCode, setForgotCode] = useState('');
const [forgotNewPw, setForgotNewPw] = useState('');
const [forgotConfirmPw, setForgotConfirmPw] = useState('');
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState('');
const [forgotSuccess, setForgotSuccess] = useState(false);
// ── Google Login State ──
const [googleEnabled, setGoogleEnabled] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
// ── React Query ──
const queryClient = useQueryClient();
void queryClient; // used for mutations via refetch
const notifications = [
...(store.invoices.length > 0 ? [{
id: '1',
title: 'Invoices processed',
description: `${store.invoices.length} invoice(s) in your account`,
time: 'Just now',
icon: FileText,
color: 'text-teal-500',
}] : []),
{
id: '2',
title: 'Welcome to OmniParse',
description: 'Upload your first invoice to get started',
time: 'Getting started',
icon: Sparkles,
color: 'text-amber-500',
},
{
id: '3',
title: 'AI Chat available',
description: store.user?.plan === 'free'
? 'Upgrade to Pro to unlock AI Chat'
: 'Ask questions about your invoices',
time: 'Feature',
icon: Bot,
color: 'text-amber-500',
},
];
// ── Settings State ──
const [dateFormat, setDateFormat] = useState<'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD'>('MM/DD/YYYY');
const [defaultCurrency, setDefaultCurrency] = useState('USD');
const [compactView, setCompactView] = useState(false);
const [settingsSaved, setSettingsSaved] = useState(false);
const saveSettings = useCallback(() => {
setSettingsSaved(true);
toast.success('Settings saved successfully');
setTimeout(() => setSettingsSaved(false), 2000);
}, []);
// ── Upload State ──
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const toggleSort = useCallback((field: string) => {
if (sortField === field) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDir('asc');
}
}, [sortField]);
// ── Derived invoice data (before callbacks so they can reference it) ──
const filteredInvoices = useMemo(() => {
let result = store.invoices.filter((inv) => {
if (invoiceSearch && !inv.vendor?.toLowerCase().includes(invoiceSearch.toLowerCase())) return false;
if (invoiceFilter === 'done') return inv.status === 'done' && !inv.isDuplicate;
if (invoiceFilter === 'review') return inv.status === 'review' || inv.isDuplicate;
if (invoiceFilter === 'duplicates') return inv.isDuplicate;
if (dateFrom && inv.invDate && inv.invDate < dateFrom) return false;
if (dateTo && inv.invDate && inv.invDate > dateTo) return false;
return true;
});
result.sort((a, b) => {
let cmp = 0;
const f = sortField;
if (f === 'vendor') cmp = (a.vendor || '').localeCompare(b.vendor || '');
else if (f === 'invNumber') cmp = (a.invNumber || '').localeCompare(b.invNumber || '');
else if (f === 'invDate') cmp = (a.invDate || '').localeCompare(b.invDate || '');
else if (f === 'amount') cmp = (a.amount ?? 0) - (b.amount ?? 0);
else if (f === 'total') cmp = (a.total ?? 0) - (b.total ?? 0);
else if (f === 'confidence') cmp = (a.confidence ?? 0) - (b.confidence ?? 0);
else if (f === 'status') cmp = a.status.localeCompare(b.status);
else cmp = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
return sortDir === 'asc' ? cmp : -cmp;
});
return result;
}, [store.invoices, invoiceSearch, invoiceFilter, dateFrom, dateTo, sortField, sortDir]);
// ── Chat scroll ref ──
const chatEndRef = useRef<HTMLDivElement>(null);
// ── Scroll refs for landing ──
const featuresRef = useRef<HTMLElement>(null);
const pricingRef = useRef<HTMLElement>(null);
// ──────────────────────────────────────────────────────────────────────────────
// API Callbacks
// ──────────────────────────────────────────────────────────────────────────────
const fetchMe = useCallback(async () => {
const { setUser, setView, setLoading } = useAppStore.getState();
try {
const res = await fetch('/api/auth/me');
if (res.ok) {
const data = await res.json();
setUser(data.user);
setView('dashboard');
} else {
setUser(null);
setLoading(false);
}
} catch {
setUser(null);
setLoading(false);
}
}, []);
const fetchLogin = useCallback(async () => {
setAuthError('');
setAuthLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: loginEmail, password: loginPassword }),
});
const data = await res.json();
if (!res.ok) {
setAuthError(data.error || 'Login failed');
setAuthLoading(false);
return;
}
const { setUser, setView } = useAppStore.getState();
setUser(data.user);
setView('dashboard');
toast.success('Welcome back!');
} catch {
setAuthError('Network error. Please try again.');
} finally {
setAuthLoading(false);
}
}, [loginEmail, loginPassword]);
const fetchSignup = useCallback(async () => {
setAuthError('');
if (signupPassword !== signupConfirm) {
setAuthError('Passwords do not match');
return;
}
setAuthLoading(true);
try {
const res = await fetch('/api/auth/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: signupName, email: signupEmail, password: signupPassword, termsAccepted: true }),
});
const data = await res.json();
if (!res.ok) {
setAuthError(data.error || 'Signup failed');
setAuthLoading(false);
return;
}
const { setUser, setView } = useAppStore.getState();
setUser(data.user);
setView('dashboard');
toast.success('Account created successfully!');
} catch {
setAuthError('Network error. Please try again.');
} finally {
setAuthLoading(false);
}
}, [signupName, signupEmail, signupPassword, signupConfirm]);
const fetchLogout = useCallback(() => {
fetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
toast.success('Logged out');
window.location.href = '/';
}, []);
const fetchInvoices = useCallback(async (filter?: string) => {
const { setLoading, setInvoices } = useAppStore.getState();
setLoading(true);
try {
const params = filter && filter !== 'all' ? `?filter=${filter}` : '';
const res = await fetch(`/api/invoices${params}`);
if (res.ok) {
const data = await res.json();
setInvoices(data.invoices);
}
} catch {
toast.error('Failed to load invoices');
} finally {
setLoading(false);
}
}, []);
const fetchUpload = useCallback(async () => {
if (selectedFiles.length === 0) return;
const { setUploadLoading, setUploadResults } = useAppStore.getState();
setUploadLoading(true);
try {
const formData = new FormData();
for (const file of selectedFiles) {
formData.append('files', file);
}
const res = await fetch('/api/invoices/upload', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Upload failed');
setUploadLoading(false);
return;
}
setUploadResults(data.invoices);
setSelectedFiles([]);
toast.success(`${data.invoices.length} invoice(s) processed successfully!`);
fetchInvoices(invoiceFilter);
} catch {
toast.error('Upload failed. Please try again.');
} finally {
setUploadLoading(false);
}
}, [selectedFiles, invoiceFilter, fetchInvoices]);
const fetchDeleteInvoice = useCallback(async (id: string) => {
if (!window.confirm('Are you sure you want to delete this invoice? This action cannot be undone.')) return;
try {
const res = await fetch('/api/invoices', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
if (res.ok) {
toast.success('Invoice deleted');
setSelectedInvoices(prev => { const n = new Set(prev); n.delete(id); return n; });
fetchInvoices(invoiceFilter);
if (expandedInvoice === id) setExpandedInvoice(null);
} else {
const data = await res.json();
toast.error(data.error || 'Failed to delete');
}
} catch {
toast.error('Failed to delete invoice');
}
}, [invoiceFilter, expandedInvoice, fetchInvoices]);
const fetchBatchDelete = useCallback(async () => {
const ids = Array.from(selectedInvoices);
if (ids.length === 0) return;
if (!window.confirm(`Delete ${ids.length} invoice(s)? This action cannot be undone.`)) return;
try {
await Promise.all(ids.map(id =>
fetch('/api/invoices', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
));
toast.success(`${ids.length} invoice(s) deleted`);
setSelectedInvoices(new Set());
fetchInvoices(invoiceFilter);
} catch {
toast.error('Failed to delete some invoices');
}
}, [selectedInvoices, invoiceFilter, fetchInvoices]);
const toggleSelect = useCallback((id: string) => {
setSelectedInvoices(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
if (selectedInvoices.size === filteredInvoices.length) {
setSelectedInvoices(new Set());
} else {
setSelectedInvoices(new Set(filteredInvoices.map(i => i.id)));
}
}, [selectedInvoices.size, filteredInvoices]);
const fetchChat = useCallback(async () => {
if (!chatInput.trim()) return;
const { addChatMessage, chatHistory, setChatLoading } = useAppStore.getState();
const userMsg: ChatMessage = { role: 'user', content: chatInput.trim() };
addChatMessage(userMsg);
const historyForApi = [...chatHistory, userMsg].map((m) => ({ role: m.role, content: m.content }));
setChatInput('');
setChatLoading(true);
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: userMsg.content, history: historyForApi.slice(0, -1) }),
});
const data = await res.json();
if (!res.ok) {
addChatMessage({ role: 'assistant', content: data.error || 'Something went wrong.' });
} else {
addChatMessage({ role: 'assistant', content: data.reply });
}
} catch {
addChatMessage({ role: 'assistant', content: 'Network error. Please try again.' });
} finally {
setChatLoading(false);
}
}, [chatInput]);
const exportCsv = useCallback(() => {
window.open('/api/invoices/export/csv', '_blank');
toast.success('CSV download started');
}, []);
const exportJson = useCallback(() => {
const { user } = useAppStore.getState();
if (user?.plan === 'free') {
toast.error('JSON export is available on Pro+ plans. Please upgrade.');
return;
}
window.open('/api/invoices/export/json', '_blank');
toast.success('JSON download started');
}, []);
const openEditModal = useCallback((inv: InvoiceRow) => {
setEditingInvoice(inv);
setEditForm({
vendor: inv.vendor || '',
invNumber: inv.invNumber || '',
invDate: inv.invDate || '',
dueDate: inv.dueDate || '',
amount: String(inv.amount || ''),
vatAmount: String(inv.vatAmount || ''),
total: String(inv.total || ''),
currency: inv.currency,
status: inv.status === 'review' ? 'review' : 'done',
});
}, []);
const saveEdit = useCallback(async () => {
if (!editingInvoice) return;
setEditSaving(true);
try {
const res = await fetch('/api/invoices/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editingInvoice.id, ...editForm }),
});
if (res.ok) {
toast.success('Invoice updated');
setEditingInvoice(null);
fetchInvoices(invoiceFilter);
} else {
const data = await res.json();
toast.error(data.error || 'Update failed');
}
} catch {
toast.error('Failed to update invoice');
} finally {
setEditSaving(false);
}
}, [editingInvoice, editForm, invoiceFilter, fetchInvoices]);
// ── Effects ──
useEffect(() => {
fetchMe();
}, [fetchMe]);
useEffect(() => {
if (store.view === 'dashboard' && store.activeDashTab === 'invoices') {
fetchInvoices(invoiceFilter);
}
}, [store.view, store.activeDashTab, invoiceFilter, fetchInvoices]);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [store.chatHistory, store.chatLoading]);
// ── Drag-and-drop handlers ──
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const files = Array.from(e.dataTransfer.files).filter(
(f) => f.type === 'application/pdf' || f.type.startsWith('image/')
);
setSelectedFiles((prev) => [...prev, ...files]);
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setSelectedFiles((prev) => [...prev, ...Array.from(e.target.files!)]);
}
}, []);
const removeFile = useCallback((index: number) => {
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
}, []);
// ── Scroll helpers ──
const scrollToFeatures = useCallback(() => {
featuresRef.current?.scrollIntoView({ behavior: 'smooth' });
setMobileMenuOpen(false);
}, []);
const scrollToPricing = useCallback(() => {
pricingRef.current?.scrollIntoView({ behavior: 'smooth' });
setMobileMenuOpen(false);
}, []);
// ── Keyboard shortcut for search (decorative hint, but functional) ──
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
const { view } = useAppStore.getState();
if (view === 'dashboard') {
setShowCommandPalette(true);
}
}
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
e.preventDefault();
const { view } = useAppStore.getState();
if (view === 'dashboard') {
setShowShortcuts(true);
}
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
useEffect(() => {
const handleScroll = () => {
setShowScrollTop(window.scrollY > 400);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// ── Google login check ──
useEffect(() => {
if (store.view === 'auth' && authMode === 'login') {
fetch('/api/auth/google')
.then(r => r.json())
.then(d => setGoogleEnabled(d.enabled === true))
.catch(() => {});
}
}, [store.view, authMode]);
// ── Forgot Password Handlers ──
const handleForgotSendCode = useCallback(async () => {
setForgotError('');
setForgotLoading(true);
try {
const res = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: forgotEmail }),
});
const data = await res.json();
if (!res.ok) {
setForgotError(data.error || 'Failed to send reset code');
} else {
setForgotStep('enterCode');
if (data.code) {
toast.info(`Demo: Your reset code is ${data.code}`);
} else {
toast.success(data.message);
}
}
} catch {
setForgotError('Network error. Please try again.');
} finally {
setForgotLoading(false);
}
}, [forgotEmail]);
const handleResetPassword = useCallback(async () => {
setForgotError('');
if (forgotNewPw !== forgotConfirmPw) {
setForgotError('Passwords do not match');
return;
}
if (forgotNewPw.length < 8 || !/[A-Z]/.test(forgotNewPw) || !/[a-z]/.test(forgotNewPw) || !/[0-9]/.test(forgotNewPw)) {
setForgotError('Password must be at least 8 characters with uppercase, lowercase, and a number');
return;
}
setForgotLoading(true);
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: forgotEmail, code: forgotCode, newPassword: forgotNewPw }),
});
const data = await res.json();
if (!res.ok) {
setForgotError(data.error || 'Failed to reset password');
} else {
setForgotSuccess(true);
toast.success('Password reset successfully! Please log in.');
setTimeout(() => {
setForgotStep('idle');
setForgotSuccess(false);
setForgotEmail('');
setForgotCode('');
setForgotNewPw('');
setForgotConfirmPw('');
}, 2000);
}
} catch {
setForgotError('Network error. Please try again.');
} finally {
setForgotLoading(false);
}
}, [forgotEmail, forgotCode, forgotNewPw, forgotConfirmPw]);
const handleGoogleLogin = useCallback(async () => {
setGoogleLoading(true);
try {
const res = await fetch('/api/auth/google');
const data = await res.json();
if (data.url) {
window.location.href = data.url;
}
} catch {
toast.error('Failed to start Google login');
} finally {
setGoogleLoading(false);
}
}, []);
// ── React Query: invoices ──
useQuery({
queryKey: ['invoices', invoiceFilter],
queryFn: async () => {
const params = invoiceFilter && invoiceFilter !== 'all' ? `?filter=${invoiceFilter}` : '';
const res = await fetch(`/api/invoices${params}`);
if (!res.ok) throw new Error('Failed to fetch invoices');
const data = await res.json();
const { setInvoices, setLoading } = useAppStore.getState();
setInvoices(data.invoices);
setLoading(false);
return data.invoices;
},
enabled: store.view === 'dashboard' && store.activeDashTab === 'invoices',
});
// ── React Query: activity ──
useQuery({
queryKey: ['activity'],
queryFn: async () => {
const res = await fetch('/api/activity');
if (!res.ok) throw new Error('Failed to fetch activity');
const data = await res.json();
setActivityLogs(data.activities);
return data.activities;
},
enabled: store.view === 'dashboard' && store.activeDashTab === 'activity',
});
// ── Loading screen ──
if (store.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-amber-500" />
</div>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// LANDING VIEW
// ──────────────────────────────────────────────────────────────────────────────
if (store.view === 'landing') {
return (
<div className="min-h-screen flex flex-col noise-overlay">
{/* ── Navbar ── */}
<header className="fixed top-0 left-0 right-0 z-50 nav-blur">
<nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="logo-glow w-8 h-8 rounded-lg bg-amber-500 flex items-center justify-center">
<ScanLine className="w-5 h-5 text-zinc-900" />
</div>
<span className="text-lg font-bold text-foreground">OmniParse</span>
</div>
<div className="hidden md:flex items-center gap-8">
<Tt text="View features"><button onClick={scrollToFeatures} className="btn-ghost text-sm">
Features
</button></Tt>
<Tt text="View pricing plans"><button onClick={scrollToPricing} className="btn-ghost text-sm">
Pricing
</button></Tt>
</div>
<div className="hidden md:flex items-center gap-3">
<Tt text={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.04] transition-colors"
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
</Tt>
<Tt text="Sign in to your account"><button
onClick={() => { setAuthMode('login'); store.setView('auth'); }}
className="btn-ghost text-sm"
>
Log in
</button></Tt>
<Tt text="Create a free account"><button
onClick={() => { setAuthMode('signup'); store.setView('auth'); }}
className="btn-primary text-sm"
>
Get Started
</button></Tt>
</div>
<button
className="md:hidden text-muted-foreground hover:text-foreground"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle navigation menu"
>
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</nav>
{mobileMenuOpen && (
<div className="md:hidden border-t border-white/[0.06] bg-background/95 backdrop-blur-md fade-in mobile-nav-slide">
<div className="px-4 py-4 space-y-3">
<button onClick={scrollToFeatures} className="block w-full text-left text-sm text-muted-foreground hover:text-foreground py-2">
Features
</button>
<button onClick={scrollToPricing} className="block w-full text-left text-sm text-muted-foreground hover:text-foreground py-2">
Pricing
</button>
<div className="pt-2 border-t border-white/[0.06] space-y-2">
<button
onClick={() => { setTheme(theme === 'dark' ? 'light' : 'dark'); }}
className="flex items-center gap-3 w-full text-sm text-muted-foreground hover:text-foreground py-2"
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{theme === 'dark' ? 'Light mode' : 'Dark mode'}
</button>
<button
onClick={() => { setAuthMode('login'); store.setView('auth'); setMobileMenuOpen(false); }}
className="block w-full text-sm text-muted-foreground hover:text-foreground py-2"
>
Log in
</button>
<button
onClick={() => { setAuthMode('signup'); store.setView('auth'); setMobileMenuOpen(false); }}
className="btn-primary text-sm block w-full text-center"
>
Get Started
</button>
</div>
</div>
</div>
)}
</header>
<main className="flex-1">
{/* ── Hero Section ── */}
<section className="relative min-h-[90vh] flex items-center justify-center grid-bg hero-grid-enhanced overflow-hidden pt-16">
<div className="hero-orb" />
<div className="hero-orb-2" />
<div className="relative z-10 max-w-4xl mx-auto px-4 text-center">
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] text-sm text-muted-foreground mb-8 fade-in float-animation">
<Sparkles className="w-4 h-4 text-amber-500" />
Powered by advanced AI extraction
</div>
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold tracking-tight mb-6 fade-in">
Invoice Processing,{' '}
<span className="gradient-text">Reimagined</span>
</h1>
<p className="text-lg sm:text-xl text-muted-foreground max-w-2xl mx-auto mb-10 fade-in">
Upload any invoice and let AI extract vendor details, amounts, dates, and line items in seconds. Export structured data to CSV, JSON, or connect via API.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 fade-in">
<button
onClick={() => { setAuthMode('signup'); store.setView('auth'); }}
className="btn-shine pulse-glow flex items-center gap-2 px-8 py-3.5 rounded-xl bg-amber-500 text-zinc-900 font-semibold text-base hover:bg-amber-400 transition-all hover:shadow-lg hover:shadow-amber-500/20"
>
Start Free Trial
<ArrowRight className="w-5 h-5" />
</button>
<button
onClick={scrollToFeatures}
className="flex items-center gap-2 px-8 py-3.5 rounded-xl border border-white/[0.1] text-foreground font-medium text-base hover:bg-white/[0.04] transition-all"
>
See How It Works
<ChevronDown className="w-5 h-5" />
</button>
</div>
</div>
<div className="hidden lg:block absolute top-32 right-16 float-animation-delayed">
<div className="glass-card p-3 flex items-center gap-2 text-xs text-muted-foreground">
<Shield className="w-4 h-4 text-teal-500" />
<span>SOC 2 Compliant</span>
</div>
</div>
</section>
{/* ── Stats Bar ── */}
<section className="border-y border-white/[0.06] bg-white/[0.01]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 grid grid-cols-1 sm:grid-cols-3 gap-8">
<div className="stat-animate text-center">
<div className="text-3xl sm:text-4xl font-bold text-foreground">10K+</div>
<div className="text-sm text-muted-foreground mt-1">Invoices Processed</div>
</div>
<div className="stat-animate text-center">
<div className="text-3xl sm:text-4xl font-bold text-foreground">99.2%</div>
<div className="text-sm text-muted-foreground mt-1">Accuracy Rate</div>
</div>
<div className="stat-animate text-center">
<div className="text-3xl sm:text-4xl font-bold text-foreground">500+</div>
<div className="text-sm text-muted-foreground mt-1">Companies Trust Us</div>
</div>
</div>
</section>
<hr className="section-glow-divider my-0" />
{/* ── How It Works ── */}
<section className="py-20 sm:py-28">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">How It Works</h2>
<p className="text-muted-foreground text-lg max-w-xl mx-auto">Three simple steps to transform your invoices into structured data</p>
</div>
<div className="relative grid grid-cols-1 md:grid-cols-3 gap-8">
<article className="step-connector text-center section-reveal">
<div className="w-14 h-14 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-5">
<span className="text-xl font-bold text-amber-500">1</span>
</div>
<h3 className="text-xl font-semibold mb-3">Upload</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
Drag and drop your invoice PDFs or images. We support batch uploads of multiple files at once.
</p>
</article>
<article className="step-connector text-center section-reveal" style={{ animationDelay: '0.15s' }}>
<div className="w-14 h-14 rounded-full bg-teal-500/10 border border-teal-500/20 flex items-center justify-center mx-auto mb-5">
<span className="text-xl font-bold text-teal-500">2</span>
</div>
<h3 className="text-xl font-semibold mb-3">Extract</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
Our AI automatically extracts vendor details, line items, amounts, dates, and detects duplicates.
</p>
</article>
<article className="step-connector text-center section-reveal" style={{ animationDelay: '0.3s' }}>
<div className="w-14 h-14 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-5">
<span className="text-xl font-bold text-amber-500">3</span>
</div>
<h3 className="text-xl font-semibold mb-3">Export</h3>
<p className="text-muted-foreground text-sm leading-relaxed">
Download your data as CSV or JSON, or integrate directly with your systems using our REST API.
</p>
</article>
</div>
</div>
</section>
<hr className="section-glow-divider my-0" />
{/* ── Features Section ── */}
<section ref={featuresRef} className="py-20 sm:py-28 bg-white/[0.01]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">Everything You Need</h2>
<p className="text-muted-foreground text-lg max-w-xl mx-auto">Powerful features designed to streamline your invoice processing workflow</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<article className="feature-card-gradient feature-amber p-6 card-enter">
<div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4">
<Zap className="w-5 h-5" />
</div>
<h3 className="text-base font-semibold mb-2">Instant Extraction</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Extract all invoice fields in under 3 seconds per document with near-perfect accuracy.
</p>
</article>
<article className="feature-card-gradient feature-teal p-6 card-enter">
<div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4">
<Shield className="w-5 h-5" />
</div>
<h3 className="text-base font-semibold mb-2">Duplicate Detection</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Automatically flags potential duplicate invoices based on vendor and amount matching.
</p>
</article>
<article className="feature-card-gradient feature-red p-6 card-enter">
<div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4">
<Bot className="w-5 h-5" />
</div>
<h3 className="text-base font-semibold mb-2">AI Chat Assistant</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Ask questions about your invoices, get spending insights, and analyze vendor relationships.
</p>
</article>
<article className="feature-card-gradient feature-green p-6 card-enter">
<div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4">
<Globe className="w-5 h-5" />
</div>
<h3 className="text-base font-semibold mb-2">Multi-Currency</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Automatically detects and processes invoices in USD, EUR, GBP, CAD, AUD, and more.
</p>
</article>
<article className="feature-card-gradient feature-sky p-6 card-enter">
<div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4">
<Layers className="w-5 h-5" />
</div>
<h3 className="text-base font-semibold mb-2">Bulk Processing</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Upload dozens of invoices at once. Our system processes them in parallel for maximum speed.
</p>
</article>
<article className="feature-card-gradient feature-rose p-6 card-enter">
<div className="feature-icon w-11 h-11 rounded-lg flex items-center justify-center mb-4">
<Lock className="w-5 h-5" />
</div>
<h3 className="text-base font-semibold mb-2">Enterprise Security</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Your data is encrypted at rest and in transit. SOC 2 compliant infrastructure.
</p>
</article>
</div>
</div>
</section>
<hr className="section-glow-divider my-0" />
{/* ── Testimonials ── */}
<section className="py-20 sm:py-28">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">Trusted by Teams Worldwide</h2>
<p className="text-muted-foreground text-lg max-w-xl mx-auto">See what our customers have to say</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<blockquote className="testimonial-card p-6 card-enter" style={{ animationDelay: '0ms' }}>
<Quote className="w-8 h-8 text-amber-500/40 mb-4" />
<p className="text-sm text-muted-foreground leading-relaxed mb-6">
&ldquo;OmniParse reduced our invoice processing time from hours to minutes. The accuracy is outstanding and the duplicate detection has saved us from overpaying vendors multiple times.&rdquo;
</p>
<footer className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center">
<span className="text-sm font-semibold text-amber-500">SM</span>
</div>
<div>
<div className="text-sm font-medium">Sarah Mitchell</div>
<div className="text-xs text-muted-foreground">CFO, TechVentures Inc.</div>
</div>
</footer>
</blockquote>
<blockquote className="testimonial-card p-6 card-enter" style={{ animationDelay: '100ms' }}>
<Quote className="w-8 h-8 text-teal-500/40 mb-4" />
<p className="text-sm text-muted-foreground leading-relaxed mb-6">
&ldquo;We process over 500 invoices per month. The API integration was seamless and the structured JSON output fits perfectly into our ERP system. A game changer for our accounting team.&rdquo;
</p>
<footer className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-teal-500/10 flex items-center justify-center">
<span className="text-sm font-semibold text-teal-500">JK</span>
</div>
<div>
<div className="text-sm font-medium">James Kim</div>
<div className="text-xs text-muted-foreground">CTO, DataFlow Systems</div>
</div>
</footer>
</blockquote>
<blockquote className="testimonial-card p-6 card-enter" style={{ animationDelay: '200ms' }}>
<Quote className="w-8 h-8 text-amber-500/40 mb-4" />
<p className="text-sm text-muted-foreground leading-relaxed mb-6">
&ldquo;The AI chat feature is incredible. I can ask questions like &apos;Which vendor had the highest spending this quarter?&apos; and get instant answers. It&apos;s like having a financial analyst on demand.&rdquo;
</p>
<footer className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-500/10 flex items-center justify-center">
<span className="text-sm font-semibold text-amber-500">RP</span>
</div>
<div>
<div className="text-sm font-medium">Rachel Patel</div>
<div className="text-xs text-muted-foreground">VP Finance, Meridian Group</div>
</div>
</footer>
</blockquote>
</div>
</div>
</section>
<hr className="section-glow-divider my-0" />
{/* ── FAQ ── */}
<section className="py-20 sm:py-28 bg-white/[0.01]">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">Frequently Asked Questions</h2>
</div>
<div className="space-y-3">
<details className="glass-card group">
<summary className="flex items-center justify-between cursor-pointer p-5 text-sm font-medium">
What file formats are supported?
<ChevronDown className="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform" />
</summary>
<div className="px-5 pb-5 text-sm text-muted-foreground leading-relaxed">
We support PDF files and common image formats including PNG, JPG, and JPEG. You can upload multiple files at once for batch processing.
</div>
</details>
<details className="glass-card group">
<summary className="flex items-center justify-between cursor-pointer p-5 text-sm font-medium">
How accurate is the extraction?
<ChevronDown className="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform" />
</summary>
<div className="px-5 pb-5 text-sm text-muted-foreground leading-relaxed">
Our AI achieves 99.2% accuracy on standard invoices. Each extraction includes a confidence score so you can review lower-confidence results. The system also flags potential duplicates automatically.
</div>
</details>
<details className="glass-card group">
<summary className="flex items-center justify-between cursor-pointer p-5 text-sm font-medium">
Is my data secure?
<ChevronDown className="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform" />
</summary>
<div className="px-5 pb-5 text-sm text-muted-foreground leading-relaxed">
Absolutely. All data is encrypted at rest and in transit. We use SOC 2 compliant infrastructure and your documents are never shared with third parties. You can delete your data at any time.
</div>
</details>
<details className="glass-card group">
<summary className="flex items-center justify-between cursor-pointer p-5 text-sm font-medium">
Can I integrate OmniParse with my existing tools?
<ChevronDown className="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform" />
</summary>
<div className="px-5 pb-5 text-sm text-muted-foreground leading-relaxed">
Yes! We provide a REST API with your unique API key. You can upload invoices, retrieve processed data, and trigger exports programmatically. Full API documentation is available in your dashboard.
</div>
</details>
<details className="glass-card group">
<summary className="flex items-center justify-between cursor-pointer p-5 text-sm font-medium">
What happens when I reach my plan limit?
<ChevronDown className="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform" />
</summary>
<div className="px-5 pb-5 text-sm text-muted-foreground leading-relaxed">
You will receive a notification when you are approaching your monthly limit. Once reached, you can upgrade your plan or wait for the next billing cycle. Enterprise plans have unlimited processing.
</div>
</details>
</div>
</div>
</section>
<hr className="section-glow-divider my-0" />
{/* ── Pricing Section ── */}
<section ref={pricingRef} className="py-20 sm:py-28">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl sm:text-4xl font-bold mb-4">Simple, Transparent Pricing</h2>
<p className="text-muted-foreground text-lg max-w-xl mx-auto">Choose the plan that fits your needs. Upgrade or downgrade anytime.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ name: 'Free', price: 0, limit: '20 invoices/mo', features: ['Basic extraction', 'CSV export', 'Email support', '1 user'] },
{ name: 'Pro', price: 129, limit: '2,000 invoices/mo', features: ['Advanced extraction', 'CSV & JSON export', 'AI Chat assistant', 'API access', 'Priority support', '5 users'], highlight: true },
{ name: 'Business', price: 29, limit: '200 invoices/mo', features: ['Standard extraction', 'CSV & JSON export', 'API access', 'Email & chat support', '3 users'] },
{ name: 'Enterprise', price: 499, limit: 'Unlimited', features: ['Custom AI models', 'All export formats', 'Dedicated AI Chat', 'Full API access', 'Dedicated support', 'Unlimited users', 'SLA guarantee'] },
].map((plan) => (
<article
key={plan.name}
className={`glass-card p-6 flex flex-col relative ${plan.highlight ? 'pricing-highlight amber-glow' : ''}`}
>
{plan.highlight && <div className="pricing-ribbon">Most Popular</div>}
<div className="mb-5">
<h3 className="text-lg font-semibold mb-1">{plan.name}</h3>
<div className="text-3xl font-bold text-foreground">
${plan.price}<span className="text-sm font-normal text-muted-foreground">/mo</span>
</div>
<div className="text-sm text-muted-foreground mt-1">{plan.limit}</div>
</div>
<ul className="space-y-3 mb-8 flex-1">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="w-4 h-4 text-teal-500 shrink-0" />
{f}
</li>
))}
</ul>
<button
onClick={() => {
if (plan.name === 'Free') {
setAuthMode('signup');
store.setView('auth');
} else {
toast.info('Coming soon! This plan will be available shortly.');
}
}}
className={`btn-shine w-full py-2.5 rounded-lg text-sm font-medium transition-colors ${
plan.highlight
? 'bg-amber-500 text-zinc-900 hover:bg-amber-400'
: 'border border-white/[0.1] text-foreground hover:bg-white/[0.04]'
}`}
>
{plan.name === 'Free' ? 'Get Started' : 'Coming Soon'}
</button>
</article>
))}
</div>
</div>
</section>
</main>
{/* ── Footer ── */}
<footer className="border-t border-white/[0.06] bg-white/[0.01] mt-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 grid grid-cols-2 md:grid-cols-4 gap-8">
<div>
<h4 className="text-sm font-semibold mb-4">Product</h4>
<ul className="space-y-2 text-sm">
<li><span className="footer-link-underline">Features</span></li>
<li><span className="footer-link-underline">Pricing</span></li>
<li><span className="footer-link-underline">API Docs</span></li>
<li><span className="footer-link-underline">Changelog</span></li>
</ul>
</div>
<div>
<h4 className="text-sm font-semibold mb-4">Company</h4>
<ul className="space-y-2 text-sm">
<li><span className="footer-link-underline">About</span></li>
<li><span className="footer-link-underline">Blog</span></li>
<li><span className="footer-link-underline">Careers</span></li>
<li><span className="footer-link-underline">Contact</span></li>
</ul>
</div>
<div>
<h4 className="text-sm font-semibold mb-4">Resources</h4>
<ul className="space-y-2 text-sm">
<li><span className="footer-link-underline">Documentation</span></li>
<li><span className="footer-link-underline">Help Center</span></li>
<li><span className="footer-link-underline">Status Page</span></li>
</ul>
</div>
<div>
<h4 className="text-sm font-semibold mb-4">Legal</h4>
<ul className="space-y-2 text-sm">
<li><span className="footer-link-underline">Privacy Policy</span></li>
<li><span className="footer-link-underline">Terms of Service</span></li>
<li><span className="footer-link-underline">Security</span></li>
</ul>
</div>
</div>
<div className="border-t border-white/[0.06]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-amber-500 flex items-center justify-center">
<ScanLine className="w-3.5 h-3.5 text-zinc-900" />
</div>
<span className="text-sm text-muted-foreground">OmniParse AI</span>
</div>
<p className="text-xs text-muted-foreground">
&copy; {new Date().getFullYear()} OmniParse AI. All rights reserved.
</p>
</div>
</div>
</footer>
{/* ── Scroll to Top ── */}
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className={`scroll-top-btn ${showScrollTop ? 'visible' : ''}`}
aria-label="Scroll to top"
>
<ChevronUp className="w-5 h-5" />
</button>
{/* ── HF Space Download ── */}
<a
href="/omniparse-hf-space.zip"
download
className="fixed bottom-20 right-5 z-50 bg-amber-500 hover:bg-amber-400 text-black font-semibold text-xs px-3 py-2 rounded-lg shadow-lg transition-colors"
>
⬇ Download Patch (5 files)
</a>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// AUTH VIEW
// ──────────────────────────────────────────────────────────────────────────────
if (store.view === 'auth') {
const pwStrength = getPasswordStrength(authMode === 'signup' ? signupPassword : '');
return (
<div className="min-h-screen flex flex-col items-center justify-center px-4 py-12 noise-overlay">
<div className="w-full max-w-md">
<button
onClick={() => store.setView('landing')}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to home
</button>
<div className="glass-card p-6 sm:p-8 amber-glow view-enter">
<div className="text-center mb-8">
<div className="w-12 h-12 rounded-xl bg-amber-500 flex items-center justify-center mx-auto mb-4">
<ScanLine className="w-7 h-7 text-zinc-900" />
</div>
<h1 className="text-2xl font-bold">
{authMode === 'login' ? 'Welcome back' : 'Create your account'}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{authMode === 'login' ? 'Sign in to your OmniParse account' : 'Get started with OmniParse for free'}
</p>
</div>
{/* Tab Toggle */}
<div className="flex border border-white/[0.08] rounded-lg p-1 mb-6">
<button
onClick={() => { setAuthMode('login'); setAuthError(''); }}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
authMode === 'login' ? 'bg-amber-500 text-zinc-900' : 'text-muted-foreground hover:text-foreground'
}`}
>
Log in
</button>
<button
onClick={() => { setAuthMode('signup'); setAuthError(''); }}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
authMode === 'signup' ? 'bg-amber-500 text-zinc-900' : 'text-muted-foreground hover:text-foreground'
}`}
>
Sign up
</button>
</div>
{/* Error Display */}
{authError && (
<div className="flex items-center gap-2 p-3 mb-6 rounded-lg bg-red-500/10 border border-red-500/20 text-sm text-red-400 fade-in">
<AlertCircle className="w-4 h-4 shrink-0" />
{authError}
</div>
)}
{authMode === 'login' ? (
<form
onSubmit={(e) => { e.preventDefault(); fetchLogin(); }}
className="space-y-4"
>
<div>
<label className="input-label" htmlFor="login-email">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
id="login-email"
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
required
placeholder="you@company.com"
className="input-field input-field-glow pl-10"
/>
</div>
</div>
<div>
<label className="input-label" htmlFor="login-password">
Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
id="login-password"
type="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
required
placeholder="Enter your password"
className="input-field input-field-glow pl-10"
/>
</div>
</div>
<button
type="submit"
disabled={authLoading}
className="btn-primary text-sm w-full flex items-center justify-center gap-2 disabled:opacity-50"
>
{authLoading && <Loader2 className="w-4 h-4 animate-spin" />}
Sign In
</button>
<div className="text-center">
<button
type="button"
onClick={() => { setForgotStep('enterEmail'); setAuthError(''); }}
className="text-xs text-muted-foreground hover:text-amber-500 transition-colors"
>
Forgot your password?
</button>
</div>
{/* Google Login Button */}
{googleEnabled && (
<div className="relative my-3">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/[0.08]" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-[#111113] px-2 text-muted-foreground">or continue with</span>
</div>
</div>
)}
{googleEnabled && (
<button
type="button"
onClick={handleGoogleLogin}
disabled={googleLoading}
className="w-full flex items-center justify-center gap-3 py-2.5 rounded-lg bg-white/[0.06] border border-white/[0.1] text-sm font-medium text-foreground hover:bg-white/[0.1] transition-colors disabled:opacity-50"
>
{googleLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Chrome className="w-4 h-4" />}
Continue with Google
</button>
)}
{/* Forgot Password: Enter Email */}
{forgotStep === 'enterEmail' && (
<div className="mt-4 p-4 rounded-lg bg-white/[0.02] border border-white/[0.06] space-y-3 fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Reset Password</h3>
<button type="button" onClick={() => { setForgotStep('idle'); setForgotError(''); }} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
Back to login
</button>
</div>
{forgotError && (
<div className="flex items-center gap-2 p-2 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
{forgotError}
</div>
)}
<div>
<label className="input-label" htmlFor="forgot-email">Email Address</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
id="forgot-email"
type="email"
value={forgotEmail}
onChange={(e) => setForgotEmail(e.target.value)}
required
placeholder="you@company.com"
className="input-field input-field-glow pl-10"
/>
</div>
</div>
<button
type="button"
onClick={handleForgotSendCode}
disabled={forgotLoading || !forgotEmail}
className="btn-primary text-sm w-full flex items-center justify-center gap-2 disabled:opacity-50"
>
{forgotLoading && <Loader2 className="w-4 h-4 animate-spin" />}
Send Reset Code
</button>
</div>
)}
{/* Forgot Password: Enter Code */}
{forgotStep === 'enterCode' && (
<div className="mt-4 p-4 rounded-lg bg-white/[0.02] border border-white/[0.06] space-y-3 fade-in">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Enter Reset Code</h3>
<button type="button" onClick={() => { setForgotStep('idle'); setForgotError(''); }} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
Back to login
</button>
</div>
{forgotError && (
<div className="flex items-center gap-2 p-2 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
{forgotError}
</div>
)}
{forgotSuccess ? (
<div className="flex items-center gap-2 p-2 rounded-lg bg-green-500/10 border border-green-500/20 text-xs text-green-400">
<CheckCircle className="w-3.5 h-3.5 shrink-0" />
Password reset successfully! Redirecting to login...
</div>
) : (
<>
<p className="text-xs text-muted-foreground">A 6-digit code was generated for <span className="text-amber-500">{forgotEmail}</span></p>
<div>
<label className="input-label" htmlFor="reset-code">Reset Code</label>
<input
id="reset-code"
type="text"
value={forgotCode}
onChange={(e) => setForgotCode(e.target.value)}
maxLength={6}
placeholder="000000"
className="input-field input-field-glow text-center tracking-[0.5em] font-mono"
/>
</div>
<div>
<label className="input-label" htmlFor="reset-pw">New Password</label>
<input
id="reset-pw"
type="password"
value={forgotNewPw}
onChange={(e) => setForgotNewPw(e.target.value)}
placeholder="Min 8 chars, uppercase, lowercase, number"
className="input-field input-field-glow"
/>
</div>
<div>
<label className="input-label" htmlFor="reset-pw-confirm">Confirm New Password</label>
<input
id="reset-pw-confirm"
type="password"
value={forgotConfirmPw}
onChange={(e) => setForgotConfirmPw(e.target.value)}
placeholder="Confirm new password"
className="input-field input-field-glow"
/>
</div>
<button
type="button"
onClick={handleResetPassword}
disabled={forgotLoading || !forgotCode || !forgotNewPw || !forgotConfirmPw}
className="btn-primary text-sm w-full flex items-center justify-center gap-2 disabled:opacity-50"
>
{forgotLoading && <Loader2 className="w-4 h-4 animate-spin" />}
Reset Password
</button>
</>
)}
</div>
)}
<div className="text-center">
<p className="text-xs text-muted-foreground mt-4">
Demo credentials: <span className="text-amber-500">demo@omniparse.ai</span> / <span className="text-amber-500">demo1234</span>
</p>
</div>
</form>
) : (
<form
onSubmit={(e) => { e.preventDefault(); fetchSignup(); }}
className="space-y-4"
>
<div>
<label className="input-label" htmlFor="signup-name">
Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
id="signup-name"
type="text"
value={signupName}
onChange={(e) => setSignupName(e.target.value)}
required
placeholder="Your full name"
className="input-field input-field-glow pl-10"
/>
</div>
</div>
<div>
<label className="input-label" htmlFor="signup-email">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
id="signup-email"
type="email"
value={signupEmail}
onChange={(e) => setSignupEmail(e.target.value)}
required
placeholder="you@company.com"
className="input-field input-field-glow pl-10"
/>
</div>
</div>
<div>
<label className="input-label" htmlFor="signup-password">
Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
id="signup-password"
type="password"
value={signupPassword}
onChange={(e) => setSignupPassword(e.target.value)}
required
minLength={6}
placeholder="At least 6 characters"
className="input-field input-field-glow pl-10"
/>
</div>
{signupPassword && (
<div className="mt-2">
<div className="h-1.5 w-full bg-zinc-800 rounded-full overflow-hidden">
<div className={`h-full ${pwStrength.color} ${pwStrength.width} rounded-full transition-all duration-300`} />
</div>
<p className="text-xs text-muted-foreground mt-1">{pwStrength.label}</p>
</div>
)}
</div>
<div>
<label className="input-label" htmlFor="signup-confirm">
Confirm Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
id="signup-confirm"
type="password"
value={signupConfirm}
onChange={(e) => setSignupConfirm(e.target.value)}
required
placeholder="Confirm your password"
className="input-field input-field-glow pl-10"
/>
</div>
</div>
<label className="flex items-start gap-2.5 cursor-pointer group">
<input
type="checkbox"
checked={signupTerms}
onChange={(e) => setSignupTerms(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-zinc-600 bg-zinc-800 text-amber-500 focus:ring-amber-500 focus:ring-offset-0 cursor-pointer"
/>
<span className="text-xs text-muted-foreground leading-relaxed">
I agree to the{' '}
<span className="text-amber-500 hover:text-amber-400 underline underline-offset-2">Terms of Service</span>
{' '}and{' '}
<span className="text-amber-500 hover:text-amber-400 underline underline-offset-2">Privacy Policy</span>
</span>
</label>
<button
type="submit"
disabled={authLoading || !signupTerms}
className="btn-primary text-sm w-full flex items-center justify-center gap-2 disabled:opacity-50"
>
{authLoading && <Loader2 className="w-4 h-4 animate-spin" />}
Create Account
</button>
</form>
)}
</div>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────────────
// DASHBOARD VIEW
// ──────────────────────────────────────────────────────────────────────────────
if (store.view === 'dashboard' && store.user) {
const planLimit = PLAN_LIMITS[store.user.plan] ?? PLAN_LIMITS.free;
const planLabel = PLAN_LABELS[store.user.plan] ?? store.user.plan;
const invoiceCount = store.invoices.length;
const totalAmount = store.invoices.reduce((s, i) => s + (i.total ?? 0), 0);
const dupCount = store.invoices.filter((i) => i.isDuplicate).length;
const avgConfidence = store.invoices.length > 0
? store.invoices.reduce((s, i) => s + (i.confidence ?? 0), 0) / store.invoices.length
: 0;
const usagePercent = planLimit === Infinity ? 0 : Math.min((invoiceCount / planLimit) * 100, 100);
return (
<div className="min-h-screen flex flex-col noise-overlay">
{/* ── Dashboard Header ── */}
<header className="sticky top-0 z-50 nav-blur">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="logo-glow w-8 h-8 rounded-lg bg-amber-500 flex items-center justify-center">
<ScanLine className="w-5 h-5 text-zinc-900" />
</div>
<span className="font-bold text-foreground hidden sm:inline">OmniParse</span>
<span className={`${store.user.plan === 'free' || store.user.plan === 'Free' ? 'badge-muted' : 'badge-amber'}`}>
{planLabel}
</span>
</div>
<div className="flex items-center gap-3">
<Tt text={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.04] transition-colors"
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
</Tt>
<Tt text="Upload invoices"><button
onClick={() => { store.setActiveDashTab('upload'); }}
className="hidden sm:flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 transition-colors"
>
<Upload className="w-3.5 h-3.5" />
Upload
</button></Tt>
<button
onClick={() => setShowCommandPalette(true)}
className="hidden sm:flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-white/[0.04] text-muted-foreground hover:text-foreground hover:bg-white/[0.06] border border-white/[0.06] transition-colors"
>
<Search className="w-3.5 h-3.5" />
Search...
<kbd className="kbd text-[10px] ml-1">Ctrl+K</kbd>
</button>
<Tt text="Export as CSV"><button
onClick={exportCsv}
className="hidden sm:flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-teal-500/10 text-teal-500 hover:bg-teal-500/20 transition-colors"
>
<FileDown className="w-3.5 h-3.5" />
Export CSV
</button></Tt>
<Tt text="Export as Excel"><button
onClick={() => { window.open('/api/invoices/export/xlsx', '_blank'); toast.success('Excel download started'); }}
className="hidden sm:flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 transition-colors"
>
<FileJson className="w-3.5 h-3.5" />
Export XLSX
</button></Tt>
<div className="relative">
<button
onClick={() => setShowNotifications(!showNotifications)}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.04] transition-colors"
aria-label="Notifications"
>
<Bell className="w-4 h-4" />
{store.invoices.length > 0 && (
<span className="notification-badge" />
)}
</button>
{showNotifications && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowNotifications(false)} />
<div className="notification-dropdown z-50">
<div className="px-4 py-3 border-b border-white/[0.06]">
<h3 className="text-sm font-semibold">Notifications</h3>
</div>
<div className="max-h-80 overflow-y-auto">
{notifications.map((n) => {
const NIcon = n.icon;
return (
<div key={n.id} className="notification-item flex items-start gap-3">
<div className={`mt-0.5 ${n.color}`}>
<NIcon className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{n.title}</div>
<div className="text-xs text-muted-foreground mt-0.5">{n.description}</div>
</div>
</div>
);
})}
</div>
</div>
</>
)}
</div>
<div className="flex items-center gap-2 ml-2">
<div className="w-8 h-8 rounded-full bg-white/[0.06] flex items-center justify-center text-sm font-medium text-amber-500">
{store.user.name.charAt(0).toUpperCase()}
</div>
<span className="text-sm font-medium hidden sm:inline">{store.user.name}</span>
</div>
<Tt text="Log out"><button
onClick={fetchLogout}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.04] transition-colors"
aria-label="Log out"
>
<LogOut className="w-4 h-4" />
</button></Tt>
<button
className="md:hidden p-2 rounded-lg text-muted-foreground hover:text-foreground"
onClick={() => setDashMobileMenu(!dashMobileMenu)}
aria-label="Toggle dashboard menu"
>
{dashMobileMenu ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</div>
</div>
{/* Mobile tab menu */}
{dashMobileMenu && (
<div className="md:hidden border-t border-white/[0.06] bg-background/95 backdrop-blur-md fade-in mobile-nav-slide">
<nav className="px-4 py-3 space-y-1 overflow-x-auto">
<button
onClick={() => { setTheme(theme === 'dark' ? 'light' : 'dark'); setDashMobileMenu(false); }}
className="flex items-center gap-3 w-full text-left px-3 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-white/[0.03] transition-colors"
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{theme === 'dark' ? 'Light mode' : 'Dark mode'}
</button>
<div className="h-px bg-white/[0.06] my-1" />
{DASH_TABS.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => { store.setActiveDashTab(tab.id); setDashMobileMenu(false); }}
className={`flex items-center gap-3 w-full text-left px-3 py-2.5 rounded-lg text-sm transition-colors ${
store.activeDashTab === tab.id
? 'bg-amber-500/10 text-amber-500'
: 'text-muted-foreground hover:text-foreground hover:bg-white/[0.03]'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</nav>
</div>
)}
</header>
{/* ── Dashboard Tabs (desktop) ── */}
<div className="hidden md:block border-b border-white/[0.06] bg-white/[0.01]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<nav className="flex gap-1 -mb-px overflow-x-auto" aria-label="Dashboard tabs">
{DASH_TABS.map((tab) => {
const Icon = tab.icon;
return (
<Tt key={tab.id} text={tab.label}>
<button
onClick={() => store.setActiveDashTab(tab.id)}
className={`dash-tab flex items-center gap-2 ${store.activeDashTab === tab.id ? 'active' : ''}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
</Tt>
);
})}
</nav>
</div>
</div>
{/* ── Dashboard Content ── */}
<main className="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<div className="view-enter">
{/* ── Stats Overview ── */}
<section className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6" aria-label="Dashboard statistics">
<div className="stat-card-glass card-accent-amber card-enter">
<div className="flex items-center gap-2 mb-1">
<FileText className="w-4 h-4 text-amber-500" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Invoices</span>
</div>
<div className="text-2xl font-bold text-foreground">{store.invoices.length}</div>
</div>
<div className="stat-card-glass card-accent-teal card-enter">
<div className="flex items-center gap-2 mb-1">
<CircleDollarSign className="w-4 h-4 text-teal-500" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Total Amount</span>
</div>
<div className="text-2xl font-bold text-foreground">
{formatCurrency(store.invoices.reduce((sum, inv) => sum + (inv.total ?? 0), 0))}
</div>
</div>
<div className="stat-card-glass card-accent-red card-enter">
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="w-4 h-4 text-red-400" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Duplicates</span>
</div>
<div className="text-2xl font-bold text-foreground">{store.invoices.filter(i => i.isDuplicate).length}</div>
</div>
<div className="stat-card-glass card-accent-green card-enter">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-green-400" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Avg Confidence</span>
</div>
<div className="text-2xl font-bold text-foreground">
{store.invoices.length > 0
? `${((store.invoices.reduce((sum, inv) => sum + (inv.confidence ?? 0), 0) / store.invoices.length) * 100).toFixed(1)}%`
: '--'}
</div>
</div>
</section>
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TAB 1: UPLOAD */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{store.activeDashTab === 'upload' && (
<section className="fade-in tab-content-area">
{/* Welcome Panel */}
<div className="glass-card welcome-panel p-5 mb-6 card-enter">
<div className="relative">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-amber-500" />
</div>
<div>
<h2 className="text-lg font-bold">Welcome back, {store.user?.name?.split(' ')[0] || 'there'}!</h2>
<p className="text-sm text-muted-foreground">Here&apos;s a quick overview of your account</p>
</div>
<span className="ml-auto hidden sm:inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-500 capitalize badge-pill">
<span className="badge-pill-dot bg-amber-500" />
<Crown className="w-3 h-3" />
{store.user?.plan || 'Free'} Plan
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
<button onClick={() => store.setActiveDashTab('invoices')} className="welcome-stat-btn">
<FileText className="w-4 h-4 welcome-stat-icon" />
<div>
<div className="text-lg font-bold">{store.invoices.length}</div>
<div className="text-xs text-muted-foreground">Invoices</div>
</div>
</button>
<button onClick={() => store.setActiveDashTab('analytics')} className="welcome-stat-btn">
<TrendingUp className="w-4 h-4 welcome-stat-icon" />
<div>
<div className="text-lg font-bold">{formatCurrency(store.invoices.reduce((s, i) => s + (i.total ?? 0), 0))}</div>
<div className="text-xs text-muted-foreground">Total Spent</div>
</div>
</button>
<button onClick={() => store.setActiveDashTab('invoices')} className="welcome-stat-btn">
<AlertCircle className="w-4 h-4 welcome-stat-icon" />
<div>
<div className="text-lg font-bold">{store.invoices.filter(i => i.isDuplicate).length}</div>
<div className="text-xs text-muted-foreground">Duplicates</div>
</div>
</button>
<button onClick={() => store.setActiveDashTab('activity')} className="welcome-stat-btn">
<Clock className="w-4 h-4 welcome-stat-icon" />
<div>
<div className="text-lg font-bold">{activityLogs.length}</div>
<div className="text-xs text-muted-foreground">Activities</div>
</div>
</button>
</div>
</div>
</div>
<div className="mb-4">
<h2 className="text-xl font-bold">Upload Invoices</h2>
<p className="text-sm text-muted-foreground mt-1">Drag and drop PDF or image files to extract invoice data</p>
</div>
{/* Usage Progress */}
<div className="glass-card p-4 mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">Monthly usage</span>
<span className="text-sm font-medium">
{invoiceCount} / {planLimit === Infinity ? 'Unlimited' : planLimit}
</span>
</div>
<div className="h-2 w-full bg-zinc-800 rounded-full overflow-hidden">
<div
className={`progress-bar ${usagePercent > 90 ? '!bg-red-500' : ''}`}
style={{ width: `${planLimit === Infinity ? 0 : usagePercent}%` }}
/>
</div>
</div>
{/* Drop Zone */}
<div
className={`drop-zone drop-zone-animated p-8 sm:p-12 text-center ${dragOver ? 'drag-over' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
role="button"
tabIndex={0}
aria-label="Click or drag files to upload"
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); }}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.png,.jpg,.jpeg"
className="hidden"
onChange={handleFileSelect}
/>
<Upload className="w-10 h-10 text-amber-500/60 mx-auto mb-4" />
<p className="text-sm font-medium mb-1">
Drop your invoice files here, or click to browse
</p>
<p className="text-xs text-muted-foreground">
Supports PDF, PNG, JPG, JPEG. You can upload multiple files at once.
</p>
</div>
{/* Selected Files List */}
{selectedFiles.length > 0 && (
<div className="glass-card p-4 mt-4 fade-in">
<h3 className="text-sm font-medium mb-3">{selectedFiles.length} file(s) selected</h3>
<ul className="space-y-2">
{selectedFiles.map((file, idx) => (
<li key={`${file.name}-${idx}`} className="flex items-center justify-between text-sm py-2 px-3 rounded-lg bg-white/[0.02]">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-4 h-4 text-amber-500 shrink-0" />
<span className="truncate">{file.name}</span>
<span className="text-xs text-muted-foreground shrink-0">
{(file.size / 1024).toFixed(1)} KB
</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); removeFile(idx); }}
className="p-1 rounded text-muted-foreground hover:text-red-400 transition-colors shrink-0"
aria-label={`Remove ${file.name}`}
>
<X className="w-4 h-4" />
</button>
</li>
))}
</ul>
<button
onClick={fetchUpload}
disabled={store.uploadLoading}
className="btn-primary text-sm mt-4 w-full flex items-center justify-center gap-2 disabled:opacity-50"
>
{store.uploadLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Processing...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload and Extract
</>
)}
</button>
</div>
)}
{/* Upload Results */}
{store.uploadResults && store.uploadResults.length > 0 && (
<div className="mt-6 fade-in">
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-500" />
Results
</h3>
<div className="glass-card overflow-hidden">
<div className="max-h-96 overflow-y-auto scrollbar-thin">
<table className="data-table text-sm">
<thead className="border-b border-white/[0.06] bg-white/[0.02]">
<tr>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Vendor</th>
<th className="text-left px-4 py-3 font-medium text-muted-foreground">Invoice #</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Amount</th>
<th className="text-right px-4 py-3 font-medium text-muted-foreground">Confidence</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody>
{store.uploadResults.map((inv) => (
<tr key={inv.id} className="border-b border-white/[0.04] hover:bg-white/[0.02]">
<td className="px-4 py-3">{inv.vendor || '--'}</td>
<td className="px-4 py-3 text-muted-foreground">{inv.invNumber || '--'}</td>
<td className="px-4 py-3 text-right font-medium">{formatCurrency(inv.total, inv.currency)}</td>
<td className="px-4 py-3 text-right">{inv.confidence ? `${(inv.confidence * 100).toFixed(1)}%` : '--'}</td>
<td className="px-4 py-3 text-center">
<span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-medium ${
inv.isDuplicate ? 'status-duplicate' : 'status-done'
}`}>
{inv.isDuplicate ? 'Duplicate' : 'Done'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Empty State */}
{!store.uploadLoading && (!store.uploadResults || store.uploadResults.length === 0) && selectedFiles.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Inbox className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p className="text-sm">Upload your first invoice to get started</p>
</div>
)}
</section>
)}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TAB 2: INVOICES */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{store.activeDashTab === 'invoices' && (
<section className="fade-in tab-content-area">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h2 className="text-xl font-bold">Invoices</h2>
<p className="text-sm text-muted-foreground mt-1">
{filteredInvoices.length} invoice(s) found
<span className="ml-2 text-xs text-muted-foreground/60">Ctrl+K</span>
</p>
</div>
<button
onClick={() => fetchInvoices(invoiceFilter)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors self-start"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
{/* Search and Filter */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
value={invoiceSearch}
onChange={(e) => setInvoiceSearch(e.target.value)}
placeholder="Search by vendor name..."
className="input-field input-field-glow pl-10"
/>
</div>
<div className="flex gap-2">
{(['all', 'done', 'review', 'duplicates'] as const).map((f) => (
<button
key={f}
onClick={() => setInvoiceFilter(f)}
className={`px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
invoiceFilter === f
? 'bg-amber-500 text-zinc-900'
: 'bg-white/[0.04] text-muted-foreground hover:text-foreground border border-white/[0.08]'
}`}
>
{f === 'all' ? 'All' : f === 'done' ? 'Done' : f === 'review' ? 'Review' : 'Duplicates'}
</button>
))}
</div>
</div>
{/* Date Range Filter */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<div className="flex-1">
<label className="input-label">From Date</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="input-field input-field-glow"
/>
</div>
<div className="flex-1">
<label className="input-label">To Date</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="input-field input-field-glow"
/>
</div>
{(dateFrom || dateTo) && (
<div className="flex items-end">
<button
onClick={() => { setDateFrom(''); setDateTo(''); }}
className="px-3 py-2.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground border border-white/[0.08] hover:border-white/[0.15] transition-colors"
>
Clear dates
</button>
</div>
)}
</div>
{/* Duplicate Alert Panel */}
{!store.isLoading && store.invoices.some(i => i.isDuplicate) && (
<div className="glass-card duplicate-alert p-4 mb-4 card-enter">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center shrink-0">
<CopyCheck className="w-5 h-5 text-amber-500" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-amber-500">Duplicate Invoices Detected</h3>
<p className="text-xs text-muted-foreground mt-1">
{store.invoices.filter(i => i.isDuplicate).length} potential duplicate(s) found. Review and decide whether to keep or remove them.
</p>
<div className="flex flex-wrap gap-2 mt-3">
<button
onClick={() => { setInvoiceFilter('duplicates'); }}
className="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 transition-colors"
>
<Eye className="w-3.5 h-3.5" />
View Duplicates
</button>
<button
onClick={() => {
const dupIds = store.invoices.filter(i => i.isDuplicate).map(i => i.id);
if (window.confirm(`Delete ${dupIds.length} duplicate invoice(s)?`)) {
Promise.all(dupIds.map(id => fetch('/api/invoices', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) })))
.then(() => { toast.success(`${dupIds.length} duplicate(s) removed`); fetchInvoices(invoiceFilter); })
.catch(() => toast.error('Failed to remove duplicates'));
}
}}
className="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Remove All Duplicates
</button>
</div>
</div>
</div>
</div>
)}
{/* Selection Toolbar */}
{selectedInvoices.size > 0 && (
<div className="flex items-center justify-between p-3 mb-4 rounded-lg bg-amber-500/10 border border-amber-500/20 fade-in">
<span className="text-sm font-medium text-amber-500">
{selectedInvoices.size} invoice(s) selected
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedInvoices(new Set())}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1"
>
Clear selection
</button>
<button
onClick={fetchBatchDelete}
className="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
Delete Selected
</button>
</div>
</div>
)}
{/* Loading Skeletons */}
{store.isLoading && (
<div className="glass-card p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="skeleton-shimmer h-12 w-full" />
))}
</div>
)}
{/* Invoice Table */}
{!store.isLoading && filteredInvoices.length > 0 && (
<div className="glass-card overflow-hidden">
<div className="max-h-96 overflow-y-auto scrollbar-thin">
<table className="data-table text-sm">
<thead className="border-b border-white/[0.06] bg-white/[0.02] sticky top-0">
<tr>
<th className="px-3 py-3 w-10">
<input
type="checkbox"
checked={selectedInvoices.size === filteredInvoices.length && filteredInvoices.length > 0}
onChange={toggleSelectAll}
className="invoice-checkbox"
aria-label="Select all invoices"
/>
</th>
<th
className="sort-header text-left px-4 py-3 font-medium text-muted-foreground"
onClick={() => toggleSort('vendor')}
>
<div className="flex items-center gap-1">
Vendor
{sortField === 'vendor' ? (
<span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span>
) : (
<span className="sort-header-inactive"></span>
)}
</div>
</th>
<th
className="sort-header text-left px-4 py-3 font-medium text-muted-foreground hidden sm:table-cell"
onClick={() => toggleSort('invNumber')}
>
<div className="flex items-center gap-1">
Invoice #
{sortField === 'invNumber' ? (
<span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span>
) : (
<span className="sort-header-inactive"></span>
)}
</div>
</th>
<th
className="sort-header text-left px-4 py-3 font-medium text-muted-foreground hidden md:table-cell"
onClick={() => toggleSort('invDate')}
>
<div className="flex items-center gap-1">
Date
{sortField === 'invDate' ? (
<span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span>
) : (
<span className="sort-header-inactive"></span>
)}
</div>
</th>
<th
className="sort-header text-right px-4 py-3 font-medium text-muted-foreground"
onClick={() => toggleSort('amount')}
>
<div className="flex items-center justify-end gap-1">
Amount
{sortField === 'amount' ? (
<span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span>
) : (
<span className="sort-header-inactive"></span>
)}
</div>
</th>
<th
className="sort-header text-right px-4 py-3 font-medium text-muted-foreground hidden sm:table-cell"
onClick={() => toggleSort('total')}
>
<div className="flex items-center justify-end gap-1">
Total
{sortField === 'total' ? (
<span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span>
) : (
<span className="sort-header-inactive"></span>
)}
</div>
</th>
<th
className="sort-header text-center px-4 py-3 font-medium text-muted-foreground"
onClick={() => toggleSort('status')}
>
<div className="flex items-center justify-center gap-1">
Status
{sortField === 'status' ? (
<span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span>
) : (
<span className="sort-header-inactive"></span>
)}
</div>
</th>
<th
className="sort-header text-right px-4 py-3 font-medium text-muted-foreground hidden lg:table-cell"
onClick={() => toggleSort('confidence')}
>
<div className="flex items-center justify-end gap-1">
Confidence
{sortField === 'confidence' ? (
<span className="sort-indicator">{sortDir === 'asc' ? '↑' : '↓'}</span>
) : (
<span className="sort-header-inactive"></span>
)}
</div>
</th>
<th className="text-center px-4 py-3 font-medium text-muted-foreground" colSpan={2}>Actions</th>
</tr>
</thead>
<tbody>
{filteredInvoices.map((inv) => (
<InvoiceTableBody
key={inv.id}
invoice={inv}
isExpanded={expandedInvoice === inv.id}
onToggle={() => setExpandedInvoice(expandedInvoice === inv.id ? null : inv.id)}
onDelete={() => fetchDeleteInvoice(inv.id)}
isSelected={selectedInvoices.has(inv.id)}
onSelect={() => toggleSelect(inv.id)}
onEdit={() => openEditModal(inv)}
/>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Empty State */}
{!store.isLoading && filteredInvoices.length === 0 && (
<div className="empty-state">
<Inbox className="w-12 h-12" />
<p className="text-sm mt-2">No invoices found</p>
<p className="text-xs text-muted-foreground mt-1">Upload your first invoice to get started</p>
</div>
)}
</section>
)}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TAB 3: ANALYTICS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{store.activeDashTab === 'analytics' && (
<AnalyticsTab invoices={store.invoices} />
)}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TAB 4: AI CHAT */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{store.activeDashTab === 'chat' && (
<section className="fade-in tab-content-area">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-bold">AI Chat</h2>
<p className="text-sm text-muted-foreground mt-1">Ask questions about your invoices</p>
</div>
{store.chatHistory.length > 0 && (
<button
onClick={() => { store.clearChat(); toast.success('Chat cleared'); }}
className="text-sm text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
>
<Trash2 className="w-3.5 h-3.5" />
Clear
</button>
)}
</div>
{/* Plan gate */}
{store.user.plan === 'free' && (
<div className="glass-card p-8 text-center mb-6">
<Bot className="w-12 h-12 text-amber-500/40 mx-auto mb-4" />
<h3 className="text-lg font-semibold mb-2">Upgrade to Use AI Chat</h3>
<p className="text-sm text-muted-foreground mb-4">
AI Chat is available on Pro and Enterprise plans. Get instant insights about your invoices, spending analysis, and more.
</p>
<button
onClick={() => store.setActiveDashTab('upgrade')}
className="btn-primary text-sm inline-flex items-center gap-2"
>
<Crown className="w-4 h-4" />
Upgrade Plan
</button>
</div>
)}
{/* Chat Messages */}
{store.chatHistory.length === 0 && store.user.plan !== 'free' && (
<div className="glass-card p-12 text-center mb-4">
<MessageSquare className="w-12 h-12 text-muted-foreground/30 mx-auto mb-4" />
<h3 className="text-base font-medium mb-2">Start a conversation</h3>
<p className="text-sm text-muted-foreground mb-6">
Ask about your invoices, get spending insights, or request analysis.
</p>
<div className="flex flex-wrap justify-center gap-2">
{['Summarize my invoices', 'Who is my top vendor?', 'Show spending trends'].map((suggestion) => (
<button
key={suggestion}
onClick={() => setChatInput(suggestion)}
className="px-3 py-1.5 rounded-full text-xs font-medium bg-white/[0.04] border border-white/[0.08] text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}
<div className="space-y-4 mb-4 max-h-[500px] overflow-y-auto">
{store.chatHistory.map((msg, idx) => (
<div
key={idx}
className={`chat-bubble flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className="flex flex-col">
<div className="flex items-end gap-2">
{msg.role === 'assistant' && (
<div className="w-6 h-6 rounded-full bg-teal-500/20 flex items-center justify-center shrink-0">
<Bot className="w-3.5 h-3.5 text-teal-500" />
</div>
)}
<div className={msg.role === 'user' ? 'chat-message-user' : 'chat-message-assistant'}>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{msg.content}</p>
</div>
</div>
</div>
</div>
))}
{store.chatLoading && (
<div className="chat-bubble flex justify-start">
<div className="bg-white/[0.06] rounded-2xl rounded-bl-md px-4 py-3 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-muted-foreground typing-dot" />
<span className="w-2 h-2 rounded-full bg-muted-foreground typing-dot" />
<span className="w-2 h-2 rounded-full bg-muted-foreground typing-dot" />
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Chat Input */}
{store.user.plan !== 'free' && (
<div className="flex gap-3">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); fetchChat(); } }}
placeholder="Ask about your invoices..."
disabled={store.chatLoading}
className="input-field input-field-glow rounded-xl disabled:opacity-50"
/>
<div className="tooltip-wrapper">
<button
onClick={fetchChat}
disabled={store.chatLoading || !chatInput.trim()}
className="p-3 rounded-xl bg-amber-500 text-zinc-900 hover:bg-amber-400 transition-colors disabled:opacity-50"
aria-label="Send message"
>
<Send className="w-5 h-5" />
</button>
<span className="tooltip-text">Send</span>
</div>
</div>
)}
</section>
)}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TAB 5: EXPORT */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{store.activeDashTab === 'export' && (
<section className="fade-in tab-content-area">
<div className="mb-6">
<h2 className="text-xl font-bold">Export Data</h2>
<p className="text-sm text-muted-foreground mt-1">Download your invoice data in your preferred format</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{/* CSV Export */}
<article className="glass-card p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-11 h-11 rounded-lg bg-teal-500/10 flex items-center justify-center">
<FileDown className="w-5 h-5 text-teal-500" />
</div>
<div>
<h3 className="text-base font-semibold">CSV Export</h3>
<p className="text-xs text-muted-foreground">Comma-separated values</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Download all your invoices as a CSV file, compatible with Excel, Google Sheets, and any spreadsheet application. Includes all fields: vendor, amounts, dates, status, and confidence scores.
</p>
<button
onClick={exportCsv}
className="w-full py-2.5 rounded-lg bg-teal-500 text-zinc-900 font-medium text-sm hover:bg-teal-400 transition-colors flex items-center justify-center gap-2"
>
<Download className="w-4 h-4" />
Download CSV
</button>
</article>
{/* JSON Export */}
<article className="glass-card p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-11 h-11 rounded-lg bg-amber-500/10 flex items-center justify-center">
<FileJson className="w-5 h-5 text-amber-500" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold">JSON Export</h3>
{store.user?.plan === 'free' && (
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-500 border border-amber-500/20">
Pro+
</span>
)}
</div>
<p className="text-xs text-muted-foreground">Structured JSON data with raw extraction</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Download structured JSON data including raw extraction results, line items, and bank details. Perfect for API integrations and programmatic processing.
</p>
<button
onClick={exportJson}
className="btn-primary text-sm w-full flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={store.user?.plan === 'free'}
>
<Download className="w-4 h-4" />
{store.user?.plan === 'free' ? 'Upgrade Required' : 'Download JSON'}
</button>
</article>
{/* XLSX Export */}
<article className="glass-card p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-11 h-11 rounded-lg bg-amber-500/10 flex items-center justify-center">
<FileJson className="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 className="text-base font-semibold">Excel Export</h3>
<p className="text-xs text-muted-foreground">XLSX spreadsheet format</p>
</div>
</div>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">
Download a professionally formatted Excel spreadsheet with styled headers, currency formatting, color-coded statuses, and a summary total row. Compatible with Microsoft Excel, Google Sheets, and LibreOffice Calc.
</p>
<button
onClick={() => { window.open('/api/invoices/export/xlsx', '_blank'); toast.success('Excel download started'); }}
className="btn-primary text-sm w-full flex items-center justify-center gap-2"
>
<Download className="w-4 h-4" />
Download Excel
</button>
</article>
</div>
{/* Empty state hint */}
{invoiceCount === 0 && (
<div className="glass-card p-8 text-center mt-6">
<Database className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No invoices to export yet. Upload some invoices first.</p>
</div>
)}
</section>
)}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TAB 6: UPGRADE */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{store.activeDashTab === 'upgrade' && (
<section className="fade-in tab-content-area">
<div className="mb-6">
<h2 className="text-xl font-bold">Upgrade Your Plan</h2>
<p className="text-sm text-muted-foreground mt-1">You are currently on the <span className="text-amber-500 font-medium">{planLabel}</span> plan</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{ name: 'Free', price: 0, limit: '20 invoices/mo', features: ['Basic extraction', 'CSV export', 'Email support'] },
{ name: 'Pro', price: 129, limit: '2,000 invoices/mo', features: ['Advanced extraction', 'CSV & JSON export', 'AI Chat assistant', 'API access', 'Priority support'] },
{ name: 'Business', price: 29, limit: '200 invoices/mo', features: ['Standard extraction', 'CSV & JSON export', 'API access', 'Email & chat support'] },
{ name: 'Enterprise', price: 499, limit: 'Unlimited', features: ['Custom AI models', 'All export formats', 'Dedicated AI Chat', 'Full API access', 'Dedicated support', 'SLA guarantee'] },
].map((plan) => {
const isCurrent = plan.name.toLowerCase() === store.user.plan;
return (
<article
key={plan.name}
className={`glass-card p-6 flex flex-col relative ${plan.name === 'Pro' ? 'pricing-highlight' : ''}`}
>
{plan.name === 'Pro' && <div className="pricing-ribbon">Most Popular</div>}
<div className="mb-5">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-semibold">{plan.name}</h3>
{isCurrent && (
<span className="text-xs font-medium px-2 py-0.5 rounded-full bg-teal-500/10 text-teal-500 border border-teal-500/20">
Current
</span>
)}
</div>
<div className="text-3xl font-bold text-foreground">
${plan.price}<span className="text-sm font-normal text-muted-foreground">/mo</span>
</div>
<div className="text-sm text-muted-foreground mt-1">{plan.limit}</div>
</div>
<ul className="space-y-3 mb-6 flex-1">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-2 text-sm text-muted-foreground">
<Check className="w-4 h-4 text-teal-500 shrink-0" />
{f}
</li>
))}
</ul>
<button
onClick={() => {
if (isCurrent) return;
toast.info('Coming soon! Plan upgrades will be available shortly.');
}}
disabled={isCurrent}
className={`w-full py-2.5 rounded-lg text-sm font-medium transition-colors ${
isCurrent
? 'bg-white/[0.04] text-muted-foreground cursor-not-allowed'
: 'bg-amber-500 text-zinc-900 hover:bg-amber-400'
}`}
>
{isCurrent ? 'Current Plan' : 'Upgrade'}
</button>
</article>
);
})}
</div>
</section>
)}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TAB 7: API ACCESS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{store.activeDashTab === 'api' && (
<section className="fade-in tab-content-area">
<div className="mb-6">
<h2 className="text-xl font-bold">API Access</h2>
<p className="text-sm text-muted-foreground mt-1">Use your API key to integrate OmniParse into your workflow</p>
</div>
{/* API Key Display */}
<div className="glass-card p-6 mb-6">
<h3 className="text-sm font-semibold mb-3">Your API Key</h3>
<div className="flex items-center gap-3">
<div className="flex-1 bg-white/[0.04] border border-white/[0.08] rounded-lg px-4 py-2.5 font-mono text-sm overflow-hidden">
{showApiKey
? (store.user.apiKey || 'Not available')
: (store.user.apiKey ? store.user.apiKey.slice(0, 10) + '...' + store.user.apiKey.slice(-4) : 'Not available')
}
</div>
<div className="tooltip-wrapper">
<button
onClick={() => setShowApiKey(!showApiKey)}
className="p-2.5 rounded-lg bg-white/[0.04] border border-white/[0.08] text-muted-foreground hover:text-foreground transition-colors"
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<span className="tooltip-text">{showApiKey ? 'Hide' : 'Show'}</span>
</div>
<div className="tooltip-wrapper">
<button
onClick={() => {
if (store.user.apiKey) {
navigator.clipboard.writeText(store.user.apiKey);
toast.success('API key copied to clipboard');
}
}}
className="p-2.5 rounded-lg bg-white/[0.04] border border-white/[0.08] text-muted-foreground hover:text-foreground transition-colors"
aria-label="Copy API key"
>
<Copy className="w-4 h-4" />
</button>
<span className="tooltip-text">Copy</span>
</div>
</div>
</div>
{/* cURL Example */}
<div className="glass-card p-6 mb-6">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Terminal className="w-4 h-4 text-amber-500" />
Quick Start Example
</h3>
<div className="code-block">
<pre>{`curl -X POST /api/invoices/upload \\
-H "Cookie: op_session=YOUR_SESSION" \\
-F "files=@invoice.pdf"`}</pre>
</div>
</div>
{/* Endpoint Documentation */}
<div className="glass-card p-6">
<h3 className="text-sm font-semibold mb-4">Available Endpoints</h3>
<div className="space-y-4">
{[
{ method: 'GET', path: '/api/auth/me', desc: 'Get current user info' },
{ method: 'POST', path: '/api/invoices/upload', desc: 'Upload and process invoice files' },
{ method: 'GET', path: '/api/invoices', desc: 'List all invoices with optional filters' },
{ method: 'DELETE', path: '/api/invoices', desc: 'Delete an invoice by ID' },
{ method: 'GET', path: '/api/invoices/export/csv', desc: 'Export all invoices as CSV' },
{ method: 'GET', path: '/api/invoices/export/json', desc: 'Export all invoices as JSON (Pro+)' },
{ method: 'POST', path: '/api/chat', desc: 'Send a message to the AI assistant (Pro+)' },
].map((ep) => (
<div key={ep.path + ep.method} className="flex items-start gap-3 py-2 border-b border-white/[0.04] last:border-0">
<span className={`text-xs font-bold px-2 py-0.5 rounded ${
ep.method === 'GET' ? 'bg-teal-500/10 text-teal-500' :
ep.method === 'POST' ? 'bg-amber-500/10 text-amber-500' :
'bg-red-500/10 text-red-500'
}`}>
{ep.method}
</span>
<div className="min-w-0">
<div className="text-sm font-mono text-foreground">{ep.path}</div>
<div className="text-xs text-muted-foreground">{ep.desc}</div>
</div>
</div>
))}
</div>
</div>
</section>
)}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TAB 8: PROFILE */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{store.activeDashTab === 'profile' && (
<section className="fade-in tab-content-area">
<div className="mb-6">
<h2 className="text-xl font-bold">Profile</h2>
<p className="text-sm text-muted-foreground mt-1">Manage your account settings</p>
</div>
{/* User Info Grid */}
<div className="glass-card p-6 mb-6">
<h3 className="text-sm font-semibold mb-4">Account Information</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="p-3 rounded-lg bg-white/[0.02]">
<div className="text-xs text-muted-foreground mb-1">Email</div>
<div className="text-sm font-medium flex items-center gap-2">
<Mail className="w-4 h-4 text-amber-500" />
{store.user.email}
</div>
</div>
<div className="p-3 rounded-lg bg-white/[0.02]">
<div className="text-xs text-muted-foreground mb-1">Name</div>
<div className="text-sm font-medium flex items-center gap-2">
<User className="w-4 h-4 text-amber-500" />
{store.user.name}
</div>
</div>
<div className="p-3 rounded-lg bg-white/[0.02]">
<div className="text-xs text-muted-foreground mb-1">Plan</div>
<div className="text-sm font-medium flex items-center gap-2">
<Crown className="w-4 h-4 text-amber-500" />
{planLabel}
</div>
</div>
<div className="p-3 rounded-lg bg-white/[0.02]">
<div className="text-xs text-muted-foreground mb-1">Member Since</div>
<div className="text-sm font-medium flex items-center gap-2">
<Building2 className="w-4 h-4 text-amber-500" />
{new Date(store.user.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</div>
<div className="p-3 rounded-lg bg-white/[0.02] sm:col-span-2">
<div className="text-xs text-muted-foreground mb-1">API Key</div>
<div className="text-sm font-mono text-muted-foreground flex items-center gap-2">
<Key className="w-4 h-4 text-amber-500 shrink-0" />
<span className="truncate">{store.user.apiKey ? store.user.apiKey.slice(0, 20) + '...' : 'Not available'}</span>
{store.user.apiKey && (
<div className="tooltip-wrapper">
<button
onClick={() => {
navigator.clipboard.writeText(store.user.apiKey || '');
toast.success('Copied to clipboard');
}}
className="p-1 rounded hover:bg-white/[0.06] transition-colors shrink-0"
aria-label="Copy API key"
>
<Copy className="w-3.5 h-3.5" />
</button>
<span className="tooltip-text">Copy</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Change Password */}
<div className="glass-card p-6 mb-6">
<h3 className="text-sm font-semibold mb-4">Change Password</h3>
<form
onSubmit={async (e) => {
e.preventDefault();
if (!profileCurrentPw || !profileNewPw || !profileConfirmPw) {
toast.error('Please fill in all password fields');
return;
}
setProfilePwLoading(true);
try {
const res = await fetch('/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
currentPassword: profileCurrentPw,
newPassword: profileNewPw,
confirmPassword: profileConfirmPw,
}),
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to update password');
return;
}
toast.success('Password updated successfully. Please log in again.');
setProfileCurrentPw('');
setProfileNewPw('');
setProfileConfirmPw('');
setTimeout(() => { fetchLogout(); }, 1500);
} catch {
toast.error('Network error. Please try again.');
} finally {
setProfilePwLoading(false);
}
}}
className="space-y-4"
>
<div>
<label className="input-label" htmlFor="current-pw">Current Password</label>
<input
id="current-pw"
type="password"
value={profileCurrentPw}
onChange={(e) => setProfileCurrentPw(e.target.value)}
placeholder="Enter current password"
className="input-field input-field-glow"
/>
</div>
<div>
<label className="input-label" htmlFor="new-pw">New Password</label>
<input
id="new-pw"
type="password"
value={profileNewPw}
onChange={(e) => setProfileNewPw(e.target.value)}
placeholder="Enter new password"
className="input-field input-field-glow"
/>
{profileNewPw && (() => {
const strength = getPasswordStrength(profileNewPw);
return (
<div className="mt-2">
<div className="h-1.5 w-full bg-zinc-800 rounded-full overflow-hidden">
<div className={`h-full ${strength.color} ${strength.width} rounded-full transition-all duration-300`} />
</div>
<p className="text-xs text-muted-foreground mt-1">{strength.label}</p>
</div>
);
})()}
</div>
<div>
<label className="input-label" htmlFor="confirm-new-pw">Confirm New Password</label>
<input
id="confirm-new-pw"
type="password"
value={profileConfirmPw}
onChange={(e) => setProfileConfirmPw(e.target.value)}
placeholder="Confirm new password"
className="input-field input-field-glow"
/>
</div>
<button
type="submit"
disabled={profilePwLoading}
className="btn-primary text-sm flex items-center gap-2 disabled:opacity-50"
>
{profilePwLoading && <Loader2 className="w-4 h-4 animate-spin" />}
Update Password
</button>
</form>
</div>
{/* Danger Zone */}
<div className="glass-card p-6 border border-red-500/20">
<h3 className="text-sm font-semibold mb-2 text-red-400">Danger Zone</h3>
<p className="text-sm text-muted-foreground mb-4">
Permanently delete your account and all associated data. This action cannot be undone.
</p>
<button
onClick={() => {
if (window.confirm('Are you sure you want to delete your account? All your invoices and data will be permanently removed.')) {
toast.info('Account deletion feature coming soon');
}
}}
className="px-6 py-2.5 rounded-lg bg-red-500/10 text-red-400 border border-red-500/20 font-medium text-sm hover:bg-red-500/20 transition-colors"
>
Delete Account
</button>
</div>
</section>
)}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TAB 10: SETTINGS */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{store.activeDashTab === 'settings' && (
<section className="fade-in tab-content-area">
<div className="mb-6">
<h2 className="text-xl font-bold">Settings</h2>
<p className="text-sm text-muted-foreground mt-1">Customize your dashboard experience</p>
</div>
<div className="space-y-6">
{/* Appearance */}
<div className="glass-card card-depth p-6 card-enter">
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
<Sun className="w-4 h-4 text-amber-500" />
Appearance
</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Theme</div>
<div className="text-xs text-muted-foreground mt-0.5">Switch between light and dark mode</div>
</div>
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white/[0.04] border border-white/[0.08] hover:border-white/[0.15] transition-colors text-sm"
>
{theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
</button>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Compact Table View</div>
<div className="text-xs text-muted-foreground mt-0.5">Reduce row height in invoice tables</div>
</div>
<button
onClick={() => setCompactView(!compactView)}
className={`toggle-switch ${compactView ? 'active' : ''}`}
>
<span className="toggle-switch-knob" />
</button>
</div>
</div>
</div>
{/* Regional */}
<div className="glass-card card-depth p-6 card-enter" style={{ animationDelay: '0.1s' }}>
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
<Globe className="w-4 h-4 text-teal-500" />
Regional
</h3>
<div className="space-y-4">
<div>
<label className="text-sm font-medium block mb-1.5">Date Format</label>
<div className="flex gap-2">
{(['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => setDateFormat(fmt)}
className={`px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
dateFormat === fmt
? 'bg-amber-500 text-zinc-900'
: 'bg-white/[0.04] text-muted-foreground hover:text-foreground border border-white/[0.08]'
}`}
>
{fmt}
</button>
))}
</div>
</div>
<div>
<label className="text-sm font-medium block mb-1.5">Default Currency</label>
<select
value={defaultCurrency}
onChange={(e) => setDefaultCurrency(e.target.value)}
className="select-field w-full sm:w-48"
>
<option value="USD">USD - US Dollar</option>
<option value="EUR">EUR - Euro</option>
<option value="GBP">GBP - British Pound</option>
<option value="CAD">CAD - Canadian Dollar</option>
<option value="AUD">AUD - Australian Dollar</option>
<option value="JPY">JPY - Japanese Yen</option>
<option value="CNY">CNY - Chinese Yuan</option>
</select>
</div>
</div>
</div>
{/* Notifications & Keyboard */}
<div className="glass-card card-depth p-6 card-enter" style={{ animationDelay: '0.2s' }}>
<h3 className="text-sm font-semibold mb-4 flex items-center gap-2">
<Bell className="w-4 h-4 text-amber-500" />
Notifications & Shortcuts
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Global Search</span>
<kbd className="kbd">Ctrl+K</kbd>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Keyboard Shortcuts</span>
<kbd className="kbd">Ctrl+/</kbd>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">Go to Invoices</span>
<kbd className="kbd">Ctrl+K</kbd>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
onClick={saveSettings}
className={`btn-primary px-6 py-2.5 text-sm font-medium ${settingsSaved ? '!bg-green-500' : ''}`}
>
{settingsSaved ? (
<span className="flex items-center gap-2"><Check className="w-4 h-4" /> Saved!</span>
) : 'Save Settings'}
</button>
</div>
</div>
</section>
)}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{/* TAB 9: ACTIVITY */}
{/* ═══════════════════════════════════════════════════════════════════════ */}
{store.activeDashTab === 'activity' && (
<section className="fade-in tab-content-area">
<div className="mb-6">
<h2 className="text-xl font-bold">Activity</h2>
<p className="text-sm text-muted-foreground mt-1">Recent actions and events</p>
</div>
<div className="glass-card p-6">
{activityLoading ? (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex gap-4">
<div className="w-8 h-8 rounded-full bg-white/[0.04] border border-white/[0.08] shrink-0 skeleton-shimmer" />
<div className="flex-1 space-y-2 pt-0.5">
<div className="skeleton-shimmer h-4 w-2/5" />
<div className="skeleton-shimmer h-3 w-3/5" />
</div>
</div>
))}
</div>
) : activityLogs.length > 0 ? (
<div className="relative">
{/* Vertical line */}
<div className="absolute left-[15px] top-2 bottom-2 w-px bg-white/[0.06]" />
<div className="space-y-6">
{activityLogs.map((log) => {
const iconMap: Record<string, LucideIcon> = {
login: LogIn,
upload: Upload,
delete: Trash2,
export: Download,
edit: Pencil,
signup: UserPlus,
password_change: Lock,
};
const colorMap: Record<string, string> = {
login: 'text-amber-500',
upload: 'text-teal-500',
delete: 'text-red-400',
export: 'text-amber-500',
edit: 'text-blue-400',
signup: 'text-green-400',
password_change: 'text-amber-500',
};
const ItemIcon = iconMap[log.type] || Clock;
const itemColor = colorMap[log.type] || 'text-muted-foreground';
return (
<div key={log.id} className="relative flex gap-4">
<div className="relative z-10 w-8 h-8 rounded-full bg-white/[0.04] border border-white/[0.08] flex items-center justify-center shrink-0">
<ItemIcon className={`w-3.5 h-3.5 ${itemColor}`} />
</div>
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-center justify-between gap-2">
<h4 className="text-sm font-medium">{log.title}</h4>
<span className="text-xs text-muted-foreground shrink-0">{relativeTime(log.createdAt)}</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5">{log.description}</p>
</div>
</div>
);
})}
</div>
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Clock className="w-10 h-10 mx-auto mb-3 opacity-40" />
<p className="text-sm">No activity yet. Actions will appear here as you use the app.</p>
</div>
)}
</div>
</section>
)}
</div>
</main>
{showCommandPalette && (
<div className="command-palette-backdrop" onClick={() => setShowCommandPalette(false)}>
<div className="command-palette" onClick={e => e.stopPropagation()}>
<div className="command-palette-input">
<Command className="w-5 h-5 text-muted-foreground shrink-0" />
<input
autoFocus
type="text"
value={commandSearch}
onChange={e => setCommandSearch(e.target.value)}
placeholder="Search invoices, navigate tabs..."
onKeyDown={e => { if (e.key === 'Escape') setShowCommandPalette(false); }}
/>
<kbd className="kbd">ESC</kbd>
</div>
<div className="command-palette-results scrollbar-thin">
<div className="command-palette-section-title">Quick Actions</div>
{DASH_TABS.filter(t => !commandSearch || t.label.toLowerCase().includes(commandSearch.toLowerCase())).map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => { store.setActiveDashTab(tab.id); setShowCommandPalette(false); setCommandSearch(''); }}
className="command-palette-item"
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
{commandSearch && (
<>
<div className="command-palette-section-title" style={{ marginTop: 8 }}>Invoice Results</div>
{store.invoices
.filter(inv => !commandSearch || inv.vendor?.toLowerCase().includes(commandSearch.toLowerCase()) || inv.invNumber?.toLowerCase().includes(commandSearch.toLowerCase()))
.slice(0, 5)
.map(inv => (
<button
key={inv.id}
onClick={() => { store.setActiveDashTab('invoices'); setExpandedInvoice(inv.id); setShowCommandPalette(false); setCommandSearch(''); }}
className="command-palette-item justify-between"
>
<div>
<div className="font-medium text-foreground">{inv.vendor || 'Unknown'}</div>
<div className="command-palette-result-sub">{inv.invNumber || 'No invoice #'}</div>
</div>
<div className="text-right">
<div className="font-medium">{formatCurrency(inv.total, inv.currency)}</div>
<div className="command-palette-result-sub">{inv.invDate || ''}</div>
</div>
</button>
))
}
{store.invoices.filter(inv => inv.vendor?.toLowerCase().includes(commandSearch.toLowerCase()) || inv.invNumber?.toLowerCase().includes(commandSearch.toLowerCase())).length === 0 && (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">No matching invoices</div>
)}
</>
)}
</div>
</div>
</div>
)}
<footer className="dash-footer py-4 pb-[env(safe-area-inset-bottom)] px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-md bg-amber-500 flex items-center justify-center">
<ScanLine className="w-2.5 h-2.5 text-zinc-900" />
</div>
<span>OmniParse AI</span>
</div>
<div className="flex items-center gap-4">
<span>Privacy</span>
<span>Terms</span>
<span>Support</span>
</div>
</div>
</footer>
{showShortcuts && (
<div className="modal-overlay" onClick={() => setShowShortcuts(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-bold">Keyboard Shortcuts</h2>
<button
onClick={() => setShowShortcuts(false)}
className="p-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3">
{[
{ keys: ['Ctrl', 'K'], desc: 'Go to Invoices' },
{ keys: ['Ctrl', '/'], desc: 'Show shortcuts' },
{ keys: ['Enter'], desc: 'Send chat message' },
].map((s) => (
<div key={s.desc} className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">{s.desc}</span>
<div className="flex items-center gap-1">
{s.keys.map((k, i) => (
<span key={k}>
<span className="kbd">{k}</span>
{i < s.keys.length - 1 && <span className="text-muted-foreground mx-1">+</span>}
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
)}
{editingInvoice && (
<div className="modal-overlay" onClick={() => setEditingInvoice(null)}>
<div className="modal-content max-w-lg" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-bold">Edit Invoice</h2>
<button onClick={() => setEditingInvoice(null)} className="p-1 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/[0.06] transition-colors" aria-label="Close">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-4">
{[
{ key: 'vendor', label: 'Vendor' },
{ key: 'invNumber', label: 'Invoice Number' },
{ key: 'invDate', label: 'Invoice Date' },
{ key: 'dueDate', label: 'Due Date' },
{ key: 'amount', label: 'Amount' },
{ key: 'vatAmount', label: 'VAT Amount' },
{ key: 'total', label: 'Total' },
{ key: 'currency', label: 'Currency' },
].map(({ key, label }) => (
<div key={key}>
<label className="input-label">{label}</label>
<input
type="text"
value={editForm[key] || ''}
onChange={(e) => setEditForm(prev => ({ ...prev, [key]: e.target.value }))}
className="input-field input-field-glow"
/>
</div>
))}
<div>
<label className="input-label">Status</label>
<div className="flex gap-3">
{['done', 'review'].map((s) => (
<button
key={s}
onClick={() => setEditForm(prev => ({ ...prev, status: s }))}
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-colors ${
editForm.status === s
? s === 'done' ? 'bg-teal-500 text-zinc-900' : 'bg-amber-500 text-zinc-900'
: 'bg-white/[0.04] text-muted-foreground border border-white/[0.08]'
}`}
>
{s === 'done' ? 'Done' : 'Review'}
</button>
))}
</div>
</div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={() => setEditingInvoice(null)} className="btn-ghost text-sm flex-1">Cancel</button>
<button onClick={saveEdit} disabled={editSaving} className="btn-primary text-sm flex-1 flex items-center justify-center gap-2 disabled:opacity-50">
{editSaving && <Loader2 className="w-4 h-4 animate-spin" />}
Save Changes
</button>
</div>
</div>
</div>
)}
{/* ── HF Space Download ── */}
<a
href="/omniparse-hf-space.zip"
download
className="fixed bottom-5 right-5 z-50 bg-amber-500 hover:bg-amber-400 text-black font-semibold text-xs px-3 py-2 rounded-lg shadow-lg transition-colors"
>
Download Patch (5 files)
</a>
</div>
);
}
return null;
}
// ──────────────────────────────────────────────────────────────────────────────
// ─── Analytics Tab Component ────────────────────────────────────────────────────
const CHART_COLORS = {
amber: '#F59E0B',
teal: '#14B8A6',
red: '#EF4444',
green: '#22C55E',
amberLight: 'rgba(245, 158, 11, 0.15)',
};
function AnalyticsTab({ invoices }: { invoices: InvoiceRow[] }) {
// ── Computed analytics data ──
const monthlyData = useMemo(() => {
const map = new Map<string, number>();
invoices.forEach((inv) => {
const dateStr = inv.invDate || inv.createdAt;
if (!dateStr) return;
const monthKey = dateStr.substring(0, 7); // YYYY-MM
map.set(monthKey, (map.get(monthKey) || 0) + (inv.total ?? 0));
});
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, total]) => {
const [y, m] = month.split('-');
const label = new Date(Number(y), Number(m) - 1).toLocaleString('en-US', {
month: 'short', year: '2-digit',
});
return { month: label, total: Math.round(total * 100) / 100 };
});
}, [invoices]);
const vendorData = useMemo(() => {
const map = new Map<string, number>();
invoices.forEach((inv) => {
const vendor = inv.vendor || 'Unknown';
map.set(vendor, (map.get(vendor) || 0) + (inv.total ?? 0));
});
return Array.from(map.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([name, total]) => ({ name: name.length > 18 ? name.slice(0, 16) + '…' : name, total: Math.round(total * 100) / 100 }));
}, [invoices]);
const statusData = useMemo(() => {
const counts = { Done: 0, Review: 0, Duplicate: 0 };
invoices.forEach((inv) => {
if (inv.status === 'done') counts.Done++;
else if (inv.status === 'review') counts.Review++;
else if (inv.status === 'duplicate' || inv.isDuplicate) counts.Duplicate++;
});
return [
{ name: 'Done', value: counts.Done, color: CHART_COLORS.teal },
{ name: 'Review', value: counts.Review, color: CHART_COLORS.amber },
{ name: 'Duplicate', value: counts.Duplicate, color: CHART_COLORS.red },
].filter((d) => d.value > 0);
}, [invoices]);
const currencyData = useMemo(() => {
const map = new Map<string, number>();
invoices.forEach((inv) => map.set(inv.currency, (map.get(inv.currency) || 0) + 1));
const entries = Array.from(map.entries()).sort((a, b) => b[1] - a[1]);
const maxCount = entries.length > 0 ? entries[0][1] : 1;
return entries.map(([currency, count]) => ({ currency, count, pct: Math.round((count / maxCount) * 100) }));
}, [invoices]);
const quickStats = useMemo(() => {
const totals = invoices.map((inv) => inv.total ?? 0).filter((v) => v > 0);
const vendors = invoices.filter((inv) => inv.vendor);
const vendorCounts = new Map<string, number>();
vendors.forEach((inv) => {
const v = inv.vendor as string;
vendorCounts.set(v, (vendorCounts.get(v) || 0) + 1);
});
let mostActive = '--';
let maxCount = 0;
vendorCounts.forEach((count, name) => {
if (count > maxCount) { mostActive = name; maxCount = count; }
});
return {
highest: totals.length > 0 ? Math.max(...totals) : null,
lowest: totals.length > 0 ? Math.min(...totals) : null,
mostActiveVendor: mostActive,
average: totals.length > 0 ? totals.reduce((a, b) => a + b, 0) / totals.length : null,
};
}, [invoices]);
const hasData = invoices.length > 0;
return (
<section className="fade-in">
{/* Section Header */}
<div className="mb-6">
<h2 className="text-xl font-bold">Analytics</h2>
<p className="text-sm text-muted-foreground mt-1">Visual insights into your invoice data</p>
</div>
{!hasData ? (
<div className="glass-card p-12 text-center empty-state">
<BarChart3 className="w-12 h-12" />
<p className="text-sm mt-2">No invoice data yet</p>
<p className="text-xs text-muted-foreground mt-1">Upload invoices to see analytics here</p>
</div>
) : (
<>
{/* ── Quick Stats Summary ── */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6" aria-label="Quick statistics">
<div className="stat-card-glass card-accent-amber card-enter">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-amber-500" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Highest Invoice</span>
</div>
<div className="text-lg font-bold text-foreground">
{quickStats.highest !== null ? formatCurrency(quickStats.highest) : '--'}
</div>
</div>
<div className="stat-card-glass card-accent-teal card-enter">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-teal-500" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Lowest Invoice</span>
</div>
<div className="text-lg font-bold text-foreground">
{quickStats.lowest !== null ? formatCurrency(quickStats.lowest) : '--'}
</div>
</div>
<div className="stat-card-glass card-accent-green card-enter">
<div className="flex items-center gap-2 mb-1">
<Building2 className="w-4 h-4 text-green-400" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Most Active Vendor</span>
</div>
<div className="text-lg font-bold text-foreground truncate" title={quickStats.mostActiveVendor}>
{quickStats.mostActiveVendor}
</div>
</div>
<div className="stat-card-glass card-accent-amber card-enter">
<div className="flex items-center gap-2 mb-1">
<CircleDollarSign className="w-4 h-4 text-amber-500" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Average Amount</span>
</div>
<div className="text-lg font-bold text-foreground">
{quickStats.average !== null ? formatCurrency(quickStats.average) : '--'}
</div>
</div>
</div>
{/* ── Charts Grid ── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* a) Monthly Spending Trend */}
<div className="glass-card p-6">
<h3 className="text-sm font-semibold mb-3">Monthly Spending Trend</h3>
{monthlyData.length === 0 ? (
<div className="h-48 flex items-center justify-center text-muted-foreground text-xs">
No date data available
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={monthlyData} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="amberGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_COLORS.amber} stopOpacity={0.3} />
<stop offset="100%" stopColor={CHART_COLORS.amber} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} />
<YAxis tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} tickFormatter={(v: number) => `$${(v / 1000).toFixed(v >= 1000 ? 1 : 0)}k`} />
<RechartsTooltip
contentStyle={{ className: 'recharts-tooltip-custom' }}
wrapperStyle={{ outline: 'none' }}
formatter={(value: number) => [formatCurrency(value), 'Total']}
/>
<Area type="monotone" dataKey="total" stroke={CHART_COLORS.amber} strokeWidth={2} fill="url(#amberGrad)" />
</AreaChart>
</ResponsiveContainer>
)}
</div>
{/* b) Vendor Spending Breakdown */}
<div className="glass-card p-6">
<h3 className="text-sm font-semibold mb-3">Top Vendors by Spending</h3>
{vendorData.length === 0 ? (
<div className="h-48 flex items-center justify-center text-muted-foreground text-xs">
No vendor data available
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={vendorData} layout="vertical" margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} tickFormatter={(v: number) => `$${(v / 1000).toFixed(v >= 1000 ? 1 : 0)}k`} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: '#a1a1aa' }} width={110} axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} />
<RechartsTooltip
contentStyle={{ className: 'recharts-tooltip-custom' }}
wrapperStyle={{ outline: 'none' }}
formatter={(value: number) => [formatCurrency(value), 'Total']}
/>
<Bar dataKey="total" fill={CHART_COLORS.teal} radius={[0, 4, 4, 0]} barSize={16} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* c) Status Distribution */}
<div className="glass-card p-6">
<h3 className="text-sm font-semibold mb-3">Status Distribution</h3>
{statusData.length === 0 ? (
<div className="h-48 flex items-center justify-center text-muted-foreground text-xs">
No status data available
</div>
) : (
<div className="flex items-center gap-6">
<div className="flex-1" style={{ minHeight: 180 }}>
<ResponsiveContainer width="100%" height={180}>
<PieChart>
<Pie
data={statusData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={75}
paddingAngle={3}
dataKey="value"
stroke="none"
>
{statusData.map((entry, idx) => (
<Cell key={`cell-${idx}`} fill={entry.color} />
))}
</Pie>
<RechartsTooltip
contentStyle={{ className: 'recharts-tooltip-custom' }}
wrapperStyle={{ outline: 'none' }}
formatter={(value: number) => [`${value} invoices`, 'Count']}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-col gap-3 min-w-[120px]">
{statusData.map((entry) => (
<div key={entry.name} className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
<div>
<span className="text-sm text-foreground font-medium">{entry.name}</span>
<span className="text-xs text-muted-foreground ml-2">{entry.value}</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* d) Currency Distribution */}
<div className="glass-card p-6">
<h3 className="text-sm font-semibold mb-3">Currency Distribution</h3>
{currencyData.length === 0 ? (
<div className="h-48 flex items-center justify-center text-muted-foreground text-xs">
No currency data available
</div>
) : (
<div className="space-y-3 mt-1">
{currencyData.map(({ currency, count, pct }) => (
<div key={currency} className="flex items-center gap-3">
<span className="text-sm text-foreground font-medium w-16 text-right">{currency}</span>
<div className="flex-1 h-6 bg-white/[0.04] rounded overflow-hidden">
<div
className="progress-bar h-full rounded"
style={{ width: `${pct}%`, backgroundColor: CHART_COLORS.teal }}
/>
</div>
<span className="text-xs text-muted-foreground w-8 text-right">{count}</span>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</section>
);
}
// Invoice Table Row Component (with expandable detail panel)
// ──────────────────────────────────────────────────────────────────────────────
function InvoiceTableBody({
invoice,
isExpanded,
onToggle,
onDelete,
isSelected,
onSelect,
onEdit,
}: {
invoice: InvoiceRow;
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
isSelected: boolean;
onSelect: () => void;
onEdit?: () => void;
}) {
const statusBadgeClass = invoice.isDuplicate ? 'badge-amber-enhanced' : invoice.status === 'review' ? 'badge-amber-enhanced' : 'badge-teal-enhanced';
const statusLabel = invoice.isDuplicate ? 'Duplicate' : invoice.status === 'review' ? 'Review' : 'Done';
return (
<>
<tr
className="invoice-row-enhanced border-b border-white/[0.04]"
onClick={onToggle}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onToggle(); }}
aria-expanded={isExpanded}
>
<td className="px-3 py-3 w-10">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => { e.stopPropagation(); onSelect(); }}
onClick={(e) => e.stopPropagation()}
className="invoice-checkbox"
aria-label={`Select invoice ${invoice.vendor || invoice.invNumber}`}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{isExpanded ? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground shrink-0" /> : <ChevronDown className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
<span className="truncate max-w-[120px] sm:max-w-none">{invoice.vendor || '--'}</span>
</div>
</td>
<td className="px-4 py-3 text-muted-foreground hidden sm:table-cell">{invoice.invNumber || '--'}</td>
<td className="px-4 py-3 text-muted-foreground hidden md:table-cell">{invoice.invDate || '--'}</td>
<td className="px-4 py-3 text-right font-medium">{formatCurrency(invoice.amount, invoice.currency)}</td>
<td className="px-4 py-3 text-right font-medium hidden sm:table-cell">{formatCurrency(invoice.total, invoice.currency)}</td>
<td className="px-4 py-3 text-center">
<span className={`badge-pill ${statusBadgeClass}`}>
<span className={`badge-pill-dot ${invoice.isDuplicate ? 'bg-amber-500' : invoice.status === 'review' ? 'bg-amber-500' : 'bg-teal-500'}`} />
{statusLabel}
</span>
</td>
<td className="px-4 py-3 text-right hidden lg:table-cell">
{invoice.confidence ? `${(invoice.confidence * 100).toFixed(1)}%` : '--'}
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1">
<div className="tooltip-wrapper">
<button
onClick={(e) => { e.stopPropagation(); onEdit?.(); }}
className="p-1.5 rounded-lg text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
aria-label="Edit invoice"
>
<Pencil className="w-4 h-4" />
</button>
<span className="tooltip-text">Edit</span>
</div>
<div className="tooltip-wrapper">
<button
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="p-1.5 rounded-lg text-muted-foreground hover:text-red-400 hover:bg-red-500/10 transition-colors"
aria-label="Delete invoice"
>
<Trash2 className="w-4 h-4" />
</button>
<span className="tooltip-text">Delete</span>
</div>
</div>
</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={10} className="p-0">
<div className="px-4 py-4 bg-white/[0.02] border-b border-white/[0.04] fade-in">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Invoice Details</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-4">
<div>
<div className="text-xs text-muted-foreground">Filename</div>
<div className="text-sm mt-0.5">{invoice.filename || '--'}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Due Date</div>
<div className="text-sm mt-0.5">{invoice.dueDate || '--'}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">VAT Amount</div>
<div className="text-sm mt-0.5">{formatCurrency(invoice.vatAmount, invoice.currency)}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Created</div>
<div className="text-sm mt-0.5">{new Date(invoice.createdAt).toLocaleDateString()}</div>
</div>
</div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Raw JSON</h4>
<div className="bg-zinc-950 rounded-lg p-4 font-mono text-xs text-muted-foreground max-h-48 overflow-y-auto">
<pre>{JSON.stringify({
vendor: invoice.vendor,
invNumber: invoice.invNumber,
invDate: invoice.invDate,
dueDate: invoice.dueDate,
amount: invoice.amount,
vatAmount: invoice.vatAmount,
total: invoice.total,
currency: invoice.currency,
confidence: invoice.confidence,
status: invoice.status,
isDuplicate: invoice.isDuplicate,
filename: invoice.filename,
}, null, 2)}</pre>
</div>
</div>
</td>
</tr>
)}
</>
);
}