Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import SignupPage from './pages/SignupPage';
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage';
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage';
|
||||
import BillingSuccessPage from './pages/billing/BillingSuccessPage';
|
||||
import BillingCancelPage from './pages/billing/BillingCancelPage';
|
||||
import { AppLayout } from './components/app/AppLayout';
|
||||
import DashboardPage from './pages/app/DashboardPage';
|
||||
import ClientsPage from './pages/app/ClientsPage';
|
||||
import ClientDetailPage from './pages/app/ClientDetailPage';
|
||||
import CasesPage from './pages/app/CasesPage';
|
||||
import CaseDetailPage from './pages/app/CaseDetailPage';
|
||||
import TimePage from './pages/app/TimePage';
|
||||
import InvoicesPage from './pages/app/InvoicesPage';
|
||||
import InvoiceDetailPage from './pages/app/InvoiceDetailPage';
|
||||
import AccountSettingsPage from './pages/app/AccountSettingsPage';
|
||||
import { CookieBanner } from './components/CookieBanner';
|
||||
import { AdminLayout } from './components/admin/AdminLayout';
|
||||
import AdminDashboardPage from './pages/admin/AdminDashboardPage';
|
||||
import AdminFirmsPage from './pages/admin/AdminFirmsPage';
|
||||
import AdminFirmDetailPage from './pages/admin/AdminFirmDetailPage';
|
||||
import AdminUsersPage from './pages/admin/AdminUsersPage';
|
||||
import AdminContactPage from './pages/admin/AdminContactPage';
|
||||
import AdminAuditPage from './pages/admin/AdminAuditPage';
|
||||
import ToolsIndexPage from './pages/tools/ToolsIndexPage';
|
||||
import HourlyRateCalculatorPage from './pages/tools/HourlyRateCalculatorPage';
|
||||
import CaseProfitabilityPage from './pages/tools/CaseProfitabilityPage';
|
||||
import BillableHoursTrackerPage from './pages/tools/BillableHoursTrackerPage';
|
||||
import DocumentTemplatesPage from './pages/tools/DocumentTemplatesPage';
|
||||
import BlogIndexPage from './pages/blog/BlogIndexPage';
|
||||
import BlogPostPage from './pages/blog/BlogPostPage';
|
||||
import PrivacyPage from './pages/legal/PrivacyPage';
|
||||
import TermsPage from './pages/legal/TermsPage';
|
||||
import CookiesPage from './pages/legal/CookiesPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
|
||||
<Route path="/billing/success" element={<BillingSuccessPage />} />
|
||||
<Route path="/billing/cancel" element={<BillingCancelPage />} />
|
||||
|
||||
<Route path="/tools" element={<ToolsIndexPage />} />
|
||||
<Route path="/tools/hourly-rate-calculator" element={<HourlyRateCalculatorPage />} />
|
||||
<Route path="/tools/case-profitability" element={<CaseProfitabilityPage />} />
|
||||
<Route path="/tools/billable-hours-tracker" element={<BillableHoursTrackerPage />} />
|
||||
<Route path="/tools/document-templates" element={<DocumentTemplatesPage />} />
|
||||
|
||||
<Route path="/blog" element={<BlogIndexPage />} />
|
||||
<Route path="/blog/:slug" element={<BlogPostPage />} />
|
||||
|
||||
<Route path="/legal/privacy" element={<PrivacyPage />} />
|
||||
<Route path="/legal/terms" element={<TermsPage />} />
|
||||
<Route path="/legal/cookies" element={<CookiesPage />} />
|
||||
|
||||
<Route path="/app" element={<AppLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="clients/:id" element={<ClientDetailPage />} />
|
||||
<Route path="cases" element={<CasesPage />} />
|
||||
<Route path="cases/:id" element={<CaseDetailPage />} />
|
||||
<Route path="time" element={<TimePage />} />
|
||||
<Route path="invoices" element={<InvoicesPage />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetailPage />} />
|
||||
<Route path="settings" element={<AccountSettingsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="firms" element={<AdminFirmsPage />} />
|
||||
<Route path="firms/:id" element={<AdminFirmDetailPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="contact" element={<AdminContactPage />} />
|
||||
<Route path="audit" element={<AdminAuditPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<LandingPage />} />
|
||||
</Routes>
|
||||
<CookieBanner />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Cookie, X } from 'lucide-react';
|
||||
|
||||
const STORAGE_KEY = 'lawdesk:cookie-consent';
|
||||
|
||||
type Consent = 'all' | 'essentials';
|
||||
|
||||
export function getConsent(): Consent | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
return v === 'all' || v === 'essentials' ? v : null;
|
||||
}
|
||||
|
||||
function setConsent(v: Consent) {
|
||||
localStorage.setItem(STORAGE_KEY, v);
|
||||
window.dispatchEvent(new CustomEvent('lawdesk:consent-changed', { detail: v }));
|
||||
}
|
||||
|
||||
export function CookieBanner() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(getConsent() === null);
|
||||
}, []);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function choose(v: Consent) {
|
||||
setConsent(v);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-50 p-3 sm:p-5">
|
||||
<div className="mx-auto max-w-3xl rounded-2xl border border-ink-100 bg-white shadow-2xl shadow-ink-900/15 p-5 md:p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="grid h-10 w-10 flex-none place-items-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<Cookie className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-ink-900">Cookies on this site</h3>
|
||||
<p className="mt-1 text-sm text-ink-600 leading-relaxed">
|
||||
We use a few essential cookies to keep you signed in and secure. With your permission
|
||||
we'd also like to use optional cookies to understand how the product is used so we can
|
||||
improve it. You can change this choice anytime from your account settings.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose('all')}
|
||||
className="btn-primary text-sm py-2 px-4"
|
||||
>
|
||||
Accept all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose('essentials')}
|
||||
className="btn-secondary text-sm py-2 px-4"
|
||||
>
|
||||
Essentials only
|
||||
</button>
|
||||
<a
|
||||
href="/legal/privacy"
|
||||
className="ml-1 text-xs font-medium text-ink-500 hover:text-ink-800 underline-offset-2 hover:underline"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose('essentials')}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-ink-400 hover:bg-ink-100 hover:text-ink-700 -mt-1 -mr-1"
|
||||
aria-label="Dismiss with essentials only"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useLogout, useMe } from '@/hooks/useAuth';
|
||||
import { AdminSidebar } from './AdminSidebar';
|
||||
|
||||
export function AdminLayout() {
|
||||
const me = useMe();
|
||||
const logout = useLogout();
|
||||
|
||||
if (me.isLoading) {
|
||||
return <div className="min-h-screen grid place-items-center text-sm text-ink-500">Loading…</div>;
|
||||
}
|
||||
if (!me.data) return <Navigate to="/login?next=/admin" replace />;
|
||||
if (!me.data.isSuperadmin) return <Navigate to="/app" replace />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-ink-50">
|
||||
<AdminSidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<header className="flex h-16 items-center justify-between gap-4 border-b border-ink-100 bg-white px-6">
|
||||
<p className="text-sm text-ink-500">Signed in as <span className="font-medium text-ink-800">{me.data.email}</span></p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout.mutate()}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-ink-600 hover:text-ink-900"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Log out
|
||||
</button>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPageHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-ink-950 font-display">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-ink-600">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="flex items-center gap-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Building2,
|
||||
Users,
|
||||
MessageSquare,
|
||||
ShieldAlert,
|
||||
ArrowLeft,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Item {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const NAV: Item[] = [
|
||||
{ to: '/admin', label: 'Overview', icon: LayoutDashboard },
|
||||
{ to: '/admin/firms', label: 'Firms', icon: Building2 },
|
||||
{ to: '/admin/users', label: 'Users', icon: Users },
|
||||
{ to: '/admin/contact', label: 'Contact inbox', icon: MessageSquare },
|
||||
{ to: '/admin/audit', label: 'Audit log', icon: ScrollText },
|
||||
];
|
||||
|
||||
export function AdminSidebar() {
|
||||
return (
|
||||
<aside className="hidden md:flex md:w-60 lg:w-64 flex-col border-r border-ink-100 bg-ink-950 text-ink-100">
|
||||
<div className="px-5 py-5">
|
||||
<a href="/admin" className="inline-flex" aria-label="Admin home">
|
||||
<img src="/logo-light.png" alt="Legal Software" className="h-7 w-auto" width={450} height={45} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="px-5 mb-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-rose-500/20 text-rose-200 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider">
|
||||
<ShieldAlert className="h-3 w-3" />
|
||||
Superadmin
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 mt-2 space-y-0.5">
|
||||
{NAV.map((item) => (
|
||||
<NavItem key={item.to} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-ink-900">
|
||||
<NavLink
|
||||
to="/app"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-ink-300 hover:bg-ink-900/60 hover:text-white transition"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to app
|
||||
</NavLink>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item }: { item: Item }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === '/admin'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition',
|
||||
isActive
|
||||
? 'bg-white text-ink-950 font-medium shadow'
|
||||
: 'text-ink-300 hover:bg-ink-900/60 hover:text-white',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useMe } from '@/hooks/useAuth';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Topbar } from './Topbar';
|
||||
|
||||
export function AppLayout() {
|
||||
const me = useMe();
|
||||
|
||||
if (me.isLoading) {
|
||||
return <div className="min-h-screen grid place-items-center text-sm text-ink-500">Loading…</div>;
|
||||
}
|
||||
|
||||
if (!me.data) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-ink-50">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-ink-950 font-display">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-ink-600">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="flex items-center gap-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import { CreditCard, Sparkles, Crown } from 'lucide-react';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useBillingStatus, useStartCheckout, useOpenPortal } from '@/hooks/useBilling';
|
||||
|
||||
const PLAN_TONES: Record<'starter' | 'pro' | 'lifetime', 'neutral' | 'brand' | 'emerald'> = {
|
||||
starter: 'neutral',
|
||||
pro: 'brand',
|
||||
lifetime: 'emerald',
|
||||
};
|
||||
|
||||
const PLAN_LABEL: Record<'starter' | 'pro' | 'lifetime', string> = {
|
||||
starter: 'Starter',
|
||||
pro: 'Professional',
|
||||
lifetime: 'Lifetime',
|
||||
};
|
||||
|
||||
export function BillingCard() {
|
||||
const status = useBillingStatus();
|
||||
const checkout = useStartCheckout();
|
||||
const portal = useOpenPortal();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function startCheckout(plan: 'pro' | 'lifetime') {
|
||||
setError(null);
|
||||
try {
|
||||
const { url } = await checkout.mutateAsync({ plan });
|
||||
if (url) window.location.href = url;
|
||||
} catch (e) {
|
||||
const code = (e as { code?: string }).code;
|
||||
setError(
|
||||
code === 'stripe_not_configured'
|
||||
? 'Billing is not configured yet. Contact support.'
|
||||
: code === 'plan_not_configured'
|
||||
? 'This plan is not available yet.'
|
||||
: 'Could not start checkout.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPortal() {
|
||||
setError(null);
|
||||
try {
|
||||
const { url } = await portal.mutateAsync();
|
||||
if (url) window.location.href = url;
|
||||
} catch {
|
||||
setError('Could not open billing portal.');
|
||||
}
|
||||
}
|
||||
|
||||
if (status.isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title="Plan & billing" />
|
||||
<CardBody>
|
||||
<p className="text-sm text-ink-500">Loading…</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const plan = status.data?.plan ?? 'starter';
|
||||
const isPaid = plan !== 'starter';
|
||||
const configured = !!status.data?.configured;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Plan & billing"
|
||||
description={isPaid ? 'Manage your subscription and payment details.' : 'Upgrade to remove limits and watermarks.'}
|
||||
/>
|
||||
<CardBody className="space-y-5">
|
||||
<div className="flex items-center justify-between rounded-xl border border-ink-100 bg-ink-50/50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-white text-brand-600 shadow-sm">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-ink-500">Current plan</p>
|
||||
<p className="font-semibold text-ink-900">
|
||||
{PLAN_LABEL[plan]} <Badge tone={PLAN_TONES[plan]}>{plan}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.data?.hasCustomer && (
|
||||
<Button variant="secondary" size="sm" onClick={openPortal} disabled={portal.isPending}>
|
||||
{portal.isPending ? 'Opening…' : 'Manage billing'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!configured && (
|
||||
<p className="rounded-lg bg-amber-50 border border-amber-200 px-3 py-2 text-sm text-amber-800">
|
||||
Stripe isn't configured on this server yet. Set <code>STRIPE_SECRET_KEY</code> and the price IDs in your environment to enable checkout.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan === 'starter' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<PlanOption
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
name="Professional"
|
||||
price="$25/mo"
|
||||
points={['Unlimited clients & invoices', '6 active cases', '8GB storage', 'No watermark']}
|
||||
cta="Upgrade to Pro"
|
||||
onClick={() => startCheckout('pro')}
|
||||
loading={checkout.isPending}
|
||||
disabled={!configured}
|
||||
/>
|
||||
<PlanOption
|
||||
icon={<Crown className="h-4 w-4" />}
|
||||
name="Lifetime"
|
||||
price="$129 once"
|
||||
points={['Everything in Pro', 'Unlimited cases', '50GB storage', 'Future updates']}
|
||||
cta="Get Lifetime"
|
||||
onClick={() => startCheckout('lifetime')}
|
||||
loading={checkout.isPending}
|
||||
disabled={!configured}
|
||||
highlight
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plan === 'pro' && (
|
||||
<p className="text-sm text-ink-600">
|
||||
You're on the Professional plan ($25/mo). Want a lifetime license instead?{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold text-brand-600 hover:text-brand-700"
|
||||
onClick={() => startCheckout('lifetime')}
|
||||
disabled={checkout.isPending || !configured}
|
||||
>
|
||||
Upgrade to Lifetime
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{plan === 'lifetime' && (
|
||||
<p className="text-sm text-ink-600">
|
||||
You're on the Lifetime plan. No renewal needed — you have full access forever.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</p>}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanOption({
|
||||
icon,
|
||||
name,
|
||||
price,
|
||||
points,
|
||||
cta,
|
||||
onClick,
|
||||
loading,
|
||||
disabled,
|
||||
highlight,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
price: string;
|
||||
points: string[];
|
||||
cta: string;
|
||||
onClick: () => void;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'rounded-xl border p-4 ' +
|
||||
(highlight ? 'border-brand-300 bg-brand-50/30' : 'border-ink-100')
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-ink-900">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-md bg-white text-brand-600 shadow-sm">
|
||||
{icon}
|
||||
</span>
|
||||
<p className="font-semibold">{name}</p>
|
||||
<span className="ml-auto text-sm font-bold text-ink-950">{price}</span>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-1 text-xs text-ink-600">
|
||||
{points.map((p) => (
|
||||
<li key={p}>· {p}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={highlight ? 'primary' : 'secondary'}
|
||||
className="mt-4 w-full"
|
||||
onClick={onClick}
|
||||
disabled={loading || disabled}
|
||||
>
|
||||
{loading ? 'Redirecting…' : cta}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Trash2, Clock } from 'lucide-react';
|
||||
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { ManualEntryDrawer } from './ManualEntryDrawer';
|
||||
import { useTimeEntries, useDeleteTimeEntry, type TimeEntry } from '@/hooks/useTime';
|
||||
import { formatDate, formatHours, formatMoney } from '@/lib/format';
|
||||
|
||||
function entryAmount(e: TimeEntry): number {
|
||||
if (!e.billable) return 0;
|
||||
return (Number(e.rate) || 0) * (e.minutes / 60);
|
||||
}
|
||||
|
||||
export function CaseTimeList({ caseId }: { caseId: string }) {
|
||||
const list = useTimeEntries({ caseId });
|
||||
const del = useDeleteTimeEntry();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const totalMinutes = (list.data?.items ?? []).reduce((acc, e) => acc + e.minutes, 0);
|
||||
const totalAmount = (list.data?.items ?? []).reduce((acc, e) => acc + entryAmount(e), 0);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Time entries"
|
||||
description={
|
||||
totalMinutes > 0 ? `${formatHours(totalMinutes)} · ${formatMoney(totalAmount)} billable` : 'No time logged yet.'
|
||||
}
|
||||
action={
|
||||
<Button size="sm" onClick={() => setDrawerOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Log time
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{list.isLoading ? (
|
||||
<div className="px-6 py-10 text-center text-sm text-ink-500">Loading…</div>
|
||||
) : !list.data?.items.length ? (
|
||||
<EmptyState
|
||||
icon={<Clock className="h-5 w-5" />}
|
||||
title="No time logged"
|
||||
description="Start the timer in the topbar or log time manually."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink-100">
|
||||
{list.data.items.map((e) => (
|
||||
<li key={e.id} className="flex items-center gap-4 px-5 py-3 hover:bg-ink-50/50">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-ink-900 truncate">{e.description}</p>
|
||||
<p className="text-xs text-ink-500 mt-0.5">{formatDate(e.startedAt)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-ink-900 tabular-nums">{formatHours(e.minutes)}</p>
|
||||
<p className="text-xs text-ink-500">
|
||||
{e.billable ? formatMoney(entryAmount(e)) : 'Non-billable'}
|
||||
</p>
|
||||
</div>
|
||||
{e.invoiceItemId ? (
|
||||
<Badge tone="brand">Invoiced</Badge>
|
||||
) : !e.endedAt ? (
|
||||
<Badge tone="emerald">Running</Badge>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('Delete this time entry?')) del.mutate(e.id);
|
||||
}}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-ink-400 hover:text-rose-600 hover:bg-rose-50 transition"
|
||||
aria-label="Delete entry"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<ManualEntryDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} initialCaseId={caseId} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Trash2, FileText } from 'lucide-react';
|
||||
import { Drawer } from '@/components/ui/Drawer';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select, Textarea } from '@/components/ui/Input';
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { useCases } from '@/hooks/useCases';
|
||||
import { useTimeEntries } from '@/hooks/useTime';
|
||||
import { useCreateInvoice, type CreateInvoiceInput } from '@/hooks/useInvoices';
|
||||
import { formatDate, formatHours, formatMoney, planLimitMessage } from '@/lib/format';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface ManualItem {
|
||||
description: string;
|
||||
quantity: string;
|
||||
rate: string;
|
||||
}
|
||||
|
||||
type Mode = 'manual' | 'time';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialClientId?: string;
|
||||
initialCaseId?: string;
|
||||
onCreated?: (invoiceId: string) => void;
|
||||
}
|
||||
|
||||
function defaultDueDate(): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 30);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function CreateInvoiceDrawer({ open, onClose, initialClientId, initialCaseId, onCreated }: Props) {
|
||||
const clients = useClients();
|
||||
const cases = useCases();
|
||||
const create = useCreateInvoice();
|
||||
|
||||
const [mode, setMode] = useState<Mode>(initialCaseId ? 'time' : 'manual');
|
||||
const [clientId, setClientId] = useState(initialClientId ?? '');
|
||||
const [caseId, setCaseId] = useState(initialCaseId ?? '');
|
||||
const [taxRate, setTaxRate] = useState('0');
|
||||
const [dueDate, setDueDate] = useState(defaultDueDate());
|
||||
const [notes, setNotes] = useState('');
|
||||
const [items, setItems] = useState<ManualItem[]>([{ description: '', quantity: '1', rate: '' }]);
|
||||
const [selectedTimeIds, setSelectedTimeIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode(initialCaseId ? 'time' : 'manual');
|
||||
setClientId(initialClientId ?? '');
|
||||
setCaseId(initialCaseId ?? '');
|
||||
setTaxRate('0');
|
||||
setDueDate(defaultDueDate());
|
||||
setNotes('');
|
||||
setItems([{ description: '', quantity: '1', rate: '' }]);
|
||||
setSelectedTimeIds(new Set());
|
||||
create.reset();
|
||||
}, [open, initialClientId, initialCaseId, create]);
|
||||
|
||||
// When client changes, clear case selection if the case doesn't belong to that client
|
||||
useEffect(() => {
|
||||
if (!caseId) return;
|
||||
const c = cases.data?.items.find((x) => x.id === caseId);
|
||||
if (c && c.clientId !== clientId) setCaseId('');
|
||||
}, [clientId, caseId, cases.data]);
|
||||
|
||||
// Pull unbilled time entries for the chosen case (or for any case of the client if no case)
|
||||
const unbilledTime = useTimeEntries(
|
||||
mode === 'time'
|
||||
? caseId
|
||||
? { caseId, invoiced: 'false' }
|
||||
: { invoiced: 'false' }
|
||||
: { invoiced: 'false' },
|
||||
);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const all = unbilledTime.data?.items ?? [];
|
||||
return all.filter((e) => {
|
||||
if (!e.billable) return false;
|
||||
if (e.endedAt === null) return false; // skip running timer
|
||||
if (clientId && e.clientId !== clientId) return false;
|
||||
if (caseId && e.caseId !== caseId) return false;
|
||||
return true;
|
||||
});
|
||||
}, [unbilledTime.data, clientId, caseId]);
|
||||
|
||||
function toggleTime(id: string) {
|
||||
setSelectedTimeIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Live totals preview
|
||||
const previewSubtotal = useMemo(() => {
|
||||
if (mode === 'manual') {
|
||||
return items.reduce((acc, it) => acc + (Number(it.quantity) || 0) * (Number(it.rate) || 0), 0);
|
||||
}
|
||||
return filteredEntries
|
||||
.filter((e) => selectedTimeIds.has(e.id))
|
||||
.reduce((acc, e) => acc + (Number(e.rate) || 0) * (e.minutes / 60), 0);
|
||||
}, [mode, items, filteredEntries, selectedTimeIds]);
|
||||
|
||||
const previewTotal = previewSubtotal * (1 + (Number(taxRate) || 0) / 100);
|
||||
|
||||
const clientCases = useMemo(
|
||||
() => (cases.data?.items ?? []).filter((c) => !clientId || c.clientId === clientId),
|
||||
[cases.data, clientId],
|
||||
);
|
||||
|
||||
async function onSubmit() {
|
||||
if (!clientId) return;
|
||||
const payload: CreateInvoiceInput = {
|
||||
clientId,
|
||||
caseId: caseId || null,
|
||||
notes: notes.trim() || null,
|
||||
taxRate: Number(taxRate) || 0,
|
||||
dueAt: dueDate ? new Date(`${dueDate}T00:00:00`).toISOString() : null,
|
||||
};
|
||||
if (mode === 'manual') {
|
||||
payload.items = items
|
||||
.filter((it) => it.description.trim() && Number(it.quantity) > 0 && Number(it.rate) >= 0)
|
||||
.map((it) => ({
|
||||
description: it.description.trim(),
|
||||
quantity: Number(it.quantity),
|
||||
rate: Number(it.rate),
|
||||
}));
|
||||
} else {
|
||||
payload.timeEntryIds = Array.from(selectedTimeIds);
|
||||
}
|
||||
if (!payload.items?.length && !payload.timeEntryIds?.length) return;
|
||||
|
||||
const created = await create.mutateAsync(payload);
|
||||
onCreated?.(created.id);
|
||||
onClose();
|
||||
}
|
||||
|
||||
const apiErr = create.error ? planLimitMessage(create.error.code, 'Could not create the invoice.') : null;
|
||||
|
||||
const canSubmit =
|
||||
!!clientId &&
|
||||
((mode === 'manual' &&
|
||||
items.some((it) => it.description.trim() && Number(it.quantity) > 0 && Number(it.rate) >= 0)) ||
|
||||
(mode === 'time' && selectedTimeIds.size > 0));
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="New invoice"
|
||||
description="Create a draft from time entries or build it manually."
|
||||
width="max-w-2xl"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-ink-600">
|
||||
<span className="text-xs text-ink-500">Total</span>{' '}
|
||||
<span className="font-semibold text-ink-900">{formatMoney(previewTotal)}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSubmit} disabled={!canSubmit || create.isPending}>
|
||||
{create.isPending ? 'Creating…' : 'Create draft'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Select label="Client" value={clientId} onChange={(e) => setClientId(e.target.value)}>
|
||||
<option value="">Select a client…</option>
|
||||
{clients.data?.items.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select label="Case (optional)" value={caseId} onChange={(e) => setCaseId(e.target.value)} disabled={!clientId}>
|
||||
<option value="">No case</option>
|
||||
{clientCases.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex rounded-xl border border-ink-200 p-1 bg-ink-50/50 w-fit">
|
||||
<ModeButton active={mode === 'time'} onClick={() => setMode('time')}>From time entries</ModeButton>
|
||||
<ModeButton active={mode === 'manual'} onClick={() => setMode('manual')}>Manual</ModeButton>
|
||||
</div>
|
||||
|
||||
{mode === 'time' ? (
|
||||
<div className="rounded-xl border border-ink-100">
|
||||
<div className="border-b border-ink-100 px-4 py-3 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-ink-700">Unbilled time entries</p>
|
||||
<p className="text-xs text-ink-500">{selectedTimeIds.size} selected</p>
|
||||
</div>
|
||||
{!clientId ? (
|
||||
<div className="px-6 py-10 text-center text-sm text-ink-500">Select a client first.</div>
|
||||
) : !filteredEntries.length ? (
|
||||
<div className="px-6 py-10 text-center text-sm text-ink-500">
|
||||
No unbilled, billable time entries{caseId ? ' for this case' : ' for this client'}.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="max-h-72 overflow-y-auto divide-y divide-ink-100">
|
||||
{filteredEntries.map((e) => {
|
||||
const checked = selectedTimeIds.has(e.id);
|
||||
const amount = (Number(e.rate) || 0) * (e.minutes / 60);
|
||||
return (
|
||||
<li key={e.id}>
|
||||
<label
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2.5 cursor-pointer hover:bg-ink-50/50 transition',
|
||||
checked && 'bg-brand-50/50',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleTime(e.id)}
|
||||
className="h-4 w-4 rounded border-ink-300"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-ink-900 truncate">{e.description}</p>
|
||||
<p className="text-xs text-ink-500 truncate">
|
||||
{e.caseTitle} · {formatDate(e.startedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-ink-500 tabular-nums">{formatHours(e.minutes)}</p>
|
||||
<p className="text-sm font-semibold text-ink-900 tabular-nums w-20 text-right">
|
||||
{formatMoney(amount)}
|
||||
</p>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-ink-100">
|
||||
<div className="border-b border-ink-100 px-4 py-3 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-ink-700">Line items</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setItems((s) => [...s, { description: '', quantity: '1', rate: '' }])}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="divide-y divide-ink-100">
|
||||
{items.map((it, i) => (
|
||||
<li key={i} className="grid grid-cols-12 gap-2 px-4 py-3 items-start">
|
||||
<input
|
||||
placeholder="Description"
|
||||
value={it.description}
|
||||
onChange={(e) => setItems((s) => s.map((x, j) => (j === i ? { ...x, description: e.target.value } : x)))}
|
||||
className="col-span-6 rounded-lg border border-ink-200 px-3 py-2 text-sm focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Qty"
|
||||
value={it.quantity}
|
||||
onChange={(e) => setItems((s) => s.map((x, j) => (j === i ? { ...x, quantity: e.target.value } : x)))}
|
||||
className="col-span-2 rounded-lg border border-ink-200 px-3 py-2 text-sm text-right focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Rate"
|
||||
value={it.rate}
|
||||
onChange={(e) => setItems((s) => s.map((x, j) => (j === i ? { ...x, rate: e.target.value } : x)))}
|
||||
className="col-span-3 rounded-lg border border-ink-200 px-3 py-2 text-sm text-right focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setItems((s) => (s.length > 1 ? s.filter((_, j) => j !== i) : s))}
|
||||
className="col-span-1 grid place-items-center text-ink-400 hover:text-rose-600 h-9"
|
||||
aria-label="Remove line"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="Tax rate (%)"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={taxRate}
|
||||
onChange={(e) => setTaxRate(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Due date"
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
label="Notes"
|
||||
rows={3}
|
||||
placeholder="Payment terms, thank-you note, etc."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
|
||||
{apiErr && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiErr}</p>}
|
||||
|
||||
<p className="flex items-start gap-2 text-xs text-ink-500">
|
||||
<FileText className="h-3.5 w-3.5 mt-0.5 flex-none" />
|
||||
The invoice will be created as a draft. You can review and send it from the invoice page.
|
||||
</p>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1.5 text-xs font-semibold transition',
|
||||
active ? 'bg-white text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-800',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Drawer } from '@/components/ui/Drawer';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select, Textarea } from '@/components/ui/Input';
|
||||
import { useCases } from '@/hooks/useCases';
|
||||
import { useCreateTimeEntry } from '@/hooks/useTime';
|
||||
|
||||
interface FormValues {
|
||||
caseId: string;
|
||||
date: string;
|
||||
minutes: string;
|
||||
description: string;
|
||||
rate?: string;
|
||||
billable: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialCaseId?: string;
|
||||
}
|
||||
|
||||
function todayLocal(): string {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function ManualEntryDrawer({ open, onClose, initialCaseId }: Props) {
|
||||
const cases = useCases();
|
||||
const create = useCreateTimeEntry();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
caseId: initialCaseId ?? '',
|
||||
date: todayLocal(),
|
||||
minutes: '60',
|
||||
description: '',
|
||||
rate: '',
|
||||
billable: true,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
reset({
|
||||
caseId: initialCaseId ?? '',
|
||||
date: todayLocal(),
|
||||
minutes: '60',
|
||||
description: '',
|
||||
rate: '',
|
||||
billable: true,
|
||||
});
|
||||
create.reset();
|
||||
}
|
||||
}, [open, initialCaseId, reset, create]);
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const minutes = Number(values.minutes);
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) return;
|
||||
const startedAt = new Date(`${values.date}T09:00:00`);
|
||||
const endedAt = new Date(startedAt.getTime() + minutes * 60_000);
|
||||
await create.mutateAsync({
|
||||
caseId: values.caseId,
|
||||
description: values.description.trim(),
|
||||
startedAt: startedAt.toISOString(),
|
||||
endedAt: endedAt.toISOString(),
|
||||
minutes,
|
||||
rate: values.rate ? Number(values.rate) : undefined,
|
||||
billable: values.billable,
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Log time"
|
||||
description="Add a manual time entry."
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit(onSubmit)} disabled={create.isPending}>
|
||||
{create.isPending ? 'Saving…' : 'Save entry'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Select
|
||||
label="Case"
|
||||
error={errors.caseId?.message}
|
||||
{...register('caseId', { required: 'Pick a case' })}
|
||||
>
|
||||
<option value="">Select a case…</option>
|
||||
{cases.data?.items.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title} · {c.clientName}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input label="Date" type="date" {...register('date', { required: true })} />
|
||||
<Input
|
||||
label="Minutes"
|
||||
type="number"
|
||||
min={1}
|
||||
step={15}
|
||||
{...register('minutes', { required: true })}
|
||||
hint="Enter the duration in minutes."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
rows={3}
|
||||
placeholder="Drafting reply brief, research, client meeting…"
|
||||
{...register('description', { required: 'Required', maxLength: 500 })}
|
||||
error={errors.description?.message}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input
|
||||
label="Rate (USD)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder="Defaults to case rate"
|
||||
{...register('rate')}
|
||||
/>
|
||||
<label className="flex items-center gap-2 mt-6 text-sm text-ink-700">
|
||||
<input type="checkbox" className="h-4 w-4 rounded border-ink-300" {...register('billable')} />
|
||||
Billable
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{create.error && (
|
||||
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
Could not save the entry. {create.error.code ? `(${create.error.code})` : ''}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Briefcase,
|
||||
Clock,
|
||||
FileText,
|
||||
Receipt,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { Logo } from '@/components/marketing/Logo';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Item {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
const NAV: Item[] = [
|
||||
{ to: '/app', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/app/clients', label: 'Clients', icon: Users },
|
||||
{ to: '/app/cases', label: 'Cases', icon: Briefcase },
|
||||
{ to: '/app/time', label: 'Time', icon: Clock },
|
||||
{ to: '/app/documents', label: 'Documents', icon: FileText, badge: 'Soon' },
|
||||
{ to: '/app/invoices', label: 'Invoices', icon: Receipt },
|
||||
];
|
||||
|
||||
const NAV_FOOT: Item[] = [{ to: '/app/settings', label: 'Settings', icon: Settings }];
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="hidden md:flex md:w-60 lg:w-64 flex-col border-r border-ink-100 bg-white">
|
||||
<div className="px-5 py-5">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{NAV.map((item) => (
|
||||
<NavItem key={item.to} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-ink-100">
|
||||
{NAV_FOOT.map((item) => (
|
||||
<NavItem key={item.to} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item }: { item: Item }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === '/app'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition',
|
||||
isActive ? 'bg-brand-50 text-brand-700 font-medium' : 'text-ink-600 hover:bg-ink-50 hover:text-ink-900',
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="rounded-full bg-ink-100 text-ink-500 text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Play, Square, Timer } from 'lucide-react';
|
||||
import { useActiveTimer, useStartTimer, useStopTimer } from '@/hooks/useTime';
|
||||
import { useCases } from '@/hooks/useCases';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
function formatElapsed(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function TimerWidget() {
|
||||
const active = useActiveTimer();
|
||||
const stop = useStopTimer();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const startedAt = active.data?.active?.startedAt;
|
||||
const [, setTick] = useState(0);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedAt) {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
intervalRef.current = setInterval(() => setTick((t) => t + 1), 1000);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [startedAt]);
|
||||
|
||||
if (active.isLoading) return null;
|
||||
|
||||
const running = active.data?.active;
|
||||
|
||||
if (!running) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Start timer
|
||||
</Button>
|
||||
{pickerOpen && <StartPicker onClose={() => setPickerOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const elapsedSec = Math.max(0, Math.floor((Date.now() - new Date(running.startedAt).getTime()) / 1000));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 pl-2 pr-1 py-1">
|
||||
<span className="flex items-center gap-1.5 text-xs">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<span className="hidden md:inline text-emerald-800 font-medium max-w-[180px] truncate">
|
||||
{running.caseTitle}
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-mono text-sm tabular-nums text-emerald-900 px-1.5">
|
||||
{formatElapsed(elapsedSec)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => running && stop.mutate(running.id)}
|
||||
disabled={stop.isPending}
|
||||
className={cn(
|
||||
'grid h-7 w-7 place-items-center rounded-full bg-white text-emerald-700 hover:bg-emerald-100 transition',
|
||||
stop.isPending && 'opacity-50',
|
||||
)}
|
||||
aria-label="Stop timer"
|
||||
>
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartPicker({ onClose }: { onClose: () => void }) {
|
||||
const cases = useCases({ status: 'open' });
|
||||
const start = useStartTimer();
|
||||
const [caseId, setCaseId] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
async function onStart() {
|
||||
if (!caseId) return;
|
||||
await start.mutateAsync({ caseId, description: description.trim() || undefined });
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-ink-950/40" onClick={onClose} />
|
||||
<div className="absolute right-6 top-20 w-[360px] rounded-2xl border border-ink-100 bg-white p-5 shadow-2xl">
|
||||
<div className="flex items-center gap-2 text-ink-900">
|
||||
<div className="grid h-8 w-8 place-items-center rounded-lg bg-brand-50 text-brand-600">
|
||||
<Timer className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold">Start a timer</h3>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-ink-700 block mb-1.5">Case</label>
|
||||
<select
|
||||
value={caseId}
|
||||
onChange={(e) => setCaseId(e.target.value)}
|
||||
className="w-full rounded-xl border border-ink-200 px-3 py-2 text-sm focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
>
|
||||
<option value="">Select a case…</option>
|
||||
{cases.data?.items.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title} · {c.clientName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{!cases.data?.items.length && (
|
||||
<p className="mt-1.5 text-xs text-ink-500">Open a case first to start tracking time.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-ink-700 block mb-1.5">What are you working on?</label>
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Drafting reply brief"
|
||||
className="w-full rounded-xl border border-ink-200 px-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{start.error && (
|
||||
<p className="rounded-lg bg-rose-50 px-3 py-2 text-xs text-rose-700">
|
||||
{start.error.code === 'timer_already_running'
|
||||
? 'You already have a running timer.'
|
||||
: 'Could not start the timer.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={onStart} disabled={!caseId || start.isPending}>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
Start
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LogOut, Search, ChevronDown, ShieldAlert } from 'lucide-react';
|
||||
import { useLogout, useMe } from '@/hooks/useAuth';
|
||||
import { TimerWidget } from './TimerWidget';
|
||||
|
||||
export function Topbar() {
|
||||
const me = useMe();
|
||||
const logout = useLogout();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const initials = (me.data?.fullName ?? me.data?.email ?? '?')
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between gap-4 border-b border-ink-100 bg-white px-6">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search…"
|
||||
className="w-full rounded-xl border border-ink-200 bg-ink-50/40 pl-9 pr-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<TimerWidget />
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-2 rounded-full px-2 py-1.5 hover:bg-ink-100"
|
||||
>
|
||||
<span className="grid h-8 w-8 place-items-center rounded-full bg-brand-100 text-brand-700 text-xs font-semibold">
|
||||
{initials}
|
||||
</span>
|
||||
<span className="hidden md:inline text-sm font-medium text-ink-800">
|
||||
{me.data?.fullName ?? me.data?.email}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-ink-500" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full z-30 mt-2 w-56 rounded-xl border border-ink-100 bg-white shadow-lg"
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
>
|
||||
<div className="border-b border-ink-100 px-4 py-3">
|
||||
<p className="text-sm font-medium text-ink-900">{me.data?.fullName ?? '—'}</p>
|
||||
<p className="text-xs text-ink-500">{me.data?.email}</p>
|
||||
</div>
|
||||
{me.data?.isSuperadmin && (
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-rose-700 hover:bg-rose-50 border-b border-ink-100"
|
||||
>
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
Superadmin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout.mutate()}
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-sm text-ink-700 hover:bg-ink-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthLayout({ title, subtitle, children, footer }: Props) {
|
||||
return (
|
||||
<div className="min-h-screen grid lg:grid-cols-2 bg-white">
|
||||
<div className="flex flex-col px-6 py-10 md:px-12 lg:px-16">
|
||||
<Link to="/" className="inline-flex" aria-label="Home">
|
||||
<img src="/logo-dark.png" alt="eLegal Software" className="h-7 w-auto" width={450} height={45} />
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-ink-950 font-display">{title}</h1>
|
||||
{subtitle && <p className="mt-2 text-sm text-ink-600">{subtitle}</p>}
|
||||
<div className="mt-8">{children}</div>
|
||||
{footer && <div className="mt-6 text-sm text-ink-600">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-ink-400">© {new Date().getFullYear()} eLegal Software</p>
|
||||
</div>
|
||||
|
||||
<aside className="hidden lg:flex relative overflow-hidden bg-gradient-to-br from-brand-600 to-brand-800 text-white">
|
||||
<div className="absolute -top-32 -right-32 h-96 w-96 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="absolute -bottom-32 -left-32 h-96 w-96 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="relative m-auto max-w-md px-10 py-16">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/70">All-in-one</p>
|
||||
<h2 className="mt-3 text-3xl font-bold font-display leading-tight">
|
||||
Run your legal practice like a pro.
|
||||
</h2>
|
||||
<p className="mt-4 text-white/80">
|
||||
Cases, billable hours, documents, and invoicing — one secure platform built exclusively for legal professionals.
|
||||
</p>
|
||||
<ul className="mt-8 space-y-3 text-sm">
|
||||
{[
|
||||
'60% less time on admin tasks',
|
||||
'3× faster client invoicing',
|
||||
'98% billing accuracy rate',
|
||||
].map((stat) => (
|
||||
<li key={stat} className="flex items-center gap-3">
|
||||
<span className="grid h-6 w-6 place-items-center rounded-full bg-white/15 text-xs">✓</span>
|
||||
{stat}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Props extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const Field = forwardRef<HTMLInputElement, Props>(function Field(
|
||||
{ label, hint, error, className, id, name, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const inputId = id ?? name;
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={inputId} className="text-xs font-medium text-ink-700">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
name={name}
|
||||
className={cn(
|
||||
'mt-1.5 w-full rounded-xl border bg-white px-3.5 py-2.5 text-sm text-ink-900 placeholder:text-ink-400',
|
||||
'focus:outline-none focus:ring-2',
|
||||
error
|
||||
? 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20'
|
||||
: 'border-ink-200 focus:border-brand-500 focus:ring-brand-500/20',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="mt-1.5 text-xs text-rose-600">{error}</p>
|
||||
) : hint ? (
|
||||
<p className="mt-1.5 text-xs text-ink-500">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Clock, ArrowRight } from 'lucide-react';
|
||||
import { POSTS } from '@/content/posts';
|
||||
import { formatDate } from '@/lib/format';
|
||||
|
||||
export function BlogTeaser() {
|
||||
const latest = POSTS.slice(0, 3);
|
||||
return (
|
||||
<section id="resources" className="section">
|
||||
<div className="container">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between mb-12">
|
||||
<div>
|
||||
<span className="eyebrow">Latest Legal Insights</span>
|
||||
<h2 className="mt-3 text-3xl md:text-5xl font-bold text-ink-950 font-display">
|
||||
Discover strategies and tips
|
||||
</h2>
|
||||
<p className="mt-3 text-lg text-ink-600 max-w-xl">
|
||||
Grow your legal practice and work smarter, not harder.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/blog" className="btn-secondary text-sm self-start md:self-end">
|
||||
View all articles
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{latest.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
to={`/blog/${p.slug}`}
|
||||
className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
|
||||
>
|
||||
<div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative">
|
||||
<div className="absolute inset-0 grid place-items-center px-6">
|
||||
<span className="text-2xl font-bold text-brand-300/50 font-display select-none text-center leading-tight">
|
||||
{p.title.split(' ').slice(0, 3).join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p>
|
||||
<h3 className="mt-2 font-semibold text-ink-900 group-hover:text-brand-700 leading-snug">
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-ink-600 leading-relaxed flex-1 line-clamp-3">{p.description}</p>
|
||||
<p className="mt-4 inline-flex items-center gap-1.5 text-xs text-ink-500">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{p.readMinutes} min read
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react';
|
||||
import { Mail, Send, Clock } from 'lucide-react';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
type State = 'idle' | 'submitting' | 'success' | 'error';
|
||||
|
||||
export function Contact() {
|
||||
const [state, setState] = useState<State>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setState('submitting');
|
||||
setError(null);
|
||||
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const payload = {
|
||||
fullName: String(fd.get('fullName') ?? '').trim(),
|
||||
email: String(fd.get('email') ?? '').trim(),
|
||||
message: String(fd.get('message') ?? '').trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
await api.post('/api/contact', payload);
|
||||
setState('success');
|
||||
e.currentTarget.reset();
|
||||
} catch (err) {
|
||||
const apiErr = err as ApiError;
|
||||
setError(apiErr.code ?? apiErr.message);
|
||||
setState('error');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="contact" className="section bg-ink-50/50">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<span className="eyebrow">Get In Touch</span>
|
||||
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
||||
Have questions about eLegal Software?
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-ink-600">
|
||||
We're here to help you streamline your legal practice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-14 grid max-w-5xl gap-8 lg:grid-cols-5">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="lg:col-span-3 rounded-2xl border border-ink-100 bg-white p-6 md:p-8 shadow-sm"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-ink-900 font-display">Send us a message</h3>
|
||||
<p className="mt-1 text-sm text-ink-500">We respond within 24 hours. Promise!</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<Field label="Full name" name="fullName" placeholder="What is your name?" required minLength={1} maxLength={120} />
|
||||
<Field label="Email" name="email" type="email" placeholder="address@email.com" required />
|
||||
<div>
|
||||
<label htmlFor="message" className="text-xs font-medium text-ink-700">Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={5}
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={5000}
|
||||
placeholder="Tell us how we can help you..."
|
||||
className="mt-1.5 w-full rounded-xl border border-ink-200 bg-white px-3.5 py-2.5 text-sm text-ink-900 placeholder:text-ink-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={state === 'submitting'} className="btn-primary mt-6 w-full">
|
||||
{state === 'submitting' ? 'Sending…' : (
|
||||
<>
|
||||
Send Message
|
||||
<Send className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{state === 'success' && (
|
||||
<p className="mt-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||
Thanks — your message is in. We'll be in touch shortly.
|
||||
</p>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<p className="mt-4 rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
Something went wrong{error ? ` (${error})` : ''}. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<aside className="lg:col-span-2 space-y-4">
|
||||
<div className="rounded-2xl border border-ink-100 bg-white p-6">
|
||||
<h4 className="text-sm font-semibold text-ink-900">Other ways to reach us</h4>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
Prefer to talk directly? Choose the method that works best for you.
|
||||
</p>
|
||||
<div className="mt-4 flex items-start gap-3 rounded-xl bg-ink-50 p-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-white text-brand-600">
|
||||
<Mail className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-ink-500">Direct Email</p>
|
||||
<p className="text-sm font-semibold text-ink-900">contact@elegalsoftware.com</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-ink-100 bg-white p-6">
|
||||
<div className="flex items-center gap-2 text-ink-700">
|
||||
<Clock className="h-4 w-4 text-brand-500" />
|
||||
<span className="text-sm font-semibold">We respond quickly</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-ink-500 leading-relaxed">
|
||||
Average response time: 4 hours during weekdays, 12 hours on weekends.
|
||||
</p>
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
Online and ready to help
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
name,
|
||||
type = 'text',
|
||||
...rest
|
||||
}: {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
} & React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={name} className="text-xs font-medium text-ink-700">{label}</label>
|
||||
<input
|
||||
id={name}
|
||||
name={name}
|
||||
type={type}
|
||||
className="mt-1.5 w-full rounded-xl border border-ink-200 bg-white px-3.5 py-2.5 text-sm text-ink-900 placeholder:text-ink-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20"
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
q: 'What makes eLegal Software different from other legal software?',
|
||||
a: 'eLegal Software is built exclusively for legal professionals and bundles case management, billable-hours tracking, document storage, and invoicing into a single, secure platform — no jumping between tools, no per-feature add-ons.',
|
||||
},
|
||||
{
|
||||
q: 'Can I try eLegal Software before committing?',
|
||||
a: 'Yes. The Starter plan is free forever and lets you manage one active case and two clients so you can experience the workflow end-to-end before upgrading.',
|
||||
},
|
||||
{
|
||||
q: 'How does client billing and payment processing work?',
|
||||
a: 'You can convert any unbilled time entry into a polished invoice in seconds, send it to your client, and track its status from sent to paid. Payment processing is wired through Stripe.',
|
||||
},
|
||||
{
|
||||
q: 'Can I import my existing cases and client data?',
|
||||
a: 'Yes. eLegal Software supports CSV imports for clients and cases. For larger migrations our team will assist directly during onboarding.',
|
||||
},
|
||||
{
|
||||
q: 'Is my client data secure and compliant?',
|
||||
a: 'All data is encrypted at rest and in transit, hosted on enterprise-grade infrastructure with daily backups and audit logging. Access is gated by role-based permissions.',
|
||||
},
|
||||
{
|
||||
q: 'What happens if I need to cancel?',
|
||||
a: 'You can cancel anytime from your billing settings. Your data remains accessible during the current billing period and can be exported in standard formats.',
|
||||
},
|
||||
];
|
||||
|
||||
export function Faq() {
|
||||
const [open, setOpen] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<section id="faq" className="section">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<span className="eyebrow">FAQ</span>
|
||||
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-ink-600">Everything you need to know about eLegal Software.</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto mt-12 max-w-3xl divide-y divide-ink-100 rounded-2xl border border-ink-100 bg-white">
|
||||
{ITEMS.map((item, i) => {
|
||||
const isOpen = open === i;
|
||||
return (
|
||||
<div key={item.q}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-4 px-6 py-5 text-left"
|
||||
onClick={() => setOpen(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="text-sm md:text-base font-semibold text-ink-900">{item.q}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 flex-none text-ink-500 transition-transform',
|
||||
isOpen && 'rotate-180 text-brand-500',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-all duration-300 ease-out',
|
||||
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<p className="px-6 pb-5 text-sm text-ink-600 leading-relaxed">{item.a}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Briefcase, Clock, FileText, Users, BarChart3, ShieldCheck } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: Briefcase,
|
||||
title: 'Case Management',
|
||||
body: 'Organize all your cases in one place — track deadlines, documents, and client communications effortlessly.',
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Client Billing Hours',
|
||||
body: 'Automated time tracking and billing that saves 15+ hours weekly on administrative tasks.',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Legal Documents',
|
||||
body: 'Store, organize, and instantly access all legal documents with powerful search and versioning.',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Team Collaboration',
|
||||
body: 'Work seamlessly with associates, paralegals, and staff with role-based access control.',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Real-Time Analytics',
|
||||
body: 'Know your most profitable cases, billable-hours trends, and practice performance metrics.',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: 'Bank-Level Security',
|
||||
body: 'Enterprise-grade encryption and compliance features to protect sensitive client data.',
|
||||
},
|
||||
];
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section id="features" className="section">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<span className="eyebrow">Everything You Need</span>
|
||||
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
||||
Powerful features for modern law firms
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-ink-600">
|
||||
All the tools you need to run your legal practice efficiently, professionally, and profitably.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{FEATURES.map((f, i) => (
|
||||
<motion.div
|
||||
key={f.title}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.4, delay: i * 0.05 }}
|
||||
className="group rounded-2xl border border-ink-100 bg-white p-6 hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition"
|
||||
>
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600 group-hover:bg-brand-500 group-hover:text-white transition">
|
||||
<f.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="mt-5 text-lg font-semibold text-ink-900">{f.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-ink-600">{f.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
export function FinalCta() {
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="relative overflow-hidden rounded-3xl bg-brand-500 p-10 md:p-16 text-center text-white">
|
||||
<div className="absolute -top-24 -right-24 h-72 w-72 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="absolute -bottom-24 -left-24 h-72 w-72 rounded-full bg-white/10 blur-3xl" />
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/70">Let's start</p>
|
||||
<h2 className="mt-3 text-3xl md:text-5xl font-bold font-display">
|
||||
Transform your practice today
|
||||
<span className="block">with a free trial.</span>
|
||||
</h2>
|
||||
<a href="/signup" className="mt-8 inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 font-semibold text-brand-600 hover:bg-ink-50 transition">
|
||||
Get Started for Free
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
title: 'Product',
|
||||
links: [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#pricing', label: 'Pricing' },
|
||||
{ href: '/signup', label: 'Sign Up' },
|
||||
{ href: '/login', label: 'Login' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Free Tools',
|
||||
links: [
|
||||
{ href: '/tools/hourly-rate-calculator', label: 'Hourly Rate Calculator' },
|
||||
{ href: '/tools/case-profitability', label: 'Case Profitability Analyzer' },
|
||||
{ href: '/tools/billable-hours-tracker', label: 'Billable Hours Tracker' },
|
||||
{ href: '/tools/document-templates', label: 'Document Templates' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Resources',
|
||||
links: [
|
||||
{ href: '/resources', label: 'Resource Hub' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
{ href: '/legal', label: 'Legal' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-ink-100 bg-white">
|
||||
<div className="container py-16 grid gap-10 md:grid-cols-4">
|
||||
<div className="md:col-span-1">
|
||||
<a href="/" className="inline-flex" aria-label="Home">
|
||||
<img src="/logo-dark.png" alt="eLegal Software" className="h-7 w-auto" width={450} height={45} />
|
||||
</a>
|
||||
<p className="mt-4 max-w-xs text-sm text-ink-600">
|
||||
The all-in-one platform for law firms and attorneys to manage their practice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{COLUMNS.map((col) => (
|
||||
<div key={col.title}>
|
||||
<h4 className="font-semibold text-ink-900 text-sm">{col.title}</h4>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{col.links.map((l) => (
|
||||
<li key={l.href}>
|
||||
<a href={l.href} className="text-sm text-ink-600 hover:text-ink-900 transition">
|
||||
{l.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-ink-100">
|
||||
<div className="container py-6 flex flex-col md:flex-row items-center justify-between gap-3 text-xs text-ink-500">
|
||||
<p>© {new Date().getFullYear()} eLegal Software. All rights reserved.</p>
|
||||
<p>Built for legal professionals.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Calculator, BarChart3, Clock, FileText, ArrowRight } from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useToolsOnline } from '@/hooks/useToolUsage';
|
||||
|
||||
interface Tool {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const TOOLS: Tool[] = [
|
||||
{
|
||||
slug: 'hourly-rate-calculator',
|
||||
title: 'Hourly Rate Calculator',
|
||||
description: 'Calculate your optimal billable rate based on expenses, target income, and hours.',
|
||||
icon: Calculator,
|
||||
},
|
||||
{
|
||||
slug: 'case-profitability',
|
||||
title: 'Case Profitability Analyzer',
|
||||
description: 'See whether a matter is making you money once overhead and costs are in.',
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
slug: 'billable-hours-tracker',
|
||||
title: 'Billable Hours Tracker',
|
||||
description: 'A no-signup timer with manual entries and CSV export.',
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
slug: 'document-templates',
|
||||
title: 'Document Templates',
|
||||
description: 'Plain-text starting points for engagement letters, NDAs, demand letters, and more.',
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
export function FreeToolsTeaser() {
|
||||
const online = useToolsOnline();
|
||||
return (
|
||||
<section className="section bg-ink-50/50">
|
||||
<div className="container">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between mb-12">
|
||||
<div>
|
||||
<span className="eyebrow">Free Legal Tools</span>
|
||||
<h2 className="mt-3 text-3xl md:text-5xl font-bold text-ink-950 font-display">
|
||||
Professional-grade tools, no signup
|
||||
</h2>
|
||||
<p className="mt-3 text-lg text-ink-600 max-w-xl">
|
||||
Help yourself to a few calculators and templates we built for our own customers.
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/tools" className="btn-secondary text-sm self-start md:self-end">
|
||||
View all tools
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-4">
|
||||
{TOOLS.map((t) => {
|
||||
const count = online.data?.online[t.slug] ?? 0;
|
||||
return (
|
||||
<Link
|
||||
key={t.slug}
|
||||
to={`/tools/${t.slug}`}
|
||||
className="group rounded-2xl border border-ink-100 bg-white p-5 hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-lg bg-brand-50 text-brand-600 group-hover:bg-brand-500 group-hover:text-white transition">
|
||||
<t.icon className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-emerald-700">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
{count} online
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-4 text-base font-semibold text-ink-900">{t.title}</h3>
|
||||
<p className="mt-1.5 text-xs text-ink-600 leading-relaxed flex-1">{t.description}</p>
|
||||
<span className="mt-3 text-xs font-semibold text-brand-600">Use tool →</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, Sparkles, Clock, FileText, Receipt } from 'lucide-react';
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section id="top" className="relative overflow-hidden pt-32 pb-20 md:pt-40 md:pb-28">
|
||||
<div className="absolute inset-0 bg-hero-radial pointer-events-none" />
|
||||
<div className="absolute inset-x-0 top-0 h-[640px] bg-gradient-to-b from-brand-50/60 to-transparent pointer-events-none" />
|
||||
|
||||
<div className="container relative">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="eyebrow"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
All-in-One Practice Management for Law Firms
|
||||
</motion.span>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.05 }}
|
||||
className="mt-6 text-4xl md:text-6xl font-bold leading-[1.05] text-ink-950"
|
||||
>
|
||||
Run your legal practice
|
||||
<span className="block text-brand-500">like a pro.</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.12 }}
|
||||
className="mt-6 text-lg md:text-xl text-ink-600 leading-relaxed"
|
||||
>
|
||||
Stop juggling spreadsheets, emails, and outdated software. Manage cases, track billable
|
||||
hours, store legal documents, and invoice clients — all in one secure platform built
|
||||
exclusively for legal professionals.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.18 }}
|
||||
className="mt-10 flex flex-wrap items-center justify-center gap-3"
|
||||
>
|
||||
<a href="/signup" className="btn-primary">
|
||||
Start free
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
<a href="#features" className="btn-secondary">
|
||||
Explore Features
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
<p className="mt-5 text-sm text-ink-500">No credit card required · Cancel anytime</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 32 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, delay: 0.25 }}
|
||||
className="relative mx-auto mt-16 max-w-5xl"
|
||||
>
|
||||
<DashboardPreview />
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPreview() {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-4 -z-10 rounded-[28px] bg-gradient-to-br from-brand-500/20 via-brand-400/10 to-transparent blur-2xl" />
|
||||
<div className="rounded-2xl border border-ink-200 bg-white shadow-2xl shadow-ink-900/10 overflow-hidden">
|
||||
{/* Window chrome */}
|
||||
<div className="flex items-center gap-2 border-b border-ink-100 bg-ink-50/60 px-4 py-3">
|
||||
<span className="h-3 w-3 rounded-full bg-red-400" />
|
||||
<span className="h-3 w-3 rounded-full bg-yellow-400" />
|
||||
<span className="h-3 w-3 rounded-full bg-green-400" />
|
||||
<span className="ml-3 text-xs text-ink-500">app.elegalsoftware.com / dashboard</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 p-6">
|
||||
{/* Sidebar */}
|
||||
<aside className="col-span-3 hidden md:block">
|
||||
<div className="space-y-1 text-sm">
|
||||
{['Dashboard', 'Cases', 'Clients', 'Time', 'Documents', 'Invoices'].map((item, i) => (
|
||||
<div
|
||||
key={item}
|
||||
className={
|
||||
'rounded-lg px-3 py-2 ' +
|
||||
(i === 0 ? 'bg-brand-50 text-brand-700 font-medium' : 'text-ink-600')
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="col-span-12 md:col-span-9 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<KpiCard icon={<Clock className="h-4 w-4" />} label="Billable hours" value="127h" trend="+12%" />
|
||||
<KpiCard icon={<FileText className="h-4 w-4" />} label="Active cases" value="12" trend="+2" />
|
||||
<KpiCard icon={<Receipt className="h-4 w-4" />} label="Outstanding" value="$18,400" trend="-8%" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-ink-100 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium text-ink-700">Weekly billable hours</p>
|
||||
<span className="text-xs text-ink-500">Last 7 days</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 h-28">
|
||||
{[40, 65, 50, 80, 55, 90, 70].map((h, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div
|
||||
className="w-full rounded-md bg-gradient-to-t from-brand-500 to-brand-400"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
<span className="text-[10px] text-ink-400">{['M', 'T', 'W', 'T', 'F', 'S', 'S'][i]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-center text-xs text-ink-400">Sample data for demonstration</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, value, trend }: { icon: React.ReactNode; label: string; value: string; trend: string }) {
|
||||
const positive = trend.startsWith('+');
|
||||
return (
|
||||
<div className="rounded-xl border border-ink-100 p-3">
|
||||
<div className="flex items-center gap-2 text-ink-500 text-xs">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline justify-between">
|
||||
<span className="text-xl font-bold text-ink-900">{value}</span>
|
||||
<span className={'text-xs font-medium ' + (positive ? 'text-emerald-600' : 'text-rose-500')}>{trend}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Plus, Clock, BarChart3 } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: 'Add your case details',
|
||||
body:
|
||||
'Enter client information and case details in seconds. Our smart system organizes everything automatically.',
|
||||
visual: <CaseVisual />,
|
||||
},
|
||||
{
|
||||
title: 'Track every billable minute',
|
||||
body:
|
||||
'Automatic time tracking that captures every minute. Never miss a billable hour with our intelligent timer.',
|
||||
visual: <TimeVisual />,
|
||||
flipped: true,
|
||||
},
|
||||
{
|
||||
title: 'Monitor case performance',
|
||||
body:
|
||||
'Real-time analytics show case profitability, time allocation, and billing efficiency at a glance.',
|
||||
visual: <AnalyticsVisual />,
|
||||
},
|
||||
];
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section id="how-it-works" className="section">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<span className="eyebrow">How It Works</span>
|
||||
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
||||
How we make the magic happen
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-ink-600">
|
||||
We handle the heavy lifting of practice management. Relax while we streamline your daily operations and keep you ahead of the competition.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 space-y-20">
|
||||
{STEPS.map((step, i) => (
|
||||
<Row key={step.title} index={i} flipped={!!step.flipped} title={step.title} body={step.body} visual={step.visual} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<a href="/signup" className="btn-primary">Start Free Trial</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
index,
|
||||
flipped,
|
||||
title,
|
||||
body,
|
||||
visual,
|
||||
}: {
|
||||
index: number;
|
||||
flipped: boolean;
|
||||
title: string;
|
||||
body: string;
|
||||
visual: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className={cn('grid gap-10 items-center md:grid-cols-2', flipped && 'md:[&>*:first-child]:order-2')}
|
||||
>
|
||||
<div className="rounded-2xl border border-ink-100 bg-white p-6 shadow-sm">{visual}</div>
|
||||
<div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-brand-600">
|
||||
Step {String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<h3 className="mt-2 text-2xl md:text-3xl font-bold text-ink-950 font-display">{title}</h3>
|
||||
<p className="mt-3 text-ink-600 leading-relaxed">{body}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function CaseVisual() {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-400">Your new case</p>
|
||||
<div className="mt-3 rounded-xl border border-ink-100 p-4">
|
||||
<p className="font-semibold text-ink-900">Smith v. Johnson Corp</p>
|
||||
<p className="mt-1 text-xs text-ink-500">Client · Corporate · Open</p>
|
||||
</div>
|
||||
<button className="mt-4 inline-flex items-center gap-2 rounded-full bg-brand-500 text-white px-4 py-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Case
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeVisual() {
|
||||
const entries = [
|
||||
{ day: 'Mon', hours: '4.5h', task: 'Client meeting & research' },
|
||||
{ day: 'Tue', hours: '3.0h', task: 'Document preparation' },
|
||||
];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map((e) => (
|
||||
<div key={e.day} className="flex items-center gap-4 rounded-xl border border-ink-100 p-4">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-lg bg-brand-50 text-brand-700 text-xs font-bold">
|
||||
{e.day}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-ink-900">{e.hours}</span>
|
||||
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
Billable
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink-500 mt-0.5">{e.task}</p>
|
||||
<p className="text-[11px] text-ink-400 mt-0.5">Rate: $250/h</p>
|
||||
</div>
|
||||
<Clock className="h-4 w-4 text-ink-400" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnalyticsVisual() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-ink-700">Case Efficiency Score</p>
|
||||
<BarChart3 className="h-4 w-4 text-ink-400" />
|
||||
</div>
|
||||
<p className="mt-2 text-5xl font-bold text-brand-500 font-display">92%</p>
|
||||
<p className="text-xs text-ink-500">Billable</p>
|
||||
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-ink-100 p-3">
|
||||
<p className="text-xs text-ink-500">Hours</p>
|
||||
<p className="text-xl font-bold text-ink-900">127h</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-ink-100 p-3">
|
||||
<p className="text-xs text-ink-500">Cases</p>
|
||||
<p className="text-xl font-bold text-ink-900">12</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Logo({ className }: { className?: string }) {
|
||||
return (
|
||||
<a href="#top" className={cn('flex items-center gap-2 font-display font-bold text-lg', className)}>
|
||||
<span className="grid h-9 w-9 place-items-center rounded-xl bg-brand-500 text-white shadow-md shadow-brand-500/30">
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
||||
<path d="M5 4h3v13h7v3H5z" />
|
||||
<path d="M14 4h3v9h-3z" opacity=".7" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="text-ink-900">eLegal Software</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: '#testimonials', label: 'Testimonials' },
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#pricing', label: 'Pricing' },
|
||||
{ href: '#resources', label: 'Resources' },
|
||||
];
|
||||
|
||||
export function Navbar() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'fixed top-0 inset-x-0 z-50 transition-all',
|
||||
scrolled
|
||||
? 'backdrop-blur-md bg-white/80 border-b border-ink-100'
|
||||
: 'bg-transparent border-b border-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
<a href="/" className="flex items-center" aria-label="Home">
|
||||
<img
|
||||
src="/logo-dark.png"
|
||||
alt="eLegal Software"
|
||||
className="h-7 md:h-8 w-auto"
|
||||
width={450}
|
||||
height={45}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm font-medium text-ink-600 hover:text-ink-900 transition"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<a href="/login" className="btn-ghost text-sm">
|
||||
Login
|
||||
</a>
|
||||
<a href="/signup" className="btn-primary text-sm py-2.5">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden grid h-10 w-10 place-items-center rounded-lg text-ink-700"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="md:hidden border-t border-ink-100 bg-white">
|
||||
<div className="container py-4 flex flex-col gap-3">
|
||||
{NAV_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="py-2 text-sm font-medium text-ink-700"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<a href="/login" className="btn-secondary flex-1 text-sm">
|
||||
Login
|
||||
</a>
|
||||
<a href="/signup" className="btn-primary flex-1 text-sm">
|
||||
Get Started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const TIERS = [
|
||||
{
|
||||
name: 'Starter',
|
||||
tag: 'For solo attorneys',
|
||||
description: 'Perfect for solo practitioners just getting started.',
|
||||
price: '$0',
|
||||
cadence: '/month',
|
||||
cta: 'Get Started',
|
||||
href: '/signup?plan=starter',
|
||||
features: ['Up to 2 clients', '2 invoices per month', '1 active case', '500MB storage', 'Email support'],
|
||||
highlight: false,
|
||||
},
|
||||
{
|
||||
name: 'Professional',
|
||||
tag: 'Most Popular',
|
||||
description: 'For growing firms managing multiple cases.',
|
||||
price: '$25',
|
||||
strike: '$49',
|
||||
cadence: '/month',
|
||||
cta: 'Get Started',
|
||||
href: '/signup?plan=pro',
|
||||
features: [
|
||||
'Unlimited clients',
|
||||
'Unlimited invoices',
|
||||
'6 active cases',
|
||||
'8GB storage',
|
||||
'Priority support',
|
||||
'Remove watermark',
|
||||
],
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
name: 'Lifetime',
|
||||
tag: 'Best Value',
|
||||
description: 'For established practices seeking long-term value.',
|
||||
price: '$129',
|
||||
strike: '$299',
|
||||
cadence: 'one-time',
|
||||
cta: 'Get Lifetime Access',
|
||||
href: '/signup?plan=lifetime',
|
||||
features: [
|
||||
'Everything in Pro',
|
||||
'Unlimited cases',
|
||||
'50GB storage',
|
||||
'Team collaboration',
|
||||
'Premium support',
|
||||
'Future updates',
|
||||
],
|
||||
highlight: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function Pricing() {
|
||||
return (
|
||||
<section id="pricing" className="section">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<span className="eyebrow">Simple Pricing</span>
|
||||
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">Invest in growth</h2>
|
||||
<p className="mt-4 text-lg text-ink-600">
|
||||
Start free, upgrade as you grow. No hidden fees, cancel anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 grid gap-6 md:grid-cols-3">
|
||||
{TIERS.map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
className={cn(
|
||||
'relative rounded-2xl border bg-white p-8 flex flex-col',
|
||||
t.highlight
|
||||
? 'border-brand-500 shadow-2xl shadow-brand-500/15 md:-translate-y-2'
|
||||
: 'border-ink-100',
|
||||
)}
|
||||
>
|
||||
{t.highlight && (
|
||||
<span className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-brand-500 px-3 py-1 text-xs font-semibold text-white">
|
||||
{t.tag}
|
||||
</span>
|
||||
)}
|
||||
{!t.highlight && (
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-ink-400">
|
||||
{t.tag}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h3 className="mt-3 text-2xl font-bold text-ink-900 font-display">{t.name}</h3>
|
||||
<p className="mt-1 text-sm text-ink-600">{t.description}</p>
|
||||
|
||||
<div className="mt-6 flex items-baseline gap-2">
|
||||
{t.strike && <span className="text-lg text-ink-400 line-through">{t.strike}</span>}
|
||||
<span className="text-5xl font-bold text-ink-950 font-display">{t.price}</span>
|
||||
<span className="text-sm text-ink-500">{t.cadence}</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={t.href}
|
||||
className={cn('mt-6 w-full text-center', t.highlight ? 'btn-primary' : 'btn-secondary')}
|
||||
>
|
||||
{t.cta}
|
||||
</a>
|
||||
|
||||
<ul className="mt-8 space-y-3">
|
||||
{t.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm text-ink-700">
|
||||
<Check className="mt-0.5 h-4 w-4 flex-none text-brand-500" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-center text-sm text-ink-500">Cancel anytime. No questions asked.</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, Briefcase, Clock, FileText, Receipt, BarChart3, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
const PROBLEMS = [
|
||||
'Multiple software for invoices, documents, and scheduling wastes time and money.',
|
||||
'Client files scattered across Excel, email, and folders — you lose critical information.',
|
||||
'Hours spent on administration instead of focusing on cases and clients.',
|
||||
'No clear visibility on billable hours and case profitability.',
|
||||
];
|
||||
|
||||
const SOLUTION_STEPS = [
|
||||
{ icon: Briefcase, label: 'Case Management' },
|
||||
{ icon: Clock, label: 'Billable Hours' },
|
||||
{ icon: FileText, label: 'Legal Documents' },
|
||||
{ icon: Receipt, label: 'Automated Invoicing' },
|
||||
{ icon: BarChart3, label: 'Reports & Analytics' },
|
||||
];
|
||||
|
||||
export function ProblemSolution() {
|
||||
return (
|
||||
<section className="section bg-ink-50/50">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<span className="eyebrow">Problem & Solution</span>
|
||||
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
||||
Your problem. Our solution.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 grid gap-8 lg:grid-cols-2 items-start">
|
||||
{/* Problems */}
|
||||
<div className="space-y-4">
|
||||
{PROBLEMS.map((p, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.4, delay: i * 0.05 }}
|
||||
className="flex items-start gap-4 rounded-2xl border border-ink-100 bg-white p-5"
|
||||
>
|
||||
<div className="grid h-10 w-10 flex-none place-items-center rounded-xl bg-rose-50 text-rose-600">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-rose-600">Attorney Problem</p>
|
||||
<p className="mt-1 text-sm text-ink-700 leading-relaxed">{p}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Solution */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative rounded-3xl border border-brand-200 bg-gradient-to-br from-brand-500 to-brand-700 p-8 text-white shadow-2xl shadow-brand-500/20 lg:sticky lg:top-24"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/15 backdrop-blur">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-white/70">eLegal Software</p>
|
||||
<p className="text-lg font-semibold font-display">Everything in one platform</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol className="mt-7 space-y-3">
|
||||
{SOLUTION_STEPS.map((step, i) => (
|
||||
<li
|
||||
key={step.label}
|
||||
className="flex items-center gap-3 rounded-xl bg-white/10 backdrop-blur px-4 py-3"
|
||||
>
|
||||
<span className="grid h-7 w-7 place-items-center rounded-full bg-white text-brand-600 text-xs font-bold">
|
||||
{i + 1}
|
||||
</span>
|
||||
<step.icon className="h-4 w-4 text-white/80" />
|
||||
<span className="text-sm font-medium">{step.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="mt-7 grid grid-cols-3 gap-3 text-center text-xs">
|
||||
<div className="rounded-lg bg-white/10 px-3 py-3">
|
||||
<p className="font-semibold">One subscription</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/10 px-3 py-3">
|
||||
<p className="font-semibold">Zero hassle</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/10 px-3 py-3">
|
||||
<p className="font-semibold">Guaranteed results</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const STATS = [
|
||||
{ value: '60%', label: 'Less time on admin tasks' },
|
||||
{ value: '3×', label: 'Faster client invoicing' },
|
||||
{ value: '98%', label: 'Billing accuracy rate' },
|
||||
];
|
||||
|
||||
export function Stats() {
|
||||
return (
|
||||
<section className="section bg-gradient-to-b from-white to-brand-50/40">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<span className="eyebrow">Real Results</span>
|
||||
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
||||
Measurable impact on your practice
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-ink-600">
|
||||
Don't rely on guesswork. The data speaks for itself about the efficiency gains our platform delivers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 grid gap-6 md:grid-cols-3">
|
||||
{STATS.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="rounded-2xl border border-ink-100 bg-white p-8 text-center shadow-sm"
|
||||
>
|
||||
<div className="text-5xl md:text-6xl font-bold text-brand-500 font-display">{s.value}</div>
|
||||
<p className="mt-3 text-sm text-ink-600">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<a href="/signup" className="btn-primary">Start your free trial</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Star } from 'lucide-react';
|
||||
|
||||
const TESTIMONIALS = [
|
||||
{
|
||||
name: 'Sarah Mitchell',
|
||||
role: 'Partner, Mitchell & Associates',
|
||||
quote:
|
||||
'eLegal Software transformed how our firm manages cases. We cut administrative time by 60% and our billing accuracy improved dramatically.',
|
||||
},
|
||||
{
|
||||
name: 'David Chen',
|
||||
role: 'Solo Attorney, Immigration Law',
|
||||
quote:
|
||||
'Managing 40+ immigration cases used to be overwhelming. Now everything is organized in one place — documents, deadlines, and client communications.',
|
||||
},
|
||||
{
|
||||
name: 'Jennifer Rodriguez',
|
||||
role: 'Managing Partner, Rodriguez Legal Group',
|
||||
quote:
|
||||
'The billable hours tracking is a game-changer. Our team captures every minute accurately, and invoicing takes seconds instead of hours.',
|
||||
},
|
||||
{
|
||||
name: 'Michael Thompson',
|
||||
role: 'Criminal Defense Attorney',
|
||||
quote:
|
||||
'As a solo practitioner, time is everything. eLegal Software helps me stay organized and bill clients accurately. Best investment for my practice.',
|
||||
},
|
||||
{
|
||||
name: 'Lisa Anderson',
|
||||
role: 'Partner, Family Law Firm',
|
||||
quote:
|
||||
'We grew from 3 to 15 cases per month without adding staff. The efficiency gains are incredible — we save 20+ hours weekly.',
|
||||
},
|
||||
{
|
||||
name: 'Robert Kim',
|
||||
role: 'Corporate Law Partner',
|
||||
quote:
|
||||
'Our clients love the transparency. They can see exactly what we are working on and billing for. Trust has never been higher.',
|
||||
},
|
||||
];
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function Testimonials() {
|
||||
return (
|
||||
<section id="testimonials" className="section bg-ink-50/50">
|
||||
<div className="container">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<span className="eyebrow">Success Stories</span>
|
||||
<h2 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950">
|
||||
Loved by attorneys worldwide
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-ink-600">
|
||||
Join thousands of legal professionals who transformed their practice with eLegal Software.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{TESTIMONIALS.map((t, i) => (
|
||||
<motion.figure
|
||||
key={t.name}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-50px' }}
|
||||
transition={{ duration: 0.4, delay: (i % 3) * 0.05 }}
|
||||
className="rounded-2xl border border-ink-100 bg-white p-6 shadow-sm hover:shadow-md transition"
|
||||
>
|
||||
<div className="flex items-center gap-1 text-amber-400">
|
||||
{Array.from({ length: 5 }).map((_, k) => (
|
||||
<Star key={k} className="h-3.5 w-3.5 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
<blockquote className="mt-4 text-sm text-ink-700 leading-relaxed">
|
||||
“{t.quote}”
|
||||
</blockquote>
|
||||
<figcaption className="mt-5 flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-full bg-brand-100 text-brand-700 text-sm font-semibold">
|
||||
{initials(t.name)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-ink-900">{t.name}</p>
|
||||
<p className="text-xs text-ink-500">{t.role}</p>
|
||||
</div>
|
||||
</figcaption>
|
||||
</motion.figure>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navbar } from '@/components/marketing/Navbar';
|
||||
import { Footer } from '@/components/marketing/Footer';
|
||||
|
||||
export function PublicLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-white">
|
||||
<Navbar />
|
||||
<main className="flex-1 pt-24">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PublicHero({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="border-b border-ink-100 bg-gradient-to-b from-brand-50/40 to-white">
|
||||
<div className="container py-16 text-center">
|
||||
<span className="eyebrow">{eyebrow}</span>
|
||||
<h1 className="mt-4 text-3xl md:text-5xl font-bold text-ink-950 font-display">{title}</h1>
|
||||
{description && <p className="mx-auto mt-4 max-w-2xl text-ink-600">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type Tone = 'neutral' | 'brand' | 'emerald' | 'amber' | 'rose' | 'ink';
|
||||
|
||||
const TONES: Record<Tone, string> = {
|
||||
neutral: 'bg-ink-100 text-ink-700',
|
||||
brand: 'bg-brand-50 text-brand-700',
|
||||
emerald: 'bg-emerald-50 text-emerald-700',
|
||||
amber: 'bg-amber-50 text-amber-700',
|
||||
rose: 'bg-rose-50 text-rose-700',
|
||||
ink: 'bg-ink-900 text-white',
|
||||
};
|
||||
|
||||
export function Badge({ tone = 'neutral', children, className }: { tone?: Tone; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium', TONES[tone], className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
const VARIANT: Record<Variant, string> = {
|
||||
primary: 'bg-brand-500 text-white hover:bg-brand-600 shadow-sm shadow-brand-500/25',
|
||||
secondary: 'bg-white text-ink-900 border border-ink-200 hover:border-ink-300',
|
||||
ghost: 'text-ink-700 hover:text-ink-900 hover:bg-ink-100',
|
||||
danger: 'bg-rose-500 text-white hover:bg-rose-600 shadow-sm shadow-rose-500/25',
|
||||
};
|
||||
|
||||
const SIZE: Record<Size, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm rounded-lg gap-1.5',
|
||||
md: 'px-4 py-2 text-sm rounded-xl gap-2',
|
||||
lg: 'px-5 py-2.5 text-sm rounded-xl gap-2',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, Props>(function Button(
|
||||
{ variant = 'primary', size = 'md', className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center font-medium transition',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-60',
|
||||
VARIANT[variant],
|
||||
SIZE[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Card({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('rounded-2xl border border-ink-100 bg-white shadow-sm', className)} {...rest} />;
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-ink-100 px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink-900">{title}</h3>
|
||||
{description && <p className="mt-0.5 text-xs text-ink-500">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardBody({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('p-5', className)} {...rest} />;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
icon,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid place-items-center px-6 py-16 text-center">
|
||||
{icon && <div className="mb-4 grid h-12 w-12 place-items-center rounded-2xl bg-brand-50 text-brand-600">{icon}</div>}
|
||||
<p className="text-sm font-semibold text-ink-900">{title}</p>
|
||||
{description && <p className="mt-1 max-w-sm text-sm text-ink-500">{description}</p>}
|
||||
{action && <div className="mt-5">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export function Drawer({ open, onClose, title, description, children, footer, width = 'max-w-md' }: Props) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 transition-opacity',
|
||||
open ? 'opacity-100' : 'pointer-events-none opacity-0',
|
||||
)}
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm" onClick={onClose} />
|
||||
<aside
|
||||
className={cn(
|
||||
'absolute right-0 top-0 h-full w-full bg-white shadow-2xl flex flex-col transition-transform',
|
||||
width,
|
||||
open ? 'translate-x-0' : 'translate-x-full',
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-4 border-b border-ink-100 px-6 py-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-ink-950 font-display">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-ink-500">{description}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="grid h-9 w-9 place-items-center rounded-lg text-ink-500 hover:bg-ink-100"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">{children}</div>
|
||||
{footer && <footer className="border-t border-ink-100 px-6 py-4 bg-ink-50/40">{footer}</footer>}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const fieldBase =
|
||||
'w-full rounded-xl border bg-white px-3.5 py-2.5 text-sm text-ink-900 placeholder:text-ink-400 ' +
|
||||
'focus:outline-none focus:ring-2';
|
||||
|
||||
const fieldOk = 'border-ink-200 focus:border-brand-500 focus:ring-brand-500/20';
|
||||
const fieldErr = 'border-rose-300 focus:border-rose-500 focus:ring-rose-500/20';
|
||||
|
||||
interface Common {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement>, Common {}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ label, hint, error, className, id, name, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const inputId = id ?? name;
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="text-xs font-medium text-ink-700 block mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input ref={ref} id={inputId} name={name} className={cn(fieldBase, error ? fieldErr : fieldOk, className)} {...rest} />
|
||||
{error ? <p className="mt-1.5 text-xs text-rose-600">{error}</p> : hint ? <p className="mt-1.5 text-xs text-ink-500">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement>, Common {}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
||||
{ label, hint, error, className, id, name, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const inputId = id ?? name;
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="text-xs font-medium text-ink-700 block mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea ref={ref} id={inputId} name={name} className={cn(fieldBase, error ? fieldErr : fieldOk, className)} {...rest} />
|
||||
{error ? <p className="mt-1.5 text-xs text-rose-600">{error}</p> : hint ? <p className="mt-1.5 text-xs text-ink-500">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement>, Common {}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||
{ label, hint, error, className, id, name, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const inputId = id ?? name;
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="text-xs font-medium text-ink-700 block mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select ref={ref} id={inputId} name={name} className={cn(fieldBase, error ? fieldErr : fieldOk, className)} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
{error ? <p className="mt-1.5 text-xs text-rose-600">{error}</p> : hint ? <p className="mt-1.5 text-xs text-ink-500">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
export interface Post {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
publishedAt: string; // ISO date
|
||||
readMinutes: number;
|
||||
author: string;
|
||||
// Body is structured as an array of blocks for simple renderable JSX
|
||||
body: Block[];
|
||||
}
|
||||
|
||||
export type Block =
|
||||
| { type: 'p'; text: string }
|
||||
| { type: 'h2'; text: string }
|
||||
| { type: 'h3'; text: string }
|
||||
| { type: 'ul'; items: string[] }
|
||||
| { type: 'ol'; items: string[] }
|
||||
| { type: 'quote'; text: string }
|
||||
| { type: 'callout'; tone: 'brand' | 'amber'; title: string; text: string };
|
||||
|
||||
export const POSTS: Post[] = [
|
||||
{
|
||||
slug: 'maximize-billable-hours-without-burnout',
|
||||
title: 'How to maximize billable hours without burning out',
|
||||
description:
|
||||
'Practical tactics for capturing more billable time, building work that scales, and keeping your evenings.',
|
||||
publishedAt: '2026-04-08',
|
||||
readMinutes: 8,
|
||||
author: 'eLegal Software Team',
|
||||
body: [
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Most attorneys do not have a billing problem. They have a capture problem. The work happens — drafting, calls, research, follow-ups — but a third of it never makes it onto an invoice. The fix is not working longer hours. It is closing the gap between the work and the entry.',
|
||||
},
|
||||
{ type: 'h2', text: 'Track in real time, not at the end of the day' },
|
||||
{
|
||||
type: 'p',
|
||||
text: "If you reconstruct your day at 6 PM you will lose 15-30 minutes per day to forgotten micro-tasks. A two-minute call here, a quick email there. None of those go on the invoice. Start a timer the moment a task begins, even if you only run it for four minutes. The point is the entry, not the elegance.",
|
||||
},
|
||||
{ type: 'h2', text: 'Build templates for the work you repeat' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Look at your last ten matters and find the documents you wrote from scratch that you have written before. Engagement letters, intake forms, demand letters, motion shells. Convert each into a template with merge fields. The first hour you spend templatizing pays back within a month.',
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
tone: 'brand',
|
||||
title: 'Quick win',
|
||||
text: "Pick the single document you've drafted most this quarter and templatize it this week. Put the placeholders in [BRACKETS] so they jump out.",
|
||||
},
|
||||
{ type: 'h2', text: 'Set a daily target, not a yearly one' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Annual targets are abstract. A daily target is real. If you bill 1,800 hours per year over 220 working days, that is roughly 8 billable hours per day — which actually means about 11 hours at your desk once you factor in admin, breaks, and unbilled time. Translating yearly to daily makes the math honest and lets you adjust before the gap snowballs.',
|
||||
},
|
||||
{ type: 'h2', text: 'Defend your deep-work block' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Most billable hours come from drafting, research, and analysis — work that requires uninterrupted focus. Block a 90-minute window every morning, decline meetings inside it, and treat email as a chore that happens after the block ends, not before. One protected morning block per day will out-earn three afternoons of context-switching.',
|
||||
},
|
||||
{ type: 'h2', text: 'Bill faster' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'A weekly billing rhythm beats a monthly one. Generate invoices every Friday for the work that closed that week. Clients pay sooner, your cash flow stabilizes, and write-offs go down because the work is fresh in everyone\'s mind. The biggest enemy of recovery is age — every additional week an invoice sits, the harder it is to defend a line item.',
|
||||
},
|
||||
{ type: 'h3', text: 'A pragmatic checklist' },
|
||||
{
|
||||
type: 'ul',
|
||||
items: [
|
||||
'Start every task with a timer running, even 2-minute tasks.',
|
||||
'Templatize anything you have drafted twice.',
|
||||
'Convert your annual hours target into a daily one.',
|
||||
'Block one focused morning window per day.',
|
||||
'Bill weekly, not monthly.',
|
||||
'Review your write-off rate every month — that number tells you where the leak is.',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'p',
|
||||
text: 'None of these is a silver bullet. Together they typically claw back 5-8 billable hours a week without working any later. Multiply that by a year at your hourly rate.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'client-intake-best-practices-2026',
|
||||
title: 'Client intake best practices for law firms in 2026',
|
||||
description:
|
||||
'A modern intake flow that converts more inquiries, sets clear expectations, and saves you time on the wrong-fit prospects.',
|
||||
publishedAt: '2026-03-21',
|
||||
readMinutes: 12,
|
||||
author: 'eLegal Software Team',
|
||||
body: [
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Intake is the first hour of the client relationship. It is also the cheapest place to fix bad-fit work, set expectations, and signal that the firm is organized. A good intake flow does three things at once: it qualifies, it informs, and it gathers.',
|
||||
},
|
||||
{ type: 'h2', text: 'Qualify before you book the consultation' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'A consultation should not be the first filter. By the time a prospect is on a call, you have already invested 30 minutes plus the prep before and the follow-up after. Move qualification earlier — into a short intake form that confirms the matter type, jurisdiction, urgency, and ability to pay before any time is committed.',
|
||||
},
|
||||
{ type: 'h2', text: 'Keep the intake form short — but specific' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'A 4-field form converts but tells you nothing. A 30-field form is abandoned. The sweet spot is 8-12 fields, mostly conditional based on practice area. Branch the questions: someone with a corporate matter does not need to answer family-law questions.',
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
tone: 'brand',
|
||||
title: 'Field worth adding',
|
||||
text: '"What outcome would make this representation a success for you?" is the single most useful intake question. The answer tells you scope, expectations, and whether they have realistic goals.',
|
||||
},
|
||||
{ type: 'h2', text: 'Acknowledge fast, even if you cannot respond fully' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'A 24-hour silence is when prospects start contacting other firms. An automated acknowledgment within 5 minutes ("we got your inquiry, we will respond by [day]") buys you the time to respond properly without losing the lead. Keep the substantive response within one business day.',
|
||||
},
|
||||
{ type: 'h2', text: 'Send the engagement materials before the call, not after' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'When you send the engagement letter and fee schedule before the consultation, the call becomes about fit and strategy rather than logistics. Prospects who balk at fees self-select out before you spend the hour. Prospects who proceed are pre-qualified and ready to sign.',
|
||||
},
|
||||
{ type: 'h2', text: 'Use a conflict check that is actually run' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Conflict checks fail in firms not because the system is bad but because it is skipped under time pressure. Make it the first step that has to be checked off before any other intake action — including booking a consultation. A conflict caught at intake costs nothing. A conflict caught after representation begins costs the matter and damages reputation.',
|
||||
},
|
||||
{ type: 'h2', text: 'Document what you decline' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Keep a short log of inquiries you turn down, with one-sentence reasons. Patterns emerge: too far away, wrong practice area, can\'t afford, conflict, missed deadline. After 50 entries you will know exactly where to invest in marketing — and exactly which referral partners you should be sending the wrong-fit work to.',
|
||||
},
|
||||
{ type: 'h3', text: 'A workable intake flow' },
|
||||
{
|
||||
type: 'ol',
|
||||
items: [
|
||||
'Inquiry hits an intake form — branched by practice area.',
|
||||
'Automated acknowledgment fires within 5 minutes.',
|
||||
'Conflict check runs before anything else (often automated against your existing client list).',
|
||||
'Engagement letter, fee schedule, and prep questionnaire go out together.',
|
||||
'Consultation happens — call is about fit and strategy, not logistics.',
|
||||
'Decision within 24 hours: signed, declined, or pending.',
|
||||
'Declined inquiries logged with a reason.',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'p',
|
||||
text: 'The result is fewer wasted hours on bad-fit prospects, faster conversions on good-fit ones, and a clear paper trail for both.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'legal-billing-software-comparison-2026',
|
||||
title: 'Legal billing software in 2026: how to actually compare options',
|
||||
description:
|
||||
'A framework for choosing legal billing software that focuses on the questions most reviews never ask.',
|
||||
publishedAt: '2026-02-14',
|
||||
readMinutes: 10,
|
||||
author: 'eLegal Software Team',
|
||||
body: [
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Most legal-billing software comparisons are checklists of features. That is the wrong frame. Every product in this category checks the same boxes: time tracking, invoices, trust accounting. The question that matters is not what they do, it is how much friction they add to the work you already do.',
|
||||
},
|
||||
{ type: 'h2', text: 'Friction one: time entry' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Most timers fail because they require attention. They live in a separate tab, ask questions before starting, or need a case picker open. The best timers are one click away no matter where you are in the app, and they default the case to whatever you were last working on. Watch how the timer feels for ten minutes — does it disappear into the background, or does it constantly ask you to manage it?',
|
||||
},
|
||||
{ type: 'h2', text: 'Friction two: turning time into invoices' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Generating an invoice should be a roll-up, not a re-entry. If you have to manually copy time entries onto an invoice, the system has lost half its value. A good system shows you unbilled time grouped by case, lets you select what to bill in one click, and produces a draft invoice in under a minute. Try it during the demo with realistic data — not with two pre-loaded sample entries.',
|
||||
},
|
||||
{ type: 'h2', text: 'Friction three: where the data lives' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Practice management is sticky. The longer you use it, the more painful it is to leave. Three questions before you commit: Can you export everything as standard formats (CSV, PDF, JSON)? Are you the data controller or are they? Where physically does the data live, and who else has access to it? If a vendor cannot answer all three on the spot, treat that as a signal.',
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
tone: 'amber',
|
||||
title: 'Things you only learn after switching',
|
||||
text: 'Hidden costs come in two forms: per-user fees that scale with your team, and "premium" feature gates (e-signing, payments, document storage) that turn a $25/month plan into $150/month. Ask for the all-in cost for your actual usage, not the headline price.',
|
||||
},
|
||||
{ type: 'h2', text: 'Onboarding is the real test' },
|
||||
{
|
||||
type: 'p',
|
||||
text: 'Every product looks great in a demo. The honest question is: how long until your team is actually using it? If migration takes three weeks and adoption takes another month, you have lost a quarter of revenue visibility. Ask for an explicit onboarding plan with named milestones — and ask to talk to two recent customers about how it actually went.',
|
||||
},
|
||||
{ type: 'h2', text: 'A short evaluation framework' },
|
||||
{
|
||||
type: 'ol',
|
||||
items: [
|
||||
'Time a real billing cycle end-to-end on each candidate. Whichever feels lighter wins.',
|
||||
'Confirm export and data ownership in writing before you commit.',
|
||||
'Get the all-in price including add-ons you will actually use.',
|
||||
'Ask for two recent customer references with similar practice size.',
|
||||
'Pilot with one matter for two weeks before rolling out firm-wide.',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'p',
|
||||
text: "The right software is the one that disappears. You should think about it less than you do today, not more. If a tool requires you to learn a new vocabulary, train your team, and follow a workflow that does not match how you already work, it is the wrong tool — no matter how many features it has.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getPost(slug: string): Post | undefined {
|
||||
return POSTS.find((p) => p.slug === slug);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export function useDeleteAccount() {
|
||||
return useMutation<void, ApiError, { password: string }>({
|
||||
mutationFn: (body) => api.post('/api/account/delete', body),
|
||||
});
|
||||
}
|
||||
|
||||
// Triggers a download of the export JSON. Uses a direct link so the browser handles the file save.
|
||||
export async function downloadAccountExport(): Promise<void> {
|
||||
const csrf = readCookie('csrf');
|
||||
const res = await fetch('/api/account/export', {
|
||||
credentials: 'same-origin',
|
||||
headers: csrf ? { 'X-CSRF-Token': csrf } : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(body || `export_failed_${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `lawdesk-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function readCookie(name: string): string | undefined {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const prefix = `${name}=`;
|
||||
for (const part of document.cookie.split('; ')) {
|
||||
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export type AdminPlan = 'starter' | 'pro' | 'lifetime';
|
||||
|
||||
export interface AdminStats {
|
||||
counters: {
|
||||
firms: number;
|
||||
users: number;
|
||||
cases: number;
|
||||
clients: number;
|
||||
invoices: number;
|
||||
unresolvedContact: number;
|
||||
paidRevenueTotal: string;
|
||||
};
|
||||
planDistribution: Array<{ plan: AdminPlan; count: number }>;
|
||||
signupsLast30Days: Array<{ day: string; count: number }>;
|
||||
}
|
||||
|
||||
export interface AdminFirmListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
plan: AdminPlan;
|
||||
watermarkEnabled: boolean;
|
||||
createdAt: string;
|
||||
userCount: number;
|
||||
caseCount: number;
|
||||
clientCount: number;
|
||||
paidTotal: string;
|
||||
}
|
||||
|
||||
export interface AdminFirmDetailResponse {
|
||||
firm: {
|
||||
id: string;
|
||||
name: string;
|
||||
plan: AdminPlan;
|
||||
watermarkEnabled: boolean;
|
||||
storageBytesUsed: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
trialEndsAt: string | null;
|
||||
stripeCustomerId: string | null;
|
||||
stripeSubscriptionId: string | null;
|
||||
};
|
||||
users: Array<{
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
role: string;
|
||||
isSuspended: boolean;
|
||||
isSuperadmin: boolean;
|
||||
createdAt: string;
|
||||
lastSeenAt: string | null;
|
||||
}>;
|
||||
counts: {
|
||||
clients: number;
|
||||
cases: number;
|
||||
invoices: number;
|
||||
paidTotal: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminUserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
role: string;
|
||||
isSuperadmin: boolean;
|
||||
isSuspended: boolean;
|
||||
createdAt: string;
|
||||
lastSeenAt: string | null;
|
||||
firmId: string | null;
|
||||
firmName: string | null;
|
||||
}
|
||||
|
||||
export interface AdminContactMessage {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
message: string;
|
||||
ip: string | null;
|
||||
resolvedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
firmId: string | null;
|
||||
action: string;
|
||||
meta: string | null;
|
||||
ip: string | null;
|
||||
createdAt: string;
|
||||
userEmail: string | null;
|
||||
}
|
||||
|
||||
export function useAdminStats() {
|
||||
return useQuery<AdminStats>({
|
||||
queryKey: ['admin', 'stats'],
|
||||
queryFn: () => api.get('/api/admin/stats'),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminFirms(params: { q?: string; plan?: AdminPlan } = {}) {
|
||||
return useQuery<{ items: AdminFirmListItem[]; total: number }>({
|
||||
queryKey: ['admin', 'firms', params],
|
||||
queryFn: () => {
|
||||
const u = new URLSearchParams();
|
||||
if (params.q) u.set('q', params.q);
|
||||
if (params.plan) u.set('plan', params.plan);
|
||||
const s = u.toString();
|
||||
return api.get(`/api/admin/firms${s ? `?${s}` : ''}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminFirm(id: string | undefined) {
|
||||
return useQuery<AdminFirmDetailResponse>({
|
||||
queryKey: id ? ['admin', 'firms', 'detail', id] : ['admin', 'firms', 'detail', 'noop'],
|
||||
queryFn: () => api.get(`/api/admin/firms/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAdminFirm(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<unknown, ApiError, { plan?: AdminPlan; watermarkEnabled?: boolean; name?: string }>({
|
||||
mutationFn: (body) => api.patch(`/api/admin/firms/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUsers(params: { q?: string; suspended?: 'true' | 'false' } = {}) {
|
||||
return useQuery<{ items: AdminUserListItem[]; total: number }>({
|
||||
queryKey: ['admin', 'users', params],
|
||||
queryFn: () => {
|
||||
const u = new URLSearchParams();
|
||||
if (params.q) u.set('q', params.q);
|
||||
if (params.suspended) u.set('suspended', params.suspended);
|
||||
const s = u.toString();
|
||||
return api.get(`/api/admin/users${s ? `?${s}` : ''}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAdminUser(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<unknown, ApiError, { isSuspended?: boolean; role?: string }>({
|
||||
mutationFn: (body) => api.patch(`/api/admin/users/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['admin'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useImpersonate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<unknown, ApiError, string>({
|
||||
mutationFn: (id) => api.post(`/api/admin/users/${id}/impersonate`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminContactMessages(params: { resolved?: 'true' | 'false' } = {}) {
|
||||
return useQuery<{ items: AdminContactMessage[]; total: number }>({
|
||||
queryKey: ['admin', 'contact', params],
|
||||
queryFn: () => {
|
||||
const u = new URLSearchParams();
|
||||
if (params.resolved) u.set('resolved', params.resolved);
|
||||
const s = u.toString();
|
||||
return api.get(`/api/admin/contact-messages${s ? `?${s}` : ''}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveContactMessage() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<unknown, ApiError, { id: string; resolved: boolean }>({
|
||||
mutationFn: ({ id, resolved }) => api.patch(`/api/admin/contact-messages/${id}`, { resolved }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'contact'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuditLog(params: { action?: string; userId?: string; firmId?: string } = {}) {
|
||||
return useQuery<{ items: AdminAuditEntry[] }>({
|
||||
queryKey: ['admin', 'audit', params],
|
||||
queryFn: () => {
|
||||
const u = new URLSearchParams();
|
||||
if (params.action) u.set('action', params.action);
|
||||
if (params.userId) u.set('userId', params.userId);
|
||||
if (params.firmId) u.set('firmId', params.firmId);
|
||||
const s = u.toString();
|
||||
return api.get(`/api/admin/audit-log${s ? `?${s}` : ''}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName?: string | null;
|
||||
firmId: string | null;
|
||||
role: string;
|
||||
isSuperadmin?: boolean;
|
||||
isSuspended?: boolean;
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
const ME_KEY = ['auth', 'me'] as const;
|
||||
|
||||
export function useMe() {
|
||||
return useQuery<AuthUser | null>({
|
||||
queryKey: ME_KEY,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const data = await api.get<MeResponse>('/api/auth/me');
|
||||
return data.user;
|
||||
} catch (err) {
|
||||
if ((err as ApiError).status === 401) return null;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<AuthUser, ApiError, { email: string; password: string }>({
|
||||
mutationFn: async (vars) => {
|
||||
const data = await api.post<MeResponse>('/api/auth/login', vars);
|
||||
return data.user;
|
||||
},
|
||||
onSuccess: (user) => qc.setQueryData(ME_KEY, user),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSignup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<AuthUser, ApiError, { email: string; password: string; fullName: string; firmName: string }>({
|
||||
mutationFn: async (vars) => {
|
||||
const data = await api.post<MeResponse>('/api/auth/signup', vars);
|
||||
return data.user;
|
||||
},
|
||||
onSuccess: (user) => qc.setQueryData(ME_KEY, user),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError>({
|
||||
mutationFn: async () => {
|
||||
await api.post('/api/auth/logout');
|
||||
},
|
||||
onSuccess: () => qc.setQueryData(ME_KEY, null),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export interface BillingStatus {
|
||||
configured: boolean;
|
||||
plan: 'starter' | 'pro' | 'lifetime';
|
||||
hasSubscription: boolean;
|
||||
hasCustomer: boolean;
|
||||
}
|
||||
|
||||
export function useBillingStatus() {
|
||||
return useQuery<BillingStatus>({
|
||||
queryKey: ['billing', 'status'],
|
||||
queryFn: () => api.get('/api/billing/status'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartCheckout() {
|
||||
return useMutation<{ url: string }, ApiError, { plan: 'pro' | 'lifetime' }>({
|
||||
mutationFn: (body) => api.post('/api/billing/checkout', body),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpenPortal() {
|
||||
return useMutation<{ url: string }, ApiError, void>({
|
||||
mutationFn: () => api.post('/api/billing/portal'),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export type CaseStatus = 'open' | 'pending' | 'closed' | 'archived';
|
||||
|
||||
export interface CaseListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
caseNumber: string | null;
|
||||
status: CaseStatus;
|
||||
practiceArea: string | null;
|
||||
hourlyRate: string | null;
|
||||
openedAt: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
billedMinutes: number;
|
||||
}
|
||||
|
||||
export interface CaseDetail {
|
||||
id: string;
|
||||
title: string;
|
||||
caseNumber: string | null;
|
||||
status: CaseStatus;
|
||||
practiceArea: string | null;
|
||||
description: string | null;
|
||||
hourlyRate: string | null;
|
||||
openedAt: string;
|
||||
closedAt: string | null;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientEmail: string | null;
|
||||
}
|
||||
|
||||
export interface CaseInput {
|
||||
clientId: string;
|
||||
title: string;
|
||||
caseNumber?: string | null;
|
||||
status?: CaseStatus;
|
||||
practiceArea?: string | null;
|
||||
description?: string | null;
|
||||
hourlyRate?: number | null;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
items: CaseListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ListParams {
|
||||
q?: string;
|
||||
status?: CaseStatus;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
const KEY = {
|
||||
list: (p: ListParams = {}) => ['cases', 'list', p] as const,
|
||||
detail: (id: string) => ['cases', 'detail', id] as const,
|
||||
};
|
||||
|
||||
function qs(p: ListParams): string {
|
||||
const u = new URLSearchParams();
|
||||
if (p.q) u.set('q', p.q);
|
||||
if (p.status) u.set('status', p.status);
|
||||
if (p.clientId) u.set('clientId', p.clientId);
|
||||
const s = u.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export function useCases(params: ListParams = {}) {
|
||||
return useQuery<ListResponse>({
|
||||
queryKey: KEY.list(params),
|
||||
queryFn: () => api.get(`/api/cases${qs(params)}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCase(id: string | undefined) {
|
||||
return useQuery<CaseDetail>({
|
||||
queryKey: id ? KEY.detail(id) : ['cases', 'detail', 'noop'],
|
||||
queryFn: () => api.get(`/api/cases/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCase() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<CaseDetail, ApiError, CaseInput>({
|
||||
mutationFn: (body) => api.post('/api/cases', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['cases'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCase(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<CaseDetail, ApiError, Partial<CaseInput>>({
|
||||
mutationFn: (body) => api.patch(`/api/cases/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCase() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError, string>({
|
||||
mutationFn: (id) => api.delete(`/api/cases/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['cases'] }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export interface ClientListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
createdAt: string;
|
||||
caseCount: number;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
firmId: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ClientInput {
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
address?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
items: ClientListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const KEY = {
|
||||
list: (q?: string) => ['clients', 'list', q ?? ''] as const,
|
||||
detail: (id: string) => ['clients', 'detail', id] as const,
|
||||
};
|
||||
|
||||
export function useClients(q?: string) {
|
||||
return useQuery<ListResponse>({
|
||||
queryKey: KEY.list(q),
|
||||
queryFn: () => api.get(`/api/clients${q ? `?q=${encodeURIComponent(q)}` : ''}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useClient(id: string | undefined) {
|
||||
return useQuery<Client>({
|
||||
queryKey: id ? KEY.detail(id) : ['clients', 'detail', 'noop'],
|
||||
queryFn: () => api.get(`/api/clients/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateClient() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<Client, ApiError, ClientInput>({
|
||||
mutationFn: (body) => api.post('/api/clients', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateClient(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<Client, ApiError, Partial<ClientInput>>({
|
||||
mutationFn: (body) => api.patch(`/api/clients/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['clients'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteClient() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError, string>({
|
||||
mutationFn: (id) => api.delete(`/api/clients/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'void';
|
||||
|
||||
export interface InvoiceListItem {
|
||||
id: string;
|
||||
number: string;
|
||||
status: InvoiceStatus;
|
||||
total: string;
|
||||
subtotal: string;
|
||||
issuedAt: string | null;
|
||||
dueAt: string | null;
|
||||
paidAt: string | null;
|
||||
createdAt: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
caseId: string | null;
|
||||
caseTitle: string | null;
|
||||
}
|
||||
|
||||
export interface InvoiceItem {
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
rate: string;
|
||||
amount: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface InvoiceDetail {
|
||||
id: string;
|
||||
number: string;
|
||||
status: InvoiceStatus;
|
||||
subtotal: string;
|
||||
taxRate: string;
|
||||
total: string;
|
||||
notes: string | null;
|
||||
issuedAt: string | null;
|
||||
dueAt: string | null;
|
||||
paidAt: string | null;
|
||||
createdAt: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientEmail: string | null;
|
||||
caseId: string | null;
|
||||
caseTitle: string | null;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
export interface CreateInvoiceInput {
|
||||
clientId: string;
|
||||
caseId?: string | null;
|
||||
notes?: string | null;
|
||||
taxRate?: number;
|
||||
dueAt?: string | null;
|
||||
items?: Array<{ description: string; quantity: number; rate: number }>;
|
||||
timeEntryIds?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateInvoiceInput {
|
||||
notes?: string | null;
|
||||
taxRate?: number;
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ListParams {
|
||||
status?: InvoiceStatus;
|
||||
clientId?: string;
|
||||
caseId?: string;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
items: InvoiceListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const KEY = {
|
||||
list: (p: ListParams = {}) => ['invoices', 'list', p] as const,
|
||||
detail: (id: string) => ['invoices', 'detail', id] as const,
|
||||
};
|
||||
|
||||
function qs(p: ListParams): string {
|
||||
const u = new URLSearchParams();
|
||||
if (p.status) u.set('status', p.status);
|
||||
if (p.clientId) u.set('clientId', p.clientId);
|
||||
if (p.caseId) u.set('caseId', p.caseId);
|
||||
const s = u.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export function useInvoices(params: ListParams = {}) {
|
||||
return useQuery<ListResponse>({
|
||||
queryKey: KEY.list(params),
|
||||
queryFn: () => api.get(`/api/invoices${qs(params)}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInvoice(id: string | undefined) {
|
||||
return useQuery<InvoiceDetail>({
|
||||
queryKey: id ? KEY.detail(id) : ['invoices', 'detail', 'noop'],
|
||||
queryFn: () => api.get(`/api/invoices/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateInvoice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<InvoiceDetail, ApiError, CreateInvoiceInput>({
|
||||
mutationFn: (body) => api.post('/api/invoices', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateInvoice(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<InvoiceDetail, ApiError, UpdateInvoiceInput>({
|
||||
mutationFn: (body) => api.patch(`/api/invoices/${id}`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendInvoice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<InvoiceDetail, ApiError, string>({
|
||||
mutationFn: (id) => api.post(`/api/invoices/${id}/send`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkPaid() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<InvoiceDetail, ApiError, string>({
|
||||
mutationFn: (id) => api.post(`/api/invoices/${id}/mark-paid`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useVoidInvoice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<InvoiceDetail, ApiError, string>({
|
||||
mutationFn: (id) => api.post(`/api/invoices/${id}/void`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['invoices'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteInvoice() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError, string>({
|
||||
mutationFn: (id) => api.delete(`/api/invoices/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['invoices'] });
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export function useRequestPasswordReset() {
|
||||
return useMutation<{ ok: boolean }, ApiError, { email: string }>({
|
||||
mutationFn: (body) => api.post('/api/auth/request-password-reset', body),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetPassword() {
|
||||
return useMutation<{ ok: boolean }, ApiError, { token: string; password: string }>({
|
||||
mutationFn: (body) => api.post('/api/auth/reset-password', body),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type ApiError } from '@/lib/api';
|
||||
|
||||
export interface TimeEntry {
|
||||
id: string;
|
||||
caseId: string;
|
||||
caseTitle: string;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
userId: string;
|
||||
description: string;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
minutes: number;
|
||||
rate: string;
|
||||
billable: boolean;
|
||||
invoiceItemId: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveTimer {
|
||||
id: string;
|
||||
caseId: string;
|
||||
caseTitle: string;
|
||||
clientName: string;
|
||||
description: string;
|
||||
startedAt: string;
|
||||
rate: string;
|
||||
}
|
||||
|
||||
export interface TimeListParams {
|
||||
caseId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
invoiced?: 'true' | 'false';
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
items: TimeEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const KEY = {
|
||||
list: (p: TimeListParams = {}) => ['time-entries', 'list', p] as const,
|
||||
active: ['time-entries', 'active'] as const,
|
||||
};
|
||||
|
||||
function qs(p: TimeListParams): string {
|
||||
const u = new URLSearchParams();
|
||||
if (p.caseId) u.set('caseId', p.caseId);
|
||||
if (p.from) u.set('from', p.from);
|
||||
if (p.to) u.set('to', p.to);
|
||||
if (p.invoiced) u.set('invoiced', p.invoiced);
|
||||
const s = u.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
export function useTimeEntries(params: TimeListParams = {}) {
|
||||
return useQuery<ListResponse>({
|
||||
queryKey: KEY.list(params),
|
||||
queryFn: () => api.get(`/api/time-entries${qs(params)}`),
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveTimer() {
|
||||
return useQuery<{ active: ActiveTimer | null }>({
|
||||
queryKey: KEY.active,
|
||||
queryFn: () => api.get('/api/time-entries/active'),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartTimer() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<TimeEntry, ApiError, { caseId: string; description?: string }>({
|
||||
mutationFn: (body) => api.post('/api/time-entries/start', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStopTimer() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<TimeEntry, ApiError, string>({
|
||||
mutationFn: (id) => api.post(`/api/time-entries/${id}/stop`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface ManualEntryInput {
|
||||
caseId: string;
|
||||
description: string;
|
||||
startedAt: string;
|
||||
endedAt?: string | null;
|
||||
minutes?: number;
|
||||
rate?: number;
|
||||
billable?: boolean;
|
||||
}
|
||||
|
||||
export function useCreateTimeEntry() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<TimeEntry, ApiError, ManualEntryInput>({
|
||||
mutationFn: (body) => api.post('/api/time-entries', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTimeEntry(id: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<TimeEntry, ApiError, Partial<ManualEntryInput>>({
|
||||
mutationFn: (body) => api.patch(`/api/time-entries/${id}`, body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTimeEntry() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation<void, ApiError, string>({
|
||||
mutationFn: (id) => api.delete(`/api/time-entries/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['time-entries'] });
|
||||
qc.invalidateQueries({ queryKey: ['cases'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export type ToolName =
|
||||
| 'hourly-rate-calculator'
|
||||
| 'case-profitability'
|
||||
| 'billable-hours-tracker'
|
||||
| 'document-templates';
|
||||
|
||||
const SESSION_KEY = 'lawdesk:tool-session';
|
||||
|
||||
function getSessionId(): string {
|
||||
if (typeof sessionStorage === 'undefined') return '';
|
||||
let v = sessionStorage.getItem(SESSION_KEY);
|
||||
if (!v) {
|
||||
v = crypto.randomUUID();
|
||||
sessionStorage.setItem(SESSION_KEY, v);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Fire-and-forget: log a hit when a tool page mounts, then ping every 90 seconds
|
||||
// so the visitor counts as "online" until they leave the page.
|
||||
export function useTrackTool(tool: ToolName): void {
|
||||
useEffect(() => {
|
||||
const sessionId = getSessionId();
|
||||
const ping = () =>
|
||||
api.post('/api/tool-usage', { tool, sessionId }).catch(() => {});
|
||||
ping();
|
||||
const id = setInterval(ping, 90_000);
|
||||
return () => clearInterval(id);
|
||||
}, [tool]);
|
||||
}
|
||||
|
||||
export function useToolsOnline() {
|
||||
return useQuery<{ online: Record<string, number>; since: string }>({
|
||||
queryKey: ['tools', 'online'],
|
||||
queryFn: () => api.get('/api/tool-usage/online'),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface ApiError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
const SAFE = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
function readCookie(name: string): string | undefined {
|
||||
if (typeof document === 'undefined') return undefined;
|
||||
const prefix = `${name}=`;
|
||||
for (const part of document.cookie.split('; ')) {
|
||||
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (!SAFE.has(method)) {
|
||||
const csrf = readCookie('csrf');
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
}
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error(data?.error ?? `request_failed_${res.status}`) as ApiError;
|
||||
err.status = res.status;
|
||||
err.code = data?.error;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export function formatDate(value: string | Date | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
const d = typeof value === 'string' ? new Date(value) : value;
|
||||
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function formatHours(minutes: number): string {
|
||||
const h = minutes / 60;
|
||||
return h >= 10 ? `${h.toFixed(0)}h` : `${h.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
export function formatMoney(amount: string | number | null | undefined): string {
|
||||
if (amount == null || amount === '') return '—';
|
||||
const n = typeof amount === 'string' ? Number(amount) : amount;
|
||||
if (!Number.isFinite(n)) return '—';
|
||||
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(n);
|
||||
}
|
||||
|
||||
export function planLimitMessage(code: string | undefined, fallback = 'Action not allowed.'): string {
|
||||
switch (code) {
|
||||
case 'plan_limit_clients':
|
||||
return "You've hit your plan's client limit. Upgrade to add more.";
|
||||
case 'plan_limit_activeCases':
|
||||
return "You've hit your plan's active-case limit. Close a case or upgrade.";
|
||||
case 'plan_limit_invoicesPerMonth':
|
||||
return "You've hit your monthly invoice limit. Upgrade for unlimited invoicing.";
|
||||
case 'plan_limit_storageBytes':
|
||||
return "You've hit your storage limit. Delete files or upgrade.";
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined;
|
||||
|
||||
export function initWebSentry(): void {
|
||||
if (!dsn) return;
|
||||
Sentry.init({
|
||||
dsn,
|
||||
environment: import.meta.env.MODE,
|
||||
tracesSampleRate: import.meta.env.PROD ? 0.1 : 0,
|
||||
replaysSessionSampleRate: 0,
|
||||
replaysOnErrorSampleRate: 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
export { Sentry };
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import { initWebSentry } from './lib/sentry';
|
||||
import './styles/globals.css';
|
||||
|
||||
initWebSentry();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ArrowRight, MailCheck } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { AuthLayout } from '@/components/auth/AuthLayout';
|
||||
import { Field } from '@/components/auth/Field';
|
||||
import { useRequestPasswordReset } from '@/hooks/useResetPassword';
|
||||
|
||||
const schema = z.object({ email: z.string().email('Enter a valid email') });
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const request = useRequestPasswordReset();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ defaultValues: { email: '' } });
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const parsed = schema.safeParse(values);
|
||||
if (!parsed.success) return;
|
||||
await request.mutateAsync(parsed.data);
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title={submitted ? 'Check your inbox' : 'Reset your password'}
|
||||
subtitle={
|
||||
submitted
|
||||
? 'If an account exists for that email, we just sent a reset link. The link expires in one hour.'
|
||||
: "Enter the email on your account and we'll send you a link to choose a new password."
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
Remembered it?{' '}
|
||||
<Link to="/login" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Back to sign in
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{submitted ? (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-5 text-center">
|
||||
<div className="mx-auto grid h-12 w-12 place-items-center rounded-xl bg-white text-emerald-600 shadow-sm">
|
||||
<MailCheck className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-ink-700">Didn't get it? Check your spam folder, or try again in a few minutes.</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@firm.com"
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || request.isPending}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{request.isPending ? 'Sending…' : (
|
||||
<>
|
||||
Send reset link
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Navbar } from '@/components/marketing/Navbar';
|
||||
import { Hero } from '@/components/marketing/Hero';
|
||||
import { ProblemSolution } from '@/components/marketing/ProblemSolution';
|
||||
import { Features } from '@/components/marketing/Features';
|
||||
import { Stats } from '@/components/marketing/Stats';
|
||||
import { HowItWorks } from '@/components/marketing/HowItWorks';
|
||||
import { Testimonials } from '@/components/marketing/Testimonials';
|
||||
import { Pricing } from '@/components/marketing/Pricing';
|
||||
import { Faq } from '@/components/marketing/Faq';
|
||||
import { Contact } from '@/components/marketing/Contact';
|
||||
import { BlogTeaser } from '@/components/marketing/BlogTeaser';
|
||||
import { FreeToolsTeaser } from '@/components/marketing/FreeToolsTeaser';
|
||||
import { FinalCta } from '@/components/marketing/FinalCta';
|
||||
import { Footer } from '@/components/marketing/Footer';
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<Navbar />
|
||||
<main>
|
||||
<Hero />
|
||||
<ProblemSolution />
|
||||
<Features />
|
||||
<Stats />
|
||||
<HowItWorks />
|
||||
<Testimonials />
|
||||
<Pricing />
|
||||
<Faq />
|
||||
<Contact />
|
||||
<BlogTeaser />
|
||||
<FreeToolsTeaser />
|
||||
<FinalCta />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { AuthLayout } from '@/components/auth/AuthLayout';
|
||||
import { Field } from '@/components/auth/Field';
|
||||
import { useLogin, useMe } from '@/hooks/useAuth';
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email('Enter a valid email'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const ERROR_COPY: Record<string, string> = {
|
||||
invalid_credentials: 'Email or password is incorrect.',
|
||||
too_many_attempts: 'Too many attempts. Try again in a few minutes.',
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const me = useMe();
|
||||
const login = useLogin();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: { email: '', password: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (me.data) navigate('/app', { replace: true });
|
||||
}, [me.data, navigate]);
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const parsed = schema.safeParse(values);
|
||||
if (!parsed.success) return;
|
||||
await login.mutateAsync(parsed.data);
|
||||
const next = new URLSearchParams(location.search).get('next') ?? '/app';
|
||||
navigate(next, { replace: true });
|
||||
}
|
||||
|
||||
const apiError = login.error?.code ? ERROR_COPY[login.error.code] ?? 'Something went wrong.' : null;
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Welcome back"
|
||||
subtitle="Log in to manage your cases, hours, and invoices."
|
||||
footer={
|
||||
<>
|
||||
New to eLegal Software?{' '}
|
||||
<Link to="/signup" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Create an account
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Field
|
||||
label="Email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@firm.com"
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="Your password"
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<div className="text-right -mt-2">
|
||||
<Link to="/forgot-password" className="text-xs font-medium text-brand-600 hover:text-brand-700">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={isSubmitting || login.isPending} className="btn-primary w-full">
|
||||
{login.isPending ? 'Signing in…' : (
|
||||
<>
|
||||
Sign in
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { z } from 'zod';
|
||||
import { AuthLayout } from '@/components/auth/AuthLayout';
|
||||
import { Field } from '@/components/auth/Field';
|
||||
import { useResetPassword } from '@/hooks/useResetPassword';
|
||||
|
||||
const schema = z.object({ password: z.string().min(10, 'At least 10 characters').max(200) });
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const ERROR_COPY: Record<string, string> = {
|
||||
invalid_or_used_token: 'This reset link is invalid or already used. Request a new one.',
|
||||
token_expired: 'This reset link has expired. Request a new one.',
|
||||
account_suspended: 'This account is suspended. Contact support.',
|
||||
};
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const [params] = useSearchParams();
|
||||
const token = params.get('token') ?? '';
|
||||
const navigate = useNavigate();
|
||||
const reset = useResetPassword();
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({ defaultValues: { password: '' } });
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
if (!token) return;
|
||||
const parsed = schema.safeParse(values);
|
||||
if (!parsed.success) return;
|
||||
await reset.mutateAsync({ token, password: parsed.data.password });
|
||||
setDone(true);
|
||||
setTimeout(() => navigate('/login', { replace: true }), 1500);
|
||||
}
|
||||
|
||||
const apiError = reset.error?.code ? ERROR_COPY[reset.error.code] ?? 'Could not reset password.' : null;
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Missing reset link"
|
||||
subtitle="Use the link from the email we sent you."
|
||||
footer={
|
||||
<Link to="/forgot-password" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Request a new link
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-ink-600">
|
||||
The reset link is missing the token parameter. If you copied it manually, make sure you copied the entire URL.
|
||||
</p>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title={done ? 'Password updated' : 'Choose a new password'}
|
||||
subtitle={
|
||||
done
|
||||
? 'Redirecting you to sign in…'
|
||||
: 'Pick something strong. After this, you can sign in with the new password.'
|
||||
}
|
||||
footer={
|
||||
<Link to="/login" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Back to sign in
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{done ? (
|
||||
<p className="text-sm text-ink-600">Your password has been updated. Taking you to the sign-in page now.</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Field
|
||||
label="New password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="At least 10 characters"
|
||||
hint="Use a strong, unique password."
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
{apiError && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || reset.isPending}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{reset.isPending ? 'Updating…' : (
|
||||
<>
|
||||
Update password
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { AuthLayout } from '@/components/auth/AuthLayout';
|
||||
import { Field } from '@/components/auth/Field';
|
||||
import { useMe, useSignup } from '@/hooks/useAuth';
|
||||
|
||||
const schema = z.object({
|
||||
fullName: z.string().min(1, 'Your name is required').max(120),
|
||||
firmName: z.string().min(1, 'Firm name is required').max(160),
|
||||
email: z.string().email('Enter a valid email'),
|
||||
password: z.string().min(10, 'At least 10 characters').max(200),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const ERROR_COPY: Record<string, string> = {
|
||||
email_taken: 'An account with that email already exists.',
|
||||
};
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const me = useMe();
|
||||
const signup = useSignup();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
defaultValues: { fullName: '', firmName: '', email: '', password: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (me.data) navigate('/app', { replace: true });
|
||||
}, [me.data, navigate]);
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const parsed = schema.safeParse(values);
|
||||
if (!parsed.success) return;
|
||||
await signup.mutateAsync(parsed.data);
|
||||
navigate('/app', { replace: true });
|
||||
}
|
||||
|
||||
const apiError = signup.error?.code ? ERROR_COPY[signup.error.code] ?? 'Something went wrong.' : null;
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
title="Start your free trial"
|
||||
subtitle="No credit card required. Cancel anytime."
|
||||
footer={
|
||||
<>
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Sign in
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Field
|
||||
label="Your full name"
|
||||
autoComplete="name"
|
||||
placeholder="Jane Doe"
|
||||
error={errors.fullName?.message}
|
||||
{...register('fullName')}
|
||||
/>
|
||||
<Field
|
||||
label="Firm name"
|
||||
autoComplete="organization"
|
||||
placeholder="Doe & Associates"
|
||||
error={errors.firmName?.message}
|
||||
{...register('firmName')}
|
||||
/>
|
||||
<Field
|
||||
label="Work email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@firm.com"
|
||||
error={errors.email?.message}
|
||||
{...register('email')}
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="At least 10 characters"
|
||||
hint="Use a strong, unique password."
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
{apiError && (
|
||||
<p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiError}</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={isSubmitting || signup.isPending} className="btn-primary w-full">
|
||||
{signup.isPending ? 'Creating your account…' : (
|
||||
<>
|
||||
Create account
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-ink-500">
|
||||
By creating an account you agree to our Terms of Service and Privacy Policy.
|
||||
</p>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { ScrollText } from 'lucide-react';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminLayout';
|
||||
import { Card, EmptyState } from '@/components/ui/Card';
|
||||
import { useAuditLog } from '@/hooks/useAdmin';
|
||||
|
||||
export default function AdminAuditPage() {
|
||||
const [action, setAction] = useState('');
|
||||
const list = useAuditLog({ action: action || undefined });
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<AdminPageHeader title="Audit log" description="Recent privileged and noteworthy actions." />
|
||||
|
||||
<Card>
|
||||
<div className="border-b border-ink-100 p-4 flex items-center gap-2">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Filter by action (e.g. impersonate, suspend, plan)…"
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
className="w-full max-w-sm rounded-xl border border-ink-200 px-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
<p className="text-xs text-ink-500 ml-auto">{list.data?.items.length ?? 0} entries</p>
|
||||
</div>
|
||||
|
||||
{list.isLoading ? (
|
||||
<div className="px-6 py-16 text-center text-sm text-ink-500">Loading…</div>
|
||||
) : !list.data?.items.length ? (
|
||||
<EmptyState icon={<ScrollText className="h-5 w-5" />} title="Audit log is empty" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-100 text-left text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
<th className="px-5 py-3">When</th>
|
||||
<th className="px-5 py-3">Actor</th>
|
||||
<th className="px-5 py-3">Action</th>
|
||||
<th className="px-5 py-3">Meta</th>
|
||||
<th className="px-5 py-3">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{list.data.items.map((e) => (
|
||||
<tr key={e.id} className="hover:bg-ink-50/50 align-top">
|
||||
<td className="px-5 py-3 text-ink-500 whitespace-nowrap">
|
||||
{new Date(e.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-900">{e.userEmail ?? '—'}</td>
|
||||
<td className="px-5 py-3">
|
||||
<code className="rounded bg-ink-100 px-1.5 py-0.5 text-[11px] text-ink-800">{e.action}</code>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-ink-600 max-w-md truncate font-mono">{e.meta ?? '—'}</td>
|
||||
<td className="px-5 py-3 text-ink-500 text-xs">{e.ip ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle2, MailOpen, RotateCcw } from 'lucide-react';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminLayout';
|
||||
import { Card, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useAdminContactMessages, useResolveContactMessage } from '@/hooks/useAdmin';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export default function AdminContactPage() {
|
||||
const [resolved, setResolved] = useState<'true' | 'false' | ''>('false');
|
||||
const list = useAdminContactMessages({ resolved: resolved || undefined });
|
||||
const mutate = useResolveContactMessage();
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-5xl mx-auto w-full">
|
||||
<AdminPageHeader
|
||||
title="Contact inbox"
|
||||
description={`${list.data?.total ?? 0} message${(list.data?.total ?? 0) === 1 ? '' : 's'}`}
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Pill active={resolved === 'false'} onClick={() => setResolved('false')}>Unresolved</Pill>
|
||||
<Pill active={resolved === 'true'} onClick={() => setResolved('true')}>Resolved</Pill>
|
||||
<Pill active={resolved === ''} onClick={() => setResolved('')}>All</Pill>
|
||||
</div>
|
||||
|
||||
{list.isLoading ? (
|
||||
<Card>
|
||||
<div className="px-6 py-16 text-center text-sm text-ink-500">Loading…</div>
|
||||
</Card>
|
||||
) : !list.data?.items.length ? (
|
||||
<Card>
|
||||
<EmptyState icon={<MailOpen className="h-5 w-5" />} title="Inbox zero" description="No messages match this filter." />
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{list.data.items.map((m) => (
|
||||
<Card key={m.id} className={m.resolvedAt ? 'opacity-80' : ''}>
|
||||
<div className="flex items-start gap-4 px-5 py-4">
|
||||
<div className="grid h-10 w-10 flex-none place-items-center rounded-full bg-brand-100 text-brand-700 text-xs font-semibold">
|
||||
{m.fullName.split(' ').map((n) => n[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-ink-900">{m.fullName}</p>
|
||||
<a href={`mailto:${m.email}`} className="text-sm text-brand-600 hover:underline">
|
||||
{m.email}
|
||||
</a>
|
||||
{m.resolvedAt && <Badge tone="emerald">resolved</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-ink-500 mt-0.5">
|
||||
{formatDate(m.createdAt)} · IP {m.ip ?? 'unknown'}
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-ink-700 whitespace-pre-wrap">{m.message}</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<a
|
||||
href={`mailto:${m.email}?subject=Re%3A%20Your%20message%20to%20eLegal%20Software`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-brand-600 hover:text-brand-700"
|
||||
>
|
||||
Reply by email
|
||||
</a>
|
||||
<span className="text-ink-300">·</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={m.resolvedAt ? 'secondary' : 'primary'}
|
||||
onClick={() => mutate.mutate({ id: m.id, resolved: !m.resolvedAt })}
|
||||
disabled={mutate.isPending}
|
||||
>
|
||||
{m.resolvedAt ? (
|
||||
<>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Reopen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Mark resolved
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'rounded-full px-3 py-1.5 text-xs font-semibold transition',
|
||||
active ? 'bg-brand-500 text-white' : 'bg-white border border-ink-200 text-ink-600 hover:bg-ink-50',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Building2, Users, Briefcase, Receipt, MessageSquare, DollarSign } from 'lucide-react';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminLayout';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useAdminStats } from '@/hooks/useAdmin';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const stats = useAdminStats();
|
||||
|
||||
if (stats.isLoading) {
|
||||
return <div className="px-10 py-16 text-sm text-ink-500">Loading…</div>;
|
||||
}
|
||||
if (!stats.data) return null;
|
||||
|
||||
const { counters, planDistribution, signupsLast30Days } = stats.data;
|
||||
const maxSignup = Math.max(1, ...signupsLast30Days.map((d) => d.count));
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<AdminPageHeader title="Overview" description="Platform-wide metrics, refreshed every minute." />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<Kpi icon={<Building2 className="h-4 w-4" />} label="Firms" value={String(counters.firms)} to="/admin/firms" />
|
||||
<Kpi icon={<Users className="h-4 w-4" />} label="Users" value={String(counters.users)} to="/admin/users" />
|
||||
<Kpi icon={<Briefcase className="h-4 w-4" />} label="Cases" value={String(counters.cases)} />
|
||||
<Kpi icon={<Receipt className="h-4 w-4" />} label="Invoices" value={String(counters.invoices)} />
|
||||
<Kpi
|
||||
icon={<DollarSign className="h-4 w-4" />}
|
||||
label="Paid revenue"
|
||||
value={formatMoney(counters.paidRevenueTotal)}
|
||||
/>
|
||||
<Kpi
|
||||
icon={<MessageSquare className="h-4 w-4" />}
|
||||
label="Inbox"
|
||||
value={String(counters.unresolvedContact)}
|
||||
to="/admin/contact"
|
||||
tone={counters.unresolvedContact > 0 ? 'amber' : 'neutral'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader title="Signups · last 30 days" />
|
||||
<CardBody>
|
||||
{!signupsLast30Days.length ? (
|
||||
<p className="text-sm text-ink-500">No signups in the last 30 days.</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-1 h-40">
|
||||
{signupsLast30Days.map((d) => (
|
||||
<div
|
||||
key={d.day}
|
||||
className="group relative flex-1 flex flex-col items-center"
|
||||
title={`${d.day}: ${d.count}`}
|
||||
>
|
||||
<div
|
||||
className="w-full rounded-t-md bg-gradient-to-t from-brand-500 to-brand-400 hover:from-brand-600 hover:to-brand-500 transition"
|
||||
style={{ height: `${(d.count / maxSignup) * 100}%`, minHeight: 4 }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Plan distribution" />
|
||||
<CardBody className="space-y-3">
|
||||
{(['starter', 'pro', 'lifetime'] as const).map((p) => {
|
||||
const row = planDistribution.find((r) => r.plan === p);
|
||||
const count = row?.count ?? 0;
|
||||
const total = planDistribution.reduce((acc, r) => acc + r.count, 0) || 1;
|
||||
const pct = Math.round((count / total) * 100);
|
||||
return (
|
||||
<div key={p}>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="capitalize text-ink-700 font-medium">{p}</span>
|
||||
<span className="text-ink-500">
|
||||
{count} <span className="text-ink-400">({pct}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 rounded-full bg-ink-100 overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
'h-full ' +
|
||||
(p === 'starter' ? 'bg-ink-400' : p === 'pro' ? 'bg-brand-500' : 'bg-emerald-500')
|
||||
}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader title="Quick links" />
|
||||
<CardBody className="space-y-2">
|
||||
<QuickLink to="/admin/firms" label="Browse firms" />
|
||||
<QuickLink to="/admin/users" label="Browse users" />
|
||||
<QuickLink to="/admin/contact" label="Open contact inbox" badge={counters.unresolvedContact > 0 ? counters.unresolvedContact : undefined} />
|
||||
<QuickLink to="/admin/audit" label="View audit log" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
to,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
to?: string;
|
||||
tone?: 'neutral' | 'amber';
|
||||
}) {
|
||||
const inner = (
|
||||
<div className="rounded-2xl border border-ink-100 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-xs text-ink-500">
|
||||
<span className={tone === 'amber' ? 'text-amber-600' : 'text-brand-600'}>{icon}</span>
|
||||
{label}
|
||||
</div>
|
||||
<p className="mt-2 text-xl font-bold text-ink-950 font-display">{value}</p>
|
||||
</div>
|
||||
);
|
||||
return to ? <Link to={to}>{inner}</Link> : inner;
|
||||
}
|
||||
|
||||
function QuickLink({ to, label, badge }: { to: string; label: string; badge?: number }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex items-center justify-between rounded-lg border border-ink-100 px-3 py-2.5 text-sm hover:border-brand-200 hover:bg-brand-50/30 transition"
|
||||
>
|
||||
<span className="font-medium text-ink-800">{label}</span>
|
||||
{badge != null && (
|
||||
<Badge tone="amber">{badge} new</Badge>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminLayout';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useAdminFirm, useUpdateAdminFirm, type AdminPlan } from '@/hooks/useAdmin';
|
||||
import { formatDate, formatMoney } from '@/lib/format';
|
||||
|
||||
export default function AdminFirmDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const detail = useAdminFirm(id);
|
||||
const update = useUpdateAdminFirm(id ?? '');
|
||||
|
||||
if (detail.isLoading) return <div className="px-10 py-16 text-sm text-ink-500">Loading…</div>;
|
||||
if (!detail.data) {
|
||||
return (
|
||||
<div className="px-10 py-16">
|
||||
<p className="text-sm text-ink-500">Firm not found.</p>
|
||||
<Link to="/admin/firms" className="text-sm text-brand-600 mt-3 inline-block">
|
||||
← Back to firms
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { firm, users, counts } = detail.data;
|
||||
|
||||
function changePlan(plan: AdminPlan) {
|
||||
if (!confirm(`Change ${firm.name} to ${plan}?`)) return;
|
||||
// Pro/Lifetime get watermark off automatically
|
||||
update.mutate({ plan, watermarkEnabled: plan === 'starter' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<Link to="/admin/firms" className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-4">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to firms
|
||||
</Link>
|
||||
|
||||
<AdminPageHeader
|
||||
title={firm.name}
|
||||
description={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Badge tone={firm.plan === 'starter' ? 'neutral' : firm.plan === 'pro' ? 'brand' : 'emerald'}>
|
||||
{firm.plan}
|
||||
</Badge>
|
||||
<span className="text-ink-500">·</span>
|
||||
<span>Joined {formatDate(firm.createdAt)}</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Stat label="Users" value={String(users.length)} />
|
||||
<Stat label="Clients" value={String(counts.clients)} />
|
||||
<Stat label="Cases" value={String(counts.cases)} />
|
||||
<Stat label="Paid revenue" value={formatMoney(counts.paidTotal)} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader title="Users in this firm" />
|
||||
{!users.length ? (
|
||||
<CardBody>
|
||||
<p className="text-sm text-ink-500">No users.</p>
|
||||
</CardBody>
|
||||
) : (
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-100 text-left text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
<th className="px-5 py-3">Email</th>
|
||||
<th className="px-5 py-3">Role</th>
|
||||
<th className="px-5 py-3">Last seen</th>
|
||||
<th className="px-5 py-3">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="hover:bg-ink-50/50">
|
||||
<td className="px-5 py-3">
|
||||
<Link to={`/admin/users?q=${encodeURIComponent(u.email)}`} className="font-medium text-ink-900 hover:text-brand-600">
|
||||
{u.email}
|
||||
</Link>
|
||||
{u.fullName && <p className="text-xs text-ink-500">{u.fullName}</p>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Badge tone="neutral">{u.role}</Badge>
|
||||
{u.isSuperadmin && <Badge tone="rose">superadmin</Badge>}
|
||||
{u.isSuspended && <Badge tone="ink">suspended</Badge>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-500">{u.lastSeenAt ? formatDate(u.lastSeenAt) : '—'}</td>
|
||||
<td className="px-5 py-3 text-ink-500">{formatDate(u.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Change plan" description="Upgrade, downgrade, or comp this firm." />
|
||||
<CardBody className="space-y-2">
|
||||
{(['starter', 'pro', 'lifetime'] as const).map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={firm.plan === p ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
disabled={firm.plan === p || update.isPending}
|
||||
onClick={() => changePlan(p)}
|
||||
>
|
||||
<span className="capitalize">{p}</span>
|
||||
{firm.plan === p && <span className="ml-auto text-xs">current</span>}
|
||||
</Button>
|
||||
))}
|
||||
<p className="mt-3 text-xs text-ink-500">
|
||||
Watermark is {firm.watermarkEnabled ? 'on' : 'off'}.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update.mutate({ watermarkEnabled: !firm.watermarkEnabled })}
|
||||
className="font-semibold text-brand-600 hover:text-brand-700"
|
||||
disabled={update.isPending}
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-ink-100 bg-white p-4 shadow-sm">
|
||||
<p className="text-xs text-ink-500">{label}</p>
|
||||
<p className="mt-1 text-xl font-bold text-ink-950 font-display">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, Building2 } from 'lucide-react';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminLayout';
|
||||
import { Card, EmptyState } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useAdminFirms, type AdminPlan } from '@/hooks/useAdmin';
|
||||
import { formatDate, formatMoney } from '@/lib/format';
|
||||
|
||||
const PLAN_TONES: Record<AdminPlan, 'neutral' | 'brand' | 'emerald'> = {
|
||||
starter: 'neutral',
|
||||
pro: 'brand',
|
||||
lifetime: 'emerald',
|
||||
};
|
||||
|
||||
export default function AdminFirmsPage() {
|
||||
const [q, setQ] = useState('');
|
||||
const [plan, setPlan] = useState<AdminPlan | ''>('');
|
||||
const list = useAdminFirms({ q: q || undefined, plan: plan || undefined });
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<AdminPageHeader title="Firms" description={`${list.data?.total ?? 0} total`} />
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3 border-b border-ink-100 p-4 md:flex-row md:items-center">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search firms…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="w-full rounded-xl border border-ink-200 pl-9 pr-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={plan}
|
||||
onChange={(e) => setPlan(e.target.value as AdminPlan | '')}
|
||||
className="rounded-xl border border-ink-200 px-3 py-2 text-sm focus:outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">All plans</option>
|
||||
<option value="starter">Starter</option>
|
||||
<option value="pro">Pro</option>
|
||||
<option value="lifetime">Lifetime</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{list.isLoading ? (
|
||||
<div className="px-6 py-16 text-center text-sm text-ink-500">Loading…</div>
|
||||
) : !list.data?.items.length ? (
|
||||
<EmptyState icon={<Building2 className="h-5 w-5" />} title="No firms match" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-100 text-left text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
<th className="px-5 py-3">Firm</th>
|
||||
<th className="px-5 py-3">Plan</th>
|
||||
<th className="px-5 py-3">Users</th>
|
||||
<th className="px-5 py-3">Cases</th>
|
||||
<th className="px-5 py-3">Clients</th>
|
||||
<th className="px-5 py-3 text-right">Paid revenue</th>
|
||||
<th className="px-5 py-3">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{list.data.items.map((f) => (
|
||||
<tr key={f.id} className="hover:bg-ink-50/50 transition">
|
||||
<td className="px-5 py-3">
|
||||
<Link to={`/admin/firms/${f.id}`} className="font-medium text-ink-900 hover:text-brand-600">
|
||||
{f.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={PLAN_TONES[f.plan]}>{f.plan}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-700">{f.userCount}</td>
|
||||
<td className="px-5 py-3 text-ink-700">{f.caseCount}</td>
|
||||
<td className="px-5 py-3 text-ink-700">{f.clientCount}</td>
|
||||
<td className="px-5 py-3 text-right font-semibold text-ink-900 tabular-nums">
|
||||
{formatMoney(f.paidTotal)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-500">{formatDate(f.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { Search, UserCheck, UserX, ShieldAlert, Eye } from 'lucide-react';
|
||||
import { AdminPageHeader } from '@/components/admin/AdminLayout';
|
||||
import { Card, EmptyState } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
useAdminUsers,
|
||||
useImpersonate,
|
||||
useUpdateAdminUser,
|
||||
} from '@/hooks/useAdmin';
|
||||
import { useMe } from '@/hooks/useAuth';
|
||||
import { formatDate } from '@/lib/format';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const initialQ = params.get('q') ?? '';
|
||||
const [q, setQ] = useState(initialQ);
|
||||
const list = useAdminUsers({ q: q || undefined });
|
||||
const me = useMe();
|
||||
|
||||
function setQuery(value: string) {
|
||||
setQ(value);
|
||||
const next = new URLSearchParams(params);
|
||||
if (value) next.set('q', value);
|
||||
else next.delete('q');
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<AdminPageHeader title="Users" description={`${list.data?.total ?? 0} total`} />
|
||||
|
||||
<Card>
|
||||
<div className="border-b border-ink-100 p-4">
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search users by email or name…"
|
||||
value={q}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-ink-200 pl-9 pr-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{list.isLoading ? (
|
||||
<div className="px-6 py-16 text-center text-sm text-ink-500">Loading…</div>
|
||||
) : !list.data?.items.length ? (
|
||||
<EmptyState icon={<UserX className="h-5 w-5" />} title="No users match" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-100 text-left text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
<th className="px-5 py-3">User</th>
|
||||
<th className="px-5 py-3">Firm</th>
|
||||
<th className="px-5 py-3">Status</th>
|
||||
<th className="px-5 py-3">Last seen</th>
|
||||
<th className="px-5 py-3">Joined</th>
|
||||
<th className="px-5 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{list.data.items.map((u) => (
|
||||
<UserRow key={u.id} user={u} isCurrent={me.data?.id === u.id} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user: u,
|
||||
isCurrent,
|
||||
}: {
|
||||
user: import('@/hooks/useAdmin').AdminUserListItem;
|
||||
isCurrent: boolean;
|
||||
}) {
|
||||
const update = useUpdateAdminUser(u.id);
|
||||
const impersonate = useImpersonate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function toggleSuspend() {
|
||||
if (isCurrent) return;
|
||||
if (!confirm(`${u.isSuspended ? 'Unsuspend' : 'Suspend'} ${u.email}?`)) return;
|
||||
update.mutate({ isSuspended: !u.isSuspended });
|
||||
}
|
||||
|
||||
async function onImpersonate() {
|
||||
if (isCurrent) return;
|
||||
if (!confirm(`Take over ${u.email}'s session? You will be logged out as yourself.`)) return;
|
||||
await impersonate.mutateAsync(u.id);
|
||||
navigate('/app', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className={'hover:bg-ink-50/50 transition ' + (u.isSuspended ? 'opacity-70' : '')}>
|
||||
<td className="px-5 py-3">
|
||||
<p className="font-medium text-ink-900">{u.email}</p>
|
||||
{u.fullName && <p className="text-xs text-ink-500">{u.fullName}</p>}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-700">
|
||||
{u.firmId ? (
|
||||
<Link to={`/admin/firms/${u.firmId}`} className="hover:text-brand-600">
|
||||
{u.firmName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-ink-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Badge tone="neutral">{u.role}</Badge>
|
||||
{u.isSuperadmin && (
|
||||
<Badge tone="rose">
|
||||
<ShieldAlert className="mr-0.5 h-3 w-3" />
|
||||
superadmin
|
||||
</Badge>
|
||||
)}
|
||||
{u.isSuspended && <Badge tone="ink">suspended</Badge>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-500">{u.lastSeenAt ? formatDate(u.lastSeenAt) : '—'}</td>
|
||||
<td className="px-5 py-3 text-ink-500">{formatDate(u.createdAt)}</td>
|
||||
<td className="px-5 py-3 text-right">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onImpersonate}
|
||||
disabled={isCurrent || u.isSuspended || impersonate.isPending}
|
||||
title={isCurrent ? 'You are this user' : 'Take over this account'}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Impersonate
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={u.isSuspended ? 'secondary' : 'danger'}
|
||||
onClick={toggleSuspend}
|
||||
disabled={isCurrent || update.isPending}
|
||||
title={isCurrent ? 'Cannot suspend yourself' : ''}
|
||||
>
|
||||
{u.isSuspended ? (
|
||||
<>
|
||||
<UserCheck className="h-3.5 w-3.5" />
|
||||
Unsuspend
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserX className="h-3.5 w-3.5" />
|
||||
Suspend
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Download, Trash2, Cookie, AlertTriangle } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useMe } from '@/hooks/useAuth';
|
||||
import { downloadAccountExport, useDeleteAccount } from '@/hooks/useAccount';
|
||||
import { getConsent } from '@/components/CookieBanner';
|
||||
import { BillingCard } from '@/components/app/BillingCard';
|
||||
|
||||
export default function AccountSettingsPage() {
|
||||
const me = useMe();
|
||||
const navigate = useNavigate();
|
||||
const del = useDeleteAccount();
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
async function onExport() {
|
||||
setExportError(null);
|
||||
setExporting(true);
|
||||
try {
|
||||
await downloadAccountExport();
|
||||
} catch (e) {
|
||||
setExportError((e as Error).message);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (confirmText.trim().toUpperCase() !== 'DELETE') return;
|
||||
if (!password) return;
|
||||
try {
|
||||
await del.mutateAsync({ password });
|
||||
navigate('/', { replace: true });
|
||||
} catch {
|
||||
// surfaced via del.error
|
||||
}
|
||||
}
|
||||
|
||||
const consent = getConsent();
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-3xl mx-auto w-full">
|
||||
<PageHeader title="Account & privacy" description="Your data, your choices." />
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader title="Profile" />
|
||||
<CardBody className="space-y-2 text-sm">
|
||||
<Field label="Email" value={me.data?.email ?? '—'} />
|
||||
<Field label="Role" value={me.data?.role ?? '—'} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<BillingCard />
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Cookie preferences" description="What you've allowed us to store on your device." />
|
||||
<CardBody>
|
||||
<div className="flex items-start gap-3 rounded-xl border border-ink-100 bg-ink-50/50 p-4">
|
||||
<Cookie className="h-5 w-5 text-brand-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-ink-900">
|
||||
{consent === 'all'
|
||||
? 'You allowed all cookies.'
|
||||
: consent === 'essentials'
|
||||
? 'You allowed essentials only.'
|
||||
: 'No choice recorded yet.'}
|
||||
</p>
|
||||
<p className="text-xs text-ink-500 mt-1">
|
||||
Essential cookies (session + CSRF) are required to keep you signed in and the
|
||||
site secure. Non-essential cookies are off until you opt in.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
localStorage.removeItem('lawdesk:cookie-consent');
|
||||
location.reload();
|
||||
}}
|
||||
className="mt-3 text-xs font-semibold text-brand-600 hover:text-brand-700"
|
||||
>
|
||||
Reset choice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Export your data"
|
||||
description="Download a JSON file with everything tied to your account: profile, firm, clients, cases, time entries, invoices, and document metadata."
|
||||
/>
|
||||
<CardBody>
|
||||
<Button onClick={onExport} disabled={exporting}>
|
||||
<Download className="h-4 w-4" />
|
||||
{exporting ? 'Preparing…' : 'Download my data'}
|
||||
</Button>
|
||||
{exportError && (
|
||||
<p className="mt-3 text-sm text-rose-700 rounded-lg bg-rose-50 px-3 py-2">{exportError}</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="border-rose-200">
|
||||
<CardHeader
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2 text-rose-700">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Delete account
|
||||
</span>
|
||||
}
|
||||
description="Permanently delete your account and all associated firm data. This cannot be undone."
|
||||
/>
|
||||
<CardBody>
|
||||
{!confirmOpen ? (
|
||||
<Button variant="danger" onClick={() => setConfirmOpen(true)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete my account
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-3 rounded-xl border border-rose-200 bg-rose-50/30 p-4">
|
||||
<p className="text-sm text-ink-700">
|
||||
This will delete your firm, clients, cases, time entries, invoices, and
|
||||
documents. You'll be signed out immediately.
|
||||
</p>
|
||||
<Input
|
||||
label="Confirm your password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Type DELETE to confirm"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
/>
|
||||
{del.error && (
|
||||
<p className="text-sm text-rose-700 rounded-lg bg-rose-100 px-3 py-2">
|
||||
{del.error.code === 'invalid_password'
|
||||
? 'Password is incorrect.'
|
||||
: del.error.code === 'firm_has_other_users'
|
||||
? 'Your firm has other users. Remove them or transfer ownership first.'
|
||||
: 'Could not delete the account.'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onDelete}
|
||||
disabled={
|
||||
del.isPending ||
|
||||
!password ||
|
||||
confirmText.trim().toUpperCase() !== 'DELETE'
|
||||
}
|
||||
>
|
||||
{del.isPending ? 'Deleting…' : 'Permanently delete'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false);
|
||||
setPassword('');
|
||||
setConfirmText('');
|
||||
del.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-ink-100 py-2 last:border-b-0">
|
||||
<span className="text-xs uppercase tracking-wider text-ink-500">{label}</span>
|
||||
<span className="text-ink-900">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ArrowLeft, Plus, Receipt, Trash2 } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select, Textarea } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { CaseTimeList } from '@/components/app/CaseTimeList';
|
||||
import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer';
|
||||
import { useInvoices, type InvoiceStatus } from '@/hooks/useInvoices';
|
||||
import { formatDate, formatMoney } from '@/lib/format';
|
||||
import {
|
||||
useCase,
|
||||
useDeleteCase,
|
||||
useUpdateCase,
|
||||
type CaseInput,
|
||||
type CaseStatus,
|
||||
} from '@/hooks/useCases';
|
||||
|
||||
const STATUSES: CaseStatus[] = ['open', 'pending', 'closed', 'archived'];
|
||||
const STATUS_TONES: Record<CaseStatus, 'emerald' | 'amber' | 'neutral' | 'ink'> = {
|
||||
open: 'emerald',
|
||||
pending: 'amber',
|
||||
closed: 'neutral',
|
||||
archived: 'ink',
|
||||
};
|
||||
|
||||
export default function CaseDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const c = useCase(id);
|
||||
const update = useUpdateCase(id ?? '');
|
||||
const del = useDeleteCase();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [invoiceDrawerOpen, setInvoiceDrawerOpen] = useState(false);
|
||||
const invoices = useInvoices(id ? { caseId: id } : undefined);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = useForm<CaseInput>({
|
||||
values: c.data
|
||||
? {
|
||||
clientId: c.data.clientId,
|
||||
title: c.data.title,
|
||||
caseNumber: c.data.caseNumber,
|
||||
status: c.data.status,
|
||||
practiceArea: c.data.practiceArea,
|
||||
description: c.data.description,
|
||||
hourlyRate: c.data.hourlyRate ? Number(c.data.hourlyRate) : null,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (c.isLoading) return <div className="px-10 py-16 text-sm text-ink-500">Loading…</div>;
|
||||
if (!c.data) {
|
||||
return (
|
||||
<div className="px-10 py-16">
|
||||
<p className="text-sm text-ink-500">Case not found.</p>
|
||||
<Link to="/app/cases" className="text-sm text-brand-600 mt-3 inline-block">
|
||||
← Back to cases
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function onSave(values: CaseInput) {
|
||||
await update.mutateAsync({
|
||||
title: values.title?.trim(),
|
||||
caseNumber: values.caseNumber?.toString().trim() || null,
|
||||
status: values.status,
|
||||
practiceArea: values.practiceArea?.toString().trim() || null,
|
||||
description: values.description?.toString().trim() || null,
|
||||
hourlyRate:
|
||||
values.hourlyRate != null && values.hourlyRate !== ('' as unknown as number)
|
||||
? Number(values.hourlyRate)
|
||||
: null,
|
||||
});
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!id) return;
|
||||
if (!confirm(`Delete case "${c.data?.title}"? This cannot be undone.`)) return;
|
||||
await del.mutateAsync(id);
|
||||
navigate('/app/cases', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<Link
|
||||
to="/app/cases"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to cases
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={c.data.title}
|
||||
description={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{c.data.caseNumber && <span>{c.data.caseNumber}</span>}
|
||||
<Badge tone={STATUS_TONES[c.data.status]}>{c.data.status}</Badge>
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
editing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit(onSave)} disabled={update.isPending || !isDirty}>
|
||||
{update.isPending ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onDelete} disabled={del.isPending}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader title="Details" />
|
||||
<CardBody>
|
||||
{editing ? (
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSave)}>
|
||||
<Input label="Title" {...register('title', { required: true })} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input label="Case number" {...register('caseNumber')} />
|
||||
<Select label="Status" {...register('status')}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input label="Practice area" {...register('practiceArea')} />
|
||||
<Input
|
||||
label="Hourly rate (USD)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
{...register('hourlyRate')}
|
||||
/>
|
||||
</div>
|
||||
<Textarea label="Description" rows={5} {...register('description')} />
|
||||
</form>
|
||||
) : (
|
||||
<dl className="grid gap-5 md:grid-cols-2 text-sm">
|
||||
<Field label="Practice area" value={c.data.practiceArea} />
|
||||
<Field label="Hourly rate" value={formatMoney(c.data.hourlyRate)} />
|
||||
<Field label="Opened" value={formatDate(c.data.openedAt)} />
|
||||
<Field label="Closed" value={c.data.closedAt ? formatDate(c.data.closedAt) : '—'} />
|
||||
{c.data.description && (
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-xs font-semibold uppercase tracking-wider text-ink-500">Description</dt>
|
||||
<dd className="mt-1 whitespace-pre-wrap text-ink-700">{c.data.description}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Client" />
|
||||
<CardBody>
|
||||
<Link
|
||||
to={`/app/clients/${c.data.clientId}`}
|
||||
className="block rounded-lg border border-ink-100 px-4 py-3 hover:border-brand-200 hover:bg-brand-50/30 transition"
|
||||
>
|
||||
<p className="font-medium text-ink-900">{c.data.clientName}</p>
|
||||
{c.data.clientEmail && <p className="text-xs text-ink-500 mt-0.5">{c.data.clientEmail}</p>}
|
||||
</Link>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<CaseTimeList caseId={c.data.id} />
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader
|
||||
title="Invoices"
|
||||
description={
|
||||
(invoices.data?.total ?? 0) > 0
|
||||
? `${invoices.data!.total} invoice${invoices.data!.total === 1 ? '' : 's'} for this case.`
|
||||
: 'Generate an invoice from this case.'
|
||||
}
|
||||
action={
|
||||
<Button size="sm" onClick={() => setInvoiceDrawerOpen(true)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New invoice
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!invoices.data?.items.length ? (
|
||||
<EmptyState
|
||||
icon={<Receipt className="h-5 w-5" />}
|
||||
title="No invoices yet"
|
||||
description="Roll up unbilled time entries into an invoice, or build one manually."
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-ink-100">
|
||||
{invoices.data.items.map((inv) => (
|
||||
<li key={inv.id}>
|
||||
<Link
|
||||
to={`/app/invoices/${inv.id}`}
|
||||
className="flex items-center justify-between gap-4 px-5 py-3 hover:bg-ink-50/50 transition"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-ink-900">{inv.number}</p>
|
||||
<p className="text-xs text-ink-500 mt-0.5">
|
||||
{inv.issuedAt
|
||||
? `Issued ${new Date(inv.issuedAt).toLocaleDateString()}`
|
||||
: 'Draft'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge tone={statusTone(inv.status)}>{inv.status}</Badge>
|
||||
<span className="text-sm font-semibold text-ink-900 tabular-nums">
|
||||
{formatMoney(inv.total)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader
|
||||
title="Documents"
|
||||
description="Document storage lands in the next iteration."
|
||||
/>
|
||||
<CardBody>
|
||||
<p className="text-sm text-ink-500">Coming next.</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<CreateInvoiceDrawer
|
||||
open={invoiceDrawerOpen}
|
||||
onClose={() => setInvoiceDrawerOpen(false)}
|
||||
initialClientId={c.data.clientId}
|
||||
initialCaseId={c.data.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(status: InvoiceStatus): 'neutral' | 'amber' | 'emerald' | 'rose' | 'ink' {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'emerald';
|
||||
case 'sent':
|
||||
return 'amber';
|
||||
case 'overdue':
|
||||
return 'rose';
|
||||
case 'void':
|
||||
return 'ink';
|
||||
default:
|
||||
return 'neutral';
|
||||
}
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wider text-ink-500">{label}</dt>
|
||||
<dd className="mt-1 text-ink-700">{value || '—'}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Plus, Briefcase, Search } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select, Textarea } from '@/components/ui/Input';
|
||||
import { Drawer } from '@/components/ui/Drawer';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useCases, useCreateCase, type CaseInput, type CaseStatus } from '@/hooks/useCases';
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { formatDate, formatHours, formatMoney, planLimitMessage } from '@/lib/format';
|
||||
|
||||
const STATUSES: CaseStatus[] = ['open', 'pending', 'closed', 'archived'];
|
||||
const STATUS_TONES: Record<CaseStatus, 'emerald' | 'amber' | 'neutral' | 'ink'> = {
|
||||
open: 'emerald',
|
||||
pending: 'amber',
|
||||
closed: 'neutral',
|
||||
archived: 'ink',
|
||||
};
|
||||
|
||||
export default function CasesPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const q = params.get('q') ?? '';
|
||||
const status = (params.get('status') as CaseStatus | null) ?? undefined;
|
||||
const clientId = params.get('clientId') ?? undefined;
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const list = useCases({ q: q || undefined, status, clientId });
|
||||
|
||||
function setParam(key: string, value: string) {
|
||||
const next = new URLSearchParams(params);
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<PageHeader
|
||||
title="Cases"
|
||||
description="All matters across your firm."
|
||||
action={
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New case
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-col gap-3 border-b border-ink-100 p-4 md:flex-row md:items-center">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search by title or case number…"
|
||||
value={q}
|
||||
onChange={(e) => setParam('q', e.target.value)}
|
||||
className="w-full rounded-xl border border-ink-200 pl-9 pr-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status ?? ''}
|
||||
onChange={(e) => setParam('status', e.target.value)}
|
||||
className="rounded-xl border border-ink-200 px-3 py-2 text-sm focus:outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{list.isLoading ? (
|
||||
<div className="px-6 py-16 text-center text-sm text-ink-500">Loading…</div>
|
||||
) : !list.data?.items.length ? (
|
||||
<EmptyState
|
||||
icon={<Briefcase className="h-5 w-5" />}
|
||||
title={q || status ? 'No cases match your filters' : 'No cases yet'}
|
||||
description={q || status ? 'Adjust your search.' : 'Open your first case to get started.'}
|
||||
action={
|
||||
!q && !status ? (
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New case
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-100 text-left text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
<th className="px-5 py-3">Case</th>
|
||||
<th className="px-5 py-3">Client</th>
|
||||
<th className="px-5 py-3">Status</th>
|
||||
<th className="px-5 py-3">Hours</th>
|
||||
<th className="px-5 py-3">Rate</th>
|
||||
<th className="px-5 py-3">Opened</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{list.data.items.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-ink-50/50 transition">
|
||||
<td className="px-5 py-3">
|
||||
<Link to={`/app/cases/${c.id}`} className="font-medium text-ink-900 hover:text-brand-600">
|
||||
{c.title}
|
||||
</Link>
|
||||
{c.caseNumber && <p className="text-xs text-ink-500">{c.caseNumber}</p>}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-600">
|
||||
<Link to={`/app/clients/${c.clientId}`} className="hover:text-brand-600">
|
||||
{c.clientName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={STATUS_TONES[c.status]}>{c.status}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-700">{formatHours(c.billedMinutes)}</td>
|
||||
<td className="px-5 py-3 text-ink-700">{formatMoney(c.hourlyRate)}</td>
|
||||
<td className="px-5 py-3 text-ink-500">{formatDate(c.openedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<CreateCaseDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} initialClientId={clientId ?? undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateCaseDrawer({
|
||||
open,
|
||||
onClose,
|
||||
initialClientId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
initialClientId?: string;
|
||||
}) {
|
||||
const create = useCreateCase();
|
||||
const clients = useClients();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<CaseInput>({
|
||||
defaultValues: { title: '', clientId: initialClientId ?? '', status: 'open' },
|
||||
});
|
||||
|
||||
async function onSubmit(values: CaseInput) {
|
||||
await create.mutateAsync({
|
||||
clientId: values.clientId,
|
||||
title: values.title.trim(),
|
||||
caseNumber: values.caseNumber?.toString().trim() || null,
|
||||
status: values.status ?? 'open',
|
||||
practiceArea: values.practiceArea?.toString().trim() || null,
|
||||
description: values.description?.toString().trim() || null,
|
||||
hourlyRate:
|
||||
values.hourlyRate != null && values.hourlyRate !== ('' as unknown as number)
|
||||
? Number(values.hourlyRate)
|
||||
: null,
|
||||
});
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
const apiErr = create.error ? planLimitMessage(create.error.code, 'Could not create the case.') : null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={() => {
|
||||
reset();
|
||||
create.reset();
|
||||
onClose();
|
||||
}}
|
||||
title="New case"
|
||||
description="Open a new matter for one of your clients."
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit(onSubmit)} disabled={create.isPending}>
|
||||
{create.isPending ? 'Creating…' : 'Create case'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Select
|
||||
label="Client"
|
||||
error={errors.clientId?.message}
|
||||
{...register('clientId', { required: 'Pick a client' })}
|
||||
>
|
||||
<option value="">Select a client…</option>
|
||||
{clients.data?.items.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
label="Title"
|
||||
placeholder="Smith v. Johnson Corp"
|
||||
error={errors.title?.message}
|
||||
{...register('title', { required: 'Title is required', maxLength: 200 })}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input label="Case number" placeholder="2025-CV-0142" {...register('caseNumber')} />
|
||||
<Select label="Status" {...register('status')}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input label="Practice area" placeholder="Corporate" {...register('practiceArea')} />
|
||||
<Input
|
||||
label="Hourly rate (USD)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
placeholder="250"
|
||||
{...register('hourlyRate')}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Description"
|
||||
rows={4}
|
||||
placeholder="Background, scope, key details…"
|
||||
{...register('description')}
|
||||
/>
|
||||
|
||||
{apiErr && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiErr}</p>}
|
||||
</form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ArrowLeft, Briefcase, Mail, MapPin, Phone, Trash2 } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Textarea } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
useClient,
|
||||
useDeleteClient,
|
||||
useUpdateClient,
|
||||
type ClientInput,
|
||||
} from '@/hooks/useClients';
|
||||
import { useCases } from '@/hooks/useCases';
|
||||
import { formatDate } from '@/lib/format';
|
||||
|
||||
export default function ClientDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const client = useClient(id);
|
||||
const cases = useCases(id ? { clientId: id } : undefined);
|
||||
const update = useUpdateClient(id ?? '');
|
||||
const del = useDeleteClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<ClientInput>({
|
||||
values: client.data
|
||||
? {
|
||||
name: client.data.name,
|
||||
email: client.data.email,
|
||||
phone: client.data.phone,
|
||||
address: client.data.address,
|
||||
notes: client.data.notes,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (client.isLoading) {
|
||||
return <div className="px-10 py-16 text-sm text-ink-500">Loading…</div>;
|
||||
}
|
||||
if (!client.data) {
|
||||
return (
|
||||
<div className="px-10 py-16">
|
||||
<p className="text-sm text-ink-500">Client not found.</p>
|
||||
<Link to="/app/clients" className="text-sm text-brand-600 mt-3 inline-block">
|
||||
← Back to clients
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function onSave(values: ClientInput) {
|
||||
await update.mutateAsync({
|
||||
name: values.name?.trim(),
|
||||
email: values.email?.toString().trim() || null,
|
||||
phone: values.phone?.toString().trim() || null,
|
||||
address: values.address?.toString().trim() || null,
|
||||
notes: values.notes?.toString().trim() || null,
|
||||
});
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!id) return;
|
||||
if (!confirm(`Delete ${client.data?.name}? This cannot be undone.`)) return;
|
||||
await del.mutateAsync(id);
|
||||
navigate('/app/clients', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<Link
|
||||
to="/app/clients"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to clients
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={client.data.name}
|
||||
description={`Client since ${formatDate(client.data.createdAt)}`}
|
||||
action={
|
||||
editing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
reset();
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit(onSave)} disabled={update.isPending || !isDirty}>
|
||||
{update.isPending ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setEditing(true)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onDelete} disabled={del.isPending}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader title="Details" />
|
||||
<CardBody>
|
||||
{editing ? (
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSave)}>
|
||||
<Input label="Name" error={errors.name?.message} {...register('name', { required: 'Name is required' })} />
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Input label="Email" type="email" {...register('email')} />
|
||||
<Input label="Phone" {...register('phone')} />
|
||||
</div>
|
||||
<Input label="Address" {...register('address')} />
|
||||
<Textarea label="Notes" rows={5} {...register('notes')} />
|
||||
</form>
|
||||
) : (
|
||||
<dl className="grid gap-4 md:grid-cols-2 text-sm">
|
||||
<Field icon={<Mail className="h-4 w-4" />} label="Email" value={client.data.email} />
|
||||
<Field icon={<Phone className="h-4 w-4" />} label="Phone" value={client.data.phone} />
|
||||
<Field icon={<MapPin className="h-4 w-4" />} label="Address" value={client.data.address} />
|
||||
{client.data.notes && (
|
||||
<div className="md:col-span-2">
|
||||
<dt className="text-xs font-semibold uppercase tracking-wider text-ink-500">Notes</dt>
|
||||
<dd className="mt-1 whitespace-pre-wrap text-ink-700">{client.data.notes}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Cases"
|
||||
description={`${cases.data?.total ?? 0} case${(cases.data?.total ?? 0) === 1 ? '' : 's'}`}
|
||||
action={
|
||||
<Link to={`/app/cases?clientId=${id}`} className="text-xs font-semibold text-brand-600">
|
||||
View all
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{cases.data?.items.length ? (
|
||||
<div className="divide-y divide-ink-100">
|
||||
{cases.data.items.slice(0, 5).map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/app/cases/${c.id}`}
|
||||
className="flex items-center justify-between gap-3 px-5 py-3 hover:bg-ink-50/60 transition"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-ink-900 truncate">{c.title}</p>
|
||||
<p className="text-xs text-ink-500">Opened {formatDate(c.openedAt)}</p>
|
||||
</div>
|
||||
<Badge tone={c.status === 'open' ? 'emerald' : 'neutral'}>{c.status}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Briefcase className="h-5 w-5" />}
|
||||
title="No cases for this client"
|
||||
description="Open a case from the Cases page."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ icon, label, value }: { icon: React.ReactNode; label: string; value: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
{icon}
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="mt-1 text-ink-700">{value || '—'}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Plus, Users, Search } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Textarea } from '@/components/ui/Input';
|
||||
import { Drawer } from '@/components/ui/Drawer';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useClients, useCreateClient, type ClientInput } from '@/hooks/useClients';
|
||||
import { formatDate, planLimitMessage } from '@/lib/format';
|
||||
|
||||
export default function ClientsPage() {
|
||||
const [q, setQ] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const list = useClients(q || undefined);
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<PageHeader
|
||||
title="Clients"
|
||||
description="Everyone you do work for, in one place."
|
||||
action={
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add client
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<div className="border-b border-ink-100 p-4">
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-ink-400" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search clients…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="w-full rounded-xl border border-ink-200 pl-9 pr-3 py-2 text-sm placeholder:text-ink-400 focus:outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{list.isLoading ? (
|
||||
<div className="px-6 py-16 text-center text-sm text-ink-500">Loading…</div>
|
||||
) : !list.data?.items.length ? (
|
||||
<EmptyState
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
title={q ? 'No clients match that search' : 'No clients yet'}
|
||||
description={q ? 'Try a different name or email.' : 'Add your first client to get started.'}
|
||||
action={
|
||||
!q ? (
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add client
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-100 text-left text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
<th className="px-5 py-3">Name</th>
|
||||
<th className="px-5 py-3">Email</th>
|
||||
<th className="px-5 py-3">Phone</th>
|
||||
<th className="px-5 py-3">Cases</th>
|
||||
<th className="px-5 py-3">Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{list.data.items.map((c) => (
|
||||
<tr key={c.id} className="hover:bg-ink-50/50 transition">
|
||||
<td className="px-5 py-3">
|
||||
<Link to={`/app/clients/${c.id}`} className="font-medium text-ink-900 hover:text-brand-600">
|
||||
{c.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-600">{c.email ?? '—'}</td>
|
||||
<td className="px-5 py-3 text-ink-600">{c.phone ?? '—'}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={c.caseCount > 0 ? 'brand' : 'neutral'}>{c.caseCount}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-500">{formatDate(c.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<CreateClientDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateClientDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const create = useCreateClient();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<ClientInput>({ defaultValues: { name: '' } });
|
||||
|
||||
async function onSubmit(values: ClientInput) {
|
||||
const payload: ClientInput = {
|
||||
name: values.name.trim(),
|
||||
email: values.email?.toString().trim() || null,
|
||||
phone: values.phone?.toString().trim() || null,
|
||||
address: values.address?.toString().trim() || null,
|
||||
notes: values.notes?.toString().trim() || null,
|
||||
};
|
||||
await create.mutateAsync(payload);
|
||||
reset();
|
||||
onClose();
|
||||
}
|
||||
|
||||
const apiErr = create.error ? planLimitMessage(create.error.code, 'Could not save the client.') : null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={() => {
|
||||
reset();
|
||||
create.reset();
|
||||
onClose();
|
||||
}}
|
||||
title="Add client"
|
||||
description="Add a person or company you do work for."
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit(onSubmit)} disabled={create.isPending}>
|
||||
{create.isPending ? 'Saving…' : 'Save client'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="Jane Doe / Acme Corp"
|
||||
error={errors.name?.message}
|
||||
{...register('name', { required: 'Name is required', maxLength: 160 })}
|
||||
/>
|
||||
<Input label="Email" type="email" placeholder="jane@acme.com" {...register('email')} />
|
||||
<Input label="Phone" placeholder="+1 555 123 4567" {...register('phone')} />
|
||||
<Input label="Address" placeholder="Street, City, Country" {...register('address')} />
|
||||
<Textarea label="Notes" rows={4} placeholder="Anything important about this client…" {...register('notes')} />
|
||||
|
||||
{apiErr && <p className="rounded-lg bg-rose-50 px-3 py-2 text-sm text-rose-700">{apiErr}</p>}
|
||||
</form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Briefcase, Clock, Receipt, Users, ArrowUpRight } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useCases } from '@/hooks/useCases';
|
||||
import { useClients } from '@/hooks/useClients';
|
||||
import { useMe } from '@/hooks/useAuth';
|
||||
import { formatHours, formatDate } from '@/lib/format';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const me = useMe();
|
||||
const cases = useCases();
|
||||
const clients = useClients();
|
||||
|
||||
const openCases = cases.data?.items.filter((c) => c.status === 'open') ?? [];
|
||||
const totalMinutes = cases.data?.items.reduce((acc, c) => acc + (c.billedMinutes ?? 0), 0) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<PageHeader
|
||||
title={`Welcome back${me.data?.fullName ? `, ${me.data.fullName.split(' ')[0]}` : ''}`}
|
||||
description="Here's what's happening across your practice."
|
||||
action={
|
||||
<Link to="/app/cases">
|
||||
<Button>Open cases</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<KpiCard
|
||||
icon={<Briefcase className="h-4 w-4" />}
|
||||
label="Active cases"
|
||||
value={String(openCases.length)}
|
||||
to="/app/cases"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
label="Clients"
|
||||
value={String(clients.data?.total ?? 0)}
|
||||
to="/app/clients"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
label="Tracked hours"
|
||||
value={formatHours(totalMinutes)}
|
||||
to="/app/time"
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Receipt className="h-4 w-4" />}
|
||||
label="Outstanding"
|
||||
value="$0"
|
||||
to="/app/invoices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader
|
||||
title="Recent cases"
|
||||
description="Latest cases across your firm."
|
||||
action={
|
||||
<Link to="/app/cases" className="text-xs font-semibold text-brand-600 hover:text-brand-700">
|
||||
View all
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
{cases.data?.items.length ? (
|
||||
<div className="divide-y divide-ink-100">
|
||||
{cases.data.items.slice(0, 6).map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/app/cases/${c.id}`}
|
||||
className="flex items-center justify-between gap-4 px-5 py-3 hover:bg-ink-50/60 transition"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-ink-900 truncate">{c.title}</p>
|
||||
<p className="text-xs text-ink-500 truncate">
|
||||
{c.clientName} · Opened {formatDate(c.openedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={c.status === 'open' ? 'emerald' : 'neutral'}>{c.status}</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Briefcase className="h-5 w-5" />}
|
||||
title="No cases yet"
|
||||
description="Create your first case from the Cases page."
|
||||
action={
|
||||
<Link to="/app/cases">
|
||||
<Button size="sm">Go to Cases</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Quick actions" />
|
||||
<CardBody className="space-y-2">
|
||||
<Link
|
||||
to="/app/clients"
|
||||
className="flex items-center justify-between rounded-lg border border-ink-100 px-3 py-2.5 text-sm hover:border-brand-200 hover:bg-brand-50/30"
|
||||
>
|
||||
<span className="font-medium text-ink-800">Add a client</span>
|
||||
<ArrowUpRight className="h-4 w-4 text-ink-400" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/app/cases"
|
||||
className="flex items-center justify-between rounded-lg border border-ink-100 px-3 py-2.5 text-sm hover:border-brand-200 hover:bg-brand-50/30"
|
||||
>
|
||||
<span className="font-medium text-ink-800">Open a new case</span>
|
||||
<ArrowUpRight className="h-4 w-4 text-ink-400" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/app/time"
|
||||
className="flex items-center justify-between rounded-lg border border-ink-100 px-3 py-2.5 text-sm hover:border-brand-200 hover:bg-brand-50/30"
|
||||
>
|
||||
<span className="font-medium text-ink-800">Log time</span>
|
||||
<ArrowUpRight className="h-4 w-4 text-ink-400" />
|
||||
</Link>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
to,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
to: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="rounded-2xl border border-ink-100 bg-white p-5 shadow-sm hover:border-brand-200 hover:shadow-md transition"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="grid h-9 w-9 place-items-center rounded-lg bg-brand-50 text-brand-600">{icon}</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-ink-400" />
|
||||
</div>
|
||||
<p className="mt-4 text-2xl font-bold text-ink-950 font-display">{value}</p>
|
||||
<p className="text-xs text-ink-500">{label}</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Download, Send, CheckCircle2, Ban, Trash2 } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
useInvoice,
|
||||
useSendInvoice,
|
||||
useMarkPaid,
|
||||
useVoidInvoice,
|
||||
useDeleteInvoice,
|
||||
type InvoiceStatus,
|
||||
} from '@/hooks/useInvoices';
|
||||
import { formatDate, formatMoney } from '@/lib/format';
|
||||
|
||||
const STATUS_TONES: Record<InvoiceStatus, 'neutral' | 'amber' | 'emerald' | 'rose' | 'ink'> = {
|
||||
draft: 'neutral',
|
||||
sent: 'amber',
|
||||
paid: 'emerald',
|
||||
overdue: 'rose',
|
||||
void: 'ink',
|
||||
};
|
||||
|
||||
export default function InvoiceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const inv = useInvoice(id);
|
||||
const send = useSendInvoice();
|
||||
const markPaid = useMarkPaid();
|
||||
const voidInv = useVoidInvoice();
|
||||
const del = useDeleteInvoice();
|
||||
|
||||
if (inv.isLoading) return <div className="px-10 py-16 text-sm text-ink-500">Loading…</div>;
|
||||
if (!inv.data) {
|
||||
return (
|
||||
<div className="px-10 py-16">
|
||||
<p className="text-sm text-ink-500">Invoice not found.</p>
|
||||
<Link to="/app/invoices" className="text-sm text-brand-600 mt-3 inline-block">
|
||||
← Back to invoices
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const i = inv.data;
|
||||
const taxAmount = (Number(i.subtotal) * Number(i.taxRate)) / 100;
|
||||
|
||||
async function onDelete() {
|
||||
if (!id) return;
|
||||
if (!confirm(`Delete draft ${i.number}? This cannot be undone.`)) return;
|
||||
await del.mutateAsync(id);
|
||||
navigate('/app/invoices', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-5xl mx-auto w-full">
|
||||
<Link
|
||||
to="/app/invoices"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-4"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to invoices
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={i.number}
|
||||
description={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Badge tone={STATUS_TONES[i.status]}>{i.status}</Badge>
|
||||
<span className="text-ink-500">·</span>
|
||||
<span>{i.clientName}</span>
|
||||
{i.caseTitle && (
|
||||
<>
|
||||
<span className="text-ink-500">·</span>
|
||||
<Link to={`/app/cases/${i.caseId}`} className="text-brand-600 hover:underline">
|
||||
{i.caseTitle}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<>
|
||||
<a
|
||||
href={`/api/invoices/${i.id}/pdf`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-ink-200 bg-white px-4 py-2 text-sm font-medium text-ink-800 hover:border-ink-300"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
PDF
|
||||
</a>
|
||||
{i.status === 'draft' && (
|
||||
<>
|
||||
<Button onClick={() => id && send.mutate(id)} disabled={send.isPending}>
|
||||
<Send className="h-4 w-4" />
|
||||
{send.isPending ? 'Sending…' : 'Send invoice'}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={onDelete} disabled={del.isPending}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(i.status === 'sent' || i.status === 'overdue') && (
|
||||
<>
|
||||
<Button onClick={() => id && markPaid.mutate(id)} disabled={markPaid.isPending}>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Mark paid
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => id && voidInv.mutate(id)} disabled={voidInv.isPending}>
|
||||
<Ban className="h-4 w-4" />
|
||||
Void
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader title="Line items" description={`${i.items.length} ${i.items.length === 1 ? 'item' : 'items'}`} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-100 text-left text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
<th className="px-5 py-3">Description</th>
|
||||
<th className="px-5 py-3 text-right">Qty</th>
|
||||
<th className="px-5 py-3 text-right">Rate</th>
|
||||
<th className="px-5 py-3 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{i.items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td className="px-5 py-3 text-ink-900">{it.description}</td>
|
||||
<td className="px-5 py-3 text-right text-ink-700 tabular-nums">{Number(it.quantity)}</td>
|
||||
<td className="px-5 py-3 text-right text-ink-700 tabular-nums">{formatMoney(it.rate)}</td>
|
||||
<td className="px-5 py-3 text-right font-semibold text-ink-900 tabular-nums">
|
||||
{formatMoney(it.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={3} className="px-5 pt-4 text-right text-xs uppercase tracking-wider text-ink-500">
|
||||
Subtotal
|
||||
</td>
|
||||
<td className="px-5 pt-4 text-right text-ink-900 tabular-nums">{formatMoney(i.subtotal)}</td>
|
||||
</tr>
|
||||
{Number(i.taxRate) > 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-5 pt-1 text-right text-xs uppercase tracking-wider text-ink-500">
|
||||
Tax ({i.taxRate}%)
|
||||
</td>
|
||||
<td className="px-5 pt-1 text-right text-ink-900 tabular-nums">{formatMoney(taxAmount)}</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td colSpan={3} className="px-5 pt-3 pb-5 text-right text-sm font-semibold uppercase tracking-wider text-ink-700">
|
||||
Total
|
||||
</td>
|
||||
<td className="px-5 pt-3 pb-5 text-right text-lg font-bold text-brand-600 tabular-nums">
|
||||
{formatMoney(i.total)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader title="Billing" />
|
||||
<CardBody className="space-y-3 text-sm">
|
||||
<Field label="Issued" value={formatDate(i.issuedAt)} />
|
||||
<Field label="Due" value={formatDate(i.dueAt)} />
|
||||
{i.paidAt && <Field label="Paid" value={formatDate(i.paidAt)} />}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Client" />
|
||||
<CardBody>
|
||||
<Link
|
||||
to={`/app/clients/${i.clientId}`}
|
||||
className="block rounded-lg border border-ink-100 px-3 py-2 hover:border-brand-200 hover:bg-brand-50/30"
|
||||
>
|
||||
<p className="font-medium text-ink-900">{i.clientName}</p>
|
||||
{i.clientEmail && <p className="text-xs text-ink-500 mt-0.5">{i.clientEmail}</p>}
|
||||
</Link>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{i.notes && (
|
||||
<Card>
|
||||
<CardHeader title="Notes" />
|
||||
<CardBody>
|
||||
<p className="whitespace-pre-wrap text-sm text-ink-700">{i.notes}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-ink-500">{label}</span>
|
||||
<span className="text-ink-900">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { Plus, Receipt } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { CreateInvoiceDrawer } from '@/components/app/CreateInvoiceDrawer';
|
||||
import { useInvoices, type InvoiceStatus } from '@/hooks/useInvoices';
|
||||
import { formatDate, formatMoney } from '@/lib/format';
|
||||
|
||||
const STATUSES: InvoiceStatus[] = ['draft', 'sent', 'paid', 'overdue', 'void'];
|
||||
|
||||
const STATUS_TONES: Record<InvoiceStatus, 'neutral' | 'amber' | 'emerald' | 'rose' | 'ink'> = {
|
||||
draft: 'neutral',
|
||||
sent: 'amber',
|
||||
paid: 'emerald',
|
||||
overdue: 'rose',
|
||||
void: 'ink',
|
||||
};
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const status = (params.get('status') as InvoiceStatus | null) ?? undefined;
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const list = useInvoices({ status });
|
||||
|
||||
function setStatus(s: InvoiceStatus | '') {
|
||||
const next = new URLSearchParams(params);
|
||||
if (s) next.set('status', s);
|
||||
else next.delete('status');
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<PageHeader
|
||||
title="Invoices"
|
||||
description="Drafts, sent, paid — all in one place."
|
||||
action={
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New invoice
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<FilterPill active={!status} onClick={() => setStatus('')}>All</FilterPill>
|
||||
{STATUSES.map((s) => (
|
||||
<FilterPill key={s} active={status === s} onClick={() => setStatus(s)}>
|
||||
{s}
|
||||
</FilterPill>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
{list.isLoading ? (
|
||||
<div className="px-6 py-16 text-center text-sm text-ink-500">Loading…</div>
|
||||
) : !list.data?.items.length ? (
|
||||
<EmptyState
|
||||
icon={<Receipt className="h-5 w-5" />}
|
||||
title={status ? `No ${status} invoices` : 'No invoices yet'}
|
||||
description={status ? 'Try a different status.' : 'Create your first invoice from time entries or from scratch.'}
|
||||
action={
|
||||
!status ? (
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New invoice
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-100 text-left text-xs font-semibold uppercase tracking-wider text-ink-500">
|
||||
<th className="px-5 py-3">Number</th>
|
||||
<th className="px-5 py-3">Client</th>
|
||||
<th className="px-5 py-3">Status</th>
|
||||
<th className="px-5 py-3 text-right">Total</th>
|
||||
<th className="px-5 py-3">Issued</th>
|
||||
<th className="px-5 py-3">Due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
{list.data.items.map((inv) => (
|
||||
<tr key={inv.id} className="hover:bg-ink-50/50 transition">
|
||||
<td className="px-5 py-3">
|
||||
<Link to={`/app/invoices/${inv.id}`} className="font-medium text-ink-900 hover:text-brand-600">
|
||||
{inv.number}
|
||||
</Link>
|
||||
{inv.caseTitle && (
|
||||
<p className="text-xs text-ink-500 truncate max-w-[260px]">{inv.caseTitle}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-700">
|
||||
<Link to={`/app/clients/${inv.clientId}`} className="hover:text-brand-600">
|
||||
{inv.clientName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={STATUS_TONES[inv.status]}>{inv.status}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right font-semibold text-ink-900 tabular-nums">
|
||||
{formatMoney(inv.total)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-ink-500">{formatDate(inv.issuedAt)}</td>
|
||||
<td className="px-5 py-3 text-ink-500">{formatDate(inv.dueAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<CreateInvoiceDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterPill({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={
|
||||
'rounded-full px-3 py-1.5 text-xs font-semibold capitalize transition ' +
|
||||
(active ? 'bg-brand-500 text-white' : 'bg-white border border-ink-200 text-ink-600 hover:bg-ink-50')
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Plus, Clock, Trash2, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/app/AppLayout';
|
||||
import { Card, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { ManualEntryDrawer } from '@/components/app/ManualEntryDrawer';
|
||||
import { useTimeEntries, useDeleteTimeEntry, type TimeEntry } from '@/hooks/useTime';
|
||||
import { formatHours, formatMoney } from '@/lib/format';
|
||||
|
||||
function startOfWeek(d: Date): Date {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
const day = x.getDay(); // 0 Sun .. 6 Sat
|
||||
const diff = day === 0 ? -6 : 1 - day; // make Monday the start
|
||||
x.setDate(x.getDate() + diff);
|
||||
return x;
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const x = new Date(d);
|
||||
x.setDate(x.getDate() + n);
|
||||
return x;
|
||||
}
|
||||
|
||||
function ymd(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function formatRange(weekStart: Date): string {
|
||||
const end = addDays(weekStart, 6);
|
||||
const fmt: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
|
||||
return `${weekStart.toLocaleDateString(undefined, fmt)} – ${end.toLocaleDateString(undefined, { ...fmt, year: 'numeric' })}`;
|
||||
}
|
||||
|
||||
function entryAmount(e: TimeEntry): number {
|
||||
if (!e.billable) return 0;
|
||||
return (Number(e.rate) || 0) * (e.minutes / 60);
|
||||
}
|
||||
|
||||
export default function TimePage() {
|
||||
const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date()));
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const from = weekStart.toISOString();
|
||||
const to = addDays(weekStart, 7).toISOString();
|
||||
|
||||
const list = useTimeEntries({ from, to });
|
||||
const del = useDeleteTimeEntry();
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, TimeEntry[]>();
|
||||
for (const e of list.data?.items ?? []) {
|
||||
const key = ymd(new Date(e.startedAt));
|
||||
const arr = map.get(key) ?? [];
|
||||
arr.push(e);
|
||||
map.set(key, arr);
|
||||
}
|
||||
return map;
|
||||
}, [list.data]);
|
||||
|
||||
const totalMinutes = (list.data?.items ?? []).reduce((acc, e) => acc + e.minutes, 0);
|
||||
const billableAmount = (list.data?.items ?? []).reduce((acc, e) => acc + entryAmount(e), 0);
|
||||
|
||||
return (
|
||||
<div className="px-6 lg:px-10 py-8 max-w-7xl mx-auto w-full">
|
||||
<PageHeader
|
||||
title="Time"
|
||||
description="Track billable and non-billable work."
|
||||
action={
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Log time
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWeekStart((d) => addDays(d, -7))}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg border border-ink-200 hover:bg-ink-50"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWeekStart(startOfWeek(new Date()))}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg border border-ink-200 hover:bg-ink-50"
|
||||
>
|
||||
This week
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setWeekStart((d) => addDays(d, 7))}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg border border-ink-200 hover:bg-ink-50"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="ml-2 text-sm text-ink-700">{formatRange(weekStart)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-ink-500">Total</p>
|
||||
<p className="font-semibold text-ink-900">{formatHours(totalMinutes)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-ink-500">Billable</p>
|
||||
<p className="font-semibold text-ink-900">{formatMoney(billableAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{list.isLoading ? (
|
||||
<Card>
|
||||
<div className="px-6 py-16 text-center text-sm text-ink-500">Loading…</div>
|
||||
</Card>
|
||||
) : !list.data?.items.length ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<Clock className="h-5 w-5" />}
|
||||
title="No time logged this week"
|
||||
description="Start the timer in the topbar, or log time manually."
|
||||
action={
|
||||
<Button onClick={() => setDrawerOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Log time
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 7 }).map((_, i) => {
|
||||
const day = addDays(weekStart, i);
|
||||
const items = grouped.get(ymd(day)) ?? [];
|
||||
if (!items.length) return null;
|
||||
const dayMinutes = items.reduce((acc, e) => acc + e.minutes, 0);
|
||||
return (
|
||||
<Card key={i}>
|
||||
<div className="flex items-center justify-between border-b border-ink-100 px-5 py-3">
|
||||
<p className="text-sm font-semibold text-ink-900">
|
||||
{day.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
<p className="text-xs text-ink-500">{formatHours(dayMinutes)}</p>
|
||||
</div>
|
||||
<ul className="divide-y divide-ink-100">
|
||||
{items.map((e) => (
|
||||
<li key={e.id} className="flex items-center gap-4 px-5 py-3 hover:bg-ink-50/50">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-ink-900 truncate">{e.description}</p>
|
||||
<p className="text-xs text-ink-500 mt-0.5 truncate">
|
||||
<Link to={`/app/cases/${e.caseId}`} className="hover:text-brand-600">
|
||||
{e.caseTitle}
|
||||
</Link>{' '}
|
||||
· {e.clientName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-ink-900 tabular-nums">{formatHours(e.minutes)}</p>
|
||||
<p className="text-xs text-ink-500">{e.billable ? formatMoney(entryAmount(e)) : 'Non-billable'}</p>
|
||||
</div>
|
||||
{e.invoiceItemId ? (
|
||||
<Badge tone="brand">Invoiced</Badge>
|
||||
) : !e.endedAt ? (
|
||||
<Badge tone="emerald">Running</Badge>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('Delete this time entry?')) del.mutate(e.id);
|
||||
}}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-ink-400 hover:text-rose-600 hover:bg-rose-50 transition"
|
||||
aria-label="Delete entry"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ManualEntryDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { XCircle, ArrowRight } from 'lucide-react';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export default function BillingCancelPage() {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-20 max-w-2xl text-center">
|
||||
<div className="mx-auto grid h-16 w-16 place-items-center rounded-2xl bg-ink-100 text-ink-500">
|
||||
<XCircle className="h-8 w-8" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-3xl md:text-4xl font-bold text-ink-950 font-display">
|
||||
Checkout cancelled
|
||||
</h1>
|
||||
<p className="mt-3 text-lg text-ink-600">
|
||||
No charge was made. Your plan is unchanged.
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<Link to="/app/settings">
|
||||
<Button>
|
||||
Back to settings
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/#pricing">
|
||||
<Button variant="secondary">See plans</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { CheckCircle2, ArrowRight } from 'lucide-react';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export default function BillingSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Refresh billing/me state when the user lands here from Stripe
|
||||
useEffect(() => {
|
||||
qc.invalidateQueries();
|
||||
}, [qc]);
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-20 max-w-2xl text-center">
|
||||
<div className="mx-auto grid h-16 w-16 place-items-center rounded-2xl bg-emerald-100 text-emerald-600">
|
||||
<CheckCircle2 className="h-8 w-8" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-3xl md:text-4xl font-bold text-ink-950 font-display">
|
||||
You're upgraded
|
||||
</h1>
|
||||
<p className="mt-3 text-lg text-ink-600">
|
||||
Stripe has confirmed your payment. Your firm's plan is being updated — usually within a
|
||||
few seconds. A confirmation email is on its way.
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<Link to="/app">
|
||||
<Button>
|
||||
Open dashboard
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/app/settings">
|
||||
<Button variant="secondary">Manage billing</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-8 text-xs text-ink-500">
|
||||
If your plan still shows as Starter after a minute,{' '}
|
||||
<button onClick={() => navigate(0)} className="underline hover:text-ink-700">
|
||||
refresh
|
||||
</button>{' '}
|
||||
this page.
|
||||
</p>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { PublicLayout, PublicHero } from '@/components/public/PublicLayout';
|
||||
import { POSTS } from '@/content/posts';
|
||||
import { formatDate } from '@/lib/format';
|
||||
|
||||
export default function BlogIndexPage() {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<PublicHero
|
||||
eyebrow="Blog"
|
||||
title="Practical writing for legal professionals"
|
||||
description="Tactics, frameworks, and short reads on running a profitable, well-organized practice."
|
||||
/>
|
||||
|
||||
<section className="container py-16">
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{POSTS.map((p) => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
to={`/blog/${p.slug}`}
|
||||
className="group rounded-2xl border border-ink-100 bg-white overflow-hidden hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
|
||||
>
|
||||
<div className="aspect-[16/9] bg-gradient-to-br from-brand-100 via-brand-50 to-white relative">
|
||||
<div className="absolute inset-0 grid place-items-center">
|
||||
<span className="text-4xl font-bold text-brand-300/50 font-display select-none">
|
||||
{p.title.split(' ').slice(0, 2).join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<p className="text-xs text-ink-500">{formatDate(p.publishedAt)}</p>
|
||||
<h3 className="mt-2 text-lg font-semibold text-ink-900 group-hover:text-brand-700 transition leading-snug">
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-ink-600 leading-relaxed flex-1 line-clamp-3">{p.description}</p>
|
||||
<p className="mt-4 inline-flex items-center gap-1.5 text-xs text-ink-500">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{p.readMinutes} min read
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Clock, Info, AlertTriangle } from 'lucide-react';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { getPost, type Block } from '@/content/posts';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export default function BlogPostPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const post = slug ? getPost(slug) : undefined;
|
||||
|
||||
if (!post) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-24 max-w-2xl text-center">
|
||||
<h1 className="text-2xl font-bold text-ink-950 font-display">Post not found</h1>
|
||||
<Link to="/blog" className="mt-4 inline-block text-brand-600 hover:text-brand-700">
|
||||
← Back to blog
|
||||
</Link>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<article className="container py-12 max-w-3xl">
|
||||
<Link to="/blog" className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-6">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All posts
|
||||
</Link>
|
||||
|
||||
<header className="mb-10">
|
||||
<p className="text-xs uppercase tracking-wider text-brand-600 font-semibold">Blog</p>
|
||||
<h1 className="mt-3 text-3xl md:text-5xl font-bold text-ink-950 font-display leading-tight">
|
||||
{post.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-ink-600 leading-relaxed">{post.description}</p>
|
||||
<div className="mt-6 flex items-center gap-3 text-sm text-ink-500">
|
||||
<span>{post.author}</span>
|
||||
<span className="text-ink-300">·</span>
|
||||
<span>{formatDate(post.publishedAt)}</span>
|
||||
<span className="text-ink-300">·</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{post.readMinutes} min read
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-5">
|
||||
{post.body.map((b, i) => (
|
||||
<RenderBlock key={i} block={b} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="mt-16 border-t border-ink-100 pt-8 flex items-center justify-between">
|
||||
<Link to="/blog" className="text-sm text-brand-600 hover:text-brand-700">
|
||||
← All posts
|
||||
</Link>
|
||||
<Link to="/signup" className="btn-primary text-sm">
|
||||
Try eLegal Software free
|
||||
</Link>
|
||||
</footer>
|
||||
</article>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderBlock({ block }: { block: Block }) {
|
||||
switch (block.type) {
|
||||
case 'p':
|
||||
return <p className="text-ink-800 leading-relaxed">{block.text}</p>;
|
||||
case 'h2':
|
||||
return <h2 className="mt-10 text-2xl md:text-3xl font-bold text-ink-950 font-display">{block.text}</h2>;
|
||||
case 'h3':
|
||||
return <h3 className="mt-8 text-xl font-semibold text-ink-900 font-display">{block.text}</h3>;
|
||||
case 'ul':
|
||||
return (
|
||||
<ul className="list-disc pl-6 space-y-2 text-ink-800">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
case 'ol':
|
||||
return (
|
||||
<ol className="list-decimal pl-6 space-y-2 text-ink-800">
|
||||
{block.items.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
case 'quote':
|
||||
return (
|
||||
<blockquote className="border-l-4 border-brand-500 pl-4 italic text-ink-700">{block.text}</blockquote>
|
||||
);
|
||||
case 'callout': {
|
||||
const tone = block.tone;
|
||||
const Icon = tone === 'amber' ? AlertTriangle : Info;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl p-5 my-2 flex items-start gap-3',
|
||||
tone === 'amber'
|
||||
? 'border border-amber-200 bg-amber-50/50 text-amber-900'
|
||||
: 'border border-brand-200 bg-brand-50/50 text-ink-800',
|
||||
)}
|
||||
>
|
||||
<Icon className={cn('h-5 w-5 flex-none mt-0.5', tone === 'amber' ? 'text-amber-600' : 'text-brand-600')} />
|
||||
<div>
|
||||
<p className="font-semibold">{block.title}</p>
|
||||
<p className="mt-1 text-sm leading-relaxed">{block.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LegalLayout, H2, P, UL } from './LegalLayout';
|
||||
|
||||
export default function CookiesPage() {
|
||||
return (
|
||||
<LegalLayout title="Cookie Policy" effectiveDate="April 2026">
|
||||
<P>
|
||||
This page explains the cookies eLegal Software sets, what they are for, and how to control
|
||||
them.
|
||||
</P>
|
||||
|
||||
<H2>What is a cookie?</H2>
|
||||
<P>
|
||||
A cookie is a small piece of data a website asks your browser to store on your device.
|
||||
It can be read back later by the same site. We use a small number of cookies and
|
||||
nothing else (no localStorage tracking, no fingerprinting).
|
||||
</P>
|
||||
|
||||
<H2>Cookies we set</H2>
|
||||
<table className="w-full border border-ink-200 rounded-xl overflow-hidden text-sm">
|
||||
<thead className="bg-ink-50 text-left text-xs uppercase tracking-wider text-ink-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2">Name</th>
|
||||
<th className="px-4 py-2">Purpose</th>
|
||||
<th className="px-4 py-2">Lifetime</th>
|
||||
<th className="px-4 py-2">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-100">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-xs">sid</td>
|
||||
<td className="px-4 py-3">Keeps you signed in. Holds an opaque session identifier whose hash is stored in our database.</td>
|
||||
<td className="px-4 py-3">30 days (sliding)</td>
|
||||
<td className="px-4 py-3">Essential</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-xs">csrf</td>
|
||||
<td className="px-4 py-3">Cross-site request forgery protection. Echoed back as a header on state-changing requests.</td>
|
||||
<td className="px-4 py-3">Session</td>
|
||||
<td className="px-4 py-3">Essential</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-xs">elegal:cookie-consent</td>
|
||||
<td className="px-4 py-3">
|
||||
Stored in <span className="font-mono">localStorage</span>, not as a cookie. Records your cookie banner choice
|
||||
so we don't ask again.
|
||||
</td>
|
||||
<td className="px-4 py-3">Until cleared</td>
|
||||
<td className="px-4 py-3">Essential</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<H2>Optional cookies</H2>
|
||||
<P>
|
||||
We do not currently set any optional analytics or advertising cookies. If we add any in
|
||||
the future, we will update this page and re-prompt you for consent.
|
||||
</P>
|
||||
|
||||
<H2>Third parties</H2>
|
||||
<UL
|
||||
items={[
|
||||
'Payment processor — when you check out, our payments processor may set its own cookies on its own domain. Those are governed by its policy.',
|
||||
'Error reporting — Sentry runs only when an error occurs and does not set cookies on your device under our domain.',
|
||||
]}
|
||||
/>
|
||||
|
||||
<H2>Controlling cookies</H2>
|
||||
<UL
|
||||
items={[
|
||||
'You can clear or block cookies in your browser settings. Blocking the essential cookies will sign you out and may prevent you from using the service.',
|
||||
'You can change your banner choice anytime from Settings → Cookie preferences while signed in.',
|
||||
]}
|
||||
/>
|
||||
|
||||
<H2>More information</H2>
|
||||
<P>
|
||||
For details on the broader handling of personal information, see the{' '}
|
||||
<Link to="/legal/privacy" className="text-brand-600 hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
</LegalLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
|
||||
const LINKS = [
|
||||
{ to: '/legal/privacy', label: 'Privacy Policy' },
|
||||
{ to: '/legal/terms', label: 'Terms of Service' },
|
||||
{ to: '/legal/cookies', label: 'Cookie Policy' },
|
||||
];
|
||||
|
||||
export function LegalLayout({
|
||||
title,
|
||||
effectiveDate,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
effectiveDate: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-12 max-w-4xl">
|
||||
<header className="mb-8">
|
||||
<p className="text-xs uppercase tracking-wider text-brand-600 font-semibold">Legal</p>
|
||||
<h1 className="mt-2 text-3xl md:text-4xl font-bold text-ink-950 font-display">{title}</h1>
|
||||
<p className="mt-2 text-sm text-ink-500">Effective {effectiveDate}</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50/40 p-4 mb-8 flex items-start gap-3 text-sm text-amber-900">
|
||||
<AlertTriangle className="h-4 w-4 flex-none mt-0.5 text-amber-600" />
|
||||
<p>
|
||||
<strong className="font-semibold">Template notice:</strong> these documents are
|
||||
starting points that the eLegal Software team has drafted in plain English. Before you put
|
||||
them on a production site, have a licensed attorney in your jurisdiction review and
|
||||
adapt them to your business and applicable law.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="mb-10 flex flex-wrap gap-2">
|
||||
{LINKS.map((l) => (
|
||||
<Link
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
className="rounded-full border border-ink-200 px-3 py-1.5 text-xs font-medium text-ink-600 hover:border-brand-200 hover:bg-brand-50/30 hover:text-brand-700 transition"
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<article className="prose-style space-y-5">{children}</article>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function H2({ children }: { children: ReactNode }) {
|
||||
return <h2 className="mt-10 text-xl md:text-2xl font-bold text-ink-950 font-display">{children}</h2>;
|
||||
}
|
||||
|
||||
export function H3({ children }: { children: ReactNode }) {
|
||||
return <h3 className="mt-6 text-lg font-semibold text-ink-900 font-display">{children}</h3>;
|
||||
}
|
||||
|
||||
export function P({ children }: { children: ReactNode }) {
|
||||
return <p className="text-ink-800 leading-relaxed">{children}</p>;
|
||||
}
|
||||
|
||||
export function UL({ items }: { items: ReactNode[] }) {
|
||||
return (
|
||||
<ul className="list-disc pl-6 space-y-2 text-ink-800">
|
||||
{items.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LegalLayout, H2, H3, P, UL } from './LegalLayout';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<LegalLayout title="Privacy Policy" effectiveDate="April 2026">
|
||||
<P>
|
||||
This policy explains what information eLegal Software collects, why we collect it, and the
|
||||
choices you have. We aim for plain language. Where a term has a specific legal meaning,
|
||||
we say so.
|
||||
</P>
|
||||
|
||||
<H2>1. Who we are</H2>
|
||||
<P>
|
||||
eLegal Software (“we,” “us”) provides practice-management software for
|
||||
law firms. When you use the service we are the data controller for the information you
|
||||
provide about yourself and your firm. For information your firm uploads about its
|
||||
clients, your firm is the controller and we act as a processor.
|
||||
</P>
|
||||
|
||||
<H2>2. Information we collect</H2>
|
||||
<H3>2.1 Information you give us</H3>
|
||||
<UL
|
||||
items={[
|
||||
'Account information — your name, email address, password (stored only as an argon2id hash), and firm name.',
|
||||
'Practice data — clients, cases, documents, time entries, invoices, and notes you create or upload.',
|
||||
'Communications — messages you send through the contact form, support requests, and email replies.',
|
||||
'Payment information — handled by our payments processor; we never see or store your full card number.',
|
||||
]}
|
||||
/>
|
||||
<H3>2.2 Information we collect automatically</H3>
|
||||
<UL
|
||||
items={[
|
||||
'Log data — IP address, user agent, requested URL, response status, and timestamp. Used for security and debugging.',
|
||||
'Cookies — see the Cookie Policy.',
|
||||
'Aggregate usage — anonymous counts of feature use to help us prioritize improvements.',
|
||||
]}
|
||||
/>
|
||||
|
||||
<H2>3. How we use information</H2>
|
||||
<UL
|
||||
items={[
|
||||
'To operate the service — let you sign in, store your data, generate documents, send invoices.',
|
||||
'To keep accounts secure — rate limiting, anomaly detection, audit logging of privileged actions.',
|
||||
'To support you — respond to questions and resolve issues you report.',
|
||||
'To improve — understand what is working and what is not, in aggregate.',
|
||||
'To comply with law — respond to legal requests, prevent fraud, and enforce our Terms.',
|
||||
]}
|
||||
/>
|
||||
<P>
|
||||
We do not sell your information. We do not use your firm's practice data to train
|
||||
machine-learning models.
|
||||
</P>
|
||||
|
||||
<H2>4. Where data is stored</H2>
|
||||
<P>
|
||||
Your data is stored on infrastructure operated by DigitalOcean in the region you
|
||||
select. Database backups are encrypted at rest and retained for 30 days. We use TLS for
|
||||
all data in transit.
|
||||
</P>
|
||||
|
||||
<H2>5. Sharing</H2>
|
||||
<P>We share information only with the parties listed below, and only as needed to operate the service:</P>
|
||||
<UL
|
||||
items={[
|
||||
'Hosting and database — DigitalOcean (managed Postgres, app hosting, Spaces object storage).',
|
||||
'Email — our transactional email provider, used to deliver verification, password resets, and invoices.',
|
||||
'Payments — our payments processor, for subscription billing.',
|
||||
'Error monitoring — Sentry, used to capture crashes; we do not send personal practice data to Sentry.',
|
||||
]}
|
||||
/>
|
||||
<P>
|
||||
We may share information when required by law, valid legal process, or to protect the
|
||||
rights, safety, or property of eLegal Software, our users, or others.
|
||||
</P>
|
||||
|
||||
<H2>6. Your rights</H2>
|
||||
<P>
|
||||
Depending on where you live, you may have the right to access, correct, port, or delete
|
||||
your personal information, and to object to or restrict certain processing. You can
|
||||
exercise most of these rights directly inside the app:
|
||||
</P>
|
||||
<UL
|
||||
items={[
|
||||
'Access and portability — go to Settings → Export your data to download a JSON file with everything tied to your account.',
|
||||
'Deletion — go to Settings → Delete account. We will permanently remove your account, your firm, and the firm’s practice data.',
|
||||
'Correction — edit your profile, clients, cases, time entries, invoices, and documents directly.',
|
||||
]}
|
||||
/>
|
||||
<P>
|
||||
For requests we cannot satisfy in-app, email <a href="mailto:privacy@elegalsoftware.com" className="text-brand-600 hover:underline">privacy@elegalsoftware.com</a>.
|
||||
</P>
|
||||
|
||||
<H2>7. Retention</H2>
|
||||
<UL
|
||||
items={[
|
||||
'Account and practice data: kept until you delete it, or up to 30 days after account deletion (in encrypted backups), then purged.',
|
||||
'Audit log: kept for 24 months.',
|
||||
'Payment records: kept as required by tax and accounting laws (typically 7 years).',
|
||||
'Server logs: kept for 30 days.',
|
||||
]}
|
||||
/>
|
||||
|
||||
<H2>8. International transfers</H2>
|
||||
<P>
|
||||
If you access the service from a country other than where the data is hosted, your
|
||||
information may be transferred to and stored in that hosting region. We use standard
|
||||
contractual clauses with our processors where required.
|
||||
</P>
|
||||
|
||||
<H2>9. Children</H2>
|
||||
<P>
|
||||
The service is not directed to children under 16, and we do not knowingly collect
|
||||
personal information from them.
|
||||
</P>
|
||||
|
||||
<H2>10. Changes to this policy</H2>
|
||||
<P>
|
||||
We will post material changes here and update the effective date. If a change is
|
||||
significant we will notify account holders by email at least 14 days before it takes
|
||||
effect.
|
||||
</P>
|
||||
|
||||
<H2>11. Contact</H2>
|
||||
<P>
|
||||
Questions or requests: <a href="mailto:privacy@elegalsoftware.com" className="text-brand-600 hover:underline">privacy@elegalsoftware.com</a>.
|
||||
For details on the cookies we set, see the{' '}
|
||||
<Link to="/legal/cookies" className="text-brand-600 hover:underline">
|
||||
Cookie Policy
|
||||
</Link>
|
||||
. For the contract that governs your use of the service, see the{' '}
|
||||
<Link to="/legal/terms" className="text-brand-600 hover:underline">
|
||||
Terms of Service
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
</LegalLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { LegalLayout, H2, P, UL } from './LegalLayout';
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<LegalLayout title="Terms of Service" effectiveDate="April 2026">
|
||||
<P>
|
||||
These Terms govern your access to and use of eLegal Software. By creating an account or using
|
||||
the service, you agree to them. If you are using eLegal Software on behalf of a firm, you
|
||||
represent that you have authority to bind that firm to these Terms.
|
||||
</P>
|
||||
|
||||
<H2>1. The service</H2>
|
||||
<P>
|
||||
eLegal Software is software that helps law firms manage cases, track billable hours, store
|
||||
documents, and produce invoices. We provide the software; you remain responsible for
|
||||
the legal work and the relationships with your own clients.
|
||||
</P>
|
||||
|
||||
<H2>2. Your account</H2>
|
||||
<UL
|
||||
items={[
|
||||
'You are responsible for safeguarding your password and for any activity under your account.',
|
||||
'Notify us promptly if you suspect unauthorized access.',
|
||||
'You must provide accurate registration information and keep it current.',
|
||||
'One person, one account. Sharing accounts is not permitted.',
|
||||
]}
|
||||
/>
|
||||
|
||||
<H2>3. Acceptable use</H2>
|
||||
<P>You agree not to:</P>
|
||||
<UL
|
||||
items={[
|
||||
'Use the service to violate any law or the rights of another person.',
|
||||
'Attempt to access accounts, data, or systems you are not authorized to access.',
|
||||
'Reverse engineer, scrape, or interfere with the service or its security features.',
|
||||
'Upload malware or content that is illegal, infringing, or harmful.',
|
||||
'Resell or sublicense the service without our written consent.',
|
||||
]}
|
||||
/>
|
||||
|
||||
<H2>4. Plans, fees, and trials</H2>
|
||||
<UL
|
||||
items={[
|
||||
'Paid plans renew automatically until canceled. You can cancel anytime from billing settings.',
|
||||
'Fees are charged in advance for each subscription period and are non-refundable except where required by law.',
|
||||
'We may change pricing with at least 30 days’ notice. Existing paid periods are honored at the prior price.',
|
||||
'Trial accounts and the free Starter plan have feature and usage limits. Limits may change as the product evolves.',
|
||||
]}
|
||||
/>
|
||||
|
||||
<H2>5. Your content</H2>
|
||||
<P>
|
||||
You retain ownership of everything you upload (clients, cases, documents, time
|
||||
entries, invoices). You grant us a limited license to host, process, and display that
|
||||
content solely to operate the service for you. We do not use your content to train
|
||||
machine-learning models, and we will not disclose it except as described in our{' '}
|
||||
<Link to="/legal/privacy" className="text-brand-600 hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</P>
|
||||
|
||||
<H2>6. Confidentiality of your clients’ data</H2>
|
||||
<P>
|
||||
We understand that information about your clients is confidential, often privileged,
|
||||
and subject to professional ethics rules. We treat it accordingly: encrypted in transit
|
||||
and at rest, scoped to your firm by our access-control system, and accessible to our
|
||||
staff only as strictly needed to operate the service or respond to a support request
|
||||
you initiate.
|
||||
</P>
|
||||
|
||||
<H2>7. Suspension and termination</H2>
|
||||
<UL
|
||||
items={[
|
||||
'You may terminate your account anytime from Settings → Delete account.',
|
||||
'We may suspend or terminate accounts that violate these Terms, present a security risk, or are inactive for an extended period (we will notify you first where reasonably possible).',
|
||||
'On termination, you can export your data for up to 30 days; after that, your data is permanently deleted from our active systems and from backups within the following 30 days.',
|
||||
]}
|
||||
/>
|
||||
|
||||
<H2>8. Service availability</H2>
|
||||
<P>
|
||||
We aim for high availability but do not guarantee uninterrupted service. We schedule
|
||||
maintenance during low-traffic windows and post notices for significant changes.
|
||||
</P>
|
||||
|
||||
<H2>9. Disclaimer</H2>
|
||||
<P>
|
||||
The service is provided “as is.” To the maximum extent permitted by law, we
|
||||
disclaim all warranties, express or implied, including merchantability, fitness for a
|
||||
particular purpose, and non-infringement. eLegal Software is software, not a substitute for
|
||||
professional legal judgment. Use of the service does not create an attorney–client
|
||||
relationship between you and eLegal Software.
|
||||
</P>
|
||||
|
||||
<H2>10. Limitation of liability</H2>
|
||||
<P>
|
||||
To the maximum extent permitted by law, our total liability for any claim arising out
|
||||
of or related to the service is limited to the amount you paid us in the 12 months
|
||||
preceding the event giving rise to the claim. We are not liable for indirect,
|
||||
incidental, special, consequential, or punitive damages, or for lost profits or
|
||||
revenues.
|
||||
</P>
|
||||
|
||||
<H2>11. Indemnification</H2>
|
||||
<P>
|
||||
You agree to indemnify and hold us harmless from any claims, losses, or expenses
|
||||
arising out of (a) your use of the service in violation of these Terms or applicable
|
||||
law, or (b) your content.
|
||||
</P>
|
||||
|
||||
<H2>12. Changes to the Terms</H2>
|
||||
<P>
|
||||
We may update these Terms from time to time. Material changes take effect 30 days after
|
||||
we post them, or sooner if required by law. Continued use after the effective date
|
||||
means you accept the updated Terms.
|
||||
</P>
|
||||
|
||||
<H2>13. Governing law and disputes</H2>
|
||||
<P>
|
||||
These Terms are governed by the laws of the jurisdiction stated in your account region
|
||||
without regard to conflict-of-laws rules. The parties will attempt to resolve any
|
||||
dispute in good faith. Where that fails, disputes will be resolved by the courts
|
||||
located in that jurisdiction unless applicable law requires otherwise.
|
||||
</P>
|
||||
|
||||
<H2>14. Contact</H2>
|
||||
<P>
|
||||
Questions about these Terms: <a href="mailto:legal@elegalsoftware.com" className="text-brand-600 hover:underline">legal@elegalsoftware.com</a>.
|
||||
</P>
|
||||
</LegalLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Clock, Play, Square, Plus, Download, Trash2 } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { Card, CardBody, CardHeader, EmptyState } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useTrackTool } from '@/hooks/useToolUsage';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
|
||||
interface Entry {
|
||||
id: string;
|
||||
description: string;
|
||||
matter: string;
|
||||
minutes: number;
|
||||
rate: number;
|
||||
date: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'lawdesk:tracker-entries';
|
||||
|
||||
function loadEntries(): Entry[] {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as Entry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveEntries(entries: Entry[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
}
|
||||
|
||||
function fmtElapsed(s: number) {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
return h > 0
|
||||
? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
: `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function todayLocal() {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function escapeCsv(v: string) {
|
||||
if (v.includes('"') || v.includes(',') || v.includes('\n')) {
|
||||
return `"${v.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export default function BillableHoursTrackerPage() {
|
||||
useTrackTool('billable-hours-tracker');
|
||||
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
useEffect(() => setEntries(loadEntries()), []);
|
||||
|
||||
// Live timer state
|
||||
const [running, setRunning] = useState<{ startedAt: number; description: string; matter: string; rate: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [, force] = useState(0);
|
||||
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (running) {
|
||||
tickRef.current = setInterval(() => force((n) => n + 1), 1000);
|
||||
return () => {
|
||||
if (tickRef.current) clearInterval(tickRef.current);
|
||||
};
|
||||
}
|
||||
}, [running]);
|
||||
|
||||
// Form state for the timer + manual entry
|
||||
const [description, setDescription] = useState('');
|
||||
const [matter, setMatter] = useState('');
|
||||
const [rate, setRate] = useState('250');
|
||||
const [manualMinutes, setManualMinutes] = useState('60');
|
||||
|
||||
function startTimer() {
|
||||
if (!description.trim()) return;
|
||||
setRunning({ startedAt: Date.now(), description: description.trim(), matter: matter.trim(), rate: Number(rate) || 0 });
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (!running) return;
|
||||
const minutes = Math.max(1, Math.round((Date.now() - running.startedAt) / 60000));
|
||||
addEntry({
|
||||
id: crypto.randomUUID(),
|
||||
description: running.description,
|
||||
matter: running.matter,
|
||||
minutes,
|
||||
rate: running.rate,
|
||||
date: todayLocal(),
|
||||
});
|
||||
setRunning(null);
|
||||
setDescription('');
|
||||
}
|
||||
|
||||
function addManual() {
|
||||
const m = Number(manualMinutes);
|
||||
if (!description.trim() || !Number.isFinite(m) || m <= 0) return;
|
||||
addEntry({
|
||||
id: crypto.randomUUID(),
|
||||
description: description.trim(),
|
||||
matter: matter.trim(),
|
||||
minutes: m,
|
||||
rate: Number(rate) || 0,
|
||||
date: todayLocal(),
|
||||
});
|
||||
setDescription('');
|
||||
}
|
||||
|
||||
function addEntry(e: Entry) {
|
||||
const next = [e, ...entries];
|
||||
setEntries(next);
|
||||
saveEntries(next);
|
||||
}
|
||||
|
||||
function removeEntry(id: string) {
|
||||
const next = entries.filter((e) => e.id !== id);
|
||||
setEntries(next);
|
||||
saveEntries(next);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if (!confirm('Delete all entries from this browser?')) return;
|
||||
setEntries([]);
|
||||
saveEntries([]);
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
const header = ['Date', 'Matter', 'Description', 'Minutes', 'Rate', 'Amount'];
|
||||
const lines = [header.join(',')];
|
||||
for (const e of entries) {
|
||||
const amount = (e.minutes / 60) * e.rate;
|
||||
lines.push(
|
||||
[e.date, escapeCsv(e.matter), escapeCsv(e.description), e.minutes, e.rate, amount.toFixed(2)].join(','),
|
||||
);
|
||||
}
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `billable-hours-${todayLocal()}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const totalMinutes = entries.reduce((s, e) => s + e.minutes, 0);
|
||||
const totalAmount = entries.reduce((s, e) => s + (e.minutes / 60) * e.rate, 0);
|
||||
const elapsed = running ? Math.floor((Date.now() - running.startedAt) / 1000) : 0;
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-12 max-w-5xl">
|
||||
<Link to="/tools" className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-6">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All tools
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<Clock className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-ink-950 font-display">Billable Hours Tracker</h1>
|
||||
<p className="text-sm text-ink-600">Saved to your browser, never sent to our server. Export to CSV anytime.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-5">
|
||||
<Card className="md:col-span-3">
|
||||
<CardHeader title="New entry" description="Start a timer or log time manually." />
|
||||
<CardBody className="space-y-4">
|
||||
<Input label="Matter / case" value={matter} onChange={(e) => setMatter(e.target.value)} placeholder="Smith v. Johnson" />
|
||||
<Input label="What are you working on?" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Drafting reply brief" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Hourly rate (USD)" type="number" min={0} step="1" value={rate} onChange={(e) => setRate(e.target.value)} />
|
||||
<Input label="Manual minutes" type="number" min={1} step="15" value={manualMinutes} onChange={(e) => setManualMinutes(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{!running ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={startTimer} disabled={!description.trim()}>
|
||||
<Play className="h-4 w-4" />
|
||||
Start timer
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={addManual} disabled={!description.trim()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Log manual entry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-emerald-200 bg-emerald-50/60 p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-emerald-700">Running</p>
|
||||
<p className="font-semibold text-emerald-900">{running.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-2xl font-bold text-emerald-900 tabular-nums">{fmtElapsed(elapsed)}</span>
|
||||
<Button variant="danger" onClick={stopTimer}>
|
||||
<Square className="h-4 w-4 fill-current" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader title="Today's totals" />
|
||||
<CardBody className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-ink-500">Hours tracked</p>
|
||||
<p className="text-3xl font-bold font-display text-ink-900 tabular-nums">{(totalMinutes / 60).toFixed(2)}h</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-ink-500">Billable amount</p>
|
||||
<p className="text-3xl font-bold font-display text-brand-600 tabular-nums">{formatMoney(totalAmount)}</p>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-ink-100 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={exportCsv} disabled={!entries.length}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clearAll} disabled={!entries.length}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader title="Entries" description={`${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}`} />
|
||||
{!entries.length ? (
|
||||
<EmptyState icon={<Clock className="h-5 w-5" />} title="Nothing logged yet" description="Start a timer above or add a manual entry." />
|
||||
) : (
|
||||
<ul className="divide-y divide-ink-100">
|
||||
{entries.map((e) => (
|
||||
<li key={e.id} className="flex items-center gap-4 px-5 py-3 hover:bg-ink-50/50">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-ink-900 truncate">{e.description}</p>
|
||||
<p className="text-xs text-ink-500 truncate">
|
||||
{e.matter || 'No matter'} · {e.date}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-ink-900 tabular-nums">{(e.minutes / 60).toFixed(2)}h</p>
|
||||
<p className="text-xs text-ink-500">{formatMoney((e.minutes / 60) * e.rate)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEntry(e.id)}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-ink-400 hover:text-rose-600 hover:bg-rose-50"
|
||||
aria-label="Delete entry"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-brand-200 bg-brand-50/40 p-6 text-sm text-ink-700">
|
||||
<strong className="text-ink-900 font-semibold">Want this synced across devices and tied to client invoices?</strong>{' '}
|
||||
That's exactly what eLegal Software does.{' '}
|
||||
<Link to="/signup" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Start free →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowLeft, BarChart3 } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTrackTool } from '@/hooks/useToolUsage';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
|
||||
export default function CaseProfitabilityPage() {
|
||||
useTrackTool('case-profitability');
|
||||
|
||||
const [hours, setHours] = useState('40');
|
||||
const [rate, setRate] = useState('250');
|
||||
const [flatFee, setFlatFee] = useState('0');
|
||||
const [hardCosts, setHardCosts] = useState('500');
|
||||
const [overheadPct, setOverheadPct] = useState('25');
|
||||
|
||||
const r = useMemo(() => {
|
||||
const billed = Number(hours) * Number(rate) + Number(flatFee);
|
||||
const overhead = (billed * Number(overheadPct)) / 100;
|
||||
const profit = billed - Number(hardCosts) - overhead;
|
||||
const margin = billed > 0 ? (profit / billed) * 100 : 0;
|
||||
const effectiveRate = Number(hours) > 0 ? profit / Number(hours) : 0;
|
||||
return { billed, overhead, profit, margin, effectiveRate };
|
||||
}, [hours, rate, flatFee, hardCosts, overheadPct]);
|
||||
|
||||
const tone = r.margin >= 35 ? 'emerald' : r.margin >= 15 ? 'amber' : 'rose';
|
||||
const verdict = r.margin >= 35 ? 'Healthy' : r.margin >= 15 ? 'Tight' : r.margin >= 0 ? 'Marginal' : 'Losing money';
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-12 max-w-4xl">
|
||||
<Link to="/tools" className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-6">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All tools
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-ink-950 font-display">Case Profitability Analyzer</h1>
|
||||
<p className="text-sm text-ink-600">See whether a matter is making you money once overhead and costs are in.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-5">
|
||||
<Card className="md:col-span-3">
|
||||
<CardHeader title="Case inputs" />
|
||||
<CardBody className="space-y-4">
|
||||
<Input label="Billable hours on this matter" type="number" min={0} step="0.5"
|
||||
value={hours} onChange={(e) => setHours(e.target.value)} />
|
||||
<Input label="Hourly rate (USD)" type="number" min={0} step="1"
|
||||
value={rate} onChange={(e) => setRate(e.target.value)} />
|
||||
<Input label="Flat fees collected (USD)" type="number" min={0} step="1"
|
||||
value={flatFee} onChange={(e) => setFlatFee(e.target.value)} hint="Set to 0 for purely hourly matters." />
|
||||
<Input label="Hard costs (filing fees, experts, etc.)" type="number" min={0} step="1"
|
||||
value={hardCosts} onChange={(e) => setHardCosts(e.target.value)} />
|
||||
<Input label="Overhead allocation (%)" type="number" min={0} max={100} step="1"
|
||||
value={overheadPct} onChange={(e) => setOverheadPct(e.target.value)}
|
||||
hint="Roughly: total firm overhead ÷ total billed revenue. 20–35% is typical for solos." />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader title="Profitability" />
|
||||
<CardBody className="space-y-4">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-ink-500">Billed</span>
|
||||
<span className="text-lg font-semibold text-ink-900 tabular-nums">{formatMoney(r.billed)}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-ink-500">Hard costs</span>
|
||||
<span className="text-sm text-ink-700 tabular-nums">−{formatMoney(Number(hardCosts))}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-ink-500">Overhead share</span>
|
||||
<span className="text-sm text-ink-700 tabular-nums">−{formatMoney(r.overhead)}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between border-t border-ink-100 pt-3">
|
||||
<span className="text-xs uppercase tracking-wider text-ink-500">Profit</span>
|
||||
<span className="text-2xl font-bold font-display text-brand-600 tabular-nums">{formatMoney(r.profit)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge tone={tone}>{verdict}</Badge>
|
||||
<span className="text-sm text-ink-700 tabular-nums">{r.margin.toFixed(1)}% margin</span>
|
||||
</div>
|
||||
<p className="pt-3 border-t border-ink-100 text-xs text-ink-500">
|
||||
Effective hourly profit: <span className="text-ink-700 font-medium">{formatMoney(r.effectiveRate)}</span>
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowLeft, FileText, Download, Copy, Check } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Input, Textarea } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useTrackTool } from '@/hooks/useToolUsage';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface TemplateField {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'date' | 'number' | 'textarea';
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
fields: TemplateField[];
|
||||
render: (v: Record<string, string>) => string;
|
||||
}
|
||||
|
||||
const TEMPLATES: Template[] = [
|
||||
{
|
||||
id: 'engagement-letter',
|
||||
title: 'Engagement Letter',
|
||||
description: 'A short letter confirming the scope, fees, and terms of representation.',
|
||||
fields: [
|
||||
{ key: 'firmName', label: 'Your firm name', defaultValue: 'Doe & Associates' },
|
||||
{ key: 'attorneyName', label: 'Attorney name', defaultValue: 'Jane Doe, Esq.' },
|
||||
{ key: 'clientName', label: 'Client name', placeholder: 'John Smith' },
|
||||
{ key: 'matter', label: 'Matter description', type: 'textarea', placeholder: 'Representation in the matter of …' },
|
||||
{ key: 'rate', label: 'Hourly rate (USD)', type: 'number', defaultValue: '250' },
|
||||
{ key: 'retainer', label: 'Initial retainer (USD)', type: 'number', defaultValue: '2500' },
|
||||
{ key: 'date', label: 'Date', type: 'date', defaultValue: new Date().toISOString().slice(0, 10) },
|
||||
],
|
||||
render: (v) => `${v.firmName}
|
||||
${v.date}
|
||||
|
||||
Re: Engagement of Legal Services
|
||||
|
||||
Dear ${v.clientName || '[Client]'},
|
||||
|
||||
Thank you for choosing ${v.firmName} to represent you. This letter confirms the scope and terms of our engagement.
|
||||
|
||||
1. Scope of Representation
|
||||
We will represent you in the following matter:
|
||||
${v.matter || '[Describe the matter being handled]'}
|
||||
|
||||
We will not represent you in any other matter unless we agree separately in writing.
|
||||
|
||||
2. Fees
|
||||
Our services will be billed at $${v.rate || '[Rate]'} per hour. You agree to pay an initial retainer of $${v.retainer || '[Retainer]'}, which will be applied to fees and costs as they are incurred. Invoices are due within thirty (30) days of receipt.
|
||||
|
||||
3. Costs
|
||||
You will be responsible for direct costs (filing fees, court reporters, expert witnesses, etc.). We will obtain your prior approval for any single expense exceeding $500.
|
||||
|
||||
4. Termination
|
||||
You may terminate our representation at any time by giving us written notice. We may withdraw with reasonable notice and consistent with our professional obligations.
|
||||
|
||||
5. Communication
|
||||
We will keep you informed of significant developments and respond to your inquiries promptly. Please direct your communications to the attorney named above.
|
||||
|
||||
If this letter accurately reflects our agreement, please sign below and return a copy to us.
|
||||
|
||||
Sincerely,
|
||||
|
||||
${v.attorneyName || '[Attorney name]'}
|
||||
${v.firmName}
|
||||
|
||||
Agreed and accepted:
|
||||
|
||||
____________________________
|
||||
${v.clientName || '[Client name]'} Date: ____________
|
||||
|
||||
— DRAFT TEMPLATE — Have a licensed attorney review before use.`,
|
||||
},
|
||||
{
|
||||
id: 'retainer-agreement',
|
||||
title: 'Retainer Agreement',
|
||||
description: 'A short retainer-fee agreement covering deposit, replenishment, and refund terms.',
|
||||
fields: [
|
||||
{ key: 'firmName', label: 'Your firm name', defaultValue: 'Doe & Associates' },
|
||||
{ key: 'clientName', label: 'Client name' },
|
||||
{ key: 'amount', label: 'Retainer amount (USD)', type: 'number', defaultValue: '5000' },
|
||||
{ key: 'minBalance', label: 'Replenish when balance falls below', type: 'number', defaultValue: '1000' },
|
||||
{ key: 'date', label: 'Date', type: 'date', defaultValue: new Date().toISOString().slice(0, 10) },
|
||||
],
|
||||
render: (v) => `RETAINER AGREEMENT
|
||||
|
||||
This Agreement is entered into on ${v.date} between ${v.firmName} ("Firm") and ${v.clientName || '[Client]'} ("Client").
|
||||
|
||||
1. Retainer Deposit. Client deposits $${v.amount || '[Amount]'} with Firm to secure availability of legal services. The deposit will be held in Firm's IOLTA trust account.
|
||||
|
||||
2. Application of Funds. Firm will draw against the deposit as fees and costs are incurred and billed. Client will receive a monthly statement showing time entries, costs, and remaining balance.
|
||||
|
||||
3. Replenishment. Whenever the trust balance falls below $${v.minBalance || '[Min]'}, Client agrees to replenish the deposit within ten (10) days of notice from Firm.
|
||||
|
||||
4. Refund. Any unused portion of the deposit will be returned to Client at the end of the engagement, after final billing.
|
||||
|
||||
5. No Guarantee of Outcome. The retainer secures Firm's availability and best efforts. It does not guarantee any particular result.
|
||||
|
||||
Firm: Client:
|
||||
|
||||
____________________________ ____________________________
|
||||
${v.firmName} ${v.clientName || '[Client name]'}
|
||||
Date: ____________ Date: ____________
|
||||
|
||||
— DRAFT TEMPLATE — Have a licensed attorney review before use.`,
|
||||
},
|
||||
{
|
||||
id: 'nda',
|
||||
title: 'Mutual Non-Disclosure Agreement',
|
||||
description: 'A simple two-party NDA for early-stage discussions.',
|
||||
fields: [
|
||||
{ key: 'partyA', label: 'Party A name' },
|
||||
{ key: 'partyB', label: 'Party B name' },
|
||||
{ key: 'purpose', label: 'Purpose of disclosure', type: 'textarea', placeholder: 'Discussion of a potential business relationship' },
|
||||
{ key: 'duration', label: 'Confidentiality period (years)', type: 'number', defaultValue: '3' },
|
||||
{ key: 'state', label: 'Governing law (state/country)', defaultValue: 'Delaware' },
|
||||
{ key: 'date', label: 'Effective date', type: 'date', defaultValue: new Date().toISOString().slice(0, 10) },
|
||||
],
|
||||
render: (v) => `MUTUAL NON-DISCLOSURE AGREEMENT
|
||||
|
||||
Effective Date: ${v.date}
|
||||
Between: ${v.partyA || '[Party A]'} and ${v.partyB || '[Party B]'} (each a "Party").
|
||||
|
||||
1. Purpose. The Parties wish to discuss the following matter (the "Purpose"):
|
||||
${v.purpose || '[Describe the purpose]'}
|
||||
|
||||
2. Confidential Information. "Confidential Information" means any non-public information disclosed by either Party to the other in connection with the Purpose, in any form, that is identified as confidential at the time of disclosure or that a reasonable person would understand to be confidential given its nature and the circumstances.
|
||||
|
||||
3. Obligations. Each Party will: (a) use the other Party's Confidential Information only for the Purpose; (b) protect it with at least the same care it uses for its own confidential information, and no less than reasonable care; and (c) not disclose it to any third party except to its own employees, advisors, and contractors who have a need to know and are bound by confidentiality obligations no less protective than this Agreement.
|
||||
|
||||
4. Exclusions. Confidential Information does not include information that: (a) is or becomes publicly known through no fault of the receiving Party; (b) was lawfully in the receiving Party's possession before disclosure; (c) is rightfully received from a third party without confidentiality obligations; or (d) is independently developed without use of the disclosing Party's Confidential Information.
|
||||
|
||||
5. Term. The obligations in this Agreement remain in effect for ${v.duration || '[N]'} years from the Effective Date.
|
||||
|
||||
6. No License. No license or other right is granted by either Party other than the limited right to use Confidential Information for the Purpose.
|
||||
|
||||
7. Return or Destruction. On request, each Party will promptly return or destroy the other Party's Confidential Information.
|
||||
|
||||
8. Governing Law. This Agreement is governed by the laws of ${v.state || '[State]'}, without regard to conflict-of-laws rules.
|
||||
|
||||
9. Entire Agreement. This Agreement is the entire understanding between the Parties regarding its subject matter and supersedes any prior agreements on that subject.
|
||||
|
||||
${v.partyA || '[Party A]'}: ${v.partyB || '[Party B]'}:
|
||||
|
||||
____________________________ ____________________________
|
||||
Name: Name:
|
||||
Title: Title:
|
||||
Date: Date:
|
||||
|
||||
— DRAFT TEMPLATE — Have a licensed attorney review before use.`,
|
||||
},
|
||||
{
|
||||
id: 'demand-letter',
|
||||
title: 'Demand Letter',
|
||||
description: 'A short, firm letter demanding payment or other action before litigation.',
|
||||
fields: [
|
||||
{ key: 'firmName', label: 'Your firm name', defaultValue: 'Doe & Associates' },
|
||||
{ key: 'attorneyName', label: 'Attorney name', defaultValue: 'Jane Doe, Esq.' },
|
||||
{ key: 'recipientName', label: 'Recipient name' },
|
||||
{ key: 'recipientAddress', label: 'Recipient address', type: 'textarea' },
|
||||
{ key: 'clientName', label: 'Your client' },
|
||||
{ key: 'amount', label: 'Amount demanded (USD)', type: 'number' },
|
||||
{ key: 'reason', label: 'Reason / underlying matter', type: 'textarea' },
|
||||
{ key: 'deadlineDays', label: 'Response deadline (days)', type: 'number', defaultValue: '14' },
|
||||
{ key: 'date', label: 'Date', type: 'date', defaultValue: new Date().toISOString().slice(0, 10) },
|
||||
],
|
||||
render: (v) => `${v.firmName}
|
||||
${v.date}
|
||||
|
||||
${v.recipientName || '[Recipient]'}
|
||||
${v.recipientAddress || '[Recipient address]'}
|
||||
|
||||
Re: Demand for Payment — ${v.clientName || '[Client]'}
|
||||
|
||||
Dear ${v.recipientName || '[Recipient]'},
|
||||
|
||||
This firm represents ${v.clientName || '[Client]'} in connection with the following matter:
|
||||
|
||||
${v.reason || '[Describe the underlying obligation, contract, or facts giving rise to the demand]'}
|
||||
|
||||
Despite repeated requests, this obligation remains unsatisfied. On behalf of our client, we hereby demand payment in the amount of $${v.amount || '[Amount]'} within ${v.deadlineDays || '14'} days of the date of this letter.
|
||||
|
||||
If we do not receive payment, or a satisfactory written response, by that date, our client has authorized us to pursue all available legal remedies, which may include filing suit and seeking interest, costs, and attorneys' fees as permitted by law.
|
||||
|
||||
Nothing in this letter is intended to waive any rights or remedies available to our client, all of which are expressly reserved.
|
||||
|
||||
We trust this matter can be resolved without further escalation. Please direct any response to the undersigned.
|
||||
|
||||
Sincerely,
|
||||
|
||||
${v.attorneyName || '[Attorney name]'}
|
||||
${v.firmName}
|
||||
|
||||
— DRAFT TEMPLATE — Have a licensed attorney review before use.`,
|
||||
},
|
||||
];
|
||||
|
||||
export default function DocumentTemplatesPage() {
|
||||
useTrackTool('document-templates');
|
||||
|
||||
const [activeId, setActiveId] = useState<string>(TEMPLATES[0]!.id);
|
||||
const active = TEMPLATES.find((t) => t.id === activeId)!;
|
||||
|
||||
const initial = useMemo(() => {
|
||||
const o: Record<string, string> = {};
|
||||
for (const f of active.fields) o[f.key] = f.defaultValue ?? '';
|
||||
return o;
|
||||
}, [active]);
|
||||
|
||||
const [values, setValues] = useState<Record<string, string>>(initial);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Reset values when switching template
|
||||
useMemo(() => setValues(initial), [initial]);
|
||||
|
||||
const rendered = active.render(values);
|
||||
|
||||
function download() {
|
||||
const blob = new Blob([rendered], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${active.id}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rendered);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-12 max-w-6xl">
|
||||
<Link to="/tools" className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-6">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All tools
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<FileText className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-ink-950 font-display">Document Templates</h1>
|
||||
<p className="text-sm text-ink-600">Plain-text starting points. Always have a licensed attorney review before use.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader title="Templates" />
|
||||
<ul className="p-2 space-y-1">
|
||||
{TEMPLATES.map((t) => (
|
||||
<li key={t.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveId(t.id)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg px-3 py-2.5 text-sm transition',
|
||||
t.id === activeId
|
||||
? 'bg-brand-50 text-brand-700 font-medium'
|
||||
: 'text-ink-700 hover:bg-ink-50',
|
||||
)}
|
||||
>
|
||||
<p>{t.title}</p>
|
||||
<p className="text-xs text-ink-500 mt-0.5 line-clamp-2">{t.description}</p>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-4">
|
||||
<CardHeader title="Fill in the details" />
|
||||
<CardBody className="space-y-4">
|
||||
{active.fields.map((f) => (
|
||||
<RenderField
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(v) => setValues((s) => ({ ...s, [f.key]: v }))}
|
||||
/>
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-5">
|
||||
<CardHeader
|
||||
title="Preview"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={copy}>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={download}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
.txt
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
<pre className="whitespace-pre-wrap text-xs leading-relaxed text-ink-800 font-mono max-h-[500px] overflow-y-auto bg-ink-50/40 rounded-lg p-4 border border-ink-100">
|
||||
{rendered}
|
||||
</pre>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-amber-200 bg-amber-50/40 p-4 text-xs text-amber-900">
|
||||
<strong>Disclaimer:</strong> these templates are educational starting points, not legal
|
||||
advice. Laws vary by jurisdiction. Have a licensed attorney review and adapt before use.
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderField({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
field: TemplateField;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<Textarea
|
||||
label={field.label}
|
||||
rows={3}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
label={field.label}
|
||||
type={field.type ?? 'text'}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowLeft, Calculator } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PublicLayout } from '@/components/public/PublicLayout';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useTrackTool } from '@/hooks/useToolUsage';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
|
||||
export default function HourlyRateCalculatorPage() {
|
||||
useTrackTool('hourly-rate-calculator');
|
||||
|
||||
const [monthlyExpenses, setMonthlyExpenses] = useState('5000');
|
||||
const [desiredProfit, setDesiredProfit] = useState('8000');
|
||||
const [billableHoursPerWeek, setBillableHoursPerWeek] = useState('25');
|
||||
const [weeksPerYear, setWeeksPerYear] = useState('48');
|
||||
const [utilizationPct, setUtilizationPct] = useState('70');
|
||||
|
||||
const result = useMemo(() => {
|
||||
const annualTarget = (Number(monthlyExpenses) + Number(desiredProfit)) * 12;
|
||||
const grossHours = Number(billableHoursPerWeek) * Number(weeksPerYear);
|
||||
const realizedHours = (grossHours * Number(utilizationPct)) / 100;
|
||||
if (realizedHours <= 0) return null;
|
||||
const baseRate = annualTarget / realizedHours;
|
||||
return {
|
||||
annualTarget,
|
||||
realizedHours,
|
||||
baseRate,
|
||||
conservative: baseRate * 1.15, // padding for write-offs and admin
|
||||
premium: baseRate * 1.4, // premium positioning
|
||||
};
|
||||
}, [monthlyExpenses, desiredProfit, billableHoursPerWeek, weeksPerYear, utilizationPct]);
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<section className="container py-12 max-w-4xl">
|
||||
<Link
|
||||
to="/tools"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-ink-500 hover:text-ink-800 mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All tools
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600">
|
||||
<Calculator className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-ink-950 font-display">Hourly Rate Calculator</h1>
|
||||
<p className="text-sm text-ink-600">Find the rate you actually need to charge to hit your numbers.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-5">
|
||||
<Card className="md:col-span-3">
|
||||
<CardHeader title="Your inputs" />
|
||||
<CardBody className="space-y-4">
|
||||
<Input
|
||||
label="Monthly business expenses (USD)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="100"
|
||||
value={monthlyExpenses}
|
||||
onChange={(e) => setMonthlyExpenses(e.target.value)}
|
||||
hint="Rent, software, malpractice insurance, staff, etc."
|
||||
/>
|
||||
<Input
|
||||
label="Desired monthly take-home profit (USD)"
|
||||
type="number"
|
||||
min={0}
|
||||
step="100"
|
||||
value={desiredProfit}
|
||||
onChange={(e) => setDesiredProfit(e.target.value)}
|
||||
hint="What you want to pay yourself, after expenses but before tax."
|
||||
/>
|
||||
<Input
|
||||
label="Billable hours per week (target)"
|
||||
type="number"
|
||||
min={1}
|
||||
max={80}
|
||||
value={billableHoursPerWeek}
|
||||
onChange={(e) => setBillableHoursPerWeek(e.target.value)}
|
||||
hint="Most attorneys realistically bill 20–30 hours weekly."
|
||||
/>
|
||||
<Input
|
||||
label="Weeks worked per year"
|
||||
type="number"
|
||||
min={1}
|
||||
max={52}
|
||||
value={weeksPerYear}
|
||||
onChange={(e) => setWeeksPerYear(e.target.value)}
|
||||
hint="52 minus vacations, holidays, and CLE time."
|
||||
/>
|
||||
<Input
|
||||
label="Utilization rate (%)"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={utilizationPct}
|
||||
onChange={(e) => setUtilizationPct(e.target.value)}
|
||||
hint="What share of those hours actually get billed and collected. 70% is common."
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader title="Recommended rates" />
|
||||
<CardBody className="space-y-4">
|
||||
{result ? (
|
||||
<>
|
||||
<Output label="Minimum to break even + profit" value={formatMoney(result.baseRate)} accent />
|
||||
<Output label="With 15% padding for write-offs" value={formatMoney(result.conservative)} />
|
||||
<Output label="Premium positioning (+40%)" value={formatMoney(result.premium)} />
|
||||
<div className="pt-3 border-t border-ink-100 text-xs text-ink-500 space-y-1">
|
||||
<p>Annual revenue target: {formatMoney(result.annualTarget)}</p>
|
||||
<p>Realized billable hours: {Math.round(result.realizedHours)} per year</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-ink-500">Enter your inputs to see suggested rates.</p>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-2xl border border-brand-200 bg-brand-50/40 p-6">
|
||||
<p className="text-sm text-ink-700">
|
||||
<strong className="font-semibold text-ink-900">Heads up:</strong> this is a quick model.
|
||||
It doesn't include taxes, retirement contributions, or one-time costs. For a fuller view
|
||||
of profitability per matter, try the{' '}
|
||||
<Link to="/tools/case-profitability" className="font-semibold text-brand-600 hover:text-brand-700">
|
||||
Case Profitability Analyzer
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function Output({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-ink-500">{label}</p>
|
||||
<p className={'mt-1 font-display font-bold ' + (accent ? 'text-3xl text-brand-600' : 'text-xl text-ink-900')}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Calculator, BarChart3, Clock, FileText } from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { PublicLayout, PublicHero } from '@/components/public/PublicLayout';
|
||||
import { useToolsOnline, type ToolName } from '@/hooks/useToolUsage';
|
||||
|
||||
interface Tool {
|
||||
slug: ToolName;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const TOOLS: Tool[] = [
|
||||
{
|
||||
slug: 'hourly-rate-calculator',
|
||||
title: 'Hourly Rate Calculator',
|
||||
description:
|
||||
'Work backwards from your target take-home to find the hourly rate you actually need to charge.',
|
||||
icon: Calculator,
|
||||
},
|
||||
{
|
||||
slug: 'case-profitability',
|
||||
title: 'Case Profitability Analyzer',
|
||||
description: 'Plug in hours, rates, and case expenses to see whether a matter is making you money.',
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
slug: 'billable-hours-tracker',
|
||||
title: 'Billable Hours Tracker',
|
||||
description: 'A no-signup timer with manual entries and CSV export. Saved locally to your browser.',
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
slug: 'document-templates',
|
||||
title: 'Document Templates',
|
||||
description: 'Fill in a few fields, download a clean text version of common law-firm documents.',
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
export default function ToolsIndexPage() {
|
||||
const online = useToolsOnline();
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<PublicHero
|
||||
eyebrow="Free Legal Tools"
|
||||
title="Practical calculators for your practice"
|
||||
description="No signup, no email. Use them as much as you like. If you want everything in one place, that's what eLegal Software is for."
|
||||
/>
|
||||
|
||||
<section className="container py-16">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
{TOOLS.map((tool) => {
|
||||
const count = online.data?.online[tool.slug] ?? 0;
|
||||
return (
|
||||
<Link
|
||||
key={tool.slug}
|
||||
to={`/tools/${tool.slug}`}
|
||||
className="group rounded-2xl border border-ink-100 bg-white p-6 hover:border-brand-200 hover:shadow-lg hover:shadow-brand-500/5 transition flex flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-xl bg-brand-50 text-brand-600 group-hover:bg-brand-500 group-hover:text-white transition">
|
||||
<tool.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 text-emerald-700 px-2 py-0.5 text-[11px] font-semibold">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
{count} online
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-5 text-lg font-semibold text-ink-900">{tool.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-ink-600">{tool.description}</p>
|
||||
<span className="mt-4 text-sm font-semibold text-brand-600 group-hover:text-brand-700">
|
||||
Open tool →
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</PublicLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-ink-900 font-sans;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
@apply font-display tracking-tight;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-brand-500/20 text-brand-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-full font-medium transition
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500
|
||||
focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-60;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-brand-500 text-white hover:bg-brand-600 px-6 py-3 shadow-lg shadow-brand-500/25;
|
||||
}
|
||||
.btn-secondary {
|
||||
@apply btn bg-white text-ink-900 border border-ink-200 hover:border-ink-300 px-6 py-3;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn text-ink-700 hover:text-ink-900 px-4 py-2;
|
||||
}
|
||||
.section {
|
||||
@apply py-20 md:py-28;
|
||||
}
|
||||
.eyebrow {
|
||||
@apply inline-flex items-center gap-2 rounded-full bg-brand-50 text-brand-700
|
||||
px-3 py-1 text-xs font-semibold uppercase tracking-wide;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user