Skip to content

rahulsjha/Contracts-Life-Cycle-Management-Frontend

Repository files navigation

CLM Frontend

CI Next.js React TypeScript Tailwind License

Contract Lifecycle Management frontend — Next.js 16 App Router, React 19, Tiptap rich-text editing, pdf.js PDF viewer, AI-powered semantic search, and Recharts analytics dashboard.


🚀 Live Deployments

Service URL Status
Frontend (Production) https://verdant-douhua-1148be.netlify.app Live
Backend API https://api.YOUR_DOMAIN.com API
Swagger UI https://api.YOUR_DOMAIN.com/api/docs/ Docs

📦 Repositories

Repo Description Stack
Frontend (this repo) Contract editor, PDF viewer, approval UI Next.js 16 / React 19
Backend REST API, AI, async tasks, multi-tenancy Python / Django 5

🎬 Resources

📹 Live Demo Video (Loom) · 📖 Architecture · 🔬 Feature Index · 🐳 Docker Setup


Table of contents


Overview

A production-ready Next.js 16 App Router SPA for the full contract lifecycle — contract creation with rich-text editing, PDF viewing and annotation, multi-stage approval workflows, AI-powered semantic search, and a Recharts analytics dashboard — all secured with in-memory JWT auth and connected to a Django REST backend.

What makes this frontend unusual:

Signal Detail
Next.js 16 + React 19 Bleeding-edge App Router with React Server Components — most CLM UIs are still on Pages Router
Tiptap (ProseMirror) The only contract editor with first-class Yjs collaborative editing support — built-in upgrade path to multi-party redlining
pdf.js + pdf-lib Client-side PDF rendering and manipulation — annotations, form filling, and redaction without a server round-trip
In-memory JWT storage Tokens never touch localStorage or sessionStorage — cleared on page close, invisible to XSS payloads
Static export + SSR parity One codebase deploys to Cloudflare Pages (air-gapped enterprise) or a Node.js server (standard SaaS)

System architecture

High-level overview

╔══════════════════════════════════════════════════════════════════════╗
║                         USER / BROWSER                              ║
╚══════════════════════╤══════════════════════════════════════════════╝
                       │  HTTPS
                       ▼
╔══════════════════════════════════════════════════════════════════════╗
║                         NETLIFY / CDN EDGE                          ║
║         Static assets (JS/CSS) served from Cloudflare edge          ║
║         Dynamic routes proxied to Next.js server (if SSR)           ║
╚══════════════════════╤══════════════════════════════════════════════╝
                       │
                       ▼
╔══════════════════════════════════════════════════════════════════════╗
║              NEXT.JS 16 APP ROUTER (React 19)                       ║
║                                                                      ║
║  ┌──────────────────────────────────────────────────────────────┐   ║
║  │  app/layout.tsx  (Root shell)                                │   ║
║  │   • AuthProvider  (in-memory JWT, token manager)             │   ║
║  │   • ThemeProvider (dark/light, system preference)            │   ║
║  │   • ToastProvider (global notifications)                     │   ║
║  └──────────────────────────────────────────────────────────────┘   ║
║                                                                      ║
║  ┌─────────────────────┐     ┌──────────────────────────────────┐  ║
║  │  Public Routes      │     │  Protected Routes (AuthGuard)    │  ║
║  │  /login             │     │  /dashboard                      │  ║
║  │  /register          │     │  /contracts/[id]                 │  ║
║  │  /verify-otp        │     │  /search                         │  ║
║  │  /forgot-password   │     │  /approvals                      │  ║
║  │                     │     │  /analytics                      │  ║
║  └─────────────────────┘     │  /templates                      │  ║
║                               │  /calendar                       │  ║
║                               │  /settings                       │  ║
║                               └──────────────────────────────────┘  ║
║                                                                      ║
║  ┌──────────────────────────────────────────────────────────────┐   ║
║  │  Shared Component Library  (app/components/)                 │   ║
║  │  Button · Modal · Table · PDFViewer · Tiptap · Recharts      │   ║
║  │  SearchBar · ApprovalTimeline · ContractCard · Skeleton       │   ║
║  └──────────────────────────────────────────────────────────────┘   ║
║                                                                      ║
║  ┌──────────────────────────────────────────────────────────────┐   ║
║  │  Core Library  (app/lib/)                                    │   ║
║  │  api-client.ts   → fetch wrapper + auto token refresh        │   ║
║  │  auth-context.tsx → React Context + in-memory state          │   ║
║  │  token-manager.ts → module-level token store (XSS-safe)      │   ║
║  │  env.ts           → NEXT_PUBLIC_* config normalization        │   ║
║  └──────────────────────────────────────────────────────────────┘   ║
╚══════════════════════╤══════════════════════════════════════════════╝
                       │  HTTPS / REST
                       ▼
╔══════════════════════════════════════════════════════════════════════╗
║                    DJANGO REST BACKEND                               ║
║       JWT Auth · Contracts · AI · Search · Approvals · Audit        ║
╚══════════════════════════════════════════════════════════════════════╝

Page + component hierarchy

app/layout.tsx  (AuthProvider, ThemeProvider, ToastProvider)
│
├── app/(public)/
│   ├── login/page.tsx
│   │   ├── LoginForm
│   │   │   ├── EmailInput
│   │   │   ├── PasswordInput
│   │   │   └── GoogleOAuthButton
│   │   └── OTPVerifyModal
│   │
│   └── register/page.tsx
│       └── RegistrationWizard
│           ├── Step 1: EmailPasswordForm
│           ├── Step 2: OTPVerify
│           └── Step 3: ProfileSetup
│
└── app/(protected)/  ← all wrapped in AuthGuard
    │
    ├── dashboard/page.tsx
    │   ├── KPICardGrid          (Recharts AreaChart — contract volume)
    │   ├── ApprovalPipelineBar  (Recharts BarChart — status breakdown)
    │   ├── RecentContractsList  (Table + ContractStatusBadge)
    │   ├── ExpiryCalendarWidget (upcoming renewals)
    │   └── ActivityTimeline
    │
    ├── contracts/
    │   ├── page.tsx             (ContractListView)
    │   │   ├── SearchBar + FilterPanel (status, type, date)
    │   │   ├── ContractTable
    │   │   │   └── ContractRow × n (status badge, version, actions)
    │   │   └── Pagination
    │   │
    │   └── [id]/page.tsx        (ContractDetailView)
    │       ├── ContractHeader   (name, status, parties, versions)
    │       ├── TiptapEditor     (ProseMirror — rich text contract body)
    │       ├── PDFViewer        (pdf.js — inline rendering + annotations)
    │       ├── AIInsightsPanel  (metadata, clauses, risk flags)
    │       ├── VersionHistory   (diffs, snapshots, restore)
    │       └── ApprovalTimeline (status, approvers, comments)
    │
    ├── search/page.tsx
    │   ├── SearchInput          (debounced, 300ms)
    │   ├── SearchModeToggle     (semantic / full-text)
    │   ├── FacetSidebar         (status, type, date, parties)
    │   ├── ClauseResultCard × n (similarity score, contract link)
    │   └── SavedSearches
    │
    ├── templates/page.tsx
    │   ├── TemplateGrid
    │   └── TemplateEditor       (Tiptap + variable interpolation)
    │
    ├── approvals/page.tsx
    │   ├── ApprovalQueue        (pending items)
    │   └── ApprovalDetailModal  (approve / reject + comment)
    │
    ├── analytics/page.tsx
    │   ├── ContractVolumeChart  (Recharts LineChart — by month)
    │   ├── StatusBreakdown      (Recharts PieChart)
    │   ├── ApprovalTimeMetric   (avg days to approval)
    │   └── ExpiryForecast       (upcoming renewals heatmap)
    │
    ├── calendar/page.tsx        (contract milestone calendar)
    ├── settings/page.tsx        (profile, notifications, API keys)
    └── admin/page.tsx           (admin-only: users, tenants)

Data flow

User Action (click, type, submit)
        │
        ▼
React Component
        │
        ├── Local state?   →  useState / useReducer
        │                     (UI: modal open, form input, tab selection)
        │
        ├── Global auth?   →  useAuth() → AuthContext
        │                     (user, isAuthenticated, logout)
        │
        └── Server data?   →  apiClient.get() / .post()
                               │
                               ▼
                    ┌──────────────────────────────┐
                    │  app/lib/api-client.ts        │
                    │                              │
                    │  1. Read access token from   │
                    │     tokenManager (memory)    │
                    │  2. Attach Authorization:    │
                    │     Bearer {token} header    │
                    │  3. fetch(NEXT_PUBLIC_API_   │
                    │     BASE_URL + path)         │
                    │  4. If 401:                  │
                    │     a. POST /auth/refresh/   │
                    │     b. Retry original request│
                    │     c. If refresh fails:     │
                    │        → logout()            │
                    │  5. Return typed response    │
                    └──────────────────────────────┘
                               │
                               ▼
                    Django REST Backend
                    (tenant-scoped response)
                               │
                               ▼
                    Component re-renders
                    with fresh server data

Auth state machine

                    ┌─────────────────┐
         app load   │      IDLE       │
    ──────────────► │  (checking...)  │
                    └────────┬────────┘
                             │
                   GET /api/auth/me/
                             │
              ┌──────────────┴──────────────┐
              │ cookie valid                │ no cookie / 401
              ▼                             ▼
  ┌───────────────────────┐    ┌───────────────────────┐
  │    AUTHENTICATED      │    │   UNAUTHENTICATED      │
  │                       │    │                        │
  │  user, tenant_id,     │    │  redirect → /login     │
  │  roles in memory      │    │  (preserve destination │
  │                       │    │   in location.state)   │
  └──────────┬────────────┘    └────────────────────────┘
             │
     [user submits JWT form]
             │
             ▼
  ┌───────────────────────┐
  │       LOADING         │
  │  (POST /auth/login/)  │
  └──────────┬────────────┘
             │
      ┌──────┴──────┐
    success       failure
      │              │
      ▼              ▼
AUTHENTICATED    show error
  (redirect       toast
  to /dashboard)
             │
    [access token expires]
             │
             ▼
  ┌───────────────────────┐
  │    TOKEN_REFRESH      │
  │ (POST /auth/refresh/) │
  └──────────┬────────────┘
             │
      ┌──────┴──────┐
    success       failure
      │              │
      ▼              ▼
AUTHENTICATED    UNAUTHENTICATED
  (transparent    (logout +
   to user)        redirect)

Token lifecycle

  Login success
       │
       ▼
tokenManager.setTokens(access, refresh)
  ┌────────────────────────────────────┐
  │  Module-level memory store        │
  │  (NOT localStorage, NOT cookie)   │
  │                                   │
  │  let accessToken: string | null   │
  │  let refreshToken: string | null  │
  │                                   │
  │  Cleared on:                      │
  │   • Page close / refresh          │
  │   • Explicit logout()             │
  │   • Refresh token expiry (7d)     │
  └────────────────────────────────────┘
       │
       │  Every API request
       ▼
  Authorization: Bearer {accessToken}
       │
       │  access expires (15 min)
       ▼
  apiClient intercepts 401
       │
       ▼
  POST /api/auth/refresh/
  { refresh: refreshToken }
       │
       ├── 200 → new access + refresh → retry original request
       └── 401 → logout() + redirect /login

  Multi-tab sync:
  window.addEventListener('auth:tokens', handler)
  ← dispatched by tokenManager.setTokens()
  ← all open tabs update their in-memory state simultaneously

PDF pipeline

User opens contract PDF
        │
        ▼
PDFViewer component mounts
        │
        ▼
┌─────────────────────────────────────────────────────────────────────┐
│  1. Fetch presigned R2 URL                                          │
│     GET /api/v1/contracts/{id}/download/                            │
│     → { url: "https://pub-HASH.r2.dev/contracts/abc.pdf" }         │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────────┐
│  2. pdf.js worker loads PDF                                         │
│     pdfjsLib.getDocument(url)                                       │
│     Worker runs in separate thread (public/pdf.worker.js)          │
│     → Does NOT block main thread                                    │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────────┐
│  3. Render pages to Canvas                                          │
│     page.render({ canvasContext, viewport })                        │
│     IntersectionObserver → lazy render only visible pages           │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────────────┐
│  4. Annotation layer (pdf-lib)                                      │
│     • Highlight text selection                                      │
│     • Add comments (stored in backend)                              │
│     • Sign fields → generate signed PDF bytes                      │
│     • Export modified PDF → upload to R2                           │
└─────────────────────────────────────────────────────────────────────┘

scripts/copy-pdfjs-assets.mjs runs at:
  postinstall / predev / prebuild
  → copies pdf.worker.js from node_modules to public/
  → ensures worker URL is served correctly in all environments

Tech stack

Category Technology Version Why chosen
Framework Next.js App Router 16.1 RSC for read-heavy pages, SSR/static parity
UI Library React 19 Concurrent features, Suspense boundaries
Language TypeScript 5 (strict) Zero implicit any, exhaustive deps
Styling Tailwind CSS 3.4 Utility-first, responsive, dark mode
Rich text editor Tiptap (ProseMirror) latest ProseMirror base, Yjs collab-ready, active maintenance
PDF rendering pdf.js Battle-tested, worker-threaded, no server needed
PDF manipulation pdf-lib Client-side annotation, form filling, signing
Charts Recharts 3.7 React-native, composable, responsive containers
Icons Lucide React 0.383 Tree-shakeable SVG, consistent style
HTTP client Fetch API (custom wrapper) Native, typed, with auto-refresh interceptor
Auth state React Context + module memory No Redux needed; XSS-safe token store
Build tool Vite / webpack (Next default) Fast HMR in dev, SWC in prod

Key features

Authentication & session management

  • Email/password + Google OAuth 2.0 login
  • OTP verification for high-security operations
  • In-memory token storage — XSS-safe, never in localStorage
  • Transparent token refresh — intercepted at the API client layer
  • Multi-tab session sync via auth:tokens custom event
  • Protected route middleware with post-login redirect

Contract management UI

  • Create & edit contracts with full Tiptap rich-text editor
  • Template library with variable interpolation and drag-and-drop clause ordering
  • PDF viewer — inline rendering, text selection, annotations, signatures
  • Version history — immutable snapshot list, side-by-side diff view
  • Clause library — searchable, draggable, category-tagged
  • Digital signatures — signature field placement + OTP-gated signing flow

AI features UI

  • Metadata extraction panel (parties, dates, values, jurisdiction)
  • Clause classification chips (payment, liability, IP, NDA)
  • Risk analysis dashboard with flag severity levels
  • Document summarization — expandable executive summary card
  • Similar clause suggestions linked to source contracts

Search

  • Semantic search input with live debouncing (300ms)
  • Mode toggle: semantic (pgvector cosine) vs full-text (pg_trgm)
  • Facet sidebar: status, contract type, date range, parties
  • Clause result cards with similarity score bars
  • Saved searches with one-click replay

Analytics dashboard

  • Contract volume over time (Recharts LineChart)
  • Status breakdown (Recharts PieChart)
  • Approval cycle time metric (avg days draft → signed)
  • Expiry forecast heatmap (upcoming renewals by month)

UI/UX polish

  • Dark mode (system preference + manual toggle)
  • Skeleton loading screens (no layout shift)
  • Error boundaries with retry — failed sections don't break the page
  • Toast notification system with auto-dismiss
  • Full keyboard navigation + ARIA labels
  • Responsive: mobile → tablet → desktop layouts

Quick start

Option A — Standard local dev

git clone https://github.com/vk93102/Contracts-Life-Cycle-Management-Frontend.git
cd Contracts-Life-Cycle-Management-Frontend

# Install dependencies (runs copy-pdfjs-assets.mjs automatically)
npm install

cp .env.local.example .env.local
# Set NEXT_PUBLIC_API_BASE_URL to your backend URL

npm run dev

Open http://localhost:3000

Option B — Docker

See Docker local development for the full stack with backend.

Available scripts

Command What it does
npm run dev Start dev server (webpack, HMR)
npm run build Production build
npm run start Run production server
npm run lint ESLint check
npm test Run Vitest test suite
npm run test:coverage Tests + coverage report
npx tsc --noEmit TypeScript type check
STATIC_EXPORT=1 npm run build Build for Cloudflare Pages / CDN

Docker local development

Run the full stack (Next.js frontend + Django backend + Postgres + Redis) together:

docker-compose.yml

version: '3.9'

services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile
      target: development
    command: npm run dev
    volumes:
      - .:/app
      - /app/node_modules
      - /app/.next
    ports:
      - "3000:3000"
    env_file:
      - .env.local
    environment:
      NEXT_PUBLIC_API_BASE_URL: http://localhost:11000
    depends_on:
      - backend

  backend:
    image: clm-backend:latest        # build from backend repo first
    ports:
      - "11000:11000"
    env_file:
      - .env.backend.local
    environment:
      SUPABASE_ONLY: "False"
      DB_HOST: db
      REDIS_URL: redis://redis:6379/0

  db:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_DB: clm_dev
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:

Dockerfile (multi-stage)

# ── Base ───────────────────────────────────────────────────────────────
FROM node:20-slim AS base
WORKDIR /app
ENV NODE_ENV=production

# ── Dependencies ───────────────────────────────────────────────────────
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci

# ── Development ────────────────────────────────────────────────────────
FROM base AS development
ENV NODE_ENV=development
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

# ── Builder ────────────────────────────────────────────────────────────
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# ── Production ─────────────────────────────────────────────────────────
FROM base AS production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

# Non-root user
RUN addgroup --system nextjs && adduser --system --group nextjs
USER nextjs

EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

Common Docker commands

# Start full stack
docker compose up -d

# View frontend logs
docker compose logs -f frontend

# Rebuild after dependency change
docker compose build frontend && docker compose up -d frontend

# Full reset
docker compose down -v

Environment variables

Copy .env.local.example to .env.local. All client-side variables must be prefixed NEXT_PUBLIC_.

# ── Backend connection ────────────────────────────────────────────────
# Required — Django/DRF backend base URL
NEXT_PUBLIC_API_BASE_URL=http://localhost:11000

# Legacy alias (still supported)
# NEXT_PUBLIC_API_URL=http://localhost:11000

# ── Google OAuth ──────────────────────────────────────────────────────
# Optional — required only if using Google login
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com

# ── Supabase (frontend read-only) ────────────────────────────────────
# Optional — only needed if frontend queries Supabase directly
NEXT_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...

# ── Build flags ───────────────────────────────────────────────────────
# Set to 1 to produce a static export for Cloudflare Pages / CDN
# STATIC_EXPORT=1

Production environment:

NEXT_PUBLIC_API_BASE_URL=https://api.yourdomain.com
NEXT_PUBLIC_GOOGLE_CLIENT_ID=...
NEXT_PUBLIC_SUPABASE_URL=https://...supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=...

CI/CD pipeline

Pipeline stages

TRIGGER: push to master/main/develop  OR  pull_request to master/main
                  │
      ┌───────────┼──────────────┐
      ▼           ▼              ▼
 ┌─────────┐ ┌─────────┐ ┌──────────────┐
 │  LINT   │ │  TYPES  │ │   BUILD      │
 │         │ │         │ │   CHECK      │
 │ eslint  │ │ tsc     │ │ npm run build│
 │         │ │ --noEmit│ │              │
 └────┬────┘ └────┬────┘ └──────┬───────┘
      └───────────┴──────────────┘
                  │  all 3 pass
                  ▼
        ┌───────────────────────────────────────┐
        │  TEST JOB                             │
        │  1. npm ci                            │
        │  2. node scripts/copy-pdfjs-assets    │
        │  3. npm test -- --coverage            │
        │  4. upload coverage artifact          │
        └──────────────────┬────────────────────┘
                           │ tests pass
                           ▼
        ┌───────────────────────────────────────┐
        │  LIGHTHOUSE CI (main only)            │
        │  1. npm run build                     │
        │  2. lhci autorun                      │
        │  3. Assert: perf > 85                 │
        │     a11y > 90, best-practices > 90    │
        │  4. Upload report as artifact         │
        └──────────────────┬────────────────────┘
                           │ passes
                           ▼
        ┌───────────────────────────────────────┐
        │  DEPLOY (main only)                   │
        │  Netlify auto-deploys on push         │
        │  Preview deploys on every PR           │
        └───────────────────────────────────────┘

Full .github/workflows/ci.yml

name: CI Quality Gate

on:
  push:
    branches: [master, main, develop]
  pull_request:
    branches: [master, main]

env:
  NODE_VERSION: '20.x'

jobs:

  lint:
    name: ESLint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
      - run: npm ci
      - run: node scripts/copy-pdfjs-assets.mjs
      - run: npm run lint

  typecheck:
    name: TypeScript
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
      - run: npm ci
      - run: npx tsc --noEmit

  build:
    name: Build Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
      - run: npm ci
      - run: node scripts/copy-pdfjs-assets.mjs
      - name: Production build
        run: npm run build
        env:
          NEXT_PUBLIC_API_BASE_URL: ${{ secrets.NEXT_PUBLIC_API_BASE_URL || 'https://api.example.com' }}
          NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${{ secrets.NEXT_PUBLIC_GOOGLE_CLIENT_ID || 'placeholder' }}

  test:
    name: Tests & Coverage
    runs-on: ubuntu-latest
    needs: [lint, typecheck, build]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
      - run: npm ci
      - run: node scripts/copy-pdfjs-assets.mjs
      - name: Run tests with coverage
        run: npm test -- --coverage --passWithNoTests
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 14

  lighthouse:
    name: Lighthouse CI
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: npm
      - run: npm ci
      - run: node scripts/copy-pdfjs-assets.mjs
      - run: npm install -g @lhci/cli
      - name: Build for Lighthouse
        run: npm run build
        env:
          NEXT_PUBLIC_API_BASE_URL: https://api.example.com
      - name: Run Lighthouse CI
        run: lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: lighthouse-report
          path: .lighthouseci/
          retention-days: 30

Lighthouse CI config (.lighthouserc.js)

module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run start',
      url: ['http://localhost:3000/login'],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:performance':     ['warn',  { minScore: 0.85 }],
        'categories:accessibility':   ['error', { minScore: 0.90 }],
        'categories:best-practices':  ['error', { minScore: 0.90 }],
        'categories:seo':             ['warn',  { minScore: 0.80 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
};

Testing

Test setup (Vitest + Testing Library)

npm install --save-dev \
  vitest \
  @vitejs/plugin-react \
  @testing-library/react \
  @testing-library/user-event \
  @testing-library/jest-dom \
  jsdom

vitest.config.ts

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', '.next/', 'out/', '**/*.config.*', 'scripts/'],
    },
  },
  resolve: {
    alias: { '@': path.resolve(__dirname, './') },
  },
})

vitest.setup.ts

import '@testing-library/jest-dom'

Test structure

app/
├── lib/
│   └── __tests__/
│       ├── api-client.test.ts       # fetch wrapper, 401 refresh, error handling
│       ├── auth-context.test.tsx    # AuthProvider default state, login/logout
│       └── token-manager.test.ts   # in-memory store, multi-tab sync
│
└── components/
    └── __tests__/
        ├── Button.test.tsx          # render, click, disabled state
        ├── Modal.test.tsx           # open/close, portal, keyboard trap
        ├── Table.test.tsx           # rows, sorting, empty state
        └── ContractCard.test.tsx   # status badge, version display

Example tests

// app/lib/__tests__/auth-context.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { AuthProvider, useAuth } from '../auth-context'

function TestComponent() {
  const { isAuthenticated, user } = useAuth()
  return (
    <div>
      <span data-testid="auth">{isAuthenticated ? 'in' : 'out'}</span>
      <span data-testid="email">{user?.email ?? 'none'}</span>
    </div>
  )
}

describe('AuthProvider', () => {
  it('starts unauthenticated', () => {
    render(<AuthProvider><TestComponent /></AuthProvider>)
    expect(screen.getByTestId('auth').textContent).toBe('out')
    expect(screen.getByTestId('email').textContent).toBe('none')
  })
})
// app/lib/__tests__/token-manager.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { tokenManager } from '../token-manager'

describe('tokenManager', () => {
  beforeEach(() => tokenManager.clearTokens())

  it('starts with no tokens', () => {
    expect(tokenManager.getAccessToken()).toBeNull()
    expect(tokenManager.isAuthenticated()).toBe(false)
  })

  it('stores and retrieves tokens', () => {
    tokenManager.setTokens('access-abc', 'refresh-xyz')
    expect(tokenManager.getAccessToken()).toBe('access-abc')
    expect(tokenManager.isAuthenticated()).toBe(true)
  })

  it('clearTokens removes all tokens', () => {
    tokenManager.setTokens('a', 'b')
    tokenManager.clearTokens()
    expect(tokenManager.getAccessToken()).toBeNull()
  })
})
// app/lib/__tests__/api-client.test.ts
import { describe, it, expect, vi, afterEach } from 'vitest'
import { tokenManager } from '../token-manager'

describe('apiClient', () => {
  afterEach(() => vi.restoreAllMocks())

  it('attaches Authorization header when token present', async () => {
    tokenManager.setTokens('test-access-token', 'refresh')
    const spy = vi.spyOn(global, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ results: [] }), { status: 200 })
    )

    const { apiClient } = await import('../api-client')
    await apiClient.get('/api/v1/contracts/')

    expect(spy).toHaveBeenCalledWith(
      expect.stringContaining('/api/v1/contracts/'),
      expect.objectContaining({
        headers: expect.objectContaining({
          Authorization: 'Bearer test-access-token',
        }),
      })
    )
  })

  it('retries with refreshed token on 401', async () => {
    tokenManager.setTokens('expired-token', 'valid-refresh')
    const fetchSpy = vi.spyOn(global, 'fetch')
      .mockResolvedValueOnce(new Response('', { status: 401 }))
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ access: 'new-token', refresh: 'new-refresh' }), { status: 200 })
      )
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ results: [] }), { status: 200 })
      )

    const { apiClient } = await import('../api-client')
    const result = await apiClient.get('/api/v1/contracts/')
    expect(result).toEqual({ results: [] })
    expect(fetchSpy).toHaveBeenCalledTimes(3)
  })
})

Running tests

# Full suite
npm test

# Watch mode (dev)
npm run test:watch

# With coverage
npm run test:coverage

# Open HTML coverage report
npm run test:coverage && open coverage/index.html

Route structure

Public routes (no auth required)

/                           Landing page
/login                      Email/password + Google OAuth login
/register                   Registration wizard (email → OTP → profile)
/verify-otp                 Standalone OTP verification
/forgot-password            Password reset flow
/terms                      Terms of service
/privacy                    Privacy policy

Protected routes (require valid JWT)

/dashboard                  KPI cards, approval pipeline, recent contracts
/contracts                  Contract list (paginated, filterable, sortable)
/contracts/[id]             Contract detail — editor, PDF viewer, AI insights
/create-contract            New contract wizard (template select → editor)
/templates                  Template library (browse, edit, create)
/search                     Semantic + full-text search with facets
/approvals                  Pending approval queue
/calendar                   Contract milestone calendar
/analytics                  Charts and metrics dashboard
/settings                   User profile, notifications, API access
/admin                      Admin panel (admin role required)

Component examples

Using the API client

import { apiClient } from '@/lib/api-client'

// GET — automatic auth header + token refresh on 401
const contracts = await apiClient.get<ContractListResponse>('/api/v1/contracts/')

// POST with typed body
const newContract = await apiClient.post<Contract>('/api/v1/contracts/', {
  title: 'Service Agreement',
  parties: ['Company A', 'Company B'],
  status: 'draft',
})

// PATCH
const updated = await apiClient.patch<Contract>(`/api/v1/contracts/${id}/`, {
  status: 'submitted',
})

// File upload (multipart/form-data)
const formData = new FormData()
formData.append('file', file)
const uploaded = await apiClient.upload('/api/v1/upload-document/', formData)

Using the auth context

import { useAuth } from '@/lib/auth-context'

function ContractActions() {
  const { user, isAuthenticated, logout } = useAuth()

  if (!isAuthenticated) return null

  return (
    <div>
      <span>Signed in as {user.email}</span>
      {user.roles.includes('approver') && (
        <button>Approve contract</button>
      )}
      <button onClick={logout}>Sign out</button>
    </div>
  )
}

Auth-gated page (App Router)

// app/(protected)/contracts/page.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth-server'

export default async function ContractsPage() {
  const session = await getServerSession()
  if (!session) redirect('/login?next=/contracts')

  // Fetch on server — zero JS shipped for the data
  const contracts = await fetch(`${process.env.API_BASE_URL}/api/v1/contracts/`, {
    headers: { Authorization: `Bearer ${session.accessToken}` },
    cache: 'no-store',  // always fresh for contract data
  }).then(r => r.json())

  return <ContractTable initialData={contracts} />
}

Design decisions

Why Next.js 16 App Router + React Server Components?

Contract list and dashboard pages are read-heavy — the data comes from the server and doesn't change on interaction. RSC lets those pages fetch data server-side and ship zero hydration JS for the table rows, significantly improving Time to Interactive on the most-visited routes.

Decision Next.js 16 App Router with RSC for data-fetching routes
✅ Zero JS for data Server-fetched tables ship as static HTML
✅ Improved TtI No client-side waterfall for initial data load
✅ SSR/static parity Same codebase works on Node.js server or CDN static export
❌ Complexity RSC boundary rules require careful 'use client' placement
Rejected Pages Router (legacy, no RSC), Vite SPA (no SSR for data-heavy pages)

Why Tiptap over Quill or Draft.js?

Contract editing requires features that most editors don't support well: collaborative multi-party redlining, complex clause nesting, and extensible marks for clause classification highlights.

Decision Tiptap (ProseMirror-based)
✅ Yjs-ready First-class collaborative editing support — natural upgrade path to multi-party redlining
✅ Actively maintained Regular releases, TypeScript-native, well-documented extensions
✅ Extensible Custom marks for clause classification, risk highlights, version diff
❌ Bundle size Larger than Quill (~100KB gzipped for full feature set)
Rejected Quill (not actively maintained), Draft.js (Meta-abandoned, no ProseMirror schema)

Why in-memory token storage?

localStorage is readable by any JavaScript injected via XSS. For a legal document platform where contract content is sensitive, the risk is unacceptable.

Decision Module-level JS variables for access + refresh tokens
✅ XSS-safe Tokens invisible to injected scripts
✅ Cleared on close Page close naturally expires the session
❌ No persistence Users re-login after tab close (trade-off accepted for security)
Rejected localStorage (XSS-readable), sessionStorage (still accessible to scripts), httpOnly cookies (requires backend CORS config changes)

Why static export support alongside SSR?

Enterprise CLM customers often require air-gapped or SOC2-audited hosting — a full Node.js server may not be permissible. Static export lets the same codebase deploy to any CDN with no server-side runtime.

Decision STATIC_EXPORT=1 flag toggles output: 'export' in next.config.ts
CDN-deployable Works on Cloudflare Pages, Netlify, S3 + CloudFront
No server required Zero Node.js runtime in air-gapped environments
No SSR Dynamic data fetching becomes client-side only
Rejected SSR-only (blocks enterprise air-gapped deployments)

Production deployment

Option 1 — Netlify (current production)

Netlify auto-deploys from the master branch. Configure in Netlify dashboard:

Build command:   npm run build
Publish dir:     .next
Node version:    20.x

Environment variables to set in Netlify dashboard:

  • NEXT_PUBLIC_API_BASE_URL
  • NEXT_PUBLIC_GOOGLE_CLIENT_ID
  • NEXT_PUBLIC_SUPABASE_URL
  • NEXT_PUBLIC_SUPABASE_ANON_KEY

Option 2 — Vercel (recommended for Next.js)

npm install -g vercel
vercel login
vercel --prod

Option 3 — Static export (Cloudflare Pages / CDN)

STATIC_EXPORT=1 npm run build
# Output: out/ directory

# Deploy to Cloudflare Pages
npx wrangler pages deploy out --project-name clm-frontend

See docs/STATIC_EXPORT.md for full static export documentation.

Option 4 — Docker (self-hosted)

docker build --target production -t clm-frontend:latest .
docker run -p 3000:3000 \
  -e NEXT_PUBLIC_API_BASE_URL=https://api.yourdomain.com \
  clm-frontend:latest

Post-deployment checklist

curl https://verdant-douhua-1148be.netlify.app/login   # → 200
# Verify: login page loads
# Verify: Google OAuth redirect works
# Verify: JWT auth flow end-to-end
# Verify: PDF viewer loads a contract
# Verify: dark mode toggles correctly

Repository structure

.
├── app/                          # Next.js App Router
│   ├── layout.tsx                # Root layout: AuthProvider, ThemeProvider, Toasts
│   ├── page.tsx                  # Landing page (redirects to /dashboard or /login)
│   │
│   ├── lib/                      # Core libraries
│   │   ├── api-client.ts         # fetch wrapper: auth headers + 401 retry
│   │   ├── auth-context.tsx      # React Context: user, isAuthenticated, logout
│   │   ├── token-manager.ts      # In-memory JWT store (XSS-safe)
│   │   └── env.ts                # NEXT_PUBLIC_* normalization + validation
│   │
│   ├── components/               # 40+ shared UI components
│   │   ├── Button.tsx
│   │   ├── Modal.tsx
│   │   ├── Table.tsx
│   │   ├── PDFViewer.tsx         # pdf.js + pdf-lib integration
│   │   ├── TiptapEditor.tsx      # ProseMirror rich-text editor
│   │   ├── ContractCard.tsx
│   │   ├── StatusBadge.tsx
│   │   ├── ApprovalTimeline.tsx
│   │   ├── Skeleton.tsx          # Loading state placeholders
│   │   └── ...
│   │
│   ├── (public)/                 # Unauthenticated routes
│   │   ├── login/
│   │   ├── register/
│   │   ├── verify-otp/
│   │   └── forgot-password/
│   │
│   └── (protected)/              # AuthGuard-wrapped routes
│       ├── dashboard/
│       ├── contracts/
│       │   └── [id]/
│       ├── create-contract/
│       ├── templates/
│       ├── search/
│       ├── approvals/
│       ├── calendar/
│       ├── analytics/
│       ├── settings/
│       └── admin/
│
├── public/                       # Static assets
│   ├── favicon.ico
│   └── pdf.worker.js             # pdf.js worker (copied by scripts/)
│
├── docs/                         # Documentation
│   ├── ARCHITECTURE.md
│   ├── FEATURES_INDEX.md
│   ├── SETUP.md
│   └── STATIC_EXPORT.md
│
├── scripts/
│   └── copy-pdfjs-assets.mjs     # Copies pdf.worker.js to public/ at install time
│
├── .github/
│   ├── workflows/ci.yml          # Lint + types + build + test + Lighthouse
│   └── pull_request_template.md
│
├── .env.local.example            # Environment variable template
├── next.config.ts                # Next.js config (static export toggle)
├── tailwind.config.ts            # Tailwind design tokens
├── tsconfig.json                 # TypeScript strict mode
├── vitest.config.ts              # Vitest + jsdom test config
├── vitest.setup.ts               # @testing-library/jest-dom setup
├── .lighthouserc.js              # Lighthouse CI thresholds
├── eslint.config.mjs             # ESLint flat config
└── package.json

Documentation index

Document Contents
docs/ARCHITECTURE.md Detailed frontend architecture, data flow, component patterns
docs/FEATURES_INDEX.md Complete implemented feature list + status
docs/SETUP.md Extended local setup guide, troubleshooting
docs/STATIC_EXPORT.md Cloudflare Pages / CDN static export guide

Contributing

git checkout -b feat/your-feature     # or fix/, chore/, docs/

npm install
# Make changes + add tests

npm run lint
npx tsc --noEmit
npm test

git commit -m "feat: add clause similarity score bar to search results"

Commit prefixes: feat: · fix: · chore: · refactor: · test: · docs: · ci: · perf:

PRs require: CI Quality Gate passing (lint + types + build + tests + Lighthouse) + one reviewer approval.


Troubleshooting

Problem Fix
Module not found errors rm -rf node_modules && npm install
PDF viewer not working node scripts/copy-pdfjs-assets.mjs
Env vars not loading Restart dev server after changing .env.local
CORS errors Add frontend URL to backend CORS_ALLOWED_ORIGINS
Stale auth state Clear sessionStorage in DevTools + hard refresh
Hot reload not working rm -rf .next && npm run dev
TypeScript errors npx tsc --noEmit for full report

CLM Frontend · Next.js 16 · React 19 · TypeScript 5 · Tiptap · pdf.js · Tailwind CSS · Recharts

📹 Demo · 🖥 Live App · 🔧 Backend · 🐛 Issues

About

This frontend delivers a beautiful, intuitive interface for contract lifecycle management. Built with Next.js 16 and React 19, it provides rich document editing with Tiptap, advanced PDF viewing, and real-time collaboration features. The app seamlessly integrates with the backend API for authentication, AI-powered analysis, and semantic search,

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages