diff --git a/src/App.tsx b/src/App.tsx index e2aa049..0291555 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,158 +1,86 @@ import { Routes, Route, Navigate } from 'react-router-dom' -import { PublicHeader } from './components/PublicHeader' -import { PublicFooter } from './components/PublicFooter' -import { AppShell } from './components/AppShell' -import { ThemeToggle } from './components/ThemeToggle' -import { Link } from 'react-router-dom' - -function PublicLayout({ children }: { children: React.ReactNode }) { - return ( -
- -
{children}
- -
- ) -} - -function LandingDemo() { - return ( - -
-

- Every Signature, Forever Verifiable -

-

- Blockchain-anchored e-signatures with independent verification. No vendor lock-in, no expiration. -

-
- - Get Started Free - - - Verify Document - -
- -
-
-

Upload

-

Upload any PDF document to get started

-
-
-

Sign

-

Add signers and collect e-signatures

-
-
-

Anchor

-

Proof anchored on Ethereum L2

-
-
-
-
- ) -} - -function DashboardDemo() { - return ( - -
-
-
-

Total Documents

-

12

-
-
-

Pending Signatures

-

3

-
-
-

Completed

-

8

-
-
-

Anchored On-Chain

-

8

-
-
- -
-

Recent Activity

-
- {[1, 2, 3].map((i) => ( -
-
-

NDA Agreement #{i}

-

Created 2 days ago

-
- Completed -
- ))} -
-
-
-
- ) -} - -function LayoutDemo() { - return ( -
-
-
-
-

Layout Components Demo

-

Step 2: Public and authenticated layouts

-
- -
- -
-
-

Public Layout

-

- Used for landing page, pricing, verify, login, and register pages. - Includes PublicHeader (sticky navigation) and PublicFooter (links & social). -

- - View Public Layout → - -
- -
-

Authenticated Layout

-

- Used for dashboard, documents, envelopes, and settings. - Includes AppShell (flex layout), Sidebar (responsive navigation), and Topbar (header with menu). -

- - View Authenticated Layout → - -
- -
-

Component Features

-
    -
  • ✓ Responsive sidebar (mobile drawer + desktop fixed)
  • -
  • ✓ Sticky headers with backdrop blur
  • -
  • ✓ Theme-aware colors and borders
  • -
  • ✓ Active route highlighting in sidebar
  • -
  • ✓ Accessible navigation with ARIA labels
  • -
  • ✓ Smooth transitions and animations
  • -
-
-
-
-
- ) -} +import { LandingPage } from './pages/LandingPage' +import { PricingPage } from './pages/PricingPage' +import { VerifyPage } from './pages/VerifyPage' +import { LoginPage } from './pages/LoginPage' +import { RegisterPage } from './pages/RegisterPage' +import { ForgotPasswordPage } from './pages/ForgotPasswordPage' +import { DashboardPage } from './pages/DashboardPage' +import { DocumentsPage } from './pages/DocumentsPage' +import { EnvelopesPage } from './pages/EnvelopesPage' +import { EnvelopeDetailPage } from './pages/EnvelopeDetailPage' +import { CreateEnvelopePage } from './pages/CreateEnvelopePage' +import { SigningPage } from './pages/SigningPage' +import { SettingsPage } from './pages/SettingsPage' +import { BillingPage } from './pages/BillingPage' +import { ProtectedRoute } from './components/ProtectedRoute' export default function App() { return ( - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> ) diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..82541ce --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,148 @@ +import axios, { AxiosInstance, AxiosError } from 'axios' +import { User, Document, Envelope, LoginRequest, RegisterRequest, AuthResponse } from './types' +import { mockUser, mockDocuments, mockEnvelopes } from './mockData' + +class ApiClient { + private client: AxiosInstance + private useMock: boolean = true + + constructor() { + this.client = axios.create({ + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api', + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, + }) + + this.client.interceptors.request.use((config) => { + const token = localStorage.getItem('auth-token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }) + + this.client.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + if (error.response?.status === 401) { + localStorage.removeItem('auth-token') + localStorage.removeItem('refresh-token') + window.location.href = '/login' + } + return Promise.reject(error) + } + ) + } + + private delay(ms: number = 500): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async login(data: LoginRequest): Promise { + if (this.useMock) { + await this.delay() + if (data.email === 'demo@sigra.io' && data.password === 'password') { + return { + user: mockUser, + token: 'mock-jwt-token-' + Date.now(), + refreshToken: 'mock-refresh-token-' + Date.now(), + } + } + throw new Error('Invalid email or password') + } + const response = await this.client.post('/auth/login', data) + return response.data + } + + async register(data: RegisterRequest): Promise { + if (this.useMock) { + await this.delay() + return { + user: { + ...mockUser, + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + company: data.company, + }, + token: 'mock-jwt-token-' + Date.now(), + refreshToken: 'mock-refresh-token-' + Date.now(), + } + } + const response = await this.client.post('/auth/register', data) + return response.data + } + + async getCurrentUser(): Promise { + if (this.useMock) { + await this.delay(300) + return mockUser + } + const response = await this.client.get('/auth/me') + return response.data + } + + async getDocuments(): Promise { + if (this.useMock) { + await this.delay() + return mockDocuments + } + const response = await this.client.get('/documents') + return response.data + } + + async getDocument(id: string): Promise { + if (this.useMock) { + await this.delay() + const doc = mockDocuments.find(d => d.id === id) + if (!doc) throw new Error('Document not found') + return doc + } + const response = await this.client.get(`/documents/${id}`) + return response.data + } + + async getEnvelopes(): Promise { + if (this.useMock) { + await this.delay() + return mockEnvelopes + } + const response = await this.client.get('/envelopes') + return response.data + } + + async getEnvelope(id: string): Promise { + if (this.useMock) { + await this.delay() + const env = mockEnvelopes.find(e => e.id === id) + if (!env) throw new Error('Envelope not found') + return env + } + const response = await this.client.get(`/envelopes/${id}`) + return response.data + } + + async verifyDocument(hash: string): Promise<{ verified: boolean; envelope?: Envelope }> { + if (this.useMock) { + await this.delay(800) + const env = mockEnvelopes.find(e => + mockDocuments.find(d => d.id === e.documentId)?.hash === hash + ) + return { verified: !!env, envelope: env } + } + const response = await this.client.post('/verify', { hash }) + return response.data + } + + async logout(): Promise { + if (this.useMock) { + await this.delay(200) + return + } + await this.client.post('/auth/logout') + } +} + +export const apiClient = new ApiClient() diff --git a/src/api/mockData.ts b/src/api/mockData.ts new file mode 100644 index 0000000..055eeb7 --- /dev/null +++ b/src/api/mockData.ts @@ -0,0 +1,114 @@ +import { User, Document, Envelope } from './types' + +export const mockUser: User = { + id: 'user-1', + email: 'demo@sigra.io', + firstName: 'Demo', + lastName: 'User', + company: 'SigraChain', + plan: 'pro', + createdAt: '2026-01-15T10:00:00Z', +} + +export const mockDocuments: Document[] = [ + { + id: 'doc-1', + filename: 'NDA_Agreement.pdf', + size: 245760, + hash: '0x1a2b3c4d5e6f...', + uploadedAt: '2026-06-08T14:30:00Z', + userId: 'user-1', + }, + { + id: 'doc-2', + filename: 'Employment_Contract.pdf', + size: 512000, + hash: '0x7g8h9i0j1k2l...', + uploadedAt: '2026-06-07T10:15:00Z', + userId: 'user-1', + }, + { + id: 'doc-3', + filename: 'Lease_Agreement.pdf', + size: 1024000, + hash: '0x3m4n5o6p7q8r...', + uploadedAt: '2026-06-05T09:00:00Z', + userId: 'user-1', + }, +] + +export const mockEnvelopes: Envelope[] = [ + { + id: 'env-1', + documentId: 'doc-1', + title: 'NDA with Acme Corp', + status: 'anchored', + createdAt: '2026-06-08T14:35:00Z', + completedAt: '2026-06-08T16:30:00Z', + anchoredAt: '2026-06-08T18:00:00Z', + signers: [ + { + id: 'signer-1', + email: 'alice@acme.com', + name: 'Alice Johnson', + status: 'signed', + signedAt: '2026-06-08T15:00:00Z', + }, + { + id: 'signer-2', + email: 'bob@acme.com', + name: 'Bob Smith', + status: 'signed', + signedAt: '2026-06-08T16:30:00Z', + }, + ], + merkleRoot: '0xabc123...', + transactionHash: '0xdef456...', + }, + { + id: 'env-2', + documentId: 'doc-2', + title: 'Employment Contract - John Doe', + status: 'pending', + createdAt: '2026-06-07T10:20:00Z', + signers: [ + { + id: 'signer-3', + email: 'john.doe@example.com', + name: 'John Doe', + status: 'signed', + signedAt: '2026-06-07T14:00:00Z', + }, + { + id: 'signer-4', + email: 'hr@company.com', + name: 'HR Manager', + status: 'pending', + }, + ], + }, + { + id: 'env-3', + documentId: 'doc-3', + title: 'Office Lease Agreement', + status: 'completed', + createdAt: '2026-06-05T09:05:00Z', + completedAt: '2026-06-06T11:00:00Z', + signers: [ + { + id: 'signer-5', + email: 'landlord@property.com', + name: 'Property Owner', + status: 'signed', + signedAt: '2026-06-05T15:00:00Z', + }, + { + id: 'signer-6', + email: 'tenant@company.com', + name: 'Company CEO', + status: 'signed', + signedAt: '2026-06-06T11:00:00Z', + }, + ], + }, +] diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..3361c1e --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,58 @@ +export interface User { + id: string + email: string + firstName: string + lastName: string + company?: string + plan: 'free' | 'pro' | 'business' + createdAt: string +} + +export interface Document { + id: string + filename: string + size: number + hash: string + uploadedAt: string + userId: string +} + +export interface Envelope { + id: string + documentId: string + title: string + status: 'draft' | 'pending' | 'completed' | 'anchored' + createdAt: string + completedAt?: string + anchoredAt?: string + signers: Signer[] + merkleRoot?: string + transactionHash?: string +} + +export interface Signer { + id: string + email: string + name: string + status: 'pending' | 'signed' | 'declined' + signedAt?: string +} + +export interface AuthResponse { + user: User + token: string + refreshToken: string +} + +export interface LoginRequest { + email: string + password: string +} + +export interface RegisterRequest { + email: string + password: string + firstName: string + lastName: string + company?: string +} diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx new file mode 100644 index 0000000..68881a7 --- /dev/null +++ b/src/components/Badge.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from 'react' + +type BadgeVariant = 'success' | 'warning' | 'error' | 'info' + +interface BadgeProps { + children: ReactNode + variant?: BadgeVariant + className?: string +} + +const variantClasses: Record = { + success: 'badge-success', + warning: 'badge-warning', + error: 'badge-error', + info: 'badge-info', +} + +export function Badge({ children, variant = 'info', className = '' }: BadgeProps) { + return ( + + {children} + + ) +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..7ce339b --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,59 @@ +import { ButtonHTMLAttributes, ReactNode } from 'react' + +type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' +type ButtonSize = 'sm' | 'md' | 'lg' + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant + size?: ButtonSize + loading?: boolean + icon?: ReactNode + children: ReactNode +} + +const variantClasses: Record = { + primary: 'btn-primary', + secondary: 'btn-secondary', + ghost: 'btn-ghost', + danger: 'btn-danger', +} + +const sizeClasses: Record = { + sm: 'px-4 py-2 text-xs', + md: 'px-6 py-3 text-sm', + lg: 'px-8 py-4 text-base', +} + +export function Button({ + variant = 'primary', + size = 'md', + loading = false, + icon, + children, + disabled, + className = '', + ...props +}: ButtonProps) { + return ( + + ) +} diff --git a/src/components/Card.tsx b/src/components/Card.tsx new file mode 100644 index 0000000..4266d00 --- /dev/null +++ b/src/components/Card.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from 'react' + +type CardVariant = 'interactive' | 'flat' + +interface CardProps { + children: ReactNode + variant?: CardVariant + className?: string + onClick?: () => void +} + +export function Card({ children, variant = 'interactive', className = '', onClick }: CardProps) { + const baseClass = variant === 'interactive' ? 'card' : 'card-flat' + + return ( +
+ {children} +
+ ) +} diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx new file mode 100644 index 0000000..70d28d4 --- /dev/null +++ b/src/components/EmptyState.tsx @@ -0,0 +1,35 @@ +import { ReactNode } from 'react' +import { FileX, Inbox, Search } from 'lucide-react' + +interface EmptyStateProps { + icon?: 'file' | 'inbox' | 'search' + title: string + description?: string + action?: ReactNode +} + +const icons = { + file: FileX, + inbox: Inbox, + search: Search, +} + +export function EmptyState({ icon = 'inbox', title, description, action }: EmptyStateProps) { + const Icon = icons[icon] + + return ( +
+
+ +
+

{title}

+ {description && ( +

{description}

+ )} + {action} +
+ ) +} diff --git a/src/components/Input.tsx b/src/components/Input.tsx new file mode 100644 index 0000000..470473d --- /dev/null +++ b/src/components/Input.tsx @@ -0,0 +1,43 @@ +import { InputHTMLAttributes, forwardRef } from 'react' + +interface InputProps extends InputHTMLAttributes { + label?: string + error?: string + helperText?: string +} + +export const Input = forwardRef( + ({ label, error, helperText, className = '', id, ...props }, ref) => { + const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') + + return ( +
+ {label && ( + + )} + + {error && ( + + )} + {helperText && !error && ( +

+ {helperText} +

+ )} +
+ ) + } +) + +Input.displayName = 'Input' diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..515b086 --- /dev/null +++ b/src/components/LanguageSwitcher.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from 'react-i18next' +import { Globe } from 'lucide-react' + +export function LanguageSwitcher() { + const { i18n } = useTranslation() + + const toggleLanguage = () => { + const newLang = i18n.language === 'pt-BR' ? 'en' : 'pt-BR' + i18n.changeLanguage(newLang) + } + + return ( + + ) +} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..a91fad3 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,71 @@ +import { ReactNode, useEffect } from 'react' +import { X } from 'lucide-react' + +interface ModalProps { + isOpen: boolean + onClose: () => void + title: string + children: ReactNode + size?: 'sm' | 'md' | 'lg' +} + +const sizeClasses = { + sm: 'max-w-md', + md: 'max-w-2xl', + lg: 'max-w-4xl', +} + +export function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) { + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = 'unset' + } + return () => { + document.body.style.overflow = 'unset' + } + }, [isOpen]) + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + if (isOpen) { + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + } + }, [isOpen, onClose]) + + if (!isOpen) return null + + return ( +
+
+
+
+ + +
+
{children}
+
+
+ ) +} diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..3527e45 --- /dev/null +++ b/src/components/ProtectedRoute.tsx @@ -0,0 +1,25 @@ +import { Navigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { ReactNode } from 'react' + +interface ProtectedRouteProps { + children: ReactNode +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthenticated, loading } = useAuth() + + if (loading) { + return ( +
+
+
+ ) + } + + if (!isAuthenticated) { + return + } + + return <>{children} +} diff --git a/src/components/PublicFooter.tsx b/src/components/PublicFooter.tsx index 1cb8f59..78c7eda 100644 --- a/src/components/PublicFooter.tsx +++ b/src/components/PublicFooter.tsx @@ -1,30 +1,33 @@ import { Link } from 'react-router-dom' +import { useTranslation } from 'react-i18next' export function PublicFooter() { + const { t } = useTranslation() + return ( -