Initial commit — eLegal Software monorepo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-04-26 02:42:42 -04:00
co-authored by Claude Sonnet 4.6
commit 0700d54225
160 changed files with 22771 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# ─────────────────────────────────────────────
# Server
# ─────────────────────────────────────────────
NODE_ENV=development
PORT=8080
PUBLIC_URL=http://localhost:8080
COOKIE_DOMAIN=
# 32+ random bytes, hex. Generate: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SESSION_SECRET=replace-me-with-32-byte-hex
CSRF_SECRET=replace-me-with-32-byte-hex
# Comma-separated emails. Any user with one of these emails is auto-promoted to superadmin
# on login/signup and gains access to /admin.
SUPERADMIN_EMAILS=
# ─────────────────────────────────────────────
# DigitalOcean Managed Postgres
# ─────────────────────────────────────────────
# Format: postgresql://user:pass@host:25060/dbname?sslmode=require
DATABASE_URL=postgresql://doadmin:password@db-postgresql-nyc1-xxxxx.b.db.ondigitalocean.com:25060/defaultdb?sslmode=require
DATABASE_CA_CERT_PATH=./certs/do-ca.crt
# ─────────────────────────────────────────────
# DigitalOcean Spaces (S3-compatible)
# ─────────────────────────────────────────────
SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
SPACES_REGION=nyc3
SPACES_BUCKET=lawdesk-uploads
SPACES_ACCESS_KEY=
SPACES_SECRET_KEY=
# ─────────────────────────────────────────────
# Email (Resend)
# ─────────────────────────────────────────────
RESEND_API_KEY=
EMAIL_FROM="eLegal Software <noreply@yourdomain.com>"
# ─────────────────────────────────────────────
# Stripe
# ─────────────────────────────────────────────
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_PRO=
STRIPE_PRICE_LIFETIME=
# ─────────────────────────────────────────────
# Sentry (optional — leave blank to disable error reporting)
# ─────────────────────────────────────────────
SENTRY_DSN_API=
# Web DSN must be exposed to the browser bundle, so prefix with VITE_
VITE_SENTRY_DSN=
+26
View File
@@ -0,0 +1,26 @@
node_modules
dist
build
.next
.turbo
.cache
coverage
.env
.env.local
.env.*.local
!.env.example
*.log
npm-debug.log*
pnpm-debug.log*
.DS_Store
Thumbs.db
.vscode
.idea
tmp/restart.txt
logs/
uploads/
+1
View File
@@ -0,0 +1 @@
20
+121
View File
@@ -0,0 +1,121 @@
# eLegal Software
All-in-one practice management for law firms. Single Node app that serves both the React SPA and the API on one port — designed to run behind Plesk's Node.js extension on a single domain.
## Stack
- **Frontend** — Vite + React 18 + TypeScript + Tailwind + Framer Motion + Recharts + lucide-react
- **API** — Fastify 5 + TypeScript + Zod
- **DB** — Drizzle ORM → DigitalOcean Managed Postgres (TLS)
- **Auth** — local: argon2id passwords + Postgres-backed sessions in httpOnly cookies (no third-party auth provider)
- **Storage** — DigitalOcean Spaces (S3-compatible, presigned uploads)
- **Email** — Resend
- **Payments** — Stripe
- **Hosting** — Plesk + Phusion Passenger (Node 20 LTS)
## Repository layout
```
.
├── apps/
│ ├── api/ # Fastify server (also serves built web/dist in prod)
│ └── web/ # Vite + React SPA
├── packages/
│ └── db/ # Drizzle schema + migrations (shared)
├── certs/ # DO Postgres CA cert (do-ca.crt) — not in git
├── scripts/
│ └── plesk-deploy.sh
├── tmp/restart.txt # touched by deploy script to bounce Passenger
└── app.js # Plesk entrypoint (loads apps/api/dist/server.js)
```
## Local development
Prerequisites: Node 20+, pnpm 9+, a Postgres database (managed DO instance, or local).
```bash
cp .env.example .env
# fill in DATABASE_URL, SESSION_SECRET, CSRF_SECRET (generate with `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`)
pnpm install
pnpm db:generate # generate SQL migrations from schema
pnpm db:migrate # apply to the DB
pnpm dev # starts api on :8080 and web on :5173 (proxies /api → :8080)
```
Visit http://localhost:5173.
## Production build
```bash
pnpm build # builds packages/db → apps/web → apps/api
pnpm start # runs node app.js → apps/api/dist/server.js
```
The API serves `apps/web/dist` at `/` with SPA fallback and routes `/api/*` to Fastify handlers.
## Plesk deployment (single domain)
1. **Create the domain** in Plesk and enable **Let's Encrypt** TLS.
2. **Install Node.js extension** (Plesk → Extensions → "Node.js"). Set Node version to **20.x** in the domain's Node.js settings.
3. **Pull the repo** into the domain's document root via Plesk → Git, or `git clone` over SSH into `/var/www/vhosts/yourdomain.com/httpdocs`.
4. **Node.js settings** in the Plesk panel for that domain:
- **Application root** → the repo root
- **Document root** → leave as default; nginx will proxy to Passenger
- **Application startup file** → `app.js`
- **Custom environment variables** → set every entry from `.env.example` (Passenger does **not** read `.env` files)
5. **Add DigitalOcean's Postgres CA** to `certs/do-ca.crt` (download from the DO Postgres dashboard) and set `DATABASE_CA_CERT_PATH=./certs/do-ca.crt`.
6. **Run the deploy script** over SSH:
```bash
bash scripts/plesk-deploy.sh
```
This installs deps, builds, runs migrations, then `touch tmp/restart.txt` to bounce Passenger.
7. **Stripe webhook** — add `https://yourdomain.com/api/stripe/webhook` in the Stripe dashboard. In Plesk → Apache & nginx → "Additional nginx directives" add:
```nginx
location /api/stripe/webhook {
proxy_request_buffering off;
}
```
8. **Auto-deploy on push** (optional) — in Plesk → Git, enable "Enable additional deploy actions" and set the script to `bash scripts/plesk-deploy.sh`.
## Environment variables
See `.env.example` for the full list. Highlights:
| Var | Purpose |
|---|---|
| `DATABASE_URL` | DO Managed Postgres connection string (`?sslmode=require`) |
| `DATABASE_CA_CERT_PATH` | Path to DO CA cert (recommended for `rejectUnauthorized: true`) |
| `SESSION_SECRET` | 32+ byte hex used to sign cookies and as Fastify cookie secret |
| `CSRF_SECRET` | 32+ byte hex for CSRF token derivation |
| `SPACES_*` | DigitalOcean Spaces credentials + bucket |
| `RESEND_API_KEY` | Transactional email |
| `STRIPE_*` | Billing |
| `PORT` | Port for Fastify (Plesk usually injects this; falls back to 8080) |
| `COOKIE_DOMAIN` | Set to your apex domain in production (e.g. `lawdesk.com`); leave blank in dev |
## Database commands
```bash
pnpm db:generate # create a new migration from schema changes
pnpm db:migrate # apply pending migrations
pnpm --filter @lawdesk/db studio # open Drizzle Studio
```
## Auth model
- Passwords hashed with **argon2id** (64MB memory cost).
- Cookie holds a 32-byte random token; the DB stores its **SHA-256 hash** (so a DB read can't impersonate users).
- Sessions are 30-day sliding (touched on every request).
- Login rate limited: 5 failed attempts per email per 15 minutes.
- All `/api/*` requests automatically attach `req.user` if a valid session cookie is present. Use `app.requireAuth` / `app.requireFirm` as preHandler guards on protected routes.
## What's next
- Wire DO Spaces upload routes for documents
- Build the `/app` dashboard (cases, time tracking, invoices)
- Free public tools (`/tools/*`)
- Stripe checkout + webhook
- Email templates via Resend
- pg-boss background jobs
+3
View File
@@ -0,0 +1,3 @@
// app.js — kept for Plesk configs that still point to app.js.
// The canonical entrypoint is server.js — this just forwards to it.
await import('./server.js');
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@lawdesk/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/server.ts",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json --noEmit",
"start": "tsx src/server.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.1",
"@fastify/cors": "^10.0.1",
"@fastify/helmet": "^12.0.1",
"@fastify/multipart": "^9.0.1",
"@fastify/rate-limit": "^10.2.1",
"@fastify/static": "^8.0.3",
"@lawdesk/db": "workspace:*",
"@sentry/node": "^8.45.0",
"argon2": "^0.41.1",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.36.4",
"fastify": "^5.1.0",
"fastify-plugin": "^5.0.1",
"fastify-type-provider-zod": "^4.0.2",
"pg": "^8.13.1",
"pdfkit": "^0.15.0",
"pino": "^9.5.0",
"resend": "^4.0.1",
"stripe": "^17.4.0",
"tsx": "^4.19.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.9.1",
"@types/pdfkit": "^0.13.5",
"@types/pg": "^8.11.10",
"pino-pretty": "^11.3.0",
"typescript": "^5.6.3"
}
}
+80
View File
@@ -0,0 +1,80 @@
import crypto from 'node:crypto';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import { isProd, env } from '../env';
import { SESSION_COOKIE } from './sessions';
export const CSRF_COOKIE = 'csrf';
export const CSRF_HEADER = 'x-csrf-token';
const TOKEN_BYTES = 32;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
// Routes that legitimately bypass CSRF — they receive their own auth (signature check)
// or have no session yet, so a CSRF attack against them is meaningless.
const CSRF_EXEMPT_PREFIXES = ['/api/auth/', '/api/contact', '/api/webhooks/', '/api/tool-usage'];
export function generateCsrfToken(): string {
return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
}
function constantTimeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
declare module 'fastify' {
interface FastifyInstance {
setCsrfCookie: (reply: FastifyReply, token: string) => void;
clearCsrfCookie: (reply: FastifyReply) => void;
}
}
async function plugin(app: FastifyInstance) {
app.decorate('setCsrfCookie', (reply: FastifyReply, token: string) => {
reply.setCookie(CSRF_COOKIE, token, {
path: '/',
httpOnly: false, // intentional — JS reads this and echoes it as a header
secure: isProd,
sameSite: 'lax',
domain: env.COOKIE_DOMAIN || undefined,
});
});
app.decorate('clearCsrfCookie', (reply: FastifyReply) => {
reply.clearCookie(CSRF_COOKIE, {
path: '/',
secure: isProd,
sameSite: 'lax',
domain: env.COOKIE_DOMAIN || undefined,
});
});
// Auto-mint a CSRF token whenever an authenticated session exists but no CSRF cookie is set.
// This makes the protection self-bootstrapping after sessions created before CSRF was enabled.
app.addHook('onRequest', async (req, reply) => {
if (!req.cookies?.[SESSION_COOKIE]) return;
if (req.cookies?.[CSRF_COOKIE]) return;
const token = generateCsrfToken();
app.setCsrfCookie(reply, token);
req.cookies = { ...req.cookies, [CSRF_COOKIE]: token };
});
// Verify CSRF on every state-changing request that has a session cookie.
app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => {
if (SAFE_METHODS.has(req.method)) return;
if (!req.cookies?.[SESSION_COOKIE]) return; // unauthenticated → nothing to protect
const url = req.routeOptions.url || req.url;
if (CSRF_EXEMPT_PREFIXES.some((p) => url.startsWith(p))) return;
const cookie = req.cookies?.[CSRF_COOKIE];
const header = (req.headers[CSRF_HEADER] as string | undefined) ?? '';
if (!cookie || !header || !constantTimeEqual(cookie, header)) {
return reply.code(403).send({ error: 'csrf_failed' });
}
});
}
export const csrfPlugin = fp(plugin, { name: 'csrf', dependencies: ['auth'] });
+16
View File
@@ -0,0 +1,16 @@
import argon2 from 'argon2';
const ARGON2_OPTIONS: argon2.Options = {
type: argon2.argon2id,
memoryCost: 64 * 1024,
timeCost: 3,
parallelism: 1,
};
export function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON2_OPTIONS);
}
export function verifyPassword(hash: string, password: string): Promise<boolean> {
return argon2.verify(hash, password);
}
+91
View File
@@ -0,0 +1,91 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
import { SESSION_COOKIE, loadSession } from './sessions';
import { isProd, env } from '../env';
import { ensureSuperadminFlag } from './superadmin';
declare module 'fastify' {
interface FastifyRequest {
user?: {
id: string;
email: string;
firmId: string | null;
role: string;
isSuperadmin: boolean;
isSuspended: boolean;
};
}
interface FastifyInstance {
requireAuth: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
requireFirm: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
requireSuperadmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
setSessionCookie: (reply: FastifyReply, token: string, expiresAt: Date) => void;
clearSessionCookie: (reply: FastifyReply) => void;
}
}
async function plugin(app: FastifyInstance) {
app.addHook('onRequest', async (req) => {
const token = req.cookies?.[SESSION_COOKIE];
if (!token) return;
const session = await loadSession(token);
if (!session) return;
// Auto-promote/demote based on SUPERADMIN_EMAILS env var, every request — cheap and self-healing.
const isSuperadmin = await ensureSuperadminFlag(
session.user.id,
session.user.email,
session.user.isSuperadmin,
);
req.user = {
id: session.user.id,
email: session.user.email,
firmId: session.user.firmId,
role: session.user.role,
isSuperadmin,
isSuspended: session.user.isSuspended,
};
});
app.decorate('requireAuth', async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
});
app.decorate('requireFirm', async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
if (!req.user.firmId) return reply.code(403).send({ error: 'no_firm' });
});
app.decorate('requireSuperadmin', async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
if (!req.user.isSuperadmin) return reply.code(403).send({ error: 'forbidden' });
});
app.decorate('setSessionCookie', (reply: FastifyReply, token: string, expiresAt: Date) => {
reply.setCookie(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
secure: isProd,
sameSite: 'lax',
domain: env.COOKIE_DOMAIN || undefined,
expires: expiresAt,
signed: false,
});
});
app.decorate('clearSessionCookie', (reply: FastifyReply) => {
reply.clearCookie(SESSION_COOKIE, {
path: '/',
httpOnly: true,
secure: isProd,
sameSite: 'lax',
domain: env.COOKIE_DOMAIN || undefined,
});
});
}
export const authPlugin = fp(plugin, { name: 'auth' });
+77
View File
@@ -0,0 +1,77 @@
import crypto from 'node:crypto';
import { eq, lt } from 'drizzle-orm';
import { getDb, sessions, users } from '@lawdesk/db';
const SESSION_BYTES = 32;
const SESSION_TTL_DAYS = 30;
export const SESSION_COOKIE = 'sid';
export function generateSessionToken(): string {
return crypto.randomBytes(SESSION_BYTES).toString('base64url');
}
export function hashSessionToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
export interface CreateSessionOpts {
userId: string;
ip?: string | null;
userAgent?: string | null;
}
export async function createSession(opts: CreateSessionOpts): Promise<{ token: string; expiresAt: Date }> {
const token = generateSessionToken();
const id = hashSessionToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000);
await getDb().insert(sessions).values({
id,
userId: opts.userId,
expiresAt,
ip: opts.ip ?? null,
userAgent: opts.userAgent ?? null,
});
return { token, expiresAt };
}
export async function loadSession(token: string) {
const id = hashSessionToken(token);
const db = getDb();
const rows = await db
.select({
session: sessions,
user: users,
})
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.id, id))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() <= Date.now()) {
await db.delete(sessions).where(eq(sessions.id, id));
return null;
}
// Touch last_seen_at (best-effort, fire and forget)
db.update(sessions)
.set({ lastSeenAt: new Date() })
.where(eq(sessions.id, id))
.catch(() => {});
return row;
}
export async function destroySession(token: string): Promise<void> {
const id = hashSessionToken(token);
await getDb().delete(sessions).where(eq(sessions.id, id));
}
export async function purgeExpiredSessions(): Promise<void> {
await getDb().delete(sessions).where(lt(sessions.expiresAt, new Date()));
}
+16
View File
@@ -0,0 +1,16 @@
import { eq } from 'drizzle-orm';
import { getDb, users } from '@lawdesk/db';
import { env } from '../env';
export function isSuperadminEmail(email: string): boolean {
return env.superadminEmails.includes(email.toLowerCase());
}
// Promote any user whose email is on the SUPERADMIN_EMAILS list. Idempotent.
// Called on signup/login so the assignment happens automatically as soon as the user shows up.
export async function ensureSuperadminFlag(userId: string, email: string, currentFlag: boolean) {
const shouldBe = isSuperadminEmail(email);
if (shouldBe === currentFlag) return shouldBe;
await getDb().update(users).set({ isSuperadmin: shouldBe, updatedAt: new Date() }).where(eq(users.id, userId));
return shouldBe;
}
+39
View File
@@ -0,0 +1,39 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import dotenv from 'dotenv';
import { z } from 'zod';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Load .env from the monorepo root regardless of cwd
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
PORT: z.coerce.number().int().positive().default(8080),
PUBLIC_URL: z.string().url().default('http://localhost:8080'),
COOKIE_DOMAIN: z.string().optional(),
SESSION_SECRET: z.string().min(32),
CSRF_SECRET: z.string().min(32),
DATABASE_URL: z.string().min(1),
DATABASE_CA_CERT_PATH: z.string().optional(),
WEB_DIST_PATH: z.string().optional(),
SUPERADMIN_EMAILS: z.string().optional().default(''),
SENTRY_DSN_API: z.string().optional().default(''),
RESEND_API_KEY: z.string().optional().default(''),
EMAIL_FROM: z.string().optional().default('eLegal Software <noreply@elegalsoftware.com>'),
STRIPE_SECRET_KEY: z.string().optional().default(''),
STRIPE_WEBHOOK_SECRET: z.string().optional().default(''),
STRIPE_PRICE_PRO: z.string().optional().default(''),
STRIPE_PRICE_LIFETIME: z.string().optional().default(''),
});
const parsed = envSchema.parse(process.env);
export const env = {
...parsed,
superadminEmails: parsed.SUPERADMIN_EMAILS.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
};
export const isProd = env.NODE_ENV === 'production';
+19
View File
@@ -0,0 +1,19 @@
import { getDb, auditLog } from '@lawdesk/db';
export interface AuditEntry {
userId?: string | null;
firmId?: string | null;
action: string;
meta?: unknown;
ip?: string | null;
}
export async function logAudit(entry: AuditEntry): Promise<void> {
await getDb().insert(auditLog).values({
userId: entry.userId ?? null,
firmId: entry.firmId ?? null,
action: entry.action,
meta: entry.meta == null ? null : JSON.stringify(entry.meta),
ip: entry.ip ?? null,
});
}
+159
View File
@@ -0,0 +1,159 @@
import { Resend } from 'resend';
import { env } from '../env';
let _resend: Resend | null = null;
function getResend(): Resend | null {
if (!env.RESEND_API_KEY) return null;
if (!_resend) _resend = new Resend(env.RESEND_API_KEY);
return _resend;
}
export interface EmailOptions {
to: string;
subject: string;
html: string;
text: string;
attachments?: Array<{ filename: string; content: Buffer | string }>;
replyTo?: string;
}
export interface SendResult {
ok: boolean;
skipped?: boolean;
id?: string;
error?: string;
}
export async function sendEmail(opts: EmailOptions): Promise<SendResult> {
const resend = getResend();
if (!resend) {
// Logged but not sent — useful in dev when RESEND_API_KEY isn't set.
console.log(`[email skipped] to=${opts.to} subject="${opts.subject}"`);
return { ok: true, skipped: true };
}
try {
const res = await resend.emails.send({
from: env.EMAIL_FROM,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
replyTo: opts.replyTo,
attachments: opts.attachments?.map((a) => ({
filename: a.filename,
content: typeof a.content === 'string' ? a.content : a.content.toString('base64'),
})),
});
if (res.error) return { ok: false, error: res.error.message };
return { ok: true, id: res.data?.id };
} catch (err) {
return { ok: false, error: (err as Error).message };
}
}
// ─────────────────────────── Templates ───────────────────────────
// Kept simple. Brand-blue header bar + readable body. Plain-text version always provided
// since some clients (and good practice) require it.
const BRAND = '#0052FF';
function shell(bodyHtml: string): string {
return `<!doctype html>
<html><head><meta charset="utf-8"><title>eLegal Software</title></head>
<body style="margin:0;padding:0;background:#f6f7f9;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#23272e;">
<div style="max-width:560px;margin:32px auto;background:#fff;border-radius:16px;overflow:hidden;border:1px solid #eceef2;">
<div style="background:${BRAND};padding:18px 24px;color:#fff;font-weight:700;letter-spacing:-0.01em;font-size:18px;">eLegal Software</div>
<div style="padding:28px 24px;line-height:1.55;font-size:15px;">${bodyHtml}</div>
<div style="border-top:1px solid #eceef2;padding:14px 24px;color:#7c8595;font-size:12px;">© ${new Date().getFullYear()} eLegal Software. You're receiving this because of activity on your account.</div>
</div>
</body></html>`;
}
export function welcomeEmail(toName: string | null, verifyUrl: string | null) {
const name = toName?.split(' ')[0] ?? 'there';
const verifyBlock = verifyUrl
? `<p>Please confirm your email address so we can send you important updates:</p>
<p><a href="${verifyUrl}" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Verify my email</a></p>
<p style="color:#5b6473;font-size:13px;">Or paste this link into your browser: ${verifyUrl}</p>`
: '';
return {
subject: 'Welcome to eLegal Software',
html: shell(
`<p>Hi ${name},</p>
<p>Welcome to eLegal Software. Your account is set up and you're ready to add your first client and case.</p>
${verifyBlock}
<p>If you have questions, just reply to this email — a real person will see it.</p>
<p>— The eLegal Software team</p>`,
),
text: `Hi ${name},\n\nWelcome to eLegal Software. Your account is set up and you're ready to add your first client and case.\n\n${verifyUrl ? `Please confirm your email: ${verifyUrl}\n\n` : ''}If you have questions, just reply to this email.\n\n— The eLegal Software team`,
};
}
export function passwordResetEmail(toName: string | null, resetUrl: string) {
const name = toName?.split(' ')[0] ?? 'there';
return {
subject: 'Reset your eLegal Software password',
html: shell(
`<p>Hi ${name},</p>
<p>We got a request to reset the password on your eLegal Software account. Click below to choose a new one:</p>
<p><a href="${resetUrl}" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Reset password</a></p>
<p style="color:#5b6473;font-size:13px;">Or paste this link into your browser: ${resetUrl}</p>
<p style="color:#5b6473;font-size:13px;">This link expires in 1 hour. If you didn't request a reset, you can safely ignore this email.</p>`,
),
text: `Hi ${name},\n\nWe got a request to reset your eLegal Software password.\n\nReset it here: ${resetUrl}\n\nThis link expires in 1 hour. If you didn't request a reset, ignore this email.`,
};
}
export function planUpgradedEmail(toName: string | null, plan: string) {
const name = toName?.split(' ')[0] ?? 'there';
return {
subject: `You're on eLegal Software ${plan}`,
html: shell(
`<p>Hi ${name},</p>
<p>Thanks for upgrading. Your firm is now on the <strong>${plan}</strong> plan and the limits and watermarks have been lifted.</p>
<p><a href="${process.env.PUBLIC_URL ?? 'https://app.elegalsoftware.com'}/app" style="display:inline-block;background:${BRAND};color:#fff;padding:12px 20px;border-radius:10px;text-decoration:none;font-weight:600;">Open eLegal Software</a></p>
<p>Manage your subscription anytime from Settings → Billing.</p>`,
),
text: `Hi ${name},\n\nThanks for upgrading. Your firm is now on the ${plan} plan and the limits and watermarks have been lifted.\n\nManage your subscription from Settings → Billing.`,
};
}
export function invoiceEmail(opts: {
clientName: string;
firmName: string;
invoiceNumber: string;
total: string;
dueDate?: string | null;
notes?: string | null;
}) {
const dueLine = opts.dueDate ? `<p>Due on <strong>${opts.dueDate}</strong>.</p>` : '';
const notesLine = opts.notes
? `<p style="background:#f6f7f9;border-radius:10px;padding:12px;color:#5b6473;font-size:13px;">${opts.notes}</p>`
: '';
return {
subject: `Invoice ${opts.invoiceNumber} from ${opts.firmName}`,
html: shell(
`<p>Hi ${opts.clientName.split(' ')[0]},</p>
<p>${opts.firmName} sent you a new invoice.</p>
<p style="font-size:18px;"><strong>${opts.invoiceNumber}</strong> — <strong>${opts.total}</strong></p>
${dueLine}
${notesLine}
<p>The PDF is attached. Reply to this email if you have any questions.</p>`,
),
text: `Hi ${opts.clientName},\n\n${opts.firmName} sent you a new invoice: ${opts.invoiceNumber}${opts.total}.${opts.dueDate ? ` Due on ${opts.dueDate}.` : ''}\n\nThe PDF is attached.${opts.notes ? `\n\nNotes: ${opts.notes}` : ''}`,
};
}
export function contactAckEmail(toName: string) {
const name = toName.split(' ')[0];
return {
subject: "Got your message — we'll be in touch",
html: shell(
`<p>Hi ${name},</p>
<p>Thanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.</p>
<p>— The eLegal Software team</p>`,
),
text: `Hi ${name},\n\nThanks for reaching out to eLegal Software. We've received your message and one of us will reply within one business day.\n\n— The eLegal Software team`,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { eq } from 'drizzle-orm';
import { getDb, firms } from '@lawdesk/db';
import type { PlanName } from './plan-limits';
export interface FirmContext {
id: string;
plan: PlanName;
name: string;
watermarkEnabled: boolean;
}
export async function loadFirm(firmId: string): Promise<FirmContext | null> {
const [row] = await getDb().select().from(firms).where(eq(firms.id, firmId)).limit(1);
if (!row) return null;
return {
id: row.id,
plan: row.plan as PlanName,
name: row.name,
watermarkEnabled: row.watermarkEnabled,
};
}
+20
View File
@@ -0,0 +1,20 @@
import { sql } from 'drizzle-orm';
import { eq, and, like } from 'drizzle-orm';
import { getDb, invoices } from '@lawdesk/db';
// Format: INV-YYYY-NNNN, scoped per firm.
// Uses a count-based sequence — the unique-on-(firm_id, number) constraint isn't enforced
// at the DB level yet, so two near-simultaneous creates could collide. For a v1 single-user
// firm this is fine; if it becomes a problem, add a per-firm Postgres sequence.
export async function nextInvoiceNumber(firmId: string): Promise<string> {
const year = new Date().getUTCFullYear();
const prefix = `INV-${year}-`;
const [row] = await getDb()
.select({ count: sql<number>`count(*)::int` })
.from(invoices)
.where(and(eq(invoices.firmId, firmId), like(invoices.number, `${prefix}%`)));
const next = (row?.count ?? 0) + 1;
return `${prefix}${String(next).padStart(4, '0')}`;
}
+148
View File
@@ -0,0 +1,148 @@
import PDFDocument from 'pdfkit';
import { PassThrough } from 'node:stream';
export interface InvoicePdfData {
number: string;
status: string;
issuedAt: Date | null;
dueAt: Date | null;
notes: string | null;
subtotal: string;
taxRate: string;
total: string;
firm: { name: string };
client: { name: string; email: string | null; address: string | null };
items: Array<{ description: string; quantity: string; rate: string; amount: string }>;
watermark?: boolean;
}
const FONT = 'Helvetica';
const FONT_BOLD = 'Helvetica-Bold';
function formatMoney(value: string | number | null | undefined): string {
if (value == null) return '$0.00';
const n = typeof value === 'string' ? Number(value) : value;
if (!Number.isFinite(n)) return '$0.00';
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
}
function formatDate(d: Date | null): string {
if (!d) return '—';
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
export function renderInvoicePdf(data: InvoicePdfData): NodeJS.ReadableStream {
const doc = new PDFDocument({ size: 'LETTER', margin: 50 });
const stream = new PassThrough();
doc.pipe(stream);
// Header bar
doc.rect(0, 0, doc.page.width, 6).fill('#0052FF');
doc.fillColor('#13161B');
// Firm + invoice meta
doc.font(FONT_BOLD).fontSize(20).text(data.firm.name, 50, 36);
doc.font(FONT).fontSize(10).fillColor('#5B6473').text('Invoice', 50, 62);
doc.fontSize(28).fillColor('#0052FF').font(FONT_BOLD).text(data.number, 0, 36, { align: 'right' });
doc.font(FONT).fontSize(10).fillColor('#5B6473');
doc.text(`Status: ${data.status.toUpperCase()}`, 0, 70, { align: 'right' });
// Bill-to + dates block
const blockY = 120;
doc.font(FONT_BOLD).fontSize(10).fillColor('#13161B').text('BILL TO', 50, blockY);
doc.font(FONT).fontSize(11).fillColor('#23272E');
doc.text(data.client.name, 50, blockY + 16);
if (data.client.email) doc.text(data.client.email, 50, blockY + 32);
if (data.client.address) doc.text(data.client.address, 50, blockY + 48, { width: 240 });
doc.font(FONT_BOLD).fontSize(10).fillColor('#13161B').text('ISSUED', 350, blockY);
doc.font(FONT).fontSize(11).fillColor('#23272E').text(formatDate(data.issuedAt), 350, blockY + 16);
doc.font(FONT_BOLD).fontSize(10).fillColor('#13161B').text('DUE', 470, blockY);
doc.font(FONT).fontSize(11).fillColor('#23272E').text(formatDate(data.dueAt), 470, blockY + 16);
// Items table
const tableY = 220;
const col = { desc: 50, qty: 340, rate: 410, amount: 480 };
const tableWidth = doc.page.width - 100;
doc.rect(50, tableY, tableWidth, 24).fill('#F6F7F9');
doc.fillColor('#5B6473').font(FONT_BOLD).fontSize(9);
doc.text('DESCRIPTION', col.desc + 8, tableY + 8);
doc.text('QTY', col.qty, tableY + 8, { width: 50, align: 'right' });
doc.text('RATE', col.rate, tableY + 8, { width: 50, align: 'right' });
doc.text('AMOUNT', col.amount, tableY + 8, { width: 65, align: 'right' });
doc.font(FONT).fontSize(10).fillColor('#23272E');
let y = tableY + 32;
for (const item of data.items) {
const descHeight = doc.heightOfString(item.description, { width: col.qty - col.desc - 16 });
const rowH = Math.max(20, descHeight + 6);
doc.text(item.description, col.desc + 8, y, { width: col.qty - col.desc - 16 });
doc.text(item.quantity, col.qty, y, { width: 50, align: 'right' });
doc.text(formatMoney(item.rate), col.rate, y, { width: 50, align: 'right' });
doc.text(formatMoney(item.amount), col.amount, y, { width: 65, align: 'right' });
y += rowH;
doc.moveTo(50, y).lineTo(50 + tableWidth, y).strokeColor('#ECEEF2').lineWidth(0.5).stroke();
y += 4;
if (y > doc.page.height - 200) {
doc.addPage();
y = 50;
}
}
// Totals
const totalsY = y + 20;
const labelX = 380;
const valueX = 480;
doc.font(FONT).fontSize(10).fillColor('#5B6473');
doc.text('Subtotal', labelX, totalsY, { width: 90, align: 'right' });
doc.fillColor('#23272E').text(formatMoney(data.subtotal), valueX, totalsY, { width: 65, align: 'right' });
if (Number(data.taxRate) > 0) {
doc.fillColor('#5B6473').text(`Tax (${data.taxRate}%)`, labelX, totalsY + 18, { width: 90, align: 'right' });
const taxAmount = (Number(data.subtotal) * Number(data.taxRate)) / 100;
doc.fillColor('#23272E').text(formatMoney(taxAmount), valueX, totalsY + 18, { width: 65, align: 'right' });
}
const totalY = totalsY + (Number(data.taxRate) > 0 ? 44 : 26);
doc.rect(labelX - 10, totalY - 6, 175, 28).fill('#0052FF');
doc.fillColor('#FFFFFF').font(FONT_BOLD).fontSize(12);
doc.text('Total', labelX, totalY + 2, { width: 90, align: 'right' });
doc.text(formatMoney(data.total), valueX, totalY + 2, { width: 65, align: 'right' });
// Notes
if (data.notes) {
const notesY = totalY + 60;
doc.fillColor('#13161B').font(FONT_BOLD).fontSize(10).text('NOTES', 50, notesY);
doc.fillColor('#23272E').font(FONT).fontSize(10).text(data.notes, 50, notesY + 16, {
width: tableWidth,
});
}
// Footer
const footerY = doc.page.height - 50;
doc.fillColor('#7C8595').font(FONT).fontSize(9).text(
`Generated by eLegal Software · ${data.firm.name}`,
50,
footerY,
{ width: tableWidth, align: 'center' },
);
// Watermark for Starter plan
if (data.watermark) {
doc.save();
doc.fillColor('#0052FF').fillOpacity(0.08).font(FONT_BOLD).fontSize(90);
doc.rotate(-30, { origin: [doc.page.width / 2, doc.page.height / 2] });
doc.text('LAWDESK', 0, doc.page.height / 2 - 60, {
width: doc.page.width,
align: 'center',
});
doc.restore();
}
doc.end();
return stream;
}
+72
View File
@@ -0,0 +1,72 @@
import { sql } from 'drizzle-orm';
import { getDb, clients, cases, invoices } from '@lawdesk/db';
import { and, eq, gte } from 'drizzle-orm';
export type PlanName = 'starter' | 'pro' | 'lifetime';
export interface PlanLimits {
clients: number | null;
activeCases: number | null;
invoicesPerMonth: number | null;
storageBytes: number | null;
}
export const PLAN_LIMITS: Record<PlanName, PlanLimits> = {
starter: {
clients: 2,
activeCases: 1,
invoicesPerMonth: 2,
storageBytes: 500 * 1024 * 1024, // 500 MB
},
pro: {
clients: null,
activeCases: 6,
invoicesPerMonth: null,
storageBytes: 8 * 1024 * 1024 * 1024, // 8 GB
},
lifetime: {
clients: null,
activeCases: null,
invoicesPerMonth: null,
storageBytes: 50 * 1024 * 1024 * 1024, // 50 GB
},
};
export class PlanLimitError extends Error {
constructor(public limit: keyof PlanLimits, public planName: PlanName) {
super(`plan_limit_${limit}`);
}
}
export async function assertCanCreateClient(firmId: string, plan: PlanName) {
const limit = PLAN_LIMITS[plan].clients;
if (limit === null) return;
const [row] = await getDb()
.select({ count: sql<number>`count(*)::int` })
.from(clients)
.where(eq(clients.firmId, firmId));
if ((row?.count ?? 0) >= limit) throw new PlanLimitError('clients', plan);
}
export async function assertCanCreateCase(firmId: string, plan: PlanName) {
const limit = PLAN_LIMITS[plan].activeCases;
if (limit === null) return;
const [row] = await getDb()
.select({ count: sql<number>`count(*)::int` })
.from(cases)
.where(and(eq(cases.firmId, firmId), eq(cases.status, 'open')));
if ((row?.count ?? 0) >= limit) throw new PlanLimitError('activeCases', plan);
}
export async function assertCanCreateInvoice(firmId: string, plan: PlanName) {
const limit = PLAN_LIMITS[plan].invoicesPerMonth;
if (limit === null) return;
const monthStart = new Date();
monthStart.setDate(1);
monthStart.setHours(0, 0, 0, 0);
const [row] = await getDb()
.select({ count: sql<number>`count(*)::int` })
.from(invoices)
.where(and(eq(invoices.firmId, firmId), gte(invoices.createdAt, monthStart)));
if ((row?.count ?? 0) >= limit) throw new PlanLimitError('invoicesPerMonth', plan);
}
+26
View File
@@ -0,0 +1,26 @@
import * as Sentry from '@sentry/node';
import { env, isProd } from '../env';
let initialized = false;
export function initSentry(): void {
if (initialized) return;
if (!env.SENTRY_DSN_API) return;
Sentry.init({
dsn: env.SENTRY_DSN_API,
environment: env.NODE_ENV,
tracesSampleRate: isProd ? 0.1 : 0,
sendDefaultPii: false,
});
initialized = true;
}
export function captureError(err: unknown, ctx?: Record<string, unknown>): void {
if (!initialized) return;
Sentry.withScope((scope) => {
if (ctx) for (const [k, v] of Object.entries(ctx)) scope.setExtra(k, v);
Sentry.captureException(err);
});
}
export { Sentry };
+37
View File
@@ -0,0 +1,37 @@
import Stripe from 'stripe';
import { env } from '../env';
let _stripe: Stripe | null = null;
export function getStripe(): Stripe {
if (!env.STRIPE_SECRET_KEY) {
throw new Error('stripe_not_configured');
}
if (!_stripe) {
_stripe = new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: '2024-11-20.acacia' as Stripe.LatestApiVersion });
}
return _stripe;
}
export function stripeIsConfigured(): boolean {
return !!env.STRIPE_SECRET_KEY;
}
export interface PlanConfig {
priceId: string;
mode: 'subscription' | 'payment';
planName: 'pro' | 'lifetime';
label: string;
}
export function getPlanConfig(plan: 'pro' | 'lifetime'): PlanConfig | null {
if (plan === 'pro') {
if (!env.STRIPE_PRICE_PRO) return null;
return { priceId: env.STRIPE_PRICE_PRO, mode: 'subscription', planName: 'pro', label: 'Professional' };
}
if (plan === 'lifetime') {
if (!env.STRIPE_PRICE_LIFETIME) return null;
return { priceId: env.STRIPE_PRICE_LIFETIME, mode: 'payment', planName: 'lifetime', label: 'Lifetime' };
}
return null;
}
+137
View File
@@ -0,0 +1,137 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { eq, inArray, sql } from 'drizzle-orm';
import {
getDb,
users,
firms,
clients,
cases,
timeEntries,
invoices,
invoiceItems,
documents,
sessions,
} from '@lawdesk/db';
import { verifyPassword } from '../auth/password';
import { logAudit } from '../lib/audit';
export async function accountRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireAuth);
// GDPR data export — full JSON dump of everything tied to the user's firm.
app.get('/api/account/export', async (req, reply) => {
const userId = req.user!.id;
const firmId = req.user!.firmId;
const db = getDb();
const [profile] = await db
.select({
id: users.id,
email: users.email,
fullName: users.fullName,
role: users.role,
emailVerifiedAt: users.emailVerifiedAt,
totpEnabled: users.totpEnabled,
lastSeenAt: users.lastSeenAt,
createdAt: users.createdAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!profile) return reply.code(404).send({ error: 'profile_not_found' });
const dump: Record<string, unknown> = {
exportedAt: new Date().toISOString(),
profile,
};
if (firmId) {
const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1);
const firmClients = await db.select().from(clients).where(eq(clients.firmId, firmId));
const firmCases = await db.select().from(cases).where(eq(cases.firmId, firmId));
const firmTime = await db.select().from(timeEntries).where(eq(timeEntries.firmId, firmId));
const firmInvoices = await db.select().from(invoices).where(eq(invoices.firmId, firmId));
const invoiceIds = firmInvoices.map((i) => i.id);
const items = invoiceIds.length
? await db.select().from(invoiceItems).where(inArray(invoiceItems.invoiceId, invoiceIds))
: [];
const docs = await db.select().from(documents).where(eq(documents.firmId, firmId));
dump.firm = firm;
dump.clients = firmClients;
dump.cases = firmCases;
dump.timeEntries = firmTime;
dump.invoices = firmInvoices.map((i) => ({
...i,
items: items.filter((it) => it.invoiceId === i.id),
}));
dump.documents = docs;
}
await logAudit({
userId,
firmId,
action: 'account.export',
ip: req.ip,
});
reply
.header('Content-Type', 'application/json; charset=utf-8')
.header(
'Content-Disposition',
`attachment; filename="lawdesk-export-${new Date().toISOString().slice(0, 10)}.json"`,
);
return JSON.stringify(dump, null, 2);
});
// GDPR delete — password-confirmed. Solo firms cascade everything; multi-user firms must
// transfer ownership first (we'll add a transfer endpoint when we add team management).
app.post('/api/account/delete', async (req, reply) => {
const userId = req.user!.id;
const firmId = req.user!.firmId;
const body = z.object({ password: z.string().min(1) }).parse(req.body);
const db = getDb();
const [me] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!me) return reply.code(404).send({ error: 'user_not_found' });
const ok = await verifyPassword(me.passwordHash, body.password);
if (!ok) return reply.code(401).send({ error: 'invalid_password' });
if (firmId) {
const [{ count }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(users)
.where(eq(users.firmId, firmId));
if (count > 1) {
return reply.code(409).send({
error: 'firm_has_other_users',
hint: 'Transfer firm ownership or remove other users before deleting this account.',
});
}
}
await logAudit({
userId,
firmId,
action: 'account.delete',
meta: { email: me.email },
ip: req.ip,
});
await db.transaction(async (tx) => {
await tx.delete(sessions).where(eq(sessions.userId, userId));
// Deleting the firm cascades: clients → cases → time_entries / documents / invoices →
// invoice_items via the foreign-key onDelete:'cascade' chain. Audit log entries pointing
// to this user keep their row but null out user_id (set null).
if (firmId) await tx.delete(firms).where(eq(firms.id, firmId));
await tx.delete(users).where(eq(users.id, userId));
});
app.clearSessionCookie(reply);
app.clearCsrfCookie(reply);
return { ok: true };
});
}
+398
View File
@@ -0,0 +1,398 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, desc, eq, gte, ilike, isNull, or, sql } from 'drizzle-orm';
import {
getDb,
users,
firms,
clients,
cases,
invoices,
contactMessages,
auditLog,
toolUsage,
} from '@lawdesk/db';
import { createSession, destroySession, SESSION_COOKIE } from '../auth/sessions';
import { generateCsrfToken } from '../auth/csrf';
import { logAudit } from '../lib/audit';
const PLANS = ['starter', 'pro', 'lifetime'] as const;
const idParam = z.object({ id: z.string().uuid() });
export async function adminRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireSuperadmin);
// ─────────────────────────── Stats ───────────────────────────
app.get('/api/admin/stats', async () => {
const db = getDb();
const since30 = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [counts] = await db
.select({
firms: sql<number>`(select count(*)::int from ${firms})`,
users: sql<number>`(select count(*)::int from ${users})`,
cases: sql<number>`(select count(*)::int from ${cases})`,
clients: sql<number>`(select count(*)::int from ${clients})`,
invoices: sql<number>`(select count(*)::int from ${invoices})`,
unresolvedContact: sql<number>`(select count(*)::int from ${contactMessages} where ${contactMessages.resolvedAt} is null)`,
})
.from(sql`(select 1) as one`);
const [paidTotalsRow] = await db
.select({
paidTotal: sql<string>`coalesce(sum(${invoices.total})::text, '0')`,
})
.from(invoices)
.where(eq(invoices.status, 'paid'));
const planRows = await db
.select({ plan: firms.plan, count: sql<number>`count(*)::int` })
.from(firms)
.groupBy(firms.plan);
const signups = await db
.select({
day: sql<string>`to_char(date_trunc('day', ${users.createdAt}), 'YYYY-MM-DD')`,
count: sql<number>`count(*)::int`,
})
.from(users)
.where(gte(users.createdAt, since30))
.groupBy(sql`date_trunc('day', ${users.createdAt})`)
.orderBy(sql`date_trunc('day', ${users.createdAt})`);
return {
counters: {
firms: counts?.firms ?? 0,
users: counts?.users ?? 0,
cases: counts?.cases ?? 0,
clients: counts?.clients ?? 0,
invoices: counts?.invoices ?? 0,
unresolvedContact: counts?.unresolvedContact ?? 0,
paidRevenueTotal: paidTotalsRow?.paidTotal ?? '0',
},
planDistribution: planRows,
signupsLast30Days: signups,
};
});
// ─────────────────────────── Firms ───────────────────────────
app.get('/api/admin/firms', async (req) => {
const q = z
.object({
q: z.string().max(160).optional(),
plan: z.enum(PLANS).optional(),
limit: z.coerce.number().int().positive().max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
})
.parse(req.query);
const db = getDb();
// Build where as raw SQL so we can use the aliased table name in the main query below.
const whereClauses: ReturnType<typeof sql>[] = [];
if (q.plan) whereClauses.push(sql`f.plan = ${q.plan}`);
if (q.q) whereClauses.push(sql`f.name ilike ${'%' + q.q + '%'}`);
const whereSql = whereClauses.length
? sql.join([sql`where`, sql.join(whereClauses, sql` and `)], sql` `)
: sql``;
// Raw SQL — Drizzle's `${firms.id}` interpolation inside `sql<T>` doesn't bind to the outer
// query's table reference inside correlated subqueries.
const result = await db.execute(sql`
select
f.id,
f.name,
f.plan,
f.watermark_enabled as "watermarkEnabled",
f.created_at as "createdAt",
coalesce((select count(*)::int from users u where u.firm_id = f.id), 0) as "userCount",
coalesce((select count(*)::int from cases c where c.firm_id = f.id), 0) as "caseCount",
coalesce((select count(*)::int from clients cl where cl.firm_id = f.id), 0) as "clientCount",
coalesce((select sum(total)::text from invoices i where i.firm_id = f.id and i.status = 'paid'), '0') as "paidTotal"
from firms f
${whereSql}
order by f.created_at desc
limit ${q.limit}
offset ${q.offset}
`);
const totalResult = await db.execute(sql`select count(*)::int as total from firms f ${whereSql}`);
const total = (totalResult.rows[0]?.total as number) ?? 0;
return { items: result.rows, total };
});
app.get('/api/admin/firms/:id', async (req, reply) => {
const { id } = idParam.parse(req.params);
const db = getDb();
const [firm] = await db.select().from(firms).where(eq(firms.id, id)).limit(1);
if (!firm) return reply.code(404).send({ error: 'not_found' });
const firmUsers = await db
.select({
id: users.id,
email: users.email,
fullName: users.fullName,
role: users.role,
isSuspended: users.isSuspended,
isSuperadmin: users.isSuperadmin,
createdAt: users.createdAt,
lastSeenAt: users.lastSeenAt,
})
.from(users)
.where(eq(users.firmId, id))
.orderBy(desc(users.createdAt));
const [counts] = await db
.select({
clients: sql<number>`(select count(*)::int from ${clients} where ${clients.firmId} = ${id})`,
cases: sql<number>`(select count(*)::int from ${cases} where ${cases.firmId} = ${id})`,
invoices: sql<number>`(select count(*)::int from ${invoices} where ${invoices.firmId} = ${id})`,
paidTotal: sql<string>`coalesce((select sum(${invoices.total})::text from ${invoices} where ${invoices.firmId} = ${id} and ${invoices.status} = 'paid'), '0')`,
})
.from(sql`(select 1) as one`);
return { firm, users: firmUsers, counts };
});
app.patch('/api/admin/firms/:id', async (req, reply) => {
const { id } = idParam.parse(req.params);
const body = z
.object({
plan: z.enum(PLANS).optional(),
watermarkEnabled: z.boolean().optional(),
name: z.string().min(1).max(160).optional(),
})
.parse(req.body);
if (!Object.keys(body).length) return reply.code(400).send({ error: 'empty_body' });
const [updated] = await getDb()
.update(firms)
.set({ ...body, updatedAt: new Date() })
.where(eq(firms.id, id))
.returning();
if (!updated) return reply.code(404).send({ error: 'not_found' });
await logAudit({
userId: req.user!.id,
firmId: id,
action: 'admin.firm.update',
meta: body,
ip: req.ip,
});
return updated;
});
// ─────────────────────────── Users ───────────────────────────
app.get('/api/admin/users', async (req) => {
const q = z
.object({
q: z.string().max(160).optional(),
suspended: z.enum(['true', 'false']).optional(),
limit: z.coerce.number().int().positive().max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
})
.parse(req.query);
const db = getDb();
const filters: Parameters<typeof and> = [];
if (q.q) filters.push(or(ilike(users.email, `%${q.q}%`), ilike(users.fullName, `%${q.q}%`))!);
if (q.suspended === 'true') filters.push(eq(users.isSuspended, true));
if (q.suspended === 'false') filters.push(eq(users.isSuspended, false));
const where = filters.length ? and(...filters) : undefined;
const rows = await db
.select({
id: users.id,
email: users.email,
fullName: users.fullName,
role: users.role,
isSuperadmin: users.isSuperadmin,
isSuspended: users.isSuspended,
createdAt: users.createdAt,
lastSeenAt: users.lastSeenAt,
firmId: users.firmId,
firmName: firms.name,
})
.from(users)
.leftJoin(firms, eq(firms.id, users.firmId))
.where(where)
.orderBy(desc(users.createdAt))
.limit(q.limit)
.offset(q.offset);
const [count] = await db.select({ total: sql<number>`count(*)::int` }).from(users).where(where);
return { items: rows, total: count?.total ?? 0 };
});
app.patch('/api/admin/users/:id', async (req, reply) => {
const { id } = idParam.parse(req.params);
const body = z
.object({
isSuspended: z.boolean().optional(),
role: z.enum(['owner', 'attorney', 'paralegal', 'staff']).optional(),
})
.parse(req.body);
if (!Object.keys(body).length) return reply.code(400).send({ error: 'empty_body' });
if (req.user!.id === id && body.isSuspended === true) {
return reply.code(409).send({ error: 'cannot_suspend_self' });
}
const [updated] = await getDb()
.update(users)
.set({ ...body, updatedAt: new Date() })
.where(eq(users.id, id))
.returning({
id: users.id,
email: users.email,
role: users.role,
isSuspended: users.isSuspended,
});
if (!updated) return reply.code(404).send({ error: 'not_found' });
if (body.isSuspended) {
// Revoke all active sessions for this user
const { sessions } = await import('@lawdesk/db');
await getDb().delete(sessions).where(eq(sessions.userId, id));
}
await logAudit({
userId: req.user!.id,
action: 'admin.user.update',
meta: { targetUserId: id, patch: body },
ip: req.ip,
});
return updated;
});
// Impersonate: end the current session, start a new one for the target user.
app.post('/api/admin/users/:id/impersonate', async (req, reply) => {
const { id } = idParam.parse(req.params);
const db = getDb();
const [target] = await db.select().from(users).where(eq(users.id, id)).limit(1);
if (!target) return reply.code(404).send({ error: 'not_found' });
if (target.isSuspended) return reply.code(409).send({ error: 'target_suspended' });
if (target.id === req.user!.id) return reply.code(409).send({ error: 'cannot_impersonate_self' });
const oldToken = req.cookies?.[SESSION_COOKIE];
if (oldToken) await destroySession(oldToken);
const { token, expiresAt } = await createSession({
userId: target.id,
ip: req.ip,
userAgent: req.headers['user-agent'] ?? null,
});
app.setSessionCookie(reply, token, expiresAt);
app.setCsrfCookie(reply, generateCsrfToken());
await logAudit({
userId: req.user!.id,
firmId: target.firmId,
action: 'admin.impersonate',
meta: { targetUserId: target.id, targetEmail: target.email },
ip: req.ip,
});
return { ok: true, impersonating: { id: target.id, email: target.email, firmId: target.firmId } };
});
// ─────────────────────────── Contact inbox ───────────────────────────
app.get('/api/admin/contact-messages', async (req) => {
const q = z
.object({
resolved: z.enum(['true', 'false']).optional(),
limit: z.coerce.number().int().positive().max(200).default(100),
offset: z.coerce.number().int().min(0).default(0),
})
.parse(req.query);
const filters: Parameters<typeof and> = [];
if (q.resolved === 'true') filters.push(sql`${contactMessages.resolvedAt} is not null`);
if (q.resolved === 'false') filters.push(isNull(contactMessages.resolvedAt));
const where = filters.length ? and(...filters) : undefined;
const db = getDb();
const rows = await db
.select()
.from(contactMessages)
.where(where)
.orderBy(desc(contactMessages.createdAt))
.limit(q.limit)
.offset(q.offset);
const [count] = await db
.select({ total: sql<number>`count(*)::int` })
.from(contactMessages)
.where(where);
return { items: rows, total: count?.total ?? 0 };
});
app.patch('/api/admin/contact-messages/:id', async (req, reply) => {
const { id } = idParam.parse(req.params);
const body = z.object({ resolved: z.boolean() }).parse(req.body);
const [updated] = await getDb()
.update(contactMessages)
.set({ resolvedAt: body.resolved ? new Date() : null })
.where(eq(contactMessages.id, id))
.returning();
if (!updated) return reply.code(404).send({ error: 'not_found' });
return updated;
});
// ─────────────────────────── Audit log ───────────────────────────
app.get('/api/admin/audit-log', async (req) => {
const q = z
.object({
userId: z.string().uuid().optional(),
firmId: z.string().uuid().optional(),
action: z.string().max(120).optional(),
limit: z.coerce.number().int().positive().max(500).default(100),
offset: z.coerce.number().int().min(0).default(0),
})
.parse(req.query);
const filters: Parameters<typeof and> = [];
if (q.userId) filters.push(eq(auditLog.userId, q.userId));
if (q.firmId) filters.push(eq(auditLog.firmId, q.firmId));
if (q.action) filters.push(ilike(auditLog.action, `%${q.action}%`));
const where = filters.length ? and(...filters) : undefined;
const db = getDb();
const rows = await db
.select({
id: auditLog.id,
userId: auditLog.userId,
firmId: auditLog.firmId,
action: auditLog.action,
meta: auditLog.meta,
ip: auditLog.ip,
createdAt: auditLog.createdAt,
userEmail: users.email,
})
.from(auditLog)
.leftJoin(users, eq(users.id, auditLog.userId))
.where(where)
.orderBy(desc(auditLog.createdAt))
.limit(q.limit)
.offset(q.offset);
return { items: rows };
});
// ─────────────────────────── Tool usage analytics ───────────────────────────
app.get('/api/admin/tool-usage', async () => {
const db = getDb();
const since30 = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const rows = await db
.select({
tool: toolUsage.tool,
count: sql<number>`count(*)::int`,
})
.from(toolUsage)
.where(gte(toolUsage.createdAt, since30))
.groupBy(toolUsage.tool)
.orderBy(sql`count(*) desc`);
return { items: rows };
});
}
+244
View File
@@ -0,0 +1,244 @@
import crypto from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, eq, gte, isNull, sql } from 'drizzle-orm';
import { getDb, users, firms, loginAttempts, passwordResets, sessions as sessionsTable } from '@lawdesk/db';
import { hashPassword, verifyPassword } from '../auth/password';
import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions';
import { ensureSuperadminFlag } from '../auth/superadmin';
import { generateCsrfToken } from '../auth/csrf';
import { sendEmail, passwordResetEmail, welcomeEmail } from '../lib/email';
import { env } from '../env';
const signupBody = z.object({
email: z.string().email().max(254).toLowerCase().trim(),
password: z.string().min(10).max(200),
fullName: z.string().min(1).max(120).trim(),
firmName: z.string().min(1).max(160).trim(),
});
const loginBody = z.object({
email: z.string().email().max(254).toLowerCase().trim(),
password: z.string().min(1).max(200),
});
const MAX_FAILS_PER_15_MIN = 5;
async function recentFailedAttempts(email: string, ip: string | null): Promise<number> {
const since = new Date(Date.now() - 15 * 60 * 1000);
const db = getDb();
const rows = await db
.select({ count: sql<number>`count(*)::int` })
.from(loginAttempts)
.where(
and(
eq(loginAttempts.email, email),
eq(loginAttempts.success, false),
gte(loginAttempts.attemptedAt, since),
),
);
return rows[0]?.count ?? 0;
}
export async function authRoutes(app: FastifyInstance) {
app.post(
'/api/auth/signup',
{ config: { rateLimit: { max: 5, timeWindow: '1 hour' } } },
async (req, reply) => {
const body = signupBody.parse(req.body);
const db = getDb();
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, body.email)).limit(1);
if (existing.length > 0) {
return reply.code(409).send({ error: 'email_taken' });
}
const passwordHash = await hashPassword(body.password);
const [firm] = await db.insert(firms).values({ name: body.firmName }).returning();
if (!firm) return reply.code(500).send({ error: 'firm_create_failed' });
const [user] = await db
.insert(users)
.values({
email: body.email,
passwordHash,
fullName: body.fullName,
firmId: firm.id,
role: 'owner',
})
.returning();
if (!user) return reply.code(500).send({ error: 'user_create_failed' });
const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin);
const { token, expiresAt } = await createSession({
userId: user.id,
ip: req.ip,
userAgent: req.headers['user-agent'] ?? null,
});
app.setSessionCookie(reply, token, expiresAt);
app.setCsrfCookie(reply, generateCsrfToken());
// Fire-and-forget welcome email (no blocking)
const welcome = welcomeEmail(user.fullName, null);
sendEmail({ to: user.email, ...welcome }).catch((err) => app.log.warn({ err }, 'welcome email failed'));
return reply.code(201).send({
user: {
id: user.id,
email: user.email,
fullName: user.fullName,
firmId: firm.id,
role: user.role,
isSuperadmin,
isSuspended: user.isSuspended,
},
});
});
app.post(
'/api/auth/login',
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
async (req, reply) => {
const body = loginBody.parse(req.body);
const db = getDb();
const ip = req.ip ?? null;
const fails = await recentFailedAttempts(body.email, ip);
if (fails >= MAX_FAILS_PER_15_MIN) {
return reply.code(429).send({ error: 'too_many_attempts' });
}
const [user] = await db.select().from(users).where(eq(users.email, body.email)).limit(1);
const ok = user ? await verifyPassword(user.passwordHash, body.password) : false;
await db.insert(loginAttempts).values({ email: body.email, ip, success: ok });
if (!ok || !user) {
return reply.code(401).send({ error: 'invalid_credentials' });
}
if (user.isSuspended) {
return reply.code(403).send({ error: 'account_suspended' });
}
const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin);
await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, user.id));
const { token, expiresAt } = await createSession({
userId: user.id,
ip,
userAgent: req.headers['user-agent'] ?? null,
});
app.setSessionCookie(reply, token, expiresAt);
app.setCsrfCookie(reply, generateCsrfToken());
return {
user: {
id: user.id,
email: user.email,
fullName: user.fullName,
firmId: user.firmId,
role: user.role,
isSuperadmin,
isSuspended: user.isSuspended,
},
};
});
app.post('/api/auth/logout', async (req, reply) => {
const token = req.cookies?.[SESSION_COOKIE];
if (token) await destroySession(token);
app.clearSessionCookie(reply);
app.clearCsrfCookie(reply);
return { ok: true };
});
app.get('/api/auth/me', async (req, reply) => {
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
return { user: req.user };
});
// ─────────────────────────── Password reset ───────────────────────────
// Request a reset link. Always returns ok=true so an attacker can't enumerate emails.
app.post(
'/api/auth/request-password-reset',
{ config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } },
async (req) => {
const parsed = z.object({ email: z.string().email().max(254).toLowerCase().trim() }).safeParse(req.body);
if (!parsed.success) return { ok: true };
const db = getDb();
const [user] = await db.select().from(users).where(eq(users.email, parsed.data.email)).limit(1);
if (!user || user.isSuspended) return { ok: true };
const rawToken = crypto.randomBytes(32).toString('base64url');
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await db.insert(passwordResets).values({ tokenHash, userId: user.id, expiresAt });
const resetUrl = `${env.PUBLIC_URL}/reset-password?token=${rawToken}`;
const tpl = passwordResetEmail(user.fullName, resetUrl);
sendEmail({ to: user.email, ...tpl }).catch((err) =>
app.log.warn({ err }, 'password reset email failed'),
);
return { ok: true };
},
);
// Apply a new password using the token from the email.
app.post(
'/api/auth/reset-password',
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
async (req, reply) => {
const parsed = z
.object({
token: z.string().min(20).max(200),
password: z.string().min(10).max(200),
})
.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const tokenHash = crypto.createHash('sha256').update(parsed.data.token).digest('hex');
const db = getDb();
const [reset] = await db
.select()
.from(passwordResets)
.where(and(eq(passwordResets.tokenHash, tokenHash), isNull(passwordResets.consumedAt)))
.limit(1);
if (!reset) return reply.code(400).send({ error: 'invalid_or_used_token' });
if (reset.expiresAt.getTime() < Date.now()) {
return reply.code(400).send({ error: 'token_expired' });
}
const [user] = await db.select().from(users).where(eq(users.id, reset.userId)).limit(1);
if (!user) return reply.code(400).send({ error: 'user_not_found' });
if (user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
const passwordHash = await hashPassword(parsed.data.password);
await db.transaction(async (tx) => {
await tx
.update(users)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(users.id, user.id));
await tx
.update(passwordResets)
.set({ consumedAt: new Date() })
.where(eq(passwordResets.tokenHash, tokenHash));
// Revoke all existing sessions for this user — they should re-login with the new password
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, user.id));
});
return { ok: true };
},
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { getDb, firms } from '@lawdesk/db';
import { env } from '../env';
import { getStripe, getPlanConfig, stripeIsConfigured } from '../lib/stripe';
export async function billingRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
// Status — what does the UI need to show? Configured at all? Current plan? Has subscription?
app.get('/api/billing/status', async (req) => {
const firmId = req.user!.firmId!;
const [firm] = await getDb().select().from(firms).where(eq(firms.id, firmId)).limit(1);
return {
configured: stripeIsConfigured(),
plan: firm?.plan ?? 'starter',
hasSubscription: !!firm?.stripeSubscriptionId,
hasCustomer: !!firm?.stripeCustomerId,
};
});
// Create a Checkout Session — returns the URL to redirect the user to.
app.post('/api/billing/checkout', async (req, reply) => {
const parsed = z
.object({ plan: z.enum(['pro', 'lifetime']) })
.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_plan' });
if (!stripeIsConfigured()) return reply.code(503).send({ error: 'stripe_not_configured' });
const firmId = req.user!.firmId!;
const userEmail = req.user!.email;
const planCfg = getPlanConfig(parsed.data.plan);
if (!planCfg) return reply.code(503).send({ error: 'plan_not_configured' });
const db = getDb();
const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1);
if (!firm) return reply.code(404).send({ error: 'firm_not_found' });
const stripe = getStripe();
// Reuse the customer if we've made one before; otherwise let Checkout create one and we'll
// capture it on the webhook.
const session = await stripe.checkout.sessions.create({
mode: planCfg.mode,
line_items: [{ price: planCfg.priceId, quantity: 1 }],
customer: firm.stripeCustomerId ?? undefined,
customer_email: firm.stripeCustomerId ? undefined : userEmail,
client_reference_id: firmId,
metadata: { firmId, plan: planCfg.planName },
subscription_data:
planCfg.mode === 'subscription' ? { metadata: { firmId, plan: planCfg.planName } } : undefined,
success_url: `${env.PUBLIC_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.PUBLIC_URL}/billing/cancel`,
allow_promotion_codes: true,
});
return { url: session.url };
});
// Customer Portal — for managing the subscription, updating payment method, viewing invoices.
app.post('/api/billing/portal', async (req, reply) => {
if (!stripeIsConfigured()) return reply.code(503).send({ error: 'stripe_not_configured' });
const firmId = req.user!.firmId!;
const [firm] = await getDb().select().from(firms).where(eq(firms.id, firmId)).limit(1);
if (!firm?.stripeCustomerId) return reply.code(404).send({ error: 'no_customer' });
const stripe = getStripe();
const session = await stripe.billingPortal.sessions.create({
customer: firm.stripeCustomerId,
return_url: `${env.PUBLIC_URL}/app/settings`,
});
return { url: session.url };
});
}
+177
View File
@@ -0,0 +1,177 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { getDb, cases, clients, timeEntries } from '@lawdesk/db';
import { loadFirm } from '../lib/firm';
import { assertCanCreateCase, PlanLimitError } from '../lib/plan-limits';
const STATUSES = ['open', 'pending', 'closed', 'archived'] as const;
const createBody = z.object({
clientId: z.string().uuid(),
title: z.string().min(1).max(200).trim(),
caseNumber: z.string().max(80).optional().nullable(),
status: z.enum(STATUSES).default('open'),
practiceArea: z.string().max(120).optional().nullable(),
description: z.string().max(5000).optional().nullable(),
hourlyRate: z.coerce.number().nonnegative().optional().nullable(),
});
const updateBody = createBody.partial();
const listQuery = z.object({
q: z.string().max(160).optional(),
status: z.enum(STATUSES).optional(),
clientId: z.string().uuid().optional(),
limit: z.coerce.number().int().positive().max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
async function assertClientBelongsToFirm(firmId: string, clientId: string): Promise<boolean> {
const [row] = await getDb()
.select({ id: clients.id })
.from(clients)
.where(and(eq(clients.id, clientId), eq(clients.firmId, firmId)))
.limit(1);
return !!row;
}
export async function casesRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
app.get('/api/cases', async (req) => {
const firmId = req.user!.firmId!;
const { q, status, clientId, limit, offset } = listQuery.parse(req.query);
const db = getDb();
const filters = [eq(cases.firmId, firmId)];
if (status) filters.push(eq(cases.status, status));
if (clientId) filters.push(eq(cases.clientId, clientId));
if (q) filters.push(or(ilike(cases.title, `%${q}%`), ilike(cases.caseNumber, `%${q}%`))!);
const where = and(...filters);
const rows = await db
.select({
id: cases.id,
title: cases.title,
caseNumber: cases.caseNumber,
status: cases.status,
practiceArea: cases.practiceArea,
hourlyRate: cases.hourlyRate,
openedAt: cases.openedAt,
clientId: cases.clientId,
clientName: clients.name,
billedMinutes: sql<number>`coalesce((select sum(${timeEntries.minutes})::int from ${timeEntries} where ${timeEntries.caseId} = ${cases.id}), 0)`,
})
.from(cases)
.innerJoin(clients, eq(clients.id, cases.clientId))
.where(where)
.orderBy(desc(cases.openedAt))
.limit(limit)
.offset(offset);
const [count] = await db.select({ total: sql<number>`count(*)::int` }).from(cases).where(where);
return { items: rows, total: count?.total ?? 0 };
});
app.get('/api/cases/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const [row] = await getDb()
.select({
id: cases.id,
title: cases.title,
caseNumber: cases.caseNumber,
status: cases.status,
practiceArea: cases.practiceArea,
description: cases.description,
hourlyRate: cases.hourlyRate,
openedAt: cases.openedAt,
closedAt: cases.closedAt,
clientId: cases.clientId,
clientName: clients.name,
clientEmail: clients.email,
})
.from(cases)
.innerJoin(clients, eq(clients.id, cases.clientId))
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
.limit(1);
if (!row) return reply.code(404).send({ error: 'not_found' });
return row;
});
app.post('/api/cases', async (req, reply) => {
const firmId = req.user!.firmId!;
const firm = await loadFirm(firmId);
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
const body = createBody.parse(req.body);
if (!(await assertClientBelongsToFirm(firmId, body.clientId))) {
return reply.code(400).send({ error: 'invalid_client' });
}
if (body.status === 'open') {
try {
await assertCanCreateCase(firmId, firm.plan);
} catch (e) {
if (e instanceof PlanLimitError) return reply.code(402).send({ error: e.message, plan: firm.plan });
throw e;
}
}
const [row] = await getDb()
.insert(cases)
.values({
firmId,
clientId: body.clientId,
title: body.title,
caseNumber: body.caseNumber ?? null,
status: body.status,
practiceArea: body.practiceArea ?? null,
description: body.description ?? null,
hourlyRate: body.hourlyRate != null ? String(body.hourlyRate) : null,
})
.returning();
return reply.code(201).send(row);
});
app.patch('/api/cases/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const body = updateBody.parse(req.body);
if (Object.keys(body).length === 0) return reply.code(400).send({ error: 'empty_body' });
if (body.clientId && !(await assertClientBelongsToFirm(firmId, body.clientId))) {
return reply.code(400).send({ error: 'invalid_client' });
}
const patch: Record<string, unknown> = { updatedAt: new Date() };
for (const [k, v] of Object.entries(body)) {
if (v === undefined) continue;
patch[k] = k === 'hourlyRate' && v != null ? String(v) : v;
}
if (body.status === 'closed') patch.closedAt = new Date();
if (body.status && body.status !== 'closed') patch.closedAt = null;
const [row] = await getDb()
.update(cases)
.set(patch)
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
.returning();
if (!row) return reply.code(404).send({ error: 'not_found' });
return row;
});
app.delete('/api/cases/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const [row] = await getDb()
.delete(cases)
.where(and(eq(cases.id, id), eq(cases.firmId, firmId)))
.returning({ id: cases.id });
if (!row) return reply.code(404).send({ error: 'not_found' });
return { ok: true };
});
}
+122
View File
@@ -0,0 +1,122 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, desc, eq, ilike, or, sql } from 'drizzle-orm';
import { getDb, clients, cases } from '@lawdesk/db';
import { loadFirm } from '../lib/firm';
import { assertCanCreateClient, PlanLimitError } from '../lib/plan-limits';
const createBody = z.object({
name: z.string().min(1).max(160).trim(),
email: z.string().email().max(254).optional().nullable(),
phone: z.string().max(40).optional().nullable(),
address: z.string().max(500).optional().nullable(),
notes: z.string().max(5000).optional().nullable(),
});
const updateBody = createBody.partial();
const listQuery = z.object({
q: z.string().max(160).optional(),
limit: z.coerce.number().int().positive().max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
export async function clientsRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
app.get('/api/clients', async (req) => {
const firmId = req.user!.firmId!;
const { q, limit, offset } = listQuery.parse(req.query);
const db = getDb();
const where = q
? and(
eq(clients.firmId, firmId),
or(ilike(clients.name, `%${q}%`), ilike(clients.email, `%${q}%`)),
)
: eq(clients.firmId, firmId);
const rows = await db
.select({
id: clients.id,
name: clients.name,
email: clients.email,
phone: clients.phone,
createdAt: clients.createdAt,
caseCount: sql<number>`(select count(*)::int from ${cases} where ${cases.clientId} = ${clients.id})`,
})
.from(clients)
.where(where)
.orderBy(desc(clients.createdAt))
.limit(limit)
.offset(offset);
const [count] = await db
.select({ total: sql<number>`count(*)::int` })
.from(clients)
.where(where);
return { items: rows, total: count?.total ?? 0 };
});
app.get('/api/clients/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const [row] = await getDb()
.select()
.from(clients)
.where(and(eq(clients.id, id), eq(clients.firmId, firmId)))
.limit(1);
if (!row) return reply.code(404).send({ error: 'not_found' });
return row;
});
app.post('/api/clients', async (req, reply) => {
const firmId = req.user!.firmId!;
const firm = await loadFirm(firmId);
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
try {
await assertCanCreateClient(firmId, firm.plan);
} catch (e) {
if (e instanceof PlanLimitError) {
return reply.code(402).send({ error: e.message, plan: firm.plan });
}
throw e;
}
const body = createBody.parse(req.body);
const [row] = await getDb()
.insert(clients)
.values({ firmId, ...body })
.returning();
return reply.code(201).send(row);
});
app.patch('/api/clients/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const body = updateBody.parse(req.body);
if (Object.keys(body).length === 0) return reply.code(400).send({ error: 'empty_body' });
const [row] = await getDb()
.update(clients)
.set({ ...body, updatedAt: new Date() })
.where(and(eq(clients.id, id), eq(clients.firmId, firmId)))
.returning();
if (!row) return reply.code(404).send({ error: 'not_found' });
return row;
});
app.delete('/api/clients/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const [row] = await getDb()
.delete(clients)
.where(and(eq(clients.id, id), eq(clients.firmId, firmId)))
.returning({ id: clients.id });
if (!row) return reply.code(404).send({ error: 'not_found' });
return { ok: true };
});
}
+33
View File
@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { getDb, contactMessages } from '@lawdesk/db';
import { sendEmail, contactAckEmail } from '../lib/email';
const contactBody = z.object({
fullName: z.string().min(1).max(120).trim(),
email: z.string().email().max(254).toLowerCase().trim(),
message: z.string().min(1).max(5000).trim(),
});
export async function contactRoutes(app: FastifyInstance) {
app.post(
'/api/contact',
{ config: { rateLimit: { max: 5, timeWindow: '10 minutes' } } },
async (req, reply) => {
const parsed = contactBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
const body = parsed.data;
await getDb().insert(contactMessages).values({
fullName: body.fullName,
email: body.email,
message: body.message,
ip: req.ip ?? null,
});
const tpl = contactAckEmail(body.fullName);
sendEmail({ to: body.email, ...tpl }).catch((err) =>
app.log.warn({ err }, 'contact ack email failed'),
);
return reply.code(201).send({ ok: true });
},
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { FastifyInstance } from 'fastify';
import { sql } from 'drizzle-orm';
import { getDb } from '@lawdesk/db';
export async function healthRoutes(app: FastifyInstance) {
app.get('/api/health', async () => ({ ok: true, ts: Date.now() }));
app.get('/api/health/db', async (_req, reply) => {
try {
await getDb().execute(sql`select 1`);
return { ok: true };
} catch (err) {
app.log.error({ err }, 'db health check failed');
return reply.code(503).send({ ok: false });
}
});
}
+562
View File
@@ -0,0 +1,562 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm';
import {
getDb,
invoices,
invoiceItems,
clients,
cases,
timeEntries,
firms,
} from '@lawdesk/db';
import { loadFirm } from '../lib/firm';
import { assertCanCreateInvoice, PlanLimitError } from '../lib/plan-limits';
import { nextInvoiceNumber } from '../lib/invoice-numbering';
import { renderInvoicePdf } from '../lib/invoice-pdf';
import { sendEmail, invoiceEmail } from '../lib/email';
const STATUSES = ['draft', 'sent', 'paid', 'overdue', 'void'] as const;
const itemBody = z.object({
description: z.string().min(1).max(500),
quantity: z.coerce.number().positive().default(1),
rate: z.coerce.number().nonnegative(),
});
const createBody = z.object({
clientId: z.string().uuid(),
caseId: z.string().uuid().nullable().optional(),
notes: z.string().max(5000).nullable().optional(),
taxRate: z.coerce.number().min(0).max(100).default(0),
dueAt: z.string().datetime().nullable().optional(),
// Either provide explicit items or supply timeEntryIds to generate items from time entries.
items: z.array(itemBody).optional(),
timeEntryIds: z.array(z.string().uuid()).optional(),
});
const updateBody = z.object({
notes: z.string().max(5000).nullable().optional(),
taxRate: z.coerce.number().min(0).max(100).optional(),
dueAt: z.string().datetime().nullable().optional(),
});
const listQuery = z.object({
status: z.enum(STATUSES).optional(),
clientId: z.string().uuid().optional(),
caseId: z.string().uuid().optional(),
limit: z.coerce.number().int().positive().max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
interface ItemAccumulator {
description: string;
quantity: string;
rate: string;
amount: string;
sortOrder: number;
timeEntryId?: string;
}
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
function computeTotals(items: { quantity: string; rate: string; amount: string }[], taxRate: number) {
const subtotal = items.reduce((acc, it) => acc + Number(it.amount), 0);
const total = round2(subtotal * (1 + taxRate / 100));
return { subtotal: round2(subtotal), total };
}
export async function invoicesRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
// List
app.get('/api/invoices', async (req) => {
const firmId = req.user!.firmId!;
const { status, clientId, caseId, limit, offset } = listQuery.parse(req.query);
const db = getDb();
const filters = [eq(invoices.firmId, firmId)];
if (status) filters.push(eq(invoices.status, status));
if (clientId) filters.push(eq(invoices.clientId, clientId));
if (caseId) filters.push(eq(invoices.caseId, caseId));
const where = and(...filters);
const rows = await db
.select({
id: invoices.id,
number: invoices.number,
status: invoices.status,
total: invoices.total,
subtotal: invoices.subtotal,
issuedAt: invoices.issuedAt,
dueAt: invoices.dueAt,
paidAt: invoices.paidAt,
createdAt: invoices.createdAt,
clientId: invoices.clientId,
clientName: clients.name,
caseId: invoices.caseId,
caseTitle: cases.title,
})
.from(invoices)
.innerJoin(clients, eq(clients.id, invoices.clientId))
.leftJoin(cases, eq(cases.id, invoices.caseId))
.where(where)
.orderBy(desc(invoices.createdAt))
.limit(limit)
.offset(offset);
const [count] = await db
.select({ total: sql<number>`count(*)::int` })
.from(invoices)
.where(where);
return { items: rows, total: count?.total ?? 0 };
});
// Get with items
app.get('/api/invoices/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [inv] = await db
.select({
id: invoices.id,
number: invoices.number,
status: invoices.status,
subtotal: invoices.subtotal,
taxRate: invoices.taxRate,
total: invoices.total,
notes: invoices.notes,
issuedAt: invoices.issuedAt,
dueAt: invoices.dueAt,
paidAt: invoices.paidAt,
createdAt: invoices.createdAt,
clientId: invoices.clientId,
clientName: clients.name,
clientEmail: clients.email,
caseId: invoices.caseId,
caseTitle: cases.title,
})
.from(invoices)
.innerJoin(clients, eq(clients.id, invoices.clientId))
.leftJoin(cases, eq(cases.id, invoices.caseId))
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!inv) return reply.code(404).send({ error: 'not_found' });
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, inv.id))
.orderBy(asc(invoiceItems.sortOrder));
return { ...inv, items };
});
// Create
app.post('/api/invoices', async (req, reply) => {
const firmId = req.user!.firmId!;
const firm = await loadFirm(firmId);
if (!firm) return reply.code(403).send({ error: 'firm_missing' });
try {
await assertCanCreateInvoice(firmId, firm.plan);
} catch (e) {
if (e instanceof PlanLimitError) return reply.code(402).send({ error: e.message, plan: firm.plan });
throw e;
}
const body = createBody.parse(req.body);
const db = getDb();
// Validate client belongs to firm
const [client] = await db
.select({ id: clients.id })
.from(clients)
.where(and(eq(clients.id, body.clientId), eq(clients.firmId, firmId)))
.limit(1);
if (!client) return reply.code(400).send({ error: 'invalid_client' });
// Validate case belongs to firm (and to client) if provided
if (body.caseId) {
const [c] = await db
.select({ id: cases.id })
.from(cases)
.where(and(eq(cases.id, body.caseId), eq(cases.firmId, firmId), eq(cases.clientId, body.clientId)))
.limit(1);
if (!c) return reply.code(400).send({ error: 'invalid_case' });
}
// Build line items
const accumulated: ItemAccumulator[] = [];
if (body.items && body.items.length) {
body.items.forEach((it, i) => {
accumulated.push({
description: it.description,
quantity: String(it.quantity),
rate: String(it.rate),
amount: String(round2(it.quantity * it.rate)),
sortOrder: i,
});
});
}
if (body.timeEntryIds && body.timeEntryIds.length) {
const entries = await db
.select({
id: timeEntries.id,
description: timeEntries.description,
minutes: timeEntries.minutes,
rate: timeEntries.rate,
billable: timeEntries.billable,
invoiceItemId: timeEntries.invoiceItemId,
caseId: timeEntries.caseId,
})
.from(timeEntries)
.where(and(eq(timeEntries.firmId, firmId), inArray(timeEntries.id, body.timeEntryIds)));
if (entries.length !== body.timeEntryIds.length) {
return reply.code(400).send({ error: 'invalid_time_entries' });
}
for (const e of entries) {
if (e.invoiceItemId) return reply.code(409).send({ error: 'time_entry_already_invoiced' });
if (!e.billable) return reply.code(400).send({ error: 'time_entry_not_billable' });
if (body.caseId && e.caseId !== body.caseId) {
return reply.code(400).send({ error: 'time_entry_case_mismatch' });
}
}
const startSort = accumulated.length;
entries.forEach((e, i) => {
const hours = round2(e.minutes / 60);
const rate = Number(e.rate);
accumulated.push({
description: e.description,
quantity: String(hours),
rate: String(rate),
amount: String(round2(hours * rate)),
sortOrder: startSort + i,
timeEntryId: e.id,
});
});
}
if (!accumulated.length) {
return reply.code(400).send({ error: 'no_items' });
}
const taxRate = body.taxRate;
const totals = computeTotals(accumulated, taxRate);
const number = await nextInvoiceNumber(firmId);
const created = await db.transaction(async (tx) => {
const [inv] = await tx
.insert(invoices)
.values({
firmId,
clientId: body.clientId,
caseId: body.caseId ?? null,
number,
status: 'draft',
subtotal: String(totals.subtotal),
taxRate: String(taxRate),
total: String(totals.total),
notes: body.notes ?? null,
dueAt: body.dueAt ? new Date(body.dueAt) : null,
})
.returning();
if (!inv) throw new Error('invoice_insert_failed');
const insertedItems = await tx
.insert(invoiceItems)
.values(
accumulated.map((a) => ({
invoiceId: inv.id,
description: a.description,
quantity: a.quantity,
rate: a.rate,
amount: a.amount,
sortOrder: a.sortOrder,
})),
)
.returning();
// Link the time entries (when generated from time) to their new invoice items
const updates: Array<Promise<unknown>> = [];
accumulated.forEach((a, i) => {
if (!a.timeEntryId) return;
const item = insertedItems[i];
if (!item) return;
updates.push(
tx
.update(timeEntries)
.set({ invoiceItemId: item.id, updatedAt: new Date() })
.where(eq(timeEntries.id, a.timeEntryId)),
);
});
await Promise.all(updates);
return inv;
});
return reply.code(201).send(created);
});
// Update (notes, dueAt, taxRate; only on drafts)
app.patch('/api/invoices/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const body = updateBody.parse(req.body);
const db = getDb();
const [existing] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.status !== 'draft') return reply.code(409).send({ error: 'invoice_not_draft' });
const patch: Record<string, unknown> = { updatedAt: new Date() };
if (body.notes !== undefined) patch.notes = body.notes;
if (body.dueAt !== undefined) patch.dueAt = body.dueAt ? new Date(body.dueAt) : null;
if (body.taxRate !== undefined) {
patch.taxRate = String(body.taxRate);
const items = await db.select().from(invoiceItems).where(eq(invoiceItems.invoiceId, id));
const totals = computeTotals(items, body.taxRate);
patch.subtotal = String(totals.subtotal);
patch.total = String(totals.total);
}
const [row] = await db.update(invoices).set(patch).where(eq(invoices.id, id)).returning();
return row;
});
// Send (draft → sent, set issuedAt). Emails the client with the PDF attached if we have
// their email on file. Email failure does not block the status change.
app.post('/api/invoices/:id/send', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [existing] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.status !== 'draft') return reply.code(409).send({ error: 'invoice_not_draft' });
const now = new Date();
const [row] = await db
.update(invoices)
.set({ status: 'sent', issuedAt: now, updatedAt: now })
.where(eq(invoices.id, id))
.returning();
if (!row) return reply.code(500).send({ error: 'update_failed' });
// Render PDF + email the client (best-effort).
try {
const [client] = await db.select().from(clients).where(eq(clients.id, row.clientId)).limit(1);
const [firm] = await db.select().from(firms).where(eq(firms.id, firmId)).limit(1);
if (!client?.email || !firm) {
app.log.info({ invoiceId: id }, 'invoice sent, skipped email (no client email or firm missing)');
return row;
}
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, id))
.orderBy(asc(invoiceItems.sortOrder));
const pdfStream = renderInvoicePdf({
number: row.number,
status: row.status,
issuedAt: row.issuedAt,
dueAt: row.dueAt,
notes: row.notes,
subtotal: row.subtotal,
taxRate: row.taxRate,
total: row.total,
firm: { name: firm.name },
client: { name: client.name, email: client.email, address: client.address },
items: items.map((it) => ({
description: it.description,
quantity: it.quantity,
rate: it.rate,
amount: it.amount,
})),
watermark: firm.watermarkEnabled,
});
// Collect the PDF stream into a buffer.
const chunks: Buffer[] = [];
for await (const chunk of pdfStream as AsyncIterable<Buffer | string>) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
const pdfBuffer = Buffer.concat(chunks);
const totalFmt = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
Number(row.total),
);
const dueDate = row.dueAt ? row.dueAt.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : null;
const tpl = invoiceEmail({
clientName: client.name,
firmName: firm.name,
invoiceNumber: row.number,
total: totalFmt,
dueDate,
notes: row.notes,
});
sendEmail({
to: client.email,
...tpl,
attachments: [{ filename: `${row.number}.pdf`, content: pdfBuffer }],
}).catch((err) => app.log.warn({ err, invoiceId: id }, 'invoice email failed'));
} catch (err) {
app.log.warn({ err, invoiceId: id }, 'failed to render/send invoice email');
}
return row;
});
// Mark paid
app.post('/api/invoices/:id/mark-paid', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [existing] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (!['sent', 'overdue'].includes(existing.status)) {
return reply.code(409).send({ error: 'invoice_not_sent' });
}
const now = new Date();
const [row] = await db
.update(invoices)
.set({ status: 'paid', paidAt: now, updatedAt: now })
.where(eq(invoices.id, id))
.returning();
return row;
});
// Void
app.post('/api/invoices/:id/void', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [existing] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.status === 'paid') return reply.code(409).send({ error: 'invoice_already_paid' });
const [row] = await db
.update(invoices)
.set({ status: 'void', updatedAt: new Date() })
.where(eq(invoices.id, id))
.returning();
return row;
});
// Delete (drafts only) — also unlinks time entries
app.delete('/api/invoices/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [existing] = await db
.select()
.from(invoices)
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.status !== 'draft') return reply.code(409).send({ error: 'invoice_not_draft' });
await db.transaction(async (tx) => {
const items = await tx
.select({ id: invoiceItems.id })
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, id));
const itemIds = items.map((i) => i.id);
if (itemIds.length) {
await tx
.update(timeEntries)
.set({ invoiceItemId: null, updatedAt: new Date() })
.where(inArray(timeEntries.invoiceItemId, itemIds));
}
await tx.delete(invoiceItems).where(eq(invoiceItems.invoiceId, id));
await tx.delete(invoices).where(eq(invoices.id, id));
});
return { ok: true };
});
// PDF download
app.get('/api/invoices/:id/pdf', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [inv] = await db
.select({
invoice: invoices,
client: clients,
firm: firms,
})
.from(invoices)
.innerJoin(clients, eq(clients.id, invoices.clientId))
.innerJoin(firms, eq(firms.id, invoices.firmId))
.where(and(eq(invoices.id, id), eq(invoices.firmId, firmId)))
.limit(1);
if (!inv) return reply.code(404).send({ error: 'not_found' });
const items = await db
.select()
.from(invoiceItems)
.where(eq(invoiceItems.invoiceId, id))
.orderBy(asc(invoiceItems.sortOrder));
const stream = renderInvoicePdf({
number: inv.invoice.number,
status: inv.invoice.status,
issuedAt: inv.invoice.issuedAt,
dueAt: inv.invoice.dueAt,
notes: inv.invoice.notes,
subtotal: inv.invoice.subtotal,
taxRate: inv.invoice.taxRate,
total: inv.invoice.total,
firm: { name: inv.firm.name },
client: { name: inv.client.name, email: inv.client.email, address: inv.client.address },
items: items.map((it) => ({
description: it.description,
quantity: it.quantity,
rate: it.rate,
amount: it.amount,
})),
watermark: inv.firm.watermarkEnabled,
});
reply
.header('Content-Type', 'application/pdf')
.header('Content-Disposition', `inline; filename="${inv.invoice.number}.pdf"`);
return reply.send(stream);
});
}
+307
View File
@@ -0,0 +1,307 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, desc, eq, gte, isNull, lte, sql } from 'drizzle-orm';
import { getDb, timeEntries, cases, clients } from '@lawdesk/db';
const STATUSES = ['open', 'pending', 'closed', 'archived'] as const;
type CaseStatus = (typeof STATUSES)[number];
const startBody = z.object({
caseId: z.string().uuid(),
description: z.string().max(500).optional().default(''),
});
const createBody = z.object({
caseId: z.string().uuid(),
description: z.string().min(1).max(500),
startedAt: z.string().datetime(),
endedAt: z.string().datetime().optional().nullable(),
minutes: z.coerce.number().int().nonnegative().optional(),
rate: z.coerce.number().nonnegative().optional(),
billable: z.boolean().optional().default(true),
});
const updateBody = z.object({
description: z.string().min(1).max(500).optional(),
startedAt: z.string().datetime().optional(),
endedAt: z.string().datetime().nullable().optional(),
minutes: z.coerce.number().int().nonnegative().optional(),
rate: z.coerce.number().nonnegative().optional(),
billable: z.boolean().optional(),
});
const listQuery = z.object({
caseId: z.string().uuid().optional(),
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
invoiced: z.enum(['true', 'false']).optional(),
limit: z.coerce.number().int().positive().max(500).default(200),
offset: z.coerce.number().int().min(0).default(0),
});
async function loadCaseForFirm(firmId: string, caseId: string) {
const [row] = await getDb()
.select({
id: cases.id,
hourlyRate: cases.hourlyRate,
status: cases.status,
})
.from(cases)
.where(and(eq(cases.id, caseId), eq(cases.firmId, firmId)))
.limit(1);
return row ?? null;
}
function diffMinutes(startedAt: Date, endedAt: Date): number {
return Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 60000));
}
export async function timeEntriesRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.requireFirm);
// List
app.get('/api/time-entries', async (req) => {
const firmId = req.user!.firmId!;
const { caseId, from, to, invoiced, limit, offset } = listQuery.parse(req.query);
const db = getDb();
const filters = [eq(timeEntries.firmId, firmId)];
if (caseId) filters.push(eq(timeEntries.caseId, caseId));
if (from) filters.push(gte(timeEntries.startedAt, new Date(from)));
if (to) filters.push(lte(timeEntries.startedAt, new Date(to)));
if (invoiced === 'true') filters.push(sql`${timeEntries.invoiceItemId} is not null`);
if (invoiced === 'false') filters.push(isNull(timeEntries.invoiceItemId));
const where = and(...filters);
const rows = await db
.select({
id: timeEntries.id,
caseId: timeEntries.caseId,
caseTitle: cases.title,
clientId: cases.clientId,
clientName: clients.name,
userId: timeEntries.userId,
description: timeEntries.description,
startedAt: timeEntries.startedAt,
endedAt: timeEntries.endedAt,
minutes: timeEntries.minutes,
rate: timeEntries.rate,
billable: timeEntries.billable,
invoiceItemId: timeEntries.invoiceItemId,
})
.from(timeEntries)
.innerJoin(cases, eq(cases.id, timeEntries.caseId))
.innerJoin(clients, eq(clients.id, cases.clientId))
.where(where)
.orderBy(desc(timeEntries.startedAt))
.limit(limit)
.offset(offset);
const [count] = await db
.select({ total: sql<number>`count(*)::int` })
.from(timeEntries)
.where(where);
return { items: rows, total: count?.total ?? 0 };
});
// Active (running) timer for the current user
app.get('/api/time-entries/active', async (req) => {
const firmId = req.user!.firmId!;
const userId = req.user!.id;
const [row] = await getDb()
.select({
id: timeEntries.id,
caseId: timeEntries.caseId,
caseTitle: cases.title,
clientName: clients.name,
description: timeEntries.description,
startedAt: timeEntries.startedAt,
rate: timeEntries.rate,
})
.from(timeEntries)
.innerJoin(cases, eq(cases.id, timeEntries.caseId))
.innerJoin(clients, eq(clients.id, cases.clientId))
.where(
and(
eq(timeEntries.firmId, firmId),
eq(timeEntries.userId, userId),
isNull(timeEntries.endedAt),
),
)
.limit(1);
return { active: row ?? null };
});
// Start a timer
app.post('/api/time-entries/start', async (req, reply) => {
const firmId = req.user!.firmId!;
const userId = req.user!.id;
const body = startBody.parse(req.body);
// Refuse if there's already a running timer for this user
const [running] = await getDb()
.select({ id: timeEntries.id })
.from(timeEntries)
.where(
and(
eq(timeEntries.firmId, firmId),
eq(timeEntries.userId, userId),
isNull(timeEntries.endedAt),
),
)
.limit(1);
if (running) return reply.code(409).send({ error: 'timer_already_running' });
const c = await loadCaseForFirm(firmId, body.caseId);
if (!c) return reply.code(400).send({ error: 'invalid_case' });
const [row] = await getDb()
.insert(timeEntries)
.values({
firmId,
caseId: body.caseId,
userId,
description: body.description || 'Untitled work',
startedAt: new Date(),
endedAt: null,
minutes: 0,
rate: c.hourlyRate ?? '0',
billable: true,
})
.returning();
return reply.code(201).send(row);
});
// Stop a running timer
app.post('/api/time-entries/:id/stop', async (req, reply) => {
const firmId = req.user!.firmId!;
const userId = req.user!.id;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const db = getDb();
const [entry] = await db
.select()
.from(timeEntries)
.where(
and(
eq(timeEntries.id, id),
eq(timeEntries.firmId, firmId),
eq(timeEntries.userId, userId),
),
)
.limit(1);
if (!entry) return reply.code(404).send({ error: 'not_found' });
if (entry.endedAt) return reply.code(409).send({ error: 'timer_not_running' });
const endedAt = new Date();
const minutes = diffMinutes(entry.startedAt, endedAt);
const [row] = await db
.update(timeEntries)
.set({ endedAt, minutes, updatedAt: endedAt })
.where(eq(timeEntries.id, id))
.returning();
return row;
});
// Manual entry create
app.post('/api/time-entries', async (req, reply) => {
const firmId = req.user!.firmId!;
const userId = req.user!.id;
const body = createBody.parse(req.body);
const c = await loadCaseForFirm(firmId, body.caseId);
if (!c) return reply.code(400).send({ error: 'invalid_case' });
const startedAt = new Date(body.startedAt);
let endedAt = body.endedAt ? new Date(body.endedAt) : null;
let minutes: number;
if (body.minutes != null) {
minutes = body.minutes;
// Manual entry with explicit duration: compute endedAt so the row isn't treated as "running"
if (!endedAt) endedAt = new Date(startedAt.getTime() + minutes * 60_000);
} else if (endedAt) {
minutes = diffMinutes(startedAt, endedAt);
} else {
minutes = 0;
}
const rate = body.rate != null ? String(body.rate) : (c.hourlyRate ?? '0');
const [row] = await getDb()
.insert(timeEntries)
.values({
firmId,
caseId: body.caseId,
userId,
description: body.description,
startedAt,
endedAt,
minutes,
rate,
billable: body.billable ?? true,
})
.returning();
return reply.code(201).send(row);
});
// Update
app.patch('/api/time-entries/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const body = updateBody.parse(req.body);
const db = getDb();
const [existing] = await db
.select()
.from(timeEntries)
.where(and(eq(timeEntries.id, id), eq(timeEntries.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.invoiceItemId) return reply.code(409).send({ error: 'already_invoiced' });
const patch: Record<string, unknown> = { updatedAt: new Date() };
if (body.description !== undefined) patch.description = body.description;
if (body.billable !== undefined) patch.billable = body.billable;
if (body.rate !== undefined) patch.rate = String(body.rate);
const startedAt = body.startedAt ? new Date(body.startedAt) : existing.startedAt;
const endedAt =
body.endedAt === null ? null : body.endedAt ? new Date(body.endedAt) : existing.endedAt;
if (body.startedAt !== undefined) patch.startedAt = startedAt;
if (body.endedAt !== undefined) patch.endedAt = endedAt;
if (body.minutes !== undefined) {
patch.minutes = body.minutes;
} else if (body.startedAt !== undefined || body.endedAt !== undefined) {
patch.minutes = endedAt ? diffMinutes(startedAt, endedAt) : 0;
}
const [row] = await db.update(timeEntries).set(patch).where(eq(timeEntries.id, id)).returning();
return row;
});
// Delete
app.delete('/api/time-entries/:id', async (req, reply) => {
const firmId = req.user!.firmId!;
const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
const [existing] = await getDb()
.select({ id: timeEntries.id, invoiceItemId: timeEntries.invoiceItemId })
.from(timeEntries)
.where(and(eq(timeEntries.id, id), eq(timeEntries.firmId, firmId)))
.limit(1);
if (!existing) return reply.code(404).send({ error: 'not_found' });
if (existing.invoiceItemId) return reply.code(409).send({ error: 'already_invoiced' });
await getDb().delete(timeEntries).where(eq(timeEntries.id, id));
return { ok: true };
});
}
// Re-export the type for shared usage if needed
export type { CaseStatus };
+53
View File
@@ -0,0 +1,53 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { and, eq, gte, sql } from 'drizzle-orm';
import { getDb, toolUsage } from '@lawdesk/db';
const TOOL_NAMES = [
'hourly-rate-calculator',
'case-profitability',
'billable-hours-tracker',
'document-templates',
] as const;
const logBody = z.object({
tool: z.enum(TOOL_NAMES),
sessionId: z.string().max(64).optional(),
});
export async function toolUsageRoutes(app: FastifyInstance) {
// Log a usage event. Rate-limited per IP so a malicious caller can't pump up "online now" counts.
app.post(
'/api/tool-usage',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (req, reply) => {
const parsed = logBody.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_tool' });
await getDb().insert(toolUsage).values({
tool: parsed.data.tool,
sessionId: parsed.data.sessionId ?? null,
ip: req.ip ?? null,
});
return { ok: true };
},
);
// Per-tool count of unique sessions in the last 5 minutes — what the public pages display
// as "X online". Public route, very cheap query.
app.get('/api/tool-usage/online', async () => {
const since = new Date(Date.now() - 5 * 60 * 1000);
const rows = await getDb()
.select({
tool: toolUsage.tool,
// Distinct (session_id, ip) so multiple page hits from the same browser don't multi-count
count: sql<number>`count(distinct coalesce(${toolUsage.sessionId}, host(${toolUsage.ip}::inet)))::int`,
})
.from(toolUsage)
.where(gte(toolUsage.createdAt, since))
.groupBy(toolUsage.tool);
const map: Record<string, number> = {};
for (const r of rows) map[r.tool] = r.count;
return { online: map, since: since.toISOString() };
});
}
+130
View File
@@ -0,0 +1,130 @@
import type { FastifyInstance } from 'fastify';
import type Stripe from 'stripe';
import { eq } from 'drizzle-orm';
import { getDb, firms, users } from '@lawdesk/db';
import { env } from '../env';
import { getStripe } from '../lib/stripe';
import { sendEmail, planUpgradedEmail } from '../lib/email';
import { logAudit } from '../lib/audit';
// Registered as a sub-app so its own buffer-only content-type parser doesn't affect the rest of
// the API. Stripe webhooks need the raw request body to verify the signature.
export async function stripeWebhookRoute(app: FastifyInstance) {
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('*', { parseAs: 'buffer' }, (_req, body, done) => done(null, body));
app.post('/api/webhooks/stripe', async (req, reply) => {
if (!env.STRIPE_WEBHOOK_SECRET) {
return reply.code(503).send({ error: 'webhook_not_configured' });
}
const sig = req.headers['stripe-signature'];
if (!sig || typeof sig !== 'string') {
return reply.code(400).send({ error: 'missing_signature' });
}
const stripe = getStripe();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body as Buffer, sig, env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
app.log.warn({ err }, 'stripe webhook signature verification failed');
return reply.code(400).send({ error: 'invalid_signature' });
}
try {
await handleEvent(event, app);
} catch (err) {
app.log.error({ err, type: event.type }, 'stripe webhook handler failed');
// Return 200 anyway for some failures? No — let Stripe retry on transient failures.
return reply.code(500).send({ error: 'handler_failed' });
}
return { received: true };
});
}
async function handleEvent(event: Stripe.Event, app: FastifyInstance) {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const firmId = session.client_reference_id ?? (session.metadata?.firmId as string | undefined);
const planFromMeta = (session.metadata?.plan ?? '') as 'pro' | 'lifetime' | '';
if (!firmId) return app.log.warn({ session: session.id }, 'checkout.session.completed without firmId');
// Determine plan from session.mode if metadata didn't pin it.
const plan: 'pro' | 'lifetime' = planFromMeta || (session.mode === 'subscription' ? 'pro' : 'lifetime');
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id ?? null;
const subscriptionId =
typeof session.subscription === 'string' ? session.subscription : session.subscription?.id ?? null;
await applyPlan(firmId, plan, { customerId, subscriptionId });
await sendPlanUpgradedNotice(firmId, plan);
break;
}
case 'customer.subscription.updated':
case 'customer.subscription.created': {
const sub = event.data.object as Stripe.Subscription;
const firmId = (sub.metadata?.firmId as string | undefined) ?? null;
if (!firmId) return;
// Only flip to 'pro' while the subscription is paying.
const active = ['active', 'trialing', 'past_due'].includes(sub.status);
if (active) await applyPlan(firmId, 'pro', { subscriptionId: sub.id });
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
const firmId = (sub.metadata?.firmId as string | undefined) ?? null;
if (!firmId) return;
await applyPlan(firmId, 'starter', { subscriptionId: null });
break;
}
case 'invoice.payment_failed': {
// Optional: surface to the user via email later. For now, just log.
const invoice = event.data.object as Stripe.Invoice;
app.log.warn({ invoice: invoice.id, customer: invoice.customer }, 'stripe invoice payment failed');
break;
}
default:
// Ignore — Stripe sends many event types we don't care about.
break;
}
}
async function applyPlan(
firmId: string,
plan: 'starter' | 'pro' | 'lifetime',
ids: { customerId?: string | null; subscriptionId?: string | null } = {},
) {
const patch: Record<string, unknown> = {
plan,
watermarkEnabled: plan === 'starter',
updatedAt: new Date(),
};
if (ids.customerId !== undefined) patch.stripeCustomerId = ids.customerId;
if (ids.subscriptionId !== undefined) patch.stripeSubscriptionId = ids.subscriptionId;
await getDb().update(firms).set(patch).where(eq(firms.id, firmId));
await logAudit({
firmId,
action: `billing.plan.${plan}`,
meta: { stripeCustomerId: ids.customerId, stripeSubscriptionId: ids.subscriptionId },
});
}
async function sendPlanUpgradedNotice(firmId: string, plan: 'pro' | 'lifetime') {
const owners = await getDb()
.select({ email: users.email, fullName: users.fullName })
.from(users)
.where(eq(users.firmId, firmId));
const label = plan === 'pro' ? 'Professional' : 'Lifetime';
for (const u of owners) {
const tpl = planUpgradedEmail(u.fullName, label);
await sendEmail({ to: u.email, ...tpl });
}
}
+150
View File
@@ -0,0 +1,150 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import Fastify from 'fastify';
import { ZodError } from 'zod';
import cookie from '@fastify/cookie';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import staticPlugin from '@fastify/static';
import { env, isProd } from './env';
import { initSentry, captureError } from './lib/sentry';
import { authPlugin } from './auth/plugin';
import { csrfPlugin } from './auth/csrf';
import { authRoutes } from './routes/auth';
import { healthRoutes } from './routes/health';
import { contactRoutes } from './routes/contact';
import { clientsRoutes } from './routes/clients';
import { casesRoutes } from './routes/cases';
import { timeEntriesRoutes } from './routes/time-entries';
import { invoicesRoutes } from './routes/invoices';
import { adminRoutes } from './routes/admin';
import { accountRoutes } from './routes/account';
import { toolUsageRoutes } from './routes/tool-usage';
import { billingRoutes } from './routes/billing';
import { stripeWebhookRoute } from './routes/webhooks-stripe';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
initSentry();
export async function buildServer() {
const app = Fastify({
logger: isProd
? { level: 'info' }
: { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } },
trustProxy: true,
bodyLimit: 5 * 1024 * 1024,
});
// Register the global error handler EARLY so it wins over plugin-default handlers and
// catches ZodErrors thrown by .parse() inside route handlers.
app.setErrorHandler((err, req, reply) => {
if (err instanceof ZodError || (err as { validation?: unknown }).validation || err.name === 'ZodError') {
req.log.info({ err }, 'validation error');
return reply
.code(400)
.send({ error: 'validation', details: err instanceof ZodError ? err.errors : (err as Error).message });
}
if ((err as { statusCode?: number }).statusCode === 429) {
// Let @fastify/rate-limit handle its own response shape.
return reply.send(err);
}
req.log.error({ err }, 'unhandled error');
captureError(err, { url: req.url, method: req.method, userId: req.user?.id });
return reply.code(500).send({ error: 'internal_error' });
});
// CSP: tight in production, off in dev (Vite HMR injects inline scripts/styles + uses eval)
await app.register(helmet, {
contentSecurityPolicy: isProd
? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:', 'blob:'],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
baseUri: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
}
: false,
crossOriginEmbedderPolicy: false,
});
await app.register(cookie, {
secret: env.SESSION_SECRET,
});
// Global rate limit floor — per-route limits override below.
await app.register(rateLimit, {
global: true,
max: 600,
timeWindow: '1 minute',
keyGenerator: (req) => `${req.ip}`,
});
// Stripe webhook BEFORE auth/CSRF — registered as its own subapp with a buffer-only parser
// so signature verification works against the raw body.
await app.register(stripeWebhookRoute);
await app.register(authPlugin);
await app.register(csrfPlugin);
await app.register(authRoutes);
await app.register(healthRoutes);
await app.register(contactRoutes);
await app.register(clientsRoutes);
await app.register(casesRoutes);
await app.register(timeEntriesRoutes);
await app.register(invoicesRoutes);
await app.register(adminRoutes);
await app.register(accountRoutes);
await app.register(toolUsageRoutes);
await app.register(billingRoutes);
// Serve the built SPA in production. In dev, the Vite dev server runs separately.
const webDist = env.WEB_DIST_PATH ?? path.resolve(__dirname, '../../web/dist');
if (fs.existsSync(webDist)) {
await app.register(staticPlugin, {
root: webDist,
prefix: '/',
cacheControl: true,
maxAge: '1y',
immutable: true,
decorateReply: false,
});
// SPA fallback: any non-/api path returns index.html
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/api/')) {
return reply.code(404).send({ error: 'not_found' });
}
return reply.type('text/html').sendFile('index.html', webDist);
});
} else {
app.log.warn({ webDist }, 'web/dist not found — SPA assets will not be served');
}
return app;
}
const isEntrypoint = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isEntrypoint) {
const app = await buildServer();
try {
await app.listen({ host: '0.0.0.0', port: env.PORT });
app.log.info(`eLegal Software API listening on :${env.PORT}`);
} catch (err) {
app.log.error(err);
captureError(err);
process.exit(1);
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": false,
"declaration": false,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*"]
}
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon.png" />
<link rel="apple-touch-icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0052FF" />
<title>eLegal Software - All-in-One Practice Management for Law Firms</title>
<meta
name="description"
content="Streamline case management, billable hours, legal documents and invoicing in one secure platform built for attorneys."
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Poppins:wght@500;600;700;800&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@lawdesk/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@sentry/react": "^8.45.0",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.13.1",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.2",
"react-router-dom": "^6.28.0",
"recharts": "^2.13.3",
"tailwind-merge": "^2.5.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.9.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0052FF"/>
<path d="M18 16h6v24h14v6H18z" fill="#fff"/>
<path d="M40 16h6v18h-6z" fill="#fff" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 227 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

+90
View File
@@ -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 />
</>
);
}
+82
View File
@@ -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>
);
}
+48
View File
@@ -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>
);
}
+204
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+169
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+82
View File
@@ -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>
);
}
+152
View File
@@ -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 &amp; 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">
&ldquo;{t.quote}&rdquo;
</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>
);
}
+21
View File
@@ -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>
);
}
+43
View File
@@ -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}
/>
);
});
+51
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+77
View File
@@ -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>
);
});
+212
View File
@@ -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);
}
+39
View File
@@ -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;
}
+201
View File
@@ -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}` : ''}`);
},
});
}
+66
View File
@@ -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),
});
}
+28
View File
@@ -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'),
});
}
+108
View File
@@ -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'] }),
});
}
+82
View File
@@ -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'] }),
});
}
+160
View File
@@ -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'] });
},
});
}
+14
View File
@@ -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),
});
}
+134
View File
@@ -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'] });
},
});
}
+42
View File
@@ -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,
});
}
+51
View File
@@ -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),
};
+6
View File
@@ -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));
}
+32
View File
@@ -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;
}
}
+16
View File
@@ -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 };
+28
View File
@@ -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>,
);
+80
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}
+114
View File
@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More