Initial commit - Frontend de template-saas migrado desde monorepo

Migración desde workspace-v2/projects/template-saas/apps/frontend
Este repositorio es parte del estándar multi-repo v2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:07:16 -06:00
parent cb173017b3
commit eb95d0e276
134 changed files with 26494 additions and 0 deletions

32
.dockerignore Normal file
View File

@ -0,0 +1,32 @@
# Dependencies
node_modules
npm-debug.log
# Build output
dist
# Development files
.env
.env.local
.env.*.local
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Documentation
README.md
# Docker
Dockerfile
.dockerignore

11
.env.example Normal file
View File

@ -0,0 +1,11 @@
# Template SaaS Frontend Configuration
# Copy this file to .env and adjust values
# API URL (proxied in development)
VITE_API_URL=/api/v1
# Tenant ID (for development - in production, extracted from subdomain)
VITE_TENANT_ID=default-tenant
# Stripe publishable key (for client-side Stripe.js)
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules/
dist/
coverage/
.env
.env.*
!.env.example
*.log
.DS_Store

48
Dockerfile Normal file
View File

@ -0,0 +1,48 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build arguments for environment variables
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
# Build the application
RUN npm run build
# Production stage - nginx
FROM nginx:alpine AS production
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built application
COPY --from=builder /app/dist /usr/share/nginx/html
# Create non-root user
RUN addgroup -g 1001 -S nginx-user && \
adduser -S nginx-user -u 1001 -G nginx-user && \
chown -R nginx-user:nginx-user /usr/share/nginx/html && \
chown -R nginx-user:nginx-user /var/cache/nginx && \
chown -R nginx-user:nginx-user /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R nginx-user:nginx-user /var/run/nginx.pid
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

44
eslint.config.js Normal file
View File

@ -0,0 +1,44 @@
import globals from 'globals';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
export default [
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.es2021,
},
},
plugins: {
'@typescript-eslint': tseslint,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...tseslint.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-empty-function': 'warn',
'no-console': 'warn',
'prefer-const': 'error',
},
},
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'tests/**', '*.config.js', '*.config.ts'],
},
];

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Template SaaS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

42
nginx.conf Normal file
View File

@ -0,0 +1,42 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Deny access to hidden files
location ~ /\. {
deny all;
}
}

6342
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
package.json Normal file
View File

@ -0,0 +1,61 @@
{
"name": "@template-saas/frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint src --report-unused-disable-directives",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:report": "playwright show-report"
},
"dependencies": {
"@tanstack/react-query": "^5.62.16",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.5.1",
"react-router-dom": "^7.1.1",
"recharts": "^2.15.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.10.5",
"@types/qrcode.react": "^1.0.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^4.0.17",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^17.0.0",
"jsdom": "^27.4.0",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.6",
"vitest": "^4.0.17"
}
}

64
playwright.config.ts Normal file
View File

@ -0,0 +1,64 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright E2E Test Configuration
* @see https://playwright.dev/docs/test-configuration
*/
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['list'],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3150',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
// Setup project for authentication
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
dependencies: ['setup'],
},
// Mobile viewport tests
{
name: 'mobile-chrome',
use: {
...devices['Pixel 5'],
},
dependencies: ['setup'],
},
],
// Web server configuration for local development
webServer: {
command: 'npm run dev',
url: 'http://localhost:3150',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

156
public/sw.js Normal file
View File

@ -0,0 +1,156 @@
// Service Worker for Push Notifications
// Template SaaS v2.0
const CACHE_NAME = 'template-saas-v1';
// Install event
self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker...');
self.skipWaiting();
});
// Activate event
self.addEventListener('activate', (event) => {
console.log('[SW] Service worker activated');
event.waitUntil(self.clients.claim());
});
// Push notification received
self.addEventListener('push', (event) => {
console.log('[SW] Push notification received');
let data = {
title: 'Nueva notificacion',
body: 'Tienes una nueva notificacion',
icon: '/icon-192.png',
badge: '/badge-72.png',
url: '/',
data: {},
};
if (event.data) {
try {
data = { ...data, ...event.data.json() };
} catch (e) {
console.error('[SW] Error parsing push data:', e);
}
}
const options = {
body: data.body,
icon: data.icon || '/icon-192.png',
badge: data.badge || '/badge-72.png',
vibrate: [100, 50, 100],
tag: data.notificationId || 'default',
renotify: true,
requireInteraction: data.requireInteraction || false,
data: {
url: data.url || '/',
notificationId: data.notificationId,
...data.data,
},
actions: data.actions || [
{ action: 'view', title: 'Ver' },
{ action: 'dismiss', title: 'Descartar' },
],
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Notification click handler
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification clicked:', event.action);
event.notification.close();
if (event.action === 'dismiss') {
return;
}
const url = event.notification.data?.url || '/';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Try to find an existing window and navigate
for (const client of clientList) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
return client.focus().then((focusedClient) => {
if (focusedClient && 'navigate' in focusedClient) {
return focusedClient.navigate(url);
}
});
}
}
// If no window found, open a new one
if (self.clients.openWindow) {
return self.clients.openWindow(url);
}
})
);
});
// Notification close handler
self.addEventListener('notificationclose', (event) => {
console.log('[SW] Notification closed');
// Optionally track notification dismissals
const notificationId = event.notification.data?.notificationId;
if (notificationId) {
// Could send analytics here
}
});
// Push subscription change handler
self.addEventListener('pushsubscriptionchange', (event) => {
console.log('[SW] Push subscription changed');
event.waitUntil(
self.registration.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: event.oldSubscription?.options?.applicationServerKey,
})
.then((subscription) => {
// Notify the app about the new subscription
return self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage({
type: 'PUSH_SUBSCRIPTION_CHANGED',
subscription: JSON.stringify(subscription),
});
});
});
})
);
});
// Message handler (for app communication)
self.addEventListener('message', (event) => {
console.log('[SW] Message received:', event.data);
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Background sync (for offline notifications)
self.addEventListener('sync', (event) => {
console.log('[SW] Background sync:', event.tag);
if (event.tag === 'sync-notifications') {
event.waitUntil(syncNotifications());
}
});
async function syncNotifications() {
// This would sync any pending notification actions
console.log('[SW] Syncing notifications...');
}
console.log('[SW] Service worker loaded');

3
public/vite.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>

After

Width:  |  Height:  |  Size: 236 B

12
src/App.tsx Normal file
View File

@ -0,0 +1,12 @@
import { BrowserRouter } from 'react-router-dom';
import { AppRouter } from './router';
function App() {
return (
<BrowserRouter>
<AppRouter />
</BrowserRouter>
);
}
export default App;

View File

@ -0,0 +1,219 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
// Mock the hook
vi.mock('@/hooks/useExport', () => ({
useExportReport: vi.fn(),
}));
// Import after mocking
import { ExportButton } from '@/components/common/ExportButton';
import { useExportReport } from '@/hooks/useExport';
const mockUseExportReport = vi.mocked(useExportReport);
const mockExportReport = vi.fn();
describe('ExportButton', () => {
beforeEach(() => {
vi.clearAllMocks();
mockExportReport.mockResolvedValue(undefined);
mockUseExportReport.mockReturnValue({
exportReport: mockExportReport,
isExporting: false,
} as any);
});
describe('rendering', () => {
it('should render export button with text', () => {
render(<ExportButton reportType="users" />);
expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument();
expect(screen.getByText('Export')).toBeInTheDocument();
});
it('should be enabled by default', () => {
render(<ExportButton reportType="users" />);
expect(screen.getByRole('button', { name: /export/i })).not.toBeDisabled();
});
it('should be disabled when disabled prop is true', () => {
render(<ExportButton reportType="users" disabled />);
expect(screen.getByRole('button', { name: /export/i })).toBeDisabled();
});
it('should apply custom className', () => {
const { container } = render(<ExportButton reportType="users" className="custom-class" />);
expect(container.firstChild).toHaveClass('custom-class');
});
});
describe('dropdown behavior', () => {
it('should not show dropdown initially', () => {
render(<ExportButton reportType="users" />);
expect(screen.queryByText('PDF')).not.toBeInTheDocument();
expect(screen.queryByText('Excel')).not.toBeInTheDocument();
expect(screen.queryByText('CSV')).not.toBeInTheDocument();
});
it('should show dropdown when clicked', () => {
render(<ExportButton reportType="users" />);
const button = screen.getByRole('button', { name: /export/i });
fireEvent.click(button);
expect(screen.getByText('PDF')).toBeInTheDocument();
expect(screen.getByText('Excel')).toBeInTheDocument();
expect(screen.getByText('CSV')).toBeInTheDocument();
});
it('should close dropdown when clicked again', () => {
render(<ExportButton reportType="users" />);
const button = screen.getByRole('button', { name: /export/i });
fireEvent.click(button);
expect(screen.getByText('PDF')).toBeInTheDocument();
fireEvent.click(button);
expect(screen.queryByText('PDF')).not.toBeInTheDocument();
});
it('should close dropdown when clicking outside', () => {
render(
<div>
<ExportButton reportType="users" />
<button>Outside</button>
</div>
);
const exportButton = screen.getByRole('button', { name: /export/i });
fireEvent.click(exportButton);
expect(screen.getByText('PDF')).toBeInTheDocument();
// Click outside
fireEvent.mouseDown(screen.getByRole('button', { name: /outside/i }));
expect(screen.queryByText('PDF')).not.toBeInTheDocument();
});
});
describe('export functionality', () => {
it('should call exportReport with PDF format', async () => {
render(<ExportButton reportType="users" />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));
fireEvent.click(screen.getByText('PDF'));
await waitFor(() => {
expect(mockExportReport).toHaveBeenCalledWith({
reportType: 'users',
format: 'pdf',
});
});
});
it('should call exportReport with Excel format', async () => {
render(<ExportButton reportType="billing" />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));
fireEvent.click(screen.getByText('Excel'));
await waitFor(() => {
expect(mockExportReport).toHaveBeenCalledWith({
reportType: 'billing',
format: 'excel',
});
});
});
it('should call exportReport with CSV format', async () => {
render(<ExportButton reportType="audit" />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));
fireEvent.click(screen.getByText('CSV'));
await waitFor(() => {
expect(mockExportReport).toHaveBeenCalledWith({
reportType: 'audit',
format: 'csv',
});
});
});
it('should close dropdown after selecting format', async () => {
render(<ExportButton reportType="users" />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));
expect(screen.getByText('PDF')).toBeInTheDocument();
fireEvent.click(screen.getByText('PDF'));
await waitFor(() => {
expect(screen.queryByText('PDF')).not.toBeInTheDocument();
});
});
});
describe('loading state', () => {
it('should disable button when exporting', () => {
mockUseExportReport.mockReturnValue({
exportReport: mockExportReport,
isExporting: true,
} as any);
render(<ExportButton reportType="users" />);
expect(screen.getByRole('button', { name: /export/i })).toBeDisabled();
});
it('should not open dropdown when exporting', () => {
mockUseExportReport.mockReturnValue({
exportReport: mockExportReport,
isExporting: true,
} as any);
render(<ExportButton reportType="users" />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));
expect(screen.queryByText('PDF')).not.toBeInTheDocument();
});
});
describe('report types', () => {
it('should support users report type', () => {
render(<ExportButton reportType="users" />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));
fireEvent.click(screen.getByText('PDF'));
expect(mockExportReport).toHaveBeenCalledWith(
expect.objectContaining({ reportType: 'users' })
);
});
it('should support billing report type', () => {
render(<ExportButton reportType="billing" />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));
fireEvent.click(screen.getByText('PDF'));
expect(mockExportReport).toHaveBeenCalledWith(
expect.objectContaining({ reportType: 'billing' })
);
});
it('should support audit report type', () => {
render(<ExportButton reportType="audit" />);
fireEvent.click(screen.getByRole('button', { name: /export/i }));
fireEvent.click(screen.getByText('PDF'));
expect(mockExportReport).toHaveBeenCalledWith(
expect.objectContaining({ reportType: 'audit' })
);
});
});
});

View File

@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Mock the hooks
vi.mock('@/hooks/useData', () => ({
useUnreadNotificationsCount: vi.fn(),
useNotifications: vi.fn(() => ({ data: { data: [] }, isLoading: false })),
useMarkNotificationAsRead: vi.fn(() => ({ mutate: vi.fn() })),
useMarkAllNotificationsAsRead: vi.fn(() => ({ mutate: vi.fn() })),
}));
// Import after mocking
import { NotificationBell } from '@/components/notifications/NotificationBell';
import { useUnreadNotificationsCount } from '@/hooks/useData';
const mockUseUnreadNotificationsCount = vi.mocked(useUnreadNotificationsCount);
// Test wrapper with providers
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
};
describe('NotificationBell', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseUnreadNotificationsCount.mockReturnValue({
data: { count: 0 },
isLoading: false,
error: null,
} as any);
});
describe('rendering', () => {
it('should render the bell button with notifications label', () => {
render(<NotificationBell />, { wrapper: createWrapper() });
// Use aria-label to find the specific bell button
const button = screen.getByRole('button', { name: /notifications/i });
expect(button).toBeInTheDocument();
});
it('should have accessible label when no unread notifications', () => {
mockUseUnreadNotificationsCount.mockReturnValue({
data: { count: 0 },
} as any);
render(<NotificationBell />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: 'Notifications' });
expect(button).toBeInTheDocument();
});
it('should show unread count in aria-label when there are unread notifications', () => {
mockUseUnreadNotificationsCount.mockReturnValue({
data: { count: 5 },
} as any);
render(<NotificationBell />, { wrapper: createWrapper() });
const button = screen.getByRole('button', { name: 'Notifications (5 unread)' });
expect(button).toBeInTheDocument();
});
});
describe('unread count badge', () => {
it('should not show badge when no unread notifications', () => {
mockUseUnreadNotificationsCount.mockReturnValue({
data: { count: 0 },
} as any);
render(<NotificationBell />, { wrapper: createWrapper() });
// Badge should not exist for 0 count
const bellButton = screen.getByRole('button', { name: /notifications/i });
expect(bellButton.querySelector('span')).toBeNull();
});
it('should show badge with count when there are unread notifications', () => {
mockUseUnreadNotificationsCount.mockReturnValue({
data: { count: 5 },
} as any);
render(<NotificationBell />, { wrapper: createWrapper() });
expect(screen.getByText('5')).toBeInTheDocument();
});
it('should show 99+ when count exceeds 99', () => {
mockUseUnreadNotificationsCount.mockReturnValue({
data: { count: 150 },
} as any);
render(<NotificationBell />, { wrapper: createWrapper() });
expect(screen.getByText('99+')).toBeInTheDocument();
});
it('should show exact count when count is 99', () => {
mockUseUnreadNotificationsCount.mockReturnValue({
data: { count: 99 },
} as any);
render(<NotificationBell />, { wrapper: createWrapper() });
expect(screen.getByText('99')).toBeInTheDocument();
});
it('should show 99+ when count is 100', () => {
mockUseUnreadNotificationsCount.mockReturnValue({
data: { count: 100 },
} as any);
render(<NotificationBell />, { wrapper: createWrapper() });
expect(screen.getByText('99+')).toBeInTheDocument();
});
});
describe('drawer interaction', () => {
it('should open drawer when bell is clicked', () => {
render(<NotificationBell />, { wrapper: createWrapper() });
const bellButton = screen.getByRole('button', { name: /notifications/i });
fireEvent.click(bellButton);
// Look for the drawer header text
expect(screen.getByText('Notifications')).toBeInTheDocument();
});
});
describe('edge cases', () => {
it('should handle undefined data gracefully', () => {
mockUseUnreadNotificationsCount.mockReturnValue({
data: undefined,
} as any);
render(<NotificationBell />, { wrapper: createWrapper() });
// Should still render without crashing
const button = screen.getByRole('button', { name: 'Notifications' });
expect(button).toBeInTheDocument();
});
it('should handle null count gracefully', () => {
mockUseUnreadNotificationsCount.mockReturnValue({
data: { count: null },
} as any);
render(<NotificationBell />, { wrapper: createWrapper() });
// Should still render without crashing
const button = screen.getByRole('button', { name: 'Notifications' });
expect(button).toBeInTheDocument();
});
});
});

62
src/__tests__/setup.ts Normal file
View File

@ -0,0 +1,62 @@
import '@testing-library/jest-dom';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
// Cleanup after each test
afterEach(() => {
cleanup();
});
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn(),
};
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
// Mock sessionStorage
const sessionStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn(),
};
Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock });
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));
// Suppress console errors in tests (optional)
vi.spyOn(console, 'error').mockImplementation(() => {});

View File

@ -0,0 +1,236 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { act } from '@testing-library/react';
import { useAuthStore, User } from '@/stores/auth.store';
// Reset store before each test
beforeEach(() => {
const { logout } = useAuthStore.getState();
act(() => {
logout();
});
vi.clearAllMocks();
});
describe('useAuthStore', () => {
const mockUser: User = {
id: 'user-123',
email: 'test@example.com',
first_name: 'John',
last_name: 'Doe',
role: 'admin',
tenant_id: 'tenant-456',
};
describe('initial state', () => {
it('should have null user by default', () => {
const { user } = useAuthStore.getState();
expect(user).toBeNull();
});
it('should have null tokens by default', () => {
const { accessToken, refreshToken } = useAuthStore.getState();
expect(accessToken).toBeNull();
expect(refreshToken).toBeNull();
});
it('should not be authenticated by default', () => {
const { isAuthenticated } = useAuthStore.getState();
expect(isAuthenticated).toBe(false);
});
it('should not be loading by default', () => {
const { isLoading } = useAuthStore.getState();
expect(isLoading).toBe(false);
});
});
describe('setUser', () => {
it('should set user', () => {
const { setUser } = useAuthStore.getState();
act(() => {
setUser(mockUser);
});
const { user } = useAuthStore.getState();
expect(user).toEqual(mockUser);
});
it('should update existing user', () => {
const { setUser } = useAuthStore.getState();
act(() => {
setUser(mockUser);
});
const updatedUser = { ...mockUser, first_name: 'Jane' };
act(() => {
setUser(updatedUser);
});
const { user } = useAuthStore.getState();
expect(user?.first_name).toBe('Jane');
});
});
describe('setTokens', () => {
it('should set access and refresh tokens', () => {
const { setTokens } = useAuthStore.getState();
act(() => {
setTokens('access-token-123', 'refresh-token-456');
});
const { accessToken, refreshToken } = useAuthStore.getState();
expect(accessToken).toBe('access-token-123');
expect(refreshToken).toBe('refresh-token-456');
});
it('should update existing tokens', () => {
const { setTokens } = useAuthStore.getState();
act(() => {
setTokens('old-access', 'old-refresh');
setTokens('new-access', 'new-refresh');
});
const { accessToken, refreshToken } = useAuthStore.getState();
expect(accessToken).toBe('new-access');
expect(refreshToken).toBe('new-refresh');
});
});
describe('login', () => {
it('should set user, tokens, and authentication status', () => {
const { login } = useAuthStore.getState();
act(() => {
login(mockUser, 'access-token', 'refresh-token');
});
const { user, accessToken, refreshToken, isAuthenticated, isLoading } = useAuthStore.getState();
expect(user).toEqual(mockUser);
expect(accessToken).toBe('access-token');
expect(refreshToken).toBe('refresh-token');
expect(isAuthenticated).toBe(true);
expect(isLoading).toBe(false);
});
it('should stop loading after login', () => {
const { setLoading, login } = useAuthStore.getState();
act(() => {
setLoading(true);
});
expect(useAuthStore.getState().isLoading).toBe(true);
act(() => {
login(mockUser, 'access', 'refresh');
});
expect(useAuthStore.getState().isLoading).toBe(false);
});
});
describe('logout', () => {
it('should clear all authentication state', () => {
const { login, logout } = useAuthStore.getState();
act(() => {
login(mockUser, 'access-token', 'refresh-token');
});
expect(useAuthStore.getState().isAuthenticated).toBe(true);
act(() => {
logout();
});
const { user, accessToken, refreshToken, isAuthenticated } = useAuthStore.getState();
expect(user).toBeNull();
expect(accessToken).toBeNull();
expect(refreshToken).toBeNull();
expect(isAuthenticated).toBe(false);
});
it('should work even if not logged in', () => {
const { logout } = useAuthStore.getState();
expect(() => {
act(() => {
logout();
});
}).not.toThrow();
});
});
describe('setLoading', () => {
it('should set loading to true', () => {
const { setLoading } = useAuthStore.getState();
act(() => {
setLoading(true);
});
expect(useAuthStore.getState().isLoading).toBe(true);
});
it('should set loading to false', () => {
const { setLoading } = useAuthStore.getState();
act(() => {
setLoading(true);
setLoading(false);
});
expect(useAuthStore.getState().isLoading).toBe(false);
});
});
describe('persistence', () => {
it('should have persist configuration for auth-storage', () => {
// The store is configured with persist middleware
// We can verify the store name is correct by checking localStorage calls
expect(useAuthStore.persist).toBeDefined();
});
});
describe('state isolation', () => {
it('should not affect other state when setting user', () => {
const { login, setUser } = useAuthStore.getState();
act(() => {
login(mockUser, 'access', 'refresh');
});
const updatedUser = { ...mockUser, email: 'new@example.com' };
act(() => {
setUser(updatedUser);
});
const { accessToken, refreshToken, isAuthenticated } = useAuthStore.getState();
expect(accessToken).toBe('access');
expect(refreshToken).toBe('refresh');
expect(isAuthenticated).toBe(true);
});
it('should not affect other state when setting tokens', () => {
const { login, setTokens } = useAuthStore.getState();
act(() => {
login(mockUser, 'access', 'refresh');
});
act(() => {
setTokens('new-access', 'new-refresh');
});
const { user, isAuthenticated } = useAuthStore.getState();
expect(user).toEqual(mockUser);
expect(isAuthenticated).toBe(true);
});
});
});

View File

@ -0,0 +1,204 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { act } from '@testing-library/react';
import { useUIStore } from '@/stores/ui.store';
// Reset store before each test
beforeEach(() => {
act(() => {
useUIStore.setState({
sidebarOpen: true,
theme: 'system',
});
});
vi.clearAllMocks();
});
describe('useUIStore', () => {
describe('initial state', () => {
it('should have sidebar open by default', () => {
const { sidebarOpen } = useUIStore.getState();
expect(sidebarOpen).toBe(true);
});
it('should have system theme by default', () => {
const { theme } = useUIStore.getState();
expect(theme).toBe('system');
});
});
describe('toggleSidebar', () => {
it('should toggle sidebar from open to closed', () => {
const { toggleSidebar } = useUIStore.getState();
act(() => {
toggleSidebar();
});
expect(useUIStore.getState().sidebarOpen).toBe(false);
});
it('should toggle sidebar from closed to open', () => {
act(() => {
useUIStore.setState({ sidebarOpen: false });
});
const { toggleSidebar } = useUIStore.getState();
act(() => {
toggleSidebar();
});
expect(useUIStore.getState().sidebarOpen).toBe(true);
});
it('should toggle multiple times correctly', () => {
const { toggleSidebar } = useUIStore.getState();
act(() => {
toggleSidebar(); // false
toggleSidebar(); // true
toggleSidebar(); // false
});
expect(useUIStore.getState().sidebarOpen).toBe(false);
});
});
describe('setSidebarOpen', () => {
it('should set sidebar to open', () => {
act(() => {
useUIStore.setState({ sidebarOpen: false });
});
const { setSidebarOpen } = useUIStore.getState();
act(() => {
setSidebarOpen(true);
});
expect(useUIStore.getState().sidebarOpen).toBe(true);
});
it('should set sidebar to closed', () => {
const { setSidebarOpen } = useUIStore.getState();
act(() => {
setSidebarOpen(false);
});
expect(useUIStore.getState().sidebarOpen).toBe(false);
});
it('should not change state when setting same value', () => {
const { setSidebarOpen } = useUIStore.getState();
act(() => {
setSidebarOpen(true);
setSidebarOpen(true);
});
expect(useUIStore.getState().sidebarOpen).toBe(true);
});
});
describe('setTheme', () => {
beforeEach(() => {
// Mock document.documentElement
document.documentElement.classList.remove('light', 'dark');
});
afterEach(() => {
document.documentElement.classList.remove('light', 'dark');
});
it('should set theme to light', () => {
const { setTheme } = useUIStore.getState();
act(() => {
setTheme('light');
});
expect(useUIStore.getState().theme).toBe('light');
expect(document.documentElement.classList.contains('light')).toBe(true);
});
it('should set theme to dark', () => {
const { setTheme } = useUIStore.getState();
act(() => {
setTheme('dark');
});
expect(useUIStore.getState().theme).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
it('should set theme to system', () => {
const { setTheme } = useUIStore.getState();
act(() => {
setTheme('system');
});
expect(useUIStore.getState().theme).toBe('system');
});
it('should remove previous theme class when changing', () => {
const { setTheme } = useUIStore.getState();
act(() => {
setTheme('dark');
});
expect(document.documentElement.classList.contains('dark')).toBe(true);
act(() => {
setTheme('light');
});
expect(document.documentElement.classList.contains('dark')).toBe(false);
expect(document.documentElement.classList.contains('light')).toBe(true);
});
it('should apply system preference when theme is system', () => {
const { setTheme } = useUIStore.getState();
// matchMedia is mocked to return matches: false (light mode)
act(() => {
setTheme('system');
});
// Should apply light since matchMedia mock returns matches: false
expect(document.documentElement.classList.contains('light')).toBe(true);
});
});
describe('persistence', () => {
it('should have persist configuration for ui-storage', () => {
expect(useUIStore.persist).toBeDefined();
});
});
describe('state isolation', () => {
it('should not affect sidebar when changing theme', () => {
const { setTheme } = useUIStore.getState();
act(() => {
setTheme('dark');
});
expect(useUIStore.getState().sidebarOpen).toBe(true);
});
it('should not affect theme when changing sidebar', () => {
const { toggleSidebar, setTheme } = useUIStore.getState();
act(() => {
setTheme('dark');
toggleSidebar();
});
expect(useUIStore.getState().theme).toBe('dark');
});
});
});

View File

@ -0,0 +1,309 @@
import { describe, it, expect } from 'vitest';
// Test utility functions commonly used in the frontend
describe('Utility Functions', () => {
describe('formatNumber', () => {
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
}
return num.toString();
}
it('should format numbers less than 1000 as is', () => {
expect(formatNumber(0)).toBe('0');
expect(formatNumber(100)).toBe('100');
expect(formatNumber(999)).toBe('999');
});
it('should format thousands with K suffix', () => {
expect(formatNumber(1000)).toBe('1K');
expect(formatNumber(1500)).toBe('1.5K');
expect(formatNumber(10000)).toBe('10K');
expect(formatNumber(999999)).toBe('1000K');
});
it('should format millions with M suffix', () => {
expect(formatNumber(1000000)).toBe('1M');
expect(formatNumber(1500000)).toBe('1.5M');
expect(formatNumber(10000000)).toBe('10M');
});
});
describe('formatCurrency', () => {
function formatCurrency(amount: number, currency: string = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount / 100); // Convert cents to dollars
}
it('should format USD amounts', () => {
expect(formatCurrency(0)).toBe('$0.00');
expect(formatCurrency(100)).toBe('$1.00');
expect(formatCurrency(1999)).toBe('$19.99');
expect(formatCurrency(99900)).toBe('$999.00');
});
it('should format different currencies', () => {
expect(formatCurrency(1000, 'EUR')).toBe('€10.00');
expect(formatCurrency(1000, 'GBP')).toBe('£10.00');
});
it('should handle large amounts', () => {
expect(formatCurrency(10000000)).toBe('$100,000.00');
});
});
describe('truncateText', () => {
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}
it('should return text as is if shorter than maxLength', () => {
expect(truncateText('Hello', 10)).toBe('Hello');
expect(truncateText('Hello', 5)).toBe('Hello');
});
it('should truncate text and add ellipsis if longer', () => {
expect(truncateText('Hello World', 8)).toBe('Hello...');
expect(truncateText('This is a long text', 10)).toBe('This is...');
});
it('should handle empty strings', () => {
expect(truncateText('', 10)).toBe('');
});
it('should handle edge cases', () => {
expect(truncateText('abc', 3)).toBe('abc');
expect(truncateText('abcd', 3)).toBe('...');
});
});
describe('slugify', () => {
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
it('should convert text to lowercase', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('should replace spaces with hyphens', () => {
expect(slugify('hello world')).toBe('hello-world');
});
it('should remove special characters', () => {
expect(slugify('hello@world!')).toBe('helloworld');
});
it('should handle multiple spaces', () => {
expect(slugify('hello world')).toBe('hello-world');
});
it('should trim leading/trailing whitespace', () => {
expect(slugify(' hello world ')).toBe('hello-world');
});
it('should handle underscores', () => {
expect(slugify('hello_world')).toBe('hello-world');
});
});
describe('classNames (clsx alternative)', () => {
function classNames(...classes: (string | undefined | null | boolean)[]): string {
return classes.filter(Boolean).join(' ');
}
it('should join class names', () => {
expect(classNames('a', 'b', 'c')).toBe('a b c');
});
it('should filter out falsy values', () => {
expect(classNames('a', undefined, 'b', null, 'c')).toBe('a b c');
expect(classNames('a', false, 'b', '', 'c')).toBe('a b c');
});
it('should handle empty input', () => {
expect(classNames()).toBe('');
});
it('should handle all falsy values', () => {
expect(classNames(undefined, null, false)).toBe('');
});
});
describe('debounce', () => {
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
it('should delay function execution', async () => {
let callCount = 0;
const fn = debounce(() => callCount++, 50);
fn();
expect(callCount).toBe(0);
await new Promise((r) => setTimeout(r, 100));
expect(callCount).toBe(1);
});
it('should only execute once for rapid calls', async () => {
let callCount = 0;
const fn = debounce(() => callCount++, 50);
fn();
fn();
fn();
await new Promise((r) => setTimeout(r, 100));
expect(callCount).toBe(1);
});
});
describe('parseQueryParams', () => {
function parseQueryParams(url: string): Record<string, string> {
const params: Record<string, string> = {};
const queryString = url.split('?')[1];
if (!queryString) return params;
queryString.split('&').forEach((param) => {
const [key, value] = param.split('=');
if (key) {
params[decodeURIComponent(key)] = decodeURIComponent(value || '');
}
});
return params;
}
it('should parse query parameters', () => {
const result = parseQueryParams('https://example.com?foo=bar&baz=qux');
expect(result).toEqual({ foo: 'bar', baz: 'qux' });
});
it('should handle URL without query params', () => {
const result = parseQueryParams('https://example.com');
expect(result).toEqual({});
});
it('should handle encoded parameters', () => {
const result = parseQueryParams('https://example.com?name=John%20Doe');
expect(result).toEqual({ name: 'John Doe' });
});
it('should handle empty values', () => {
const result = parseQueryParams('https://example.com?empty=');
expect(result).toEqual({ empty: '' });
});
});
describe('formatDate', () => {
function formatDate(date: Date | string, format: 'short' | 'long' = 'short'): string {
const d = typeof date === 'string' ? new Date(date) : date;
if (format === 'long') {
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
it('should format date in short format', () => {
// Use explicit UTC date to avoid timezone issues
const result = formatDate(new Date(2024, 0, 15)); // Month is 0-indexed
expect(result).toContain('Jan');
expect(result).toContain('2024');
});
it('should format date string', () => {
const result = formatDate(new Date(2024, 0, 15));
expect(result).toContain('Jan');
});
it('should format date in long format', () => {
const result = formatDate(new Date(2024, 0, 15, 10, 30, 0), 'long');
expect(result).toContain('January');
expect(result).toContain('2024');
});
});
describe('isValidEmail', () => {
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
it('should validate correct emails', () => {
expect(isValidEmail('test@example.com')).toBe(true);
expect(isValidEmail('user.name@domain.co.uk')).toBe(true);
expect(isValidEmail('user+tag@example.com')).toBe(true);
});
it('should reject invalid emails', () => {
expect(isValidEmail('invalid')).toBe(false);
expect(isValidEmail('@example.com')).toBe(false);
expect(isValidEmail('test@')).toBe(false);
expect(isValidEmail('test @example.com')).toBe(false);
});
});
describe('getInitials', () => {
function getInitials(firstName?: string, lastName?: string): string {
const first = firstName?.charAt(0)?.toUpperCase() || '';
const last = lastName?.charAt(0)?.toUpperCase() || '';
return first + last || '?';
}
it('should return initials from first and last name', () => {
expect(getInitials('John', 'Doe')).toBe('JD');
});
it('should handle missing last name', () => {
expect(getInitials('John')).toBe('J');
});
it('should handle missing first name', () => {
expect(getInitials(undefined, 'Doe')).toBe('D');
});
it('should return ? for no names', () => {
expect(getInitials()).toBe('?');
expect(getInitials('', '')).toBe('?');
});
it('should handle lowercase names', () => {
expect(getInitials('john', 'doe')).toBe('JD');
});
});
});

View File

@ -0,0 +1,232 @@
import { useState, useRef, useEffect, FormEvent } from 'react';
import { Send, Trash2, Settings2, Loader2 } from 'lucide-react';
import clsx from 'clsx';
import { ChatMessage, ChatMessageProps } from './ChatMessage';
import { useAIChat, useAIConfig, useAIModels } from '@/hooks/useAI';
import { ChatMessage as ChatMessageType } from '@/services/api';
interface Message extends ChatMessageProps {
id: string;
}
interface AIChatProps {
systemPrompt?: string;
placeholder?: string;
className?: string;
showModelSelector?: boolean;
onUsageUpdate?: () => void;
}
export function AIChat({
systemPrompt,
placeholder = 'Type your message...',
className,
showModelSelector = false,
onUsageUpdate,
}: AIChatProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [selectedModel, setSelectedModel] = useState<string | undefined>();
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const { data: config } = useAIConfig();
const { data: models } = useAIModels();
const chatMutation = useAIChat();
// Set default model from config
useEffect(() => {
if (config?.default_model && !selectedModel) {
setSelectedModel(config.default_model);
}
}, [config, selectedModel]);
// Auto scroll to bottom
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto resize textarea
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || chatMutation.isPending) return;
const userMessage: Message = {
id: `user-${Date.now()}`,
role: 'user',
content: trimmedInput,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
// Reset textarea height
if (inputRef.current) {
inputRef.current.style.height = 'auto';
}
// Build message history for API
const apiMessages: ChatMessageType[] = [];
// Add system prompt if provided
if (systemPrompt) {
apiMessages.push({ role: 'system', content: systemPrompt });
} else if (config?.system_prompt) {
apiMessages.push({ role: 'system', content: config.system_prompt });
}
// Add conversation history
messages.forEach((msg) => {
if (msg.role !== 'system') {
apiMessages.push({ role: msg.role, content: msg.content });
}
});
// Add current user message
apiMessages.push({ role: 'user', content: trimmedInput });
// Add loading message
const loadingId = `assistant-${Date.now()}`;
setMessages((prev) => [
...prev,
{ id: loadingId, role: 'assistant', content: '', isLoading: true },
]);
try {
const response = await chatMutation.mutateAsync({
messages: apiMessages,
model: selectedModel,
temperature: config?.temperature,
max_tokens: config?.max_tokens,
});
// Replace loading message with actual response
setMessages((prev) =>
prev.map((msg) =>
msg.id === loadingId
? {
...msg,
content: response.choices[0]?.message?.content || 'No response',
isLoading: false,
timestamp: new Date(),
}
: msg
)
);
onUsageUpdate?.();
} catch {
// Remove loading message on error
setMessages((prev) => prev.filter((msg) => msg.id !== loadingId));
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
const clearChat = () => {
setMessages([]);
};
return (
<div className={clsx('flex flex-col bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700', className)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-2">
<Settings2 className="w-4 h-4 text-secondary-500" />
<span className="text-sm font-medium text-secondary-900 dark:text-white">
AI Assistant
</span>
</div>
<div className="flex items-center gap-2">
{showModelSelector && models && models.length > 0 && (
<select
value={selectedModel || ''}
onChange={(e) => setSelectedModel(e.target.value)}
className="text-xs bg-secondary-100 dark:bg-secondary-700 border-0 rounded px-2 py-1 text-secondary-700 dark:text-secondary-300"
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
)}
<button
onClick={clearChat}
disabled={messages.length === 0}
className="p-1.5 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 disabled:opacity-50 disabled:cursor-not-allowed"
title="Clear chat"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto min-h-[300px] max-h-[500px]">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-secondary-400 py-12">
<Settings2 className="w-12 h-12 mb-3 opacity-50" />
<p className="text-sm">Start a conversation</p>
<p className="text-xs mt-1">Type a message below to begin</p>
</div>
) : (
<div className="py-2">
{messages.map((message) => (
<ChatMessage key={message.id} {...message} />
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input Area */}
<form onSubmit={handleSubmit} className="border-t border-secondary-200 dark:border-secondary-700 p-3">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={chatMutation.isPending || !config?.is_enabled}
rows={1}
className="flex-1 resize-none bg-secondary-50 dark:bg-secondary-700 border-0 rounded-lg px-4 py-2.5 text-sm text-secondary-900 dark:text-white placeholder-secondary-400 focus:ring-2 focus:ring-primary-500 disabled:opacity-50"
/>
<button
type="submit"
disabled={!input.trim() || chatMutation.isPending || !config?.is_enabled}
className="flex-shrink-0 p-2.5 bg-primary-500 hover:bg-primary-600 disabled:bg-secondary-300 dark:disabled:bg-secondary-600 text-white rounded-lg transition-colors disabled:cursor-not-allowed"
>
{chatMutation.isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
{!config?.is_enabled && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
AI is currently disabled. Enable it in settings to start chatting.
</p>
)}
</form>
</div>
);
}

View File

@ -0,0 +1,300 @@
import { useForm } from 'react-hook-form';
import { Bot, Zap, AlertCircle, Loader2, CheckCircle, XCircle } from 'lucide-react';
import clsx from 'clsx';
import { useAIConfig, useUpdateAIConfig, useAIModels, useCurrentAIUsage, useAIHealth } from '@/hooks/useAI';
interface AIConfigForm {
is_enabled: boolean;
default_model: string;
temperature: number;
max_tokens: number;
system_prompt: string;
allow_custom_prompts: boolean;
log_conversations: boolean;
}
export function AISettings() {
const { data: config, isLoading: configLoading } = useAIConfig();
const { data: models } = useAIModels();
const { data: usage } = useCurrentAIUsage();
const { data: health } = useAIHealth();
const updateConfig = useUpdateAIConfig();
const {
register,
handleSubmit,
watch,
formState: { isDirty },
} = useForm<AIConfigForm>({
values: config ? {
is_enabled: config.is_enabled,
default_model: config.default_model,
temperature: config.temperature,
max_tokens: config.max_tokens,
system_prompt: config.system_prompt || '',
allow_custom_prompts: config.allow_custom_prompts,
log_conversations: config.log_conversations,
} : undefined,
});
const isEnabled = watch('is_enabled');
const temperature = watch('temperature');
const onSubmit = async (data: AIConfigForm) => {
await updateConfig.mutateAsync(data);
};
if (configLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
</div>
);
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 4,
}).format(amount);
};
return (
<div className="space-y-6">
{/* Status Card */}
<div className="card">
<div className="card-header flex items-center gap-3">
<Bot className="w-5 h-5 text-secondary-500" />
<div>
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
AI Integration
</h2>
<p className="text-sm text-secondary-500">
Configure AI assistant settings for your organization
</p>
</div>
</div>
<div className="card-body">
{/* Health Status */}
<div className="flex items-center justify-between p-4 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg mb-4">
<div className="flex items-center gap-3">
<div className={clsx(
'w-10 h-10 rounded-lg flex items-center justify-center',
health?.status === 'healthy' ? 'bg-green-100 dark:bg-green-900/30' :
health?.status === 'degraded' ? 'bg-yellow-100 dark:bg-yellow-900/30' :
'bg-red-100 dark:bg-red-900/30'
)}>
{health?.status === 'healthy' ? (
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" />
) : health?.status === 'degraded' ? (
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
) : (
<XCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
)}
</div>
<div>
<p className="font-medium text-secondary-900 dark:text-white">
{health?.status === 'healthy' ? 'AI Service Operational' :
health?.status === 'degraded' ? 'AI Service Degraded' :
'AI Service Unavailable'}
</p>
<p className="text-sm text-secondary-500">
{health?.provider || 'OpenRouter'} - {health?.models_available || 0} models available
</p>
</div>
</div>
{health?.latency_ms && (
<span className="text-sm text-secondary-500">
{health.latency_ms}ms latency
</span>
)}
</div>
{/* Usage Stats */}
{usage && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
<p className="text-xs text-secondary-500 uppercase tracking-wide">Requests</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{usage.request_count.toLocaleString()}
</p>
</div>
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
<p className="text-xs text-secondary-500 uppercase tracking-wide">Tokens Used</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{usage.total_tokens.toLocaleString()}
</p>
</div>
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
<p className="text-xs text-secondary-500 uppercase tracking-wide">Cost (Month)</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{formatCurrency(usage.total_cost)}
</p>
</div>
<div className="p-3 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
<p className="text-xs text-secondary-500 uppercase tracking-wide">Avg Latency</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{Math.round(usage.avg_latency_ms)}ms
</p>
</div>
</div>
)}
</div>
</div>
{/* Configuration Form */}
<form onSubmit={handleSubmit(onSubmit)} className="card">
<div className="card-header flex items-center gap-3">
<Zap className="w-5 h-5 text-secondary-500" />
<div>
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Configuration
</h2>
<p className="text-sm text-secondary-500">
Customize AI behavior for your organization
</p>
</div>
</div>
<div className="card-body space-y-6">
{/* Enable AI */}
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-secondary-900 dark:text-white">Enable AI</p>
<p className="text-sm text-secondary-500">Allow AI features in your organization</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
{...register('is_enabled')}
/>
<div className="w-11 h-6 bg-secondary-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-secondary-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-secondary-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-secondary-600 peer-checked:bg-primary-500"></div>
</label>
</div>
{/* Model Selection */}
<div>
<label className="label">Default Model</label>
<select
className="input"
disabled={!isEnabled}
{...register('default_model')}
>
{models?.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.provider})
</option>
))}
</select>
<p className="text-xs text-secondary-500 mt-1">
This model will be used by default for all AI requests
</p>
</div>
{/* Temperature */}
<div>
<label className="label">Temperature: {temperature}</label>
<input
type="range"
min="0"
max="2"
step="0.1"
disabled={!isEnabled}
className="w-full h-2 bg-secondary-200 rounded-lg appearance-none cursor-pointer dark:bg-secondary-700 disabled:opacity-50"
{...register('temperature', { valueAsNumber: true })}
/>
<div className="flex justify-between text-xs text-secondary-500 mt-1">
<span>Precise (0)</span>
<span>Balanced (1)</span>
<span>Creative (2)</span>
</div>
</div>
{/* Max Tokens */}
<div>
<label className="label">Max Tokens</label>
<input
type="number"
className="input"
min="100"
max="128000"
disabled={!isEnabled}
{...register('max_tokens', { valueAsNumber: true })}
/>
<p className="text-xs text-secondary-500 mt-1">
Maximum number of tokens per response (100-128,000)
</p>
</div>
{/* System Prompt */}
<div>
<label className="label">System Prompt</label>
<textarea
className="input min-h-[100px]"
placeholder="You are a helpful assistant..."
disabled={!isEnabled}
{...register('system_prompt')}
/>
<p className="text-xs text-secondary-500 mt-1">
Custom instructions for the AI assistant
</p>
</div>
{/* Additional Settings */}
<div className="space-y-4 pt-4 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-secondary-900 dark:text-white">Allow Custom Prompts</p>
<p className="text-sm text-secondary-500">Let users override the system prompt</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
disabled={!isEnabled}
{...register('allow_custom_prompts')}
/>
<div className="w-11 h-6 bg-secondary-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-secondary-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-secondary-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-secondary-600 peer-checked:bg-primary-500 peer-disabled:opacity-50"></div>
</label>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-secondary-900 dark:text-white">Log Conversations</p>
<p className="text-sm text-secondary-500">Store conversation history for analytics</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
disabled={!isEnabled}
{...register('log_conversations')}
/>
<div className="w-11 h-6 bg-secondary-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-secondary-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-secondary-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-secondary-600 peer-checked:bg-primary-500 peer-disabled:opacity-50"></div>
</label>
</div>
</div>
{/* Submit Button */}
<div className="pt-4">
<button
type="submit"
disabled={!isDirty || updateConfig.isPending}
className="btn-primary"
>
{updateConfig.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Save Changes'
)}
</button>
</div>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,83 @@
import { Bot, User } from 'lucide-react';
import clsx from 'clsx';
export interface ChatMessageProps {
role: 'user' | 'assistant' | 'system';
content: string;
timestamp?: Date;
isLoading?: boolean;
}
export function ChatMessage({ role, content, timestamp, isLoading }: ChatMessageProps) {
const isUser = role === 'user';
const isSystem = role === 'system';
if (isSystem) {
return (
<div className="flex justify-center my-2">
<span className="text-xs text-secondary-500 bg-secondary-100 dark:bg-secondary-800 px-3 py-1 rounded-full">
{content}
</span>
</div>
);
}
return (
<div
className={clsx(
'flex gap-3 p-4',
isUser ? 'flex-row-reverse' : 'flex-row'
)}
>
{/* Avatar */}
<div
className={clsx(
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
isUser
? 'bg-primary-100 dark:bg-primary-900/30'
: 'bg-secondary-100 dark:bg-secondary-800'
)}
>
{isUser ? (
<User className="w-4 h-4 text-primary-600 dark:text-primary-400" />
) : (
<Bot className="w-4 h-4 text-secondary-600 dark:text-secondary-400" />
)}
</div>
{/* Message Content */}
<div
className={clsx(
'flex flex-col max-w-[80%]',
isUser ? 'items-end' : 'items-start'
)}
>
<div
className={clsx(
'rounded-2xl px-4 py-2',
isUser
? 'bg-primary-500 text-white rounded-tr-sm'
: 'bg-secondary-100 dark:bg-secondary-800 text-secondary-900 dark:text-white rounded-tl-sm'
)}
>
{isLoading ? (
<div className="flex items-center gap-1">
<span className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-current rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
) : (
<p className="text-sm whitespace-pre-wrap">{content}</p>
)}
</div>
{/* Timestamp */}
{timestamp && (
<span className="text-xs text-secondary-400 mt-1">
{timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,4 @@
export { AIChat } from './AIChat';
export { AISettings } from './AISettings';
export { ChatMessage } from './ChatMessage';
export type { ChatMessageProps } from './ChatMessage';

View File

@ -0,0 +1,104 @@
import { LucideIcon, TrendingUp, TrendingDown } from 'lucide-react';
import clsx from 'clsx';
export interface MetricCardProps {
title: string;
value: string | number;
change?: number;
trend?: 'up' | 'down';
icon?: LucideIcon;
isLoading?: boolean;
format?: 'number' | 'currency' | 'percentage';
subtitle?: string;
}
export function MetricCard({
title,
value,
change,
trend,
icon: Icon,
isLoading = false,
format = 'number',
subtitle,
}: MetricCardProps) {
// Format the value based on type
const formatValue = (val: string | number): string => {
if (typeof val === 'string') return val;
switch (format) {
case 'currency':
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(val);
case 'percentage':
return `${val.toFixed(1)}%`;
case 'number':
default:
return new Intl.NumberFormat('en-US').format(val);
}
};
// Format change percentage
const formatChange = (val: number): string => {
const sign = val >= 0 ? '+' : '';
return `${sign}${val.toFixed(1)}%`;
};
if (isLoading) {
return (
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<div className="animate-pulse space-y-4">
<div className="flex items-center justify-between">
<div className="h-10 w-10 bg-secondary-200 dark:bg-secondary-700 rounded-lg" />
<div className="h-5 w-16 bg-secondary-200 dark:bg-secondary-700 rounded" />
</div>
<div className="space-y-2">
<div className="h-8 w-24 bg-secondary-200 dark:bg-secondary-700 rounded" />
<div className="h-4 w-20 bg-secondary-200 dark:bg-secondary-700 rounded" />
</div>
</div>
</div>
);
}
const trendColor =
trend === 'up'
? 'text-green-600 dark:text-green-400'
: trend === 'down'
? 'text-red-600 dark:text-red-400'
: 'text-secondary-500 dark:text-secondary-400';
const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : null;
return (
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6 transition-shadow hover:shadow-md">
<div className="flex items-center justify-between">
{Icon && (
<div className="w-10 h-10 bg-primary-50 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<Icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
)}
{change !== undefined && (
<div className={clsx('flex items-center gap-1 text-sm font-medium', trendColor)}>
{TrendIcon && <TrendIcon className="w-4 h-4" />}
<span>{formatChange(change)}</span>
</div>
)}
</div>
<div className="mt-4">
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
{formatValue(value)}
</p>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">{title}</p>
{subtitle && (
<p className="text-xs text-secondary-500 dark:text-secondary-500 mt-1">{subtitle}</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,166 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
TooltipProps,
} from 'recharts';
import { format, parseISO } from 'date-fns';
export interface TrendDataPoint {
date: string;
value: number;
}
export interface TrendChartProps {
data: TrendDataPoint[];
title: string;
color?: string;
isLoading?: boolean;
height?: number;
showGrid?: boolean;
valueFormatter?: (value: number) => string;
dateFormat?: string;
}
// Custom tooltip component
function CustomTooltip({
active,
payload,
label,
valueFormatter,
dateFormat,
}: TooltipProps<number, string> & {
valueFormatter?: (value: number) => string;
dateFormat?: string;
}) {
if (!active || !payload || !payload.length) {
return null;
}
const value = payload[0].value as number;
const formattedDate = (() => {
try {
return format(parseISO(label), dateFormat || 'MMM d, yyyy');
} catch {
return label;
}
})();
return (
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-700 rounded-lg shadow-lg p-3">
<p className="text-xs text-secondary-500 dark:text-secondary-400 mb-1">{formattedDate}</p>
<p className="text-sm font-semibold text-secondary-900 dark:text-white">
{valueFormatter ? valueFormatter(value) : value.toLocaleString()}
</p>
</div>
);
}
export function TrendChart({
data,
title,
color = '#3b82f6', // primary blue
isLoading = false,
height = 300,
showGrid = true,
valueFormatter,
dateFormat = 'MMM d',
}: TrendChartProps) {
if (isLoading) {
return (
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<div className="animate-pulse space-y-4">
<div className="h-5 w-32 bg-secondary-200 dark:bg-secondary-700 rounded" />
<div
className="bg-secondary-200 dark:bg-secondary-700 rounded"
style={{ height: height - 40 }}
/>
</div>
</div>
);
}
// Format X axis dates
const formatXAxis = (dateStr: string): string => {
try {
return format(parseISO(dateStr), dateFormat);
} catch {
return dateStr;
}
};
// Calculate min and max for better Y axis
const values = data.map((d) => d.value);
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const padding = (maxValue - minValue) * 0.1 || 10;
const yMin = Math.max(0, Math.floor(minValue - padding));
const yMax = Math.ceil(maxValue + padding);
return (
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
{title}
</h3>
{data.length === 0 ? (
<div
className="flex items-center justify-center text-secondary-500 dark:text-secondary-400"
style={{ height: height - 40 }}
>
No data available
</div>
) : (
<ResponsiveContainer width="100%" height={height - 40}>
<LineChart data={data} margin={{ top: 5, right: 10, left: 10, bottom: 5 }}>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="currentColor"
className="text-secondary-200 dark:text-secondary-700"
/>
)}
<XAxis
dataKey="date"
tickFormatter={formatXAxis}
tick={{ fill: 'currentColor', fontSize: 12 }}
className="text-secondary-500 dark:text-secondary-400"
axisLine={{ stroke: 'currentColor' }}
tickLine={{ stroke: 'currentColor' }}
/>
<YAxis
domain={[yMin, yMax]}
tick={{ fill: 'currentColor', fontSize: 12 }}
className="text-secondary-500 dark:text-secondary-400"
axisLine={{ stroke: 'currentColor' }}
tickLine={{ stroke: 'currentColor' }}
tickFormatter={valueFormatter}
/>
<Tooltip
content={
<CustomTooltip valueFormatter={valueFormatter} dateFormat="MMMM d, yyyy" />
}
/>
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
dot={false}
activeDot={{
r: 6,
fill: color,
stroke: 'white',
strokeWidth: 2,
}}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
);
}

View File

@ -0,0 +1,2 @@
export * from './MetricCard';
export * from './TrendChart';

View File

@ -0,0 +1,168 @@
import { ActivityLog } from '@/services/api';
import { getActivityTypeLabel } from '@/hooks/useAudit';
import {
Eye,
Zap,
Search,
Download,
Upload,
Share2,
UserPlus,
Settings,
CreditCard,
DollarSign,
Activity,
} from 'lucide-react';
import clsx from 'clsx';
interface ActivityTimelineProps {
activities: ActivityLog[];
isLoading?: boolean;
}
const iconMap: Record<string, any> = {
page_view: Eye,
feature_use: Zap,
search: Search,
download: Download,
upload: Upload,
share: Share2,
invite: UserPlus,
settings_change: Settings,
subscription_change: CreditCard,
payment: DollarSign,
};
const colorMap: Record<string, string> = {
page_view: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
feature_use: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
search: 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400',
download: 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400',
upload: 'bg-cyan-100 text-cyan-600 dark:bg-cyan-900/30 dark:text-cyan-400',
share: 'bg-pink-100 text-pink-600 dark:bg-pink-900/30 dark:text-pink-400',
invite: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400',
settings_change: 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400',
subscription_change: 'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400',
payment: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400',
};
export function ActivityTimeline({ activities, isLoading }: ActivityTimelineProps) {
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3 animate-pulse">
<div className="w-10 h-10 rounded-full bg-secondary-200 dark:bg-secondary-700" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-secondary-200 dark:bg-secondary-700 rounded w-3/4" />
<div className="h-3 bg-secondary-200 dark:bg-secondary-700 rounded w-1/2" />
</div>
</div>
))}
</div>
);
}
if (activities.length === 0) {
return (
<div className="text-center py-8">
<Activity className="w-12 h-12 mx-auto text-secondary-400 mb-3" />
<p className="text-secondary-500">No recent activity</p>
</div>
);
}
const formatTime = (date: string) => {
const d = new Date(date);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
};
return (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-secondary-200 dark:bg-secondary-700" />
<div className="space-y-4">
{activities.map((activity) => {
const Icon = iconMap[activity.activity_type] || Activity;
const colorClass =
colorMap[activity.activity_type] ||
'bg-secondary-100 text-secondary-600 dark:bg-secondary-700 dark:text-secondary-400';
const userName = activity.user
? `${activity.user.first_name || ''} ${activity.user.last_name || ''}`.trim() ||
activity.user.email
: 'Unknown';
return (
<div key={activity.id} className="relative flex gap-4 pl-0">
{/* Icon */}
<div
className={clsx(
'relative z-10 flex items-center justify-center w-10 h-10 rounded-full',
colorClass
)}
>
<Icon className="w-5 h-5" />
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-4">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
{getActivityTypeLabel(activity.activity_type)}
</p>
{activity.description && (
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-0.5">
{activity.description}
</p>
)}
<p className="text-xs text-secondary-500 mt-1">
{userName}
{activity.resource_type && (
<span className="ml-1">
- {activity.resource_type.replace(/_/g, ' ')}
</span>
)}
</p>
</div>
<span className="text-xs text-secondary-400 whitespace-nowrap">
{formatTime(activity.created_at)}
</span>
</div>
{activity.metadata && Object.keys(activity.metadata).length > 0 && (
<div className="mt-2 p-2 bg-secondary-50 dark:bg-secondary-800/50 rounded text-xs">
{Object.entries(activity.metadata).map(([key, value]) => (
<div key={key} className="flex gap-2">
<span className="text-secondary-500">{key}:</span>
<span className="text-secondary-700 dark:text-secondary-300">
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,193 @@
import { useState } from 'react';
import { AuditAction, QueryAuditLogsParams } from '@/services/api';
import { getAuditActionLabel } from '@/hooks/useAudit';
import { Filter, X } from 'lucide-react';
import clsx from 'clsx';
interface AuditFiltersProps {
filters: QueryAuditLogsParams;
onFiltersChange: (filters: QueryAuditLogsParams) => void;
entityTypes?: string[];
}
const AUDIT_ACTIONS: AuditAction[] = [
'create',
'update',
'delete',
'read',
'login',
'logout',
'export',
'import',
];
export function AuditFilters({ filters, onFiltersChange, entityTypes = [] }: AuditFiltersProps) {
const [isOpen, setIsOpen] = useState(false);
const activeFiltersCount = [
filters.action,
filters.entity_type,
filters.user_id,
filters.from_date,
filters.to_date,
].filter(Boolean).length;
const handleChange = (key: keyof QueryAuditLogsParams, value: string | undefined) => {
onFiltersChange({
...filters,
[key]: value || undefined,
page: 1, // Reset to first page on filter change
});
};
const clearFilters = () => {
onFiltersChange({ page: 1, limit: filters.limit });
};
return (
<div className="space-y-3">
{/* Filter toggle button */}
<div className="flex items-center justify-between">
<button
onClick={() => setIsOpen(!isOpen)}
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isOpen || activeFiltersCount > 0
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
)}
>
<Filter className="w-4 h-4" />
Filters
{activeFiltersCount > 0 && (
<span className="px-1.5 py-0.5 bg-primary-600 text-white text-xs rounded-full">
{activeFiltersCount}
</span>
)}
</button>
{activeFiltersCount > 0 && (
<button
onClick={clearFilters}
className="flex items-center gap-1 text-sm text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300"
>
<X className="w-4 h-4" />
Clear all
</button>
)}
</div>
{/* Filter panel */}
{isOpen && (
<div className="p-4 bg-secondary-50 dark:bg-secondary-800/50 rounded-lg border border-secondary-200 dark:border-secondary-700">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Action filter */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Action
</label>
<select
value={filters.action || ''}
onChange={(e) => handleChange('action', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All actions</option>
{AUDIT_ACTIONS.map((action) => (
<option key={action} value={action}>
{getAuditActionLabel(action)}
</option>
))}
</select>
</div>
{/* Entity type filter */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Entity Type
</label>
<select
value={filters.entity_type || ''}
onChange={(e) => handleChange('entity_type', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All entities</option>
{entityTypes.map((type) => (
<option key={type} value={type}>
{type.replace(/_/g, ' ')}
</option>
))}
</select>
</div>
{/* From date filter */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
From Date
</label>
<input
type="date"
value={filters.from_date || ''}
onChange={(e) => handleChange('from_date', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* To date filter */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
To Date
</label>
<input
type="date"
value={filters.to_date || ''}
onChange={(e) => handleChange('to_date', e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
</div>
)}
{/* Active filter badges */}
{activeFiltersCount > 0 && !isOpen && (
<div className="flex flex-wrap gap-2">
{filters.action && (
<FilterBadge
label={`Action: ${getAuditActionLabel(filters.action)}`}
onRemove={() => handleChange('action', undefined)}
/>
)}
{filters.entity_type && (
<FilterBadge
label={`Entity: ${filters.entity_type}`}
onRemove={() => handleChange('entity_type', undefined)}
/>
)}
{filters.from_date && (
<FilterBadge
label={`From: ${filters.from_date}`}
onRemove={() => handleChange('from_date', undefined)}
/>
)}
{filters.to_date && (
<FilterBadge
label={`To: ${filters.to_date}`}
onRemove={() => handleChange('to_date', undefined)}
/>
)}
</div>
)}
</div>
);
}
function FilterBadge({ label, onRemove }: { label: string; onRemove: () => void }) {
return (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 rounded-full text-sm">
{label}
<button onClick={onRemove} className="hover:text-primary-900 dark:hover:text-primary-200">
<X className="w-3 h-3" />
</button>
</span>
);
}

View File

@ -0,0 +1,203 @@
import { useState } from 'react';
import { AuditLog } from '@/services/api';
import { getAuditActionLabel, getAuditActionColor } from '@/hooks/useAudit';
import { ChevronDown, ChevronRight, User, Clock, Globe, Code } from 'lucide-react';
import clsx from 'clsx';
interface AuditLogRowProps {
log: AuditLog;
onSelect?: (log: AuditLog) => void;
}
export function AuditLogRow({ log, onSelect }: AuditLogRowProps) {
const [expanded, setExpanded] = useState(false);
const hasChanges = log.old_values || log.new_values;
const userName = log.user
? `${log.user.first_name || ''} ${log.user.last_name || ''}`.trim() || log.user.email
: 'System';
const formatDate = (date: string) => {
return new Date(date).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="border-b border-secondary-200 dark:border-secondary-700 last:border-b-0">
<div
className={clsx(
'flex items-center gap-4 px-4 py-3 hover:bg-secondary-50 dark:hover:bg-secondary-800/50 cursor-pointer',
expanded && 'bg-secondary-50 dark:bg-secondary-800/50'
)}
onClick={() => setExpanded(!expanded)}
>
<button className="p-1 text-secondary-400">
{expanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
<span
className={clsx(
'px-2 py-1 rounded-full text-xs font-medium',
getAuditActionColor(log.action)
)}
>
{getAuditActionLabel(log.action)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-secondary-900 dark:text-secondary-100">
{log.entity_type}
</span>
{log.entity_id && (
<span className="text-xs text-secondary-500 font-mono truncate max-w-[120px]">
{log.entity_id.slice(0, 8)}...
</span>
)}
</div>
{log.description && (
<p className="text-sm text-secondary-500 truncate">{log.description}</p>
)}
</div>
<div className="flex items-center gap-2 text-sm text-secondary-500">
<User className="w-4 h-4" />
<span className="max-w-[100px] truncate">{userName}</span>
</div>
<div className="flex items-center gap-1 text-sm text-secondary-400">
<Clock className="w-4 h-4" />
{formatDate(log.created_at)}
</div>
</div>
{expanded && (
<div className="px-4 py-3 bg-secondary-50 dark:bg-secondary-800/30 border-t border-secondary-200 dark:border-secondary-700">
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
<div>
<span className="text-secondary-500">User:</span>{' '}
<span className="text-secondary-900 dark:text-secondary-100">
{log.user?.email || 'System'}
</span>
</div>
<div>
<span className="text-secondary-500">Entity ID:</span>{' '}
<span className="text-secondary-900 dark:text-secondary-100 font-mono text-xs">
{log.entity_id || 'N/A'}
</span>
</div>
{log.ip_address && (
<div className="flex items-center gap-1">
<Globe className="w-4 h-4 text-secondary-400" />
<span className="text-secondary-500">IP:</span>{' '}
<span className="text-secondary-900 dark:text-secondary-100">{log.ip_address}</span>
</div>
)}
{log.endpoint && (
<div className="flex items-center gap-1">
<Code className="w-4 h-4 text-secondary-400" />
<span className="text-secondary-500">Endpoint:</span>{' '}
<span className="text-secondary-900 dark:text-secondary-100 font-mono text-xs">
{log.http_method} {log.endpoint}
</span>
</div>
)}
{log.duration_ms && (
<div>
<span className="text-secondary-500">Duration:</span>{' '}
<span className="text-secondary-900 dark:text-secondary-100">{log.duration_ms}ms</span>
</div>
)}
{log.response_status && (
<div>
<span className="text-secondary-500">Status:</span>{' '}
<span
className={clsx(
'font-medium',
log.response_status < 400
? 'text-green-600'
: log.response_status < 500
? 'text-yellow-600'
: 'text-red-600'
)}
>
{log.response_status}
</span>
</div>
)}
</div>
{hasChanges && (
<div className="space-y-2">
{log.changed_fields && log.changed_fields.length > 0 && (
<div>
<span className="text-xs font-medium text-secondary-500 uppercase">
Changed Fields:
</span>
<div className="flex flex-wrap gap-1 mt-1">
{log.changed_fields.map((field) => (
<span
key={field}
className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs"
>
{field}
</span>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{log.old_values && Object.keys(log.old_values).length > 0 && (
<div>
<span className="text-xs font-medium text-secondary-500 uppercase">
Old Values:
</span>
<pre className="mt-1 p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs overflow-auto max-h-32">
{JSON.stringify(log.old_values, null, 2)}
</pre>
</div>
)}
{log.new_values && Object.keys(log.new_values).length > 0 && (
<div>
<span className="text-xs font-medium text-secondary-500 uppercase">
New Values:
</span>
<pre className="mt-1 p-2 bg-green-50 dark:bg-green-900/20 rounded text-xs overflow-auto max-h-32">
{JSON.stringify(log.new_values, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
{log.metadata && Object.keys(log.metadata).length > 0 && (
<div className="mt-2">
<span className="text-xs font-medium text-secondary-500 uppercase">Metadata:</span>
<pre className="mt-1 p-2 bg-secondary-100 dark:bg-secondary-700 rounded text-xs overflow-auto max-h-24">
{JSON.stringify(log.metadata, null, 2)}
</pre>
</div>
)}
{onSelect && (
<button
onClick={(e) => {
e.stopPropagation();
onSelect(log);
}}
className="mt-3 text-sm text-primary-600 hover:text-primary-700"
>
View full details
</button>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,165 @@
import { AuditStats } from '@/services/api';
import { getAuditActionLabel, getAuditActionColor } from '@/hooks/useAudit';
import { Activity, TrendingUp, Users, FileText } from 'lucide-react';
import clsx from 'clsx';
interface AuditStatsCardProps {
stats: AuditStats;
isLoading?: boolean;
}
export function AuditStatsCard({ stats, isLoading }: AuditStatsCardProps) {
if (isLoading) {
return (
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<div className="animate-pulse space-y-4">
<div className="h-6 bg-secondary-200 dark:bg-secondary-700 rounded w-1/3"></div>
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-20 bg-secondary-200 dark:bg-secondary-700 rounded"></div>
))}
</div>
</div>
</div>
);
}
const topActions = Object.entries(stats.by_action)
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
const topEntityTypes = Object.entries(stats.by_entity_type)
.sort((a, b) => b[1] - a[1])
.slice(0, 4);
return (
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
Audit Overview
</h3>
<div className="flex items-center gap-2 text-sm text-secondary-500">
<Activity className="w-4 h-4" />
Last 7 days
</div>
</div>
{/* Summary stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
<div className="flex items-center gap-2 text-primary-600 dark:text-primary-400 mb-1">
<FileText className="w-4 h-4" />
<span className="text-sm font-medium">Total Logs</span>
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{stats.total_logs.toLocaleString()}
</p>
</div>
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex items-center gap-2 text-green-600 dark:text-green-400 mb-1">
<TrendingUp className="w-4 h-4" />
<span className="text-sm font-medium">Actions</span>
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{Object.keys(stats.by_action).length}
</p>
</div>
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="flex items-center gap-2 text-blue-600 dark:text-blue-400 mb-1">
<FileText className="w-4 h-4" />
<span className="text-sm font-medium">Entities</span>
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{Object.keys(stats.by_entity_type).length}
</p>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<div className="flex items-center gap-2 text-purple-600 dark:text-purple-400 mb-1">
<Users className="w-4 h-4" />
<span className="text-sm font-medium">Active Users</span>
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{stats.top_users.length}
</p>
</div>
</div>
{/* Actions breakdown */}
<div className="grid grid-cols-2 gap-6">
<div>
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
Actions by Type
</h4>
<div className="space-y-2">
{topActions.map(([action, count]) => (
<div key={action} className="flex items-center justify-between">
<span
className={clsx(
'px-2 py-1 rounded-full text-xs font-medium',
getAuditActionColor(action as any)
)}
>
{getAuditActionLabel(action as any)}
</span>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
{count.toLocaleString()}
</span>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
Top Entity Types
</h4>
<div className="space-y-2">
{topEntityTypes.map(([type, count]) => (
<div key={type} className="flex items-center justify-between">
<span className="text-sm text-secondary-900 dark:text-secondary-100 capitalize">
{type.replace(/_/g, ' ')}
</span>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
{count.toLocaleString()}
</span>
</div>
))}
</div>
</div>
</div>
{/* Daily trend */}
{stats.by_day && stats.by_day.length > 0 && (
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-700">
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
Daily Activity
</h4>
<div className="flex items-end justify-between h-16 gap-1">
{stats.by_day.map((day) => {
const maxCount = Math.max(...stats.by_day.map((d) => d.count));
const height = maxCount > 0 ? (day.count / maxCount) * 100 : 0;
return (
<div
key={day.date}
className="flex-1 flex flex-col items-center gap-1"
title={`${day.date}: ${day.count} logs`}
>
<div
className="w-full bg-primary-500 dark:bg-primary-400 rounded-t"
style={{ height: `${Math.max(height, 4)}%` }}
/>
<span className="text-xs text-secondary-400">
{new Date(day.date).toLocaleDateString('en-US', { weekday: 'narrow' })}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,4 @@
export * from './AuditLogRow';
export * from './AuditStatsCard';
export * from './AuditFilters';
export * from './ActivityTimeline';

View File

@ -0,0 +1,193 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import toast from 'react-hot-toast';
import { useOAuthUrl } from '@/hooks/useOAuth';
export type OAuthMode = 'login' | 'register' | 'link';
export type OAuthProvider = 'google' | 'microsoft' | 'github' | 'apple';
interface OAuthButtonsProps {
mode: OAuthMode;
onSuccess?: () => void;
onError?: (error: Error) => void;
disabled?: boolean;
}
interface ProviderConfig {
id: OAuthProvider;
name: string;
icon: React.ReactNode;
bgColor: string;
hoverBgColor: string;
textColor: string;
}
// Google Icon Component
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
}
// Microsoft Icon Component
function MicrosoftIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M11.4 11.4H0V0h11.4v11.4z" fill="#F25022"/>
<path d="M24 11.4H12.6V0H24v11.4z" fill="#7FBA00"/>
<path d="M11.4 24H0V12.6h11.4V24z" fill="#00A4EF"/>
<path d="M24 24H12.6V12.6H24V24z" fill="#FFB900"/>
</svg>
);
}
// GitHub Icon Component
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/>
</svg>
);
}
// Apple Icon Component
function AppleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.41-1.09-.47-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.41C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.79 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.53 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.27 2.33-1.97 4.27-3.74 4.25z"/>
</svg>
);
}
const providers: ProviderConfig[] = [
{
id: 'google',
name: 'Google',
icon: <GoogleIcon className="w-5 h-5" />,
bgColor: 'bg-white',
hoverBgColor: 'hover:bg-gray-50',
textColor: 'text-gray-700',
},
{
id: 'microsoft',
name: 'Microsoft',
icon: <MicrosoftIcon className="w-5 h-5" />,
bgColor: 'bg-white',
hoverBgColor: 'hover:bg-gray-50',
textColor: 'text-gray-700',
},
{
id: 'github',
name: 'GitHub',
icon: <GitHubIcon className="w-5 h-5" />,
bgColor: 'bg-gray-900',
hoverBgColor: 'hover:bg-gray-800',
textColor: 'text-white',
},
{
id: 'apple',
name: 'Apple',
icon: <AppleIcon className="w-5 h-5" />,
bgColor: 'bg-black',
hoverBgColor: 'hover:bg-gray-900',
textColor: 'text-white',
},
];
export function OAuthButtons({ mode, onSuccess, onError, disabled }: OAuthButtonsProps) {
const [loadingProvider, setLoadingProvider] = useState<OAuthProvider | null>(null);
const getOAuthUrl = useOAuthUrl();
const handleOAuthClick = async (provider: OAuthProvider) => {
if (loadingProvider || disabled) return;
setLoadingProvider(provider);
try {
const response = await getOAuthUrl.mutateAsync({
provider,
mode,
});
// Redirect to the OAuth provider
if (response.url) {
window.location.href = response.url;
onSuccess?.();
} else {
throw new Error('No authorization URL received');
}
} catch (error) {
const err = error instanceof Error ? error : new Error('Failed to initiate OAuth');
toast.error(err.message);
onError?.(err);
setLoadingProvider(null);
}
};
const getButtonLabel = (providerName: string) => {
switch (mode) {
case 'login':
return `Continue with ${providerName}`;
case 'register':
return `Sign up with ${providerName}`;
case 'link':
return `Connect ${providerName}`;
default:
return `Continue with ${providerName}`;
}
};
return (
<div className="space-y-3">
{providers.map((provider) => {
const isLoading = loadingProvider === provider.id;
const isDisabled = disabled || (loadingProvider !== null && loadingProvider !== provider.id);
return (
<button
key={provider.id}
type="button"
onClick={() => handleOAuthClick(provider.id)}
disabled={isDisabled || isLoading}
className={`
w-full flex items-center justify-center gap-3 px-4 py-2.5
border border-secondary-300 dark:border-secondary-600 rounded-lg
font-medium transition-colors duration-200
${provider.bgColor} ${provider.hoverBgColor} ${provider.textColor}
disabled:opacity-50 disabled:cursor-not-allowed
dark:bg-secondary-800 dark:hover:bg-secondary-700 dark:text-white
`}
>
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
provider.icon
)}
<span>{getButtonLabel(provider.name)}</span>
</button>
);
})}
</div>
);
}
// Separator component for use between form and OAuth buttons
export function OAuthSeparator() {
return (
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-secondary-300 dark:border-secondary-600" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-4 bg-white dark:bg-secondary-900 text-secondary-500">
Or continue with
</span>
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export * from './OAuthButtons';

View File

@ -0,0 +1,84 @@
import { useState, useRef, useEffect } from 'react';
import { Download, FileSpreadsheet, FileText, File, Loader2, ChevronDown } from 'lucide-react';
import { useExportReport } from '@/hooks/useExport';
import clsx from 'clsx';
export type ReportType = 'users' | 'billing' | 'audit';
export type ExportFormat = 'pdf' | 'excel' | 'csv';
interface ExportButtonProps {
reportType: ReportType;
disabled?: boolean;
className?: string;
}
const formatOptions: { value: ExportFormat; label: string; icon: React.ReactNode }[] = [
{ value: 'pdf', label: 'PDF', icon: <FileText className="w-4 h-4" /> },
{ value: 'excel', label: 'Excel', icon: <FileSpreadsheet className="w-4 h-4" /> },
{ value: 'csv', label: 'CSV', icon: <File className="w-4 h-4" /> },
];
export function ExportButton({ reportType, disabled = false, className }: ExportButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { exportReport, isExporting } = useExportReport();
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleExport = async (format: ExportFormat) => {
setIsOpen(false);
await exportReport({ reportType, format });
};
return (
<div className={clsx('relative', className)} ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={disabled || isExporting}
className={clsx(
'inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg',
'bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600',
'text-secondary-700 dark:text-secondary-300',
'hover:bg-secondary-50 dark:hover:bg-secondary-700',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-secondary-900',
'disabled:opacity-50 disabled:cursor-not-allowed',
'transition-colors'
)}
>
{isExporting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
<span>Export</span>
<ChevronDown className={clsx('w-4 h-4 transition-transform', isOpen && 'rotate-180')} />
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-40 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50">
{formatOptions.map((option) => (
<button
key={option.value}
onClick={() => handleExport(option.value)}
className="w-full flex items-center gap-3 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors"
>
{option.icon}
<span>{option.label}</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,231 @@
import { useState } from 'react';
import { X, Download, FileSpreadsheet, FileText, File, Loader2, Calendar } from 'lucide-react';
import clsx from 'clsx';
import { ReportType, ExportFormat } from './ExportButton';
export interface ExportParams {
format: ExportFormat;
dateFrom?: string;
dateTo?: string;
}
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
reportType: ReportType;
onExport: (params: ExportParams) => Promise<void>;
isExporting?: boolean;
}
const formatOptions: { value: ExportFormat; label: string; description: string; icon: React.ReactNode }[] = [
{
value: 'pdf',
label: 'PDF',
description: 'Best for printing and sharing',
icon: <FileText className="w-5 h-5" />
},
{
value: 'excel',
label: 'Excel',
description: 'Best for data analysis in spreadsheets',
icon: <FileSpreadsheet className="w-5 h-5" />
},
{
value: 'csv',
label: 'CSV',
description: 'Universal format for data import',
icon: <File className="w-5 h-5" />
},
];
const reportTypeLabels: Record<ReportType, string> = {
users: 'Users Report',
billing: 'Billing Report',
audit: 'Audit Logs Report',
};
export function ExportModal({
isOpen,
onClose,
reportType,
onExport,
isExporting = false
}: ExportModalProps) {
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('excel');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
if (!isOpen) return null;
const handleExport = async () => {
await onExport({
format: selectedFormat,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
});
};
const handleClose = () => {
if (!isExporting) {
onClose();
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={handleClose}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-secondary-800 rounded-xl shadow-xl max-w-md w-full">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<Download className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Export {reportTypeLabels[reportType]}
</h2>
<p className="text-sm text-secondary-500">
Choose format and date range
</p>
</div>
</div>
<button
onClick={handleClose}
disabled={isExporting}
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 text-secondary-500 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 py-4 space-y-6">
{/* Format Selection */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-3">
Export Format
</label>
<div className="space-y-2">
{formatOptions.map((option) => (
<button
key={option.value}
onClick={() => setSelectedFormat(option.value)}
className={clsx(
'w-full flex items-center gap-4 p-3 rounded-lg border-2 transition-all',
selectedFormat === option.value
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-secondary-200 dark:border-secondary-700 hover:border-secondary-300 dark:hover:border-secondary-600'
)}
>
<div className={clsx(
'p-2 rounded-lg',
selectedFormat === option.value
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400'
: 'bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-400'
)}>
{option.icon}
</div>
<div className="flex-1 text-left">
<p className={clsx(
'font-medium',
selectedFormat === option.value
? 'text-primary-700 dark:text-primary-300'
: 'text-secondary-900 dark:text-secondary-100'
)}>
{option.label}
</p>
<p className="text-sm text-secondary-500">
{option.description}
</p>
</div>
<div className={clsx(
'w-5 h-5 rounded-full border-2 flex items-center justify-center',
selectedFormat === option.value
? 'border-primary-500 bg-primary-500'
: 'border-secondary-300 dark:border-secondary-600'
)}>
{selectedFormat === option.value && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
</button>
))}
</div>
</div>
{/* Date Range (Optional) */}
<div>
<div className="flex items-center gap-2 mb-3">
<Calendar className="w-4 h-4 text-secondary-500" />
<label className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Date Range
</label>
<span className="text-xs text-secondary-400">(Optional)</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="dateFrom" className="block text-xs text-secondary-500 mb-1">
From
</label>
<input
type="date"
id="dateFrom"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label htmlFor="dateTo" className="block text-xs text-secondary-500 mb-1">
To
</label>
<input
type="date"
id="dateTo"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-secondary-200 dark:border-secondary-700">
<button
onClick={handleClose}
disabled={isExporting}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleExport}
disabled={isExporting}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 rounded-lg transition-colors disabled:opacity-50"
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="w-4 h-4" />
Export
</>
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
export * from './ExportButton';
export * from './ExportModal';

View File

@ -0,0 +1,172 @@
import { FeatureFlag } from '@/services/api';
import {
getFlagTypeLabel,
getFlagTypeColor,
getFlagScopeLabel,
getFlagScopeColor,
} from '@/hooks/useFeatureFlags';
import { MoreVertical, Pencil, Trash2, ToggleLeft, ToggleRight, Copy } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import clsx from 'clsx';
interface FeatureFlagCardProps {
flag: FeatureFlag;
onEdit: (flag: FeatureFlag) => void;
onDelete: (flag: FeatureFlag) => void;
onToggle: (flag: FeatureFlag) => void;
isToggling?: boolean;
}
export function FeatureFlagCard({
flag,
onEdit,
onDelete,
onToggle,
isToggling,
}: FeatureFlagCardProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const copyKey = async () => {
await navigator.clipboard.writeText(flag.key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-secondary-900 dark:text-secondary-100 truncate">
{flag.name}
</h3>
<button
onClick={() => onToggle(flag)}
disabled={isToggling}
className={clsx(
'p-1 rounded transition-colors',
flag.is_enabled
? 'text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'
: 'text-secondary-400 hover:bg-secondary-100 dark:hover:bg-secondary-700'
)}
title={flag.is_enabled ? 'Disable flag' : 'Enable flag'}
>
{flag.is_enabled ? (
<ToggleRight className="w-5 h-5" />
) : (
<ToggleLeft className="w-5 h-5" />
)}
</button>
</div>
<div className="flex items-center gap-2 mt-1">
<code className="text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-0.5 rounded font-mono text-secondary-600 dark:text-secondary-400">
{flag.key}
</code>
<button
onClick={copyKey}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
title="Copy key"
>
<Copy className="w-3 h-3" />
</button>
{copied && (
<span className="text-xs text-green-600">Copied!</span>
)}
</div>
</div>
<div className="relative" ref={menuRef}>
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-1.5 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
<MoreVertical className="w-4 h-4 text-secondary-500" />
</button>
{menuOpen && (
<div className="absolute right-0 mt-1 w-40 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-10">
<button
onClick={() => {
setMenuOpen(false);
onEdit(flag);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<Pencil className="w-4 h-4" />
Edit
</button>
<button
onClick={() => {
setMenuOpen(false);
onDelete(flag);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
)}
</div>
</div>
{flag.description && (
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-3 line-clamp-2">
{flag.description}
</p>
)}
<div className="flex items-center flex-wrap gap-2">
<span
className={clsx(
'px-2 py-0.5 rounded-full text-xs font-medium',
getFlagTypeColor(flag.flag_type)
)}
>
{getFlagTypeLabel(flag.flag_type)}
</span>
<span
className={clsx(
'px-2 py-0.5 rounded-full text-xs font-medium',
getFlagScopeColor(flag.scope)
)}
>
{getFlagScopeLabel(flag.scope)}
</span>
{flag.category && (
<span className="px-2 py-0.5 bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-400 rounded-full text-xs">
{flag.category}
</span>
)}
{flag.rollout_percentage !== null && flag.rollout_percentage < 100 && (
<span className="px-2 py-0.5 bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 rounded-full text-xs">
{flag.rollout_percentage}% rollout
</span>
)}
</div>
{flag.default_value !== null && flag.default_value !== undefined && (
<div className="mt-3 pt-3 border-t border-secondary-200 dark:border-secondary-700">
<span className="text-xs text-secondary-500">Default value:</span>
<code className="ml-2 text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-0.5 rounded">
{typeof flag.default_value === 'object'
? JSON.stringify(flag.default_value)
: String(flag.default_value)}
</code>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,315 @@
import { useState } from 'react';
import { FeatureFlag, FlagType, FlagScope, CreateFlagRequest, UpdateFlagRequest } from '@/services/api';
import { Loader2 } from 'lucide-react';
interface FeatureFlagFormProps {
flag?: FeatureFlag;
onSubmit: (data: CreateFlagRequest | UpdateFlagRequest) => void;
onCancel: () => void;
isLoading?: boolean;
}
const FLAG_TYPES: { value: FlagType; label: string }[] = [
{ value: 'boolean', label: 'Boolean' },
{ value: 'string', label: 'String' },
{ value: 'number', label: 'Number' },
{ value: 'json', label: 'JSON' },
];
const FLAG_SCOPES: { value: FlagScope; label: string; description: string }[] = [
{ value: 'global', label: 'Global', description: 'Applies to all tenants and users' },
{ value: 'tenant', label: 'Tenant', description: 'Can be overridden per tenant' },
{ value: 'user', label: 'User', description: 'Can be overridden per user' },
{ value: 'plan', label: 'Plan', description: 'Based on subscription plan' },
];
export function FeatureFlagForm({ flag, onSubmit, onCancel, isLoading }: FeatureFlagFormProps) {
const isEditing = !!flag;
const [formData, setFormData] = useState({
key: flag?.key || '',
name: flag?.name || '',
description: flag?.description || '',
flag_type: flag?.flag_type || ('boolean' as FlagType),
scope: flag?.scope || ('global' as FlagScope),
default_value: flag?.default_value ?? '',
is_enabled: flag?.is_enabled ?? false,
rollout_percentage: flag?.rollout_percentage ?? 100,
category: flag?.category || '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validateKey = (key: string): string | null => {
if (!key) return 'Key is required';
if (!/^[a-z][a-z0-9_]*$/.test(key)) {
return 'Key must start with lowercase letter and contain only lowercase letters, numbers, and underscores';
}
if (key.length > 100) return 'Key must be 100 characters or less';
return null;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
if (!isEditing) {
const keyError = validateKey(formData.key);
if (keyError) newErrors.key = keyError;
}
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
// Parse default value based on type
let defaultValue: any = formData.default_value;
if (formData.flag_type === 'boolean') {
defaultValue = formData.default_value === 'true' || formData.default_value === true;
} else if (formData.flag_type === 'number') {
defaultValue = parseFloat(formData.default_value as string) || 0;
} else if (formData.flag_type === 'json') {
try {
defaultValue = formData.default_value ? JSON.parse(formData.default_value as string) : null;
} catch {
setErrors({ default_value: 'Invalid JSON' });
return;
}
}
const submitData: CreateFlagRequest | UpdateFlagRequest = {
...(isEditing ? {} : { key: formData.key }),
name: formData.name.trim(),
description: formData.description.trim() || undefined,
flag_type: formData.flag_type,
scope: formData.scope,
default_value: defaultValue,
is_enabled: formData.is_enabled,
rollout_percentage: formData.rollout_percentage,
category: formData.category.trim() || undefined,
};
onSubmit(submitData);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Key (only for create) */}
{!isEditing && (
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Key *
</label>
<input
type="text"
value={formData.key}
onChange={(e) => {
setFormData({ ...formData, key: e.target.value.toLowerCase() });
setErrors({ ...errors, key: '' });
}}
placeholder="e.g., enable_dark_mode"
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
{errors.key && (
<p className="mt-1 text-sm text-red-600">{errors.key}</p>
)}
<p className="mt-1 text-xs text-secondary-500">
Unique identifier. Lowercase letters, numbers, and underscores only.
</p>
</div>
)}
{/* Name */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
setErrors({ ...errors, name: '' });
}}
placeholder="e.g., Enable Dark Mode"
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
)}
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe what this flag controls..."
rows={2}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Type and Scope */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Type
</label>
<select
value={formData.flag_type}
onChange={(e) =>
setFormData({ ...formData, flag_type: e.target.value as FlagType })
}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{FLAG_TYPES.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Scope
</label>
<select
value={formData.scope}
onChange={(e) =>
setFormData({ ...formData, scope: e.target.value as FlagScope })
}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{FLAG_SCOPES.map((scope) => (
<option key={scope.value} value={scope.value}>
{scope.label}
</option>
))}
</select>
</div>
</div>
{/* Default Value */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Default Value
</label>
{formData.flag_type === 'boolean' ? (
<select
value={String(formData.default_value)}
onChange={(e) =>
setFormData({ ...formData, default_value: e.target.value === 'true' })
}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="true">True</option>
<option value="false">False</option>
</select>
) : formData.flag_type === 'json' ? (
<textarea
value={
typeof formData.default_value === 'object'
? JSON.stringify(formData.default_value, null, 2)
: formData.default_value
}
onChange={(e) => setFormData({ ...formData, default_value: e.target.value })}
placeholder='{"key": "value"}'
rows={3}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm"
/>
) : (
<input
type={formData.flag_type === 'number' ? 'number' : 'text'}
value={formData.default_value}
onChange={(e) => setFormData({ ...formData, default_value: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
)}
{errors.default_value && (
<p className="mt-1 text-sm text-red-600">{errors.default_value}</p>
)}
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Category
</label>
<input
type="text"
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
placeholder="e.g., UI, Beta, Experimental"
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Rollout Percentage */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Rollout Percentage: {formData.rollout_percentage}%
</label>
<input
type="range"
min="0"
max="100"
value={formData.rollout_percentage}
onChange={(e) =>
setFormData({ ...formData, rollout_percentage: parseInt(e.target.value) })
}
className="w-full"
/>
<p className="mt-1 text-xs text-secondary-500">
Percentage of users who will see this flag enabled (for gradual rollouts)
</p>
</div>
{/* Enabled */}
<div className="flex items-center gap-3">
<input
type="checkbox"
id="is_enabled"
checked={formData.is_enabled}
onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })}
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<label
htmlFor="is_enabled"
className="text-sm font-medium text-secondary-700 dark:text-secondary-300"
>
Enable flag globally
</label>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-secondary-200 dark:border-secondary-700">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
>
{isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
{isEditing ? 'Update Flag' : 'Create Flag'}
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,187 @@
import { useState } from 'react';
import { FeatureFlag, TenantFlag } from '@/services/api';
import { Plus, Trash2, ToggleLeft, ToggleRight, AlertCircle } from 'lucide-react';
import clsx from 'clsx';
interface TenantOverridesPanelProps {
flags: FeatureFlag[];
overrides: TenantFlag[];
onAdd: (flagId: string, isEnabled: boolean, value?: any) => void;
onRemove: (flagId: string) => void;
isLoading?: boolean;
}
export function TenantOverridesPanel({
flags,
overrides,
onAdd,
onRemove,
isLoading,
}: TenantOverridesPanelProps) {
const [showAdd, setShowAdd] = useState(false);
const [selectedFlagId, setSelectedFlagId] = useState('');
const [overrideEnabled, setOverrideEnabled] = useState(true);
// Get flags that don't have overrides yet
const overriddenFlagIds = new Set(overrides.map((o) => o.flag_id));
const availableFlags = flags.filter((f) => !overriddenFlagIds.has(f.id));
const handleAdd = () => {
if (!selectedFlagId) return;
onAdd(selectedFlagId, overrideEnabled);
setSelectedFlagId('');
setOverrideEnabled(true);
setShowAdd(false);
};
const getFlag = (flagId: string) => flags.find((f) => f.id === flagId);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-secondary-900 dark:text-secondary-100">
Tenant Overrides
</h3>
<p className="text-sm text-secondary-500">
Override global flag settings for your organization
</p>
</div>
{availableFlags.length > 0 && (
<button
onClick={() => setShowAdd(!showAdd)}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
<Plus className="w-4 h-4" />
Add Override
</button>
)}
</div>
{/* Add override form */}
{showAdd && (
<div className="p-4 bg-secondary-50 dark:bg-secondary-800/50 rounded-lg border border-secondary-200 dark:border-secondary-700">
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Select Flag
</label>
<select
value={selectedFlagId}
onChange={(e) => setSelectedFlagId(e.target.value)}
className="w-full px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100"
>
<option value="">Choose a flag...</option>
{availableFlags.map((flag) => (
<option key={flag.id} value={flag.id}>
{flag.name} ({flag.key})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Enabled
</label>
<button
onClick={() => setOverrideEnabled(!overrideEnabled)}
className={clsx(
'p-2 rounded-lg border',
overrideEnabled
? 'bg-green-50 border-green-200 text-green-600 dark:bg-green-900/20 dark:border-green-800'
: 'bg-secondary-50 border-secondary-200 text-secondary-400 dark:bg-secondary-700 dark:border-secondary-600'
)}
>
{overrideEnabled ? (
<ToggleRight className="w-5 h-5" />
) : (
<ToggleLeft className="w-5 h-5" />
)}
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowAdd(false)}
className="px-3 py-2 text-secondary-600 hover:bg-secondary-200 dark:text-secondary-400 dark:hover:bg-secondary-700 rounded-lg"
>
Cancel
</button>
<button
onClick={handleAdd}
disabled={!selectedFlagId || isLoading}
className="px-3 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
Add
</button>
</div>
</div>
</div>
)}
{/* Overrides list */}
{overrides.length === 0 ? (
<div className="text-center py-8 text-secondary-500">
<AlertCircle className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No tenant overrides configured</p>
<p className="text-sm">All flags use their global default values</p>
</div>
) : (
<div className="space-y-2">
{overrides.map((override) => {
const flag = getFlag(override.flag_id);
return (
<div
key={override.id}
className="flex items-center justify-between p-3 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700"
>
<div className="flex items-center gap-3">
<div
className={clsx(
'p-1.5 rounded-lg',
override.is_enabled
? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400'
: 'bg-secondary-100 text-secondary-400 dark:bg-secondary-700'
)}
>
{override.is_enabled ? (
<ToggleRight className="w-4 h-4" />
) : (
<ToggleLeft className="w-4 h-4" />
)}
</div>
<div>
<p className="font-medium text-secondary-900 dark:text-secondary-100">
{flag?.name || 'Unknown Flag'}
</p>
<code className="text-xs text-secondary-500 font-mono">
{flag?.key}
</code>
</div>
</div>
<div className="flex items-center gap-3">
<span
className={clsx(
'px-2 py-0.5 rounded-full text-xs font-medium',
override.is_enabled
? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400'
: 'bg-secondary-100 text-secondary-600 dark:bg-secondary-700 dark:text-secondary-400'
)}
>
{override.is_enabled ? 'Enabled' : 'Disabled'}
</span>
<button
onClick={() => onRemove(override.flag_id)}
className="p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg"
title="Remove override"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,3 @@
export * from './FeatureFlagCard';
export * from './FeatureFlagForm';
export * from './TenantOverridesPanel';

26
src/components/index.ts Normal file
View File

@ -0,0 +1,26 @@
// Common
export * from './common';
// Notifications
export * from './notifications';
// AI
export * from './ai';
// Storage
export * from './storage';
// Webhooks
export * from './webhooks';
// Audit
export * from './audit';
// Feature Flags
export * from './feature-flags';
// Auth
export * from './auth';
// Analytics
export * from './analytics';

View File

@ -0,0 +1,185 @@
import { useDevices, usePushNotifications, UserDevice } from '@/hooks/usePushNotifications';
interface DevicesManagerProps {
showHeader?: boolean;
}
export function DevicesManager({ showHeader = true }: DevicesManagerProps) {
const { data: devices, isLoading, error, refetch } = useDevices();
const { unregisterDevice, isSubscribed, requestPermission, isLoading: pushLoading } = usePushNotifications();
const handleUnregister = async (deviceId: string) => {
if (confirm('Estas seguro de que deseas eliminar este dispositivo?')) {
await unregisterDevice.mutateAsync(deviceId);
refetch();
}
};
const getDeviceIcon = (device: UserDevice) => {
switch (device.device_type) {
case 'mobile':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
);
case 'desktop':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
);
default:
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
);
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
if (isLoading) {
return (
<div className="animate-pulse space-y-4">
{[1, 2].map((i) => (
<div key={i} className="h-16 bg-gray-100 rounded-lg" />
))}
</div>
);
}
if (error) {
return (
<div className="text-red-500 text-sm">
Error al cargar dispositivos. Por favor intenta de nuevo.
</div>
);
}
return (
<div className="space-y-4">
{showHeader && (
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-gray-900">
Dispositivos registrados
</h3>
<p className="text-sm text-gray-500">
Gestiona los dispositivos donde recibes notificaciones push
</p>
</div>
{!isSubscribed && (
<button
onClick={() => requestPermission()}
disabled={pushLoading}
className="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
>
{pushLoading ? 'Registrando...' : 'Agregar este dispositivo'}
</button>
)}
</div>
)}
{devices && devices.length === 0 ? (
<div className="text-center py-8 bg-gray-50 rounded-lg">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
Sin dispositivos registrados
</h3>
<p className="mt-1 text-sm text-gray-500">
Registra este dispositivo para recibir notificaciones push.
</p>
<button
onClick={() => requestPermission()}
disabled={pushLoading}
className="mt-4 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50"
>
Registrar dispositivo
</button>
</div>
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg">
{devices?.map((device) => (
<li
key={device.id}
className="flex items-center justify-between p-4 hover:bg-gray-50"
>
<div className="flex items-center gap-4">
<div
className={`p-2 rounded-lg ${
device.is_active
? 'bg-green-100 text-green-600'
: 'bg-gray-100 text-gray-400'
}`}
>
{getDeviceIcon(device)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">
{device.device_name || `${device.browser} en ${device.os}`}
</span>
{device.is_active ? (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 rounded-full">
Activo
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
Inactivo
</span>
)}
</div>
<div className="text-sm text-gray-500">
Ultimo uso: {formatDate(device.last_used_at)}
</div>
</div>
</div>
<button
onClick={() => handleUnregister(device.id)}
disabled={unregisterDevice.isPending}
className="text-red-600 hover:text-red-800 text-sm font-medium disabled:opacity-50"
>
Eliminar
</button>
</li>
))}
</ul>
)}
{devices && devices.length > 0 && (
<p className="text-xs text-gray-500">
Los dispositivos inactivos se eliminan automaticamente despues de 90
dias sin uso.
</p>
)}
</div>
);
}
export default DevicesManager;

View File

@ -0,0 +1,42 @@
import { useState } from 'react';
import { Bell } from 'lucide-react';
import clsx from 'clsx';
import { useUnreadNotificationsCount } from '@/hooks/useData';
import { NotificationDrawer } from './NotificationDrawer';
export function NotificationBell() {
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const { data } = useUnreadNotificationsCount();
const unreadCount = data?.count ?? 0;
const hasUnread = unreadCount > 0;
return (
<>
<button
onClick={() => setIsDrawerOpen(true)}
className="relative p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors"
aria-label={`Notifications${hasUnread ? ` (${unreadCount} unread)` : ''}`}
>
<Bell
className={clsx(
'w-5 h-5 transition-colors',
hasUnread
? 'text-primary-600 dark:text-primary-400'
: 'text-secondary-600 dark:text-secondary-400'
)}
/>
{hasUnread && (
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-bold text-white bg-red-500 rounded-full">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
<NotificationDrawer
isOpen={isDrawerOpen}
onClose={() => setIsDrawerOpen(false)}
/>
</>
);
}

View File

@ -0,0 +1,117 @@
import { X, Bell, CheckCheck, Loader2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import clsx from 'clsx';
import { useNotifications, useMarkNotificationAsRead, useMarkAllNotificationsAsRead } from '@/hooks/useData';
import { NotificationItem } from './NotificationItem';
interface NotificationDrawerProps {
isOpen: boolean;
onClose: () => void;
}
export function NotificationDrawer({ isOpen, onClose }: NotificationDrawerProps) {
const navigate = useNavigate();
const { data, isLoading } = useNotifications(1, 20);
const markAsRead = useMarkNotificationAsRead();
const markAllAsRead = useMarkAllNotificationsAsRead();
const notifications = data?.data ?? [];
const hasUnread = notifications.some((n) => !n.read_at);
const handleMarkAsRead = (id: string) => {
markAsRead.mutate(id);
};
const handleMarkAllAsRead = () => {
markAllAsRead.mutate();
};
const handleNavigate = (url: string) => {
onClose();
navigate(url);
};
return (
<>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-40 bg-black/20 dark:bg-black/40"
onClick={onClose}
/>
)}
{/* Drawer */}
<div
className={clsx(
'fixed top-0 right-0 z-50 h-full w-full sm:w-96 bg-white dark:bg-secondary-800 shadow-xl transform transition-transform duration-300 ease-in-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
>
{/* Header */}
<div className="flex items-center justify-between h-16 px-4 border-b border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-2">
<Bell className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
Notifications
</h2>
</div>
<div className="flex items-center gap-2">
{hasUnread && (
<button
onClick={handleMarkAllAsRead}
disabled={markAllAsRead.isPending}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/20 rounded-md transition-colors disabled:opacity-50"
>
{markAllAsRead.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<CheckCheck className="w-3 h-3" />
)}
Mark all read
</button>
)}
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 transition-colors"
>
<X className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
</button>
</div>
</div>
{/* Content */}
<div className="h-[calc(100%-4rem)] overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center px-4">
<div className="w-16 h-16 bg-secondary-100 dark:bg-secondary-700 rounded-full flex items-center justify-center mb-4">
<Bell className="w-8 h-8 text-secondary-400" />
</div>
<p className="text-secondary-600 dark:text-secondary-400 font-medium">
No notifications
</p>
<p className="text-sm text-secondary-500 dark:text-secondary-500 mt-1">
You're all caught up!
</p>
</div>
) : (
<div className="divide-y divide-secondary-100 dark:divide-secondary-700">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onRead={handleMarkAsRead}
onNavigate={handleNavigate}
/>
))}
</div>
)}
</div>
</div>
</>
);
}

View File

@ -0,0 +1,83 @@
import { formatDistanceToNow } from 'date-fns';
import { Bell, AlertCircle, CheckCircle, Info, AlertTriangle } from 'lucide-react';
import clsx from 'clsx';
import type { Notification } from '@/hooks/useData';
interface NotificationItemProps {
notification: Notification;
onRead: (id: string) => void;
onNavigate?: (url: string) => void;
}
const typeIcons: Record<string, typeof Bell> = {
info: Info,
success: CheckCircle,
warning: AlertTriangle,
error: AlertCircle,
default: Bell,
};
const typeColors: Record<string, string> = {
info: 'text-blue-500 bg-blue-50 dark:bg-blue-900/20',
success: 'text-green-500 bg-green-50 dark:bg-green-900/20',
warning: 'text-yellow-500 bg-yellow-50 dark:bg-yellow-900/20',
error: 'text-red-500 bg-red-50 dark:bg-red-900/20',
default: 'text-secondary-500 bg-secondary-50 dark:bg-secondary-800',
};
export function NotificationItem({ notification, onRead, onNavigate }: NotificationItemProps) {
const isRead = !!notification.read_at;
const Icon = typeIcons[notification.type] || typeIcons.default;
const colorClass = typeColors[notification.type] || typeColors.default;
const handleClick = () => {
if (!isRead) {
onRead(notification.id);
}
// If notification has action_url, navigate
const actionUrl = (notification as any).action_url;
if (actionUrl && onNavigate) {
onNavigate(actionUrl);
}
};
return (
<button
onClick={handleClick}
className={clsx(
'w-full flex items-start gap-3 p-3 text-left transition-colors rounded-lg',
isRead
? 'bg-transparent hover:bg-secondary-50 dark:hover:bg-secondary-800'
: 'bg-primary-50/50 dark:bg-primary-900/10 hover:bg-primary-50 dark:hover:bg-primary-900/20'
)}
>
{/* Icon */}
<div className={clsx('flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center', colorClass)}>
<Icon className="w-4 h-4" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p
className={clsx(
'text-sm font-medium truncate',
isRead ? 'text-secondary-600 dark:text-secondary-400' : 'text-secondary-900 dark:text-secondary-100'
)}
>
{notification.title}
</p>
{!isRead && (
<span className="flex-shrink-0 w-2 h-2 bg-primary-500 rounded-full" />
)}
</div>
<p className="text-xs text-secondary-500 dark:text-secondary-400 line-clamp-2 mt-0.5">
{notification.message}
</p>
<p className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">
{formatDistanceToNow(new Date(notification.created_at), { addSuffix: true })}
</p>
</div>
</button>
);
}

View File

@ -0,0 +1,133 @@
import { usePushNotifications } from '@/hooks/usePushNotifications';
interface PushPermissionBannerProps {
onDismiss?: () => void;
}
export function PushPermissionBanner({ onDismiss }: PushPermissionBannerProps) {
const {
isSupported,
isEnabled,
permission,
isSubscribed,
isLoading,
error,
requestPermission,
} = usePushNotifications();
// Don't show if not supported, not enabled, already subscribed, or denied
if (!isSupported || !isEnabled || isSubscribed || permission === 'denied') {
return null;
}
const handleEnable = async () => {
await requestPermission();
};
const handleDismiss = () => {
// Store dismissal in localStorage to not show again this session
localStorage.setItem('push-banner-dismissed', 'true');
onDismiss?.();
};
// Check if already dismissed
if (localStorage.getItem('push-banner-dismissed')) {
return null;
}
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<svg
className="w-6 h-6 text-blue-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</div>
<div className="flex-1">
<h3 className="text-sm font-medium text-blue-800">
Activar notificaciones push
</h3>
<p className="mt-1 text-sm text-blue-600">
Recibe notificaciones en tiempo real sobre actualizaciones
importantes, incluso cuando no estes usando la aplicacion.
</p>
{error && (
<p className="mt-2 text-sm text-red-600">
Error: {error}
</p>
)}
<div className="mt-3 flex gap-3">
<button
onClick={handleEnable}
disabled={isLoading}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Activando...
</>
) : (
'Activar notificaciones'
)}
</button>
<button
onClick={handleDismiss}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-blue-700 hover:text-blue-800"
>
Ahora no
</button>
</div>
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-blue-400 hover:text-blue-600"
>
<span className="sr-only">Cerrar</span>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
</div>
);
}
export default PushPermissionBanner;

View File

@ -0,0 +1,5 @@
export { NotificationBell } from './NotificationBell';
export { NotificationDrawer } from './NotificationDrawer';
export { NotificationItem } from './NotificationItem';
export { PushPermissionBanner } from './PushPermissionBanner';
export { DevicesManager } from './DevicesManager';

View File

@ -0,0 +1,186 @@
import { useState } from 'react';
import {
File,
Image,
FileText,
Table,
Download,
Trash2,
Eye,
Lock,
Users,
Globe,
} from 'lucide-react';
import { StorageFile, storageApi } from '@/services/api';
import { useDeleteFile } from '@/hooks/useStorage';
interface FileItemProps {
file: StorageFile;
view?: 'grid' | 'list';
onDelete?: () => void;
onPreview?: (file: StorageFile) => void;
}
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) return Image;
if (mimeType.includes('pdf') || mimeType.includes('word')) return FileText;
if (mimeType.includes('excel') || mimeType.includes('sheet') || mimeType.includes('csv')) return Table;
return File;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function VisibilityIcon({ visibility }: { visibility: string }) {
switch (visibility) {
case 'public':
return <Globe className="w-3 h-3 text-green-500" />;
case 'tenant':
return <Users className="w-3 h-3 text-blue-500" />;
default:
return <Lock className="w-3 h-3 text-gray-500" />;
}
}
export function FileItem({ file, view = 'list', onDelete, onPreview }: FileItemProps) {
const [downloading, setDownloading] = useState(false);
const deleteMutation = useDeleteFile();
const FileIcon = getFileIcon(file.mimeType);
const isImage = file.mimeType.startsWith('image/');
const handleDownload = async () => {
setDownloading(true);
try {
const { url } = await storageApi.getDownloadUrl(file.id);
window.open(url, '_blank');
} catch (error) {
console.error('Download error:', error);
} finally {
setDownloading(false);
}
};
const handleDelete = async () => {
if (!window.confirm(`Delete "${file.originalName}"?`)) return;
try {
await deleteMutation.mutateAsync(file.id);
onDelete?.();
} catch (error) {
console.error('Delete error:', error);
}
};
if (view === 'grid') {
return (
<div className="relative group bg-white border rounded-lg p-4 hover:shadow-md transition-shadow">
{/* Thumbnail or Icon */}
<div className="aspect-square bg-gray-100 rounded-lg flex items-center justify-center mb-3 overflow-hidden">
{isImage && file.thumbnails?.small ? (
<img
src={file.thumbnails.small}
alt={file.originalName}
className="w-full h-full object-cover"
/>
) : (
<FileIcon className="w-12 h-12 text-gray-400" />
)}
</div>
{/* File info */}
<div className="truncate text-sm font-medium text-gray-900" title={file.originalName}>
{file.originalName}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500">
<VisibilityIcon visibility={file.visibility} />
<span>{formatBytes(file.sizeBytes)}</span>
</div>
{/* Hover actions */}
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2">
{isImage && (
<button
onClick={() => onPreview?.(file)}
className="p-2 bg-white rounded-full hover:bg-gray-100"
>
<Eye className="w-4 h-4" />
</button>
)}
<button
onClick={handleDownload}
disabled={downloading}
className="p-2 bg-white rounded-full hover:bg-gray-100"
>
<Download className="w-4 h-4" />
</button>
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="p-2 bg-white rounded-full hover:bg-red-100 text-red-600"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
}
// List view
return (
<div className="flex items-center gap-4 p-3 bg-white border rounded-lg hover:bg-gray-50">
{/* Icon */}
<div className="flex-shrink-0 p-2 bg-gray-100 rounded-lg">
<FileIcon className="w-6 h-6 text-gray-500" />
</div>
{/* File info */}
<div className="flex-grow min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-gray-900" title={file.originalName}>
{file.originalName}
</span>
<VisibilityIcon visibility={file.visibility} />
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>{formatBytes(file.sizeBytes)}</span>
<span>{file.folder}</span>
<span>{formatDate(file.createdAt)}</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={handleDownload}
disabled={downloading}
className="p-2 text-gray-400 hover:text-gray-600"
title="Download"
>
<Download className="w-5 h-5" />
</button>
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="p-2 text-gray-400 hover:text-red-600"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,152 @@
import { useState } from 'react';
import { Grid, List, Search, Folder, ChevronLeft, ChevronRight } from 'lucide-react';
import { useFiles } from '@/hooks/useStorage';
import { StorageFile } from '@/services/api';
import { FileItem } from './FileItem';
interface FileListProps {
folder?: string;
onPreview?: (file: StorageFile) => void;
className?: string;
}
export function FileList({ folder, onPreview, className = '' }: FileListProps) {
const [view, setView] = useState<'grid' | 'list'>('list');
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const limit = 12;
const { data, isLoading, error, refetch } = useFiles({
page,
limit,
folder,
search: search || undefined,
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
if (error) {
return (
<div className="text-center py-12 text-red-600">
<p>Failed to load files</p>
<button
onClick={() => refetch()}
className="mt-2 text-blue-600 hover:underline"
>
Try again
</button>
</div>
);
}
return (
<div className={className}>
{/* Toolbar */}
<div className="flex items-center justify-between mb-4">
{/* Search */}
<form onSubmit={handleSearch} className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search files..."
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</form>
{/* View toggle */}
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => setView('list')}
className={`p-2 rounded ${view === 'list' ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
>
<List className="w-5 h-5" />
</button>
<button
onClick={() => setView('grid')}
className={`p-2 rounded ${view === 'grid' ? 'bg-gray-200' : 'hover:bg-gray-100'}`}
>
<Grid className="w-5 h-5" />
</button>
</div>
</div>
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
</div>
)}
{/* Empty state */}
{!isLoading && data?.data.length === 0 && (
<div className="text-center py-12 text-gray-500">
<Folder className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg font-medium">No files found</p>
<p className="text-sm mt-1">
{search ? 'Try a different search term' : 'Upload your first file to get started'}
</p>
</div>
)}
{/* File list */}
{!isLoading && data && data.data.length > 0 && (
<>
<div
className={
view === 'grid'
? 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4'
: 'space-y-2'
}
>
{data.data.map((file) => (
<FileItem
key={file.id}
file={file}
view={view}
onPreview={onPreview}
/>
))}
</div>
{/* Pagination */}
{data.totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<div className="text-sm text-gray-500">
Showing {(page - 1) * limit + 1} to{' '}
{Math.min(page * limit, data.total)} of {data.total} files
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 rounded border disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="px-3 py-1 text-sm">
Page {page} of {data.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
disabled={page === data.totalPages}
className="p-2 rounded border disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,244 @@
import { useState, useRef, useCallback } from 'react';
import { Upload, X, File, Image, FileText, Table, AlertCircle } from 'lucide-react';
import { useUploadFile } from '@/hooks/useStorage';
interface FileUploadProps {
folder?: string;
visibility?: 'private' | 'tenant' | 'public';
accept?: string;
maxSize?: number; // in bytes
onSuccess?: (file: { id: string; filename: string }) => void;
onError?: (error: Error) => void;
className?: string;
}
const ALLOWED_TYPES = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/csv',
'application/json',
'text/plain',
];
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) return Image;
if (mimeType.includes('pdf') || mimeType.includes('word')) return FileText;
if (mimeType.includes('excel') || mimeType.includes('sheet') || mimeType.includes('csv')) return Table;
return File;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export function FileUpload({
folder = 'files',
visibility = 'private',
accept,
maxSize = 50 * 1024 * 1024, // 50MB default
onSuccess,
onError,
className = '',
}: FileUploadProps) {
const [dragActive, setDragActive] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const uploadMutation = useUploadFile();
const validateFile = (file: File): string | null => {
if (!ALLOWED_TYPES.includes(file.type)) {
return 'File type not allowed';
}
if (file.size > maxSize) {
return `File too large (max: ${formatBytes(maxSize)})`;
}
return null;
};
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
setError(null);
const file = e.dataTransfer.files?.[0];
if (file) {
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
setSelectedFile(file);
}
}, [maxSize]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
const file = e.target.files?.[0];
if (file) {
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
setSelectedFile(file);
}
};
const handleUpload = async () => {
if (!selectedFile) return;
setError(null);
setUploadProgress(0);
try {
const result = await uploadMutation.mutateAsync({
file: selectedFile,
folder,
visibility,
onProgress: setUploadProgress,
});
onSuccess?.({ id: result.id, filename: result.filename });
setSelectedFile(null);
setUploadProgress(0);
} catch (err) {
const error = err as Error;
setError(error.message);
onError?.(error);
}
};
const clearFile = () => {
setSelectedFile(null);
setUploadProgress(0);
setError(null);
if (inputRef.current) {
inputRef.current.value = '';
}
};
const FileIcon = selectedFile ? getFileIcon(selectedFile.type) : File;
return (
<div className={`w-full ${className}`}>
{/* Drop zone */}
<div
className={`
relative border-2 border-dashed rounded-lg p-8
transition-colors cursor-pointer
${dragActive ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}
${selectedFile ? 'bg-gray-50' : ''}
`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => !selectedFile && inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
accept={accept || ALLOWED_TYPES.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
{!selectedFile ? (
<div className="flex flex-col items-center justify-center text-gray-500">
<Upload className="w-12 h-12 mb-4" />
<p className="text-lg font-medium">
Drop a file here or click to browse
</p>
<p className="text-sm mt-2">
Max size: {formatBytes(maxSize)}
</p>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-gray-100 rounded-lg">
<FileIcon className="w-8 h-8 text-gray-600" />
</div>
<div>
<p className="font-medium text-gray-900 truncate max-w-xs">
{selectedFile.name}
</p>
<p className="text-sm text-gray-500">
{formatBytes(selectedFile.size)}
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
clearFile();
}}
className="p-2 text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
</div>
)}
</div>
{/* Error message */}
{error && (
<div className="flex items-center gap-2 mt-3 text-red-600">
<AlertCircle className="w-4 h-4" />
<span className="text-sm">{error}</span>
</div>
)}
{/* Progress bar */}
{uploadMutation.isPending && uploadProgress > 0 && (
<div className="mt-4">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>Uploading...</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{/* Upload button */}
{selectedFile && !uploadMutation.isPending && (
<button
onClick={handleUpload}
className="mt-4 w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Upload File
</button>
)}
</div>
);
}

View File

@ -0,0 +1,109 @@
import { HardDrive, Folder } from 'lucide-react';
import { useStorageUsage } from '@/hooks/useStorage';
interface StorageUsageCardProps {
className?: string;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export function StorageUsageCard({ className = '' }: StorageUsageCardProps) {
const { data, isLoading, error } = useStorageUsage();
if (isLoading) {
return (
<div className={`bg-white rounded-lg border p-6 ${className}`}>
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-1/3 mb-4" />
<div className="h-8 bg-gray-200 rounded w-1/2 mb-4" />
<div className="h-2 bg-gray-200 rounded w-full" />
</div>
</div>
);
}
if (error || !data) {
return (
<div className={`bg-white rounded-lg border p-6 ${className}`}>
<p className="text-gray-500">Unable to load storage usage</p>
</div>
);
}
const usedPercent = data.maxBytes ? data.usagePercent : 0;
const progressColor =
usedPercent > 90 ? 'bg-red-500' : usedPercent > 70 ? 'bg-yellow-500' : 'bg-blue-500';
return (
<div className={`bg-white rounded-lg border p-6 ${className}`}>
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-blue-100 rounded-lg">
<HardDrive className="w-5 h-5 text-blue-600" />
</div>
<h3 className="font-semibold text-gray-900">Storage Usage</h3>
</div>
{/* Usage stats */}
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">
{formatBytes(data.totalBytes)} used
</span>
{data.maxBytes && (
<span className="text-gray-500">
of {formatBytes(data.maxBytes)}
</span>
)}
</div>
{data.maxBytes && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`${progressColor} h-2 rounded-full transition-all duration-300`}
style={{ width: `${Math.min(usedPercent, 100)}%` }}
/>
</div>
)}
</div>
{/* Files count */}
<div className="flex justify-between text-sm">
<span className="text-gray-600">Total files</span>
<span className="font-medium">{data.totalFiles.toLocaleString()}</span>
</div>
{/* Max file size */}
{data.maxFileSize && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">Max file size</span>
<span className="font-medium">{formatBytes(data.maxFileSize)}</span>
</div>
)}
{/* Files by folder */}
{Object.keys(data.filesByFolder).length > 0 && (
<div className="pt-4 border-t">
<h4 className="text-sm font-medium text-gray-900 mb-2">By Folder</h4>
<div className="space-y-2">
{Object.entries(data.filesByFolder).map(([folder, count]) => (
<div key={folder} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-gray-600">
<Folder className="w-4 h-4" />
<span>{folder}</span>
</div>
<span className="font-medium">{count}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,4 @@
export { FileUpload } from './FileUpload';
export { FileItem } from './FileItem';
export { FileList } from './FileList';
export { StorageUsageCard } from './StorageUsageCard';

View File

@ -0,0 +1,213 @@
import { useState } from 'react';
import { Webhook } from '@/services/api';
import {
Globe,
MoreVertical,
Trash2,
Edit2,
Play,
RefreshCw,
CheckCircle,
XCircle,
Clock,
Activity,
} from 'lucide-react';
import clsx from 'clsx';
interface WebhookCardProps {
webhook: Webhook;
onEdit: (webhook: Webhook) => void;
onDelete: (webhook: Webhook) => void;
onTest: (webhook: Webhook) => void;
onToggle: (webhook: Webhook) => void;
onViewDeliveries: (webhook: Webhook) => void;
}
export function WebhookCard({
webhook,
onEdit,
onDelete,
onTest,
onToggle,
onViewDeliveries,
}: WebhookCardProps) {
const [menuOpen, setMenuOpen] = useState(false);
const stats = webhook.stats;
return (
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div
className={clsx(
'p-2 rounded-lg',
webhook.isActive
? 'bg-green-100 dark:bg-green-900/20'
: 'bg-secondary-100 dark:bg-secondary-700'
)}
>
<Globe
className={clsx(
'w-5 h-5',
webhook.isActive
? 'text-green-600 dark:text-green-400'
: 'text-secondary-400'
)}
/>
</div>
<div>
<h3 className="font-medium text-secondary-900 dark:text-secondary-100">
{webhook.name}
</h3>
<p className="text-sm text-secondary-500 truncate max-w-xs" title={webhook.url}>
{webhook.url}
</p>
</div>
</div>
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<MoreVertical className="w-5 h-5 text-secondary-400" />
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 mt-1 w-48 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50">
<button
onClick={() => {
onEdit(webhook);
setMenuOpen(false);
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<Edit2 className="w-4 h-4" />
Edit
</button>
<button
onClick={() => {
onTest(webhook);
setMenuOpen(false);
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<Play className="w-4 h-4" />
Send Test
</button>
<button
onClick={() => {
onViewDeliveries(webhook);
setMenuOpen(false);
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<Activity className="w-4 h-4" />
View Deliveries
</button>
<button
onClick={() => {
onToggle(webhook);
setMenuOpen(false);
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<RefreshCw className="w-4 h-4" />
{webhook.isActive ? 'Disable' : 'Enable'}
</button>
<hr className="my-1 border-secondary-200 dark:border-secondary-700" />
<button
onClick={() => {
onDelete(webhook);
setMenuOpen(false);
}}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
</>
)}
</div>
</div>
{/* Events */}
<div className="mt-3 flex flex-wrap gap-1">
{webhook.events.slice(0, 3).map((event) => (
<span
key={event}
className="px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 rounded"
>
{event}
</span>
))}
{webhook.events.length > 3 && (
<span className="px-2 py-0.5 text-xs bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-400 rounded">
+{webhook.events.length - 3} more
</span>
)}
</div>
{/* Stats */}
{stats && (
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-700">
<div className="grid grid-cols-4 gap-4 text-center">
<div>
<div className="flex items-center justify-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle className="w-4 h-4" />
<span className="font-semibold">{stats.successfulDeliveries}</span>
</div>
<p className="text-xs text-secondary-500">Delivered</p>
</div>
<div>
<div className="flex items-center justify-center gap-1 text-red-600 dark:text-red-400">
<XCircle className="w-4 h-4" />
<span className="font-semibold">{stats.failedDeliveries}</span>
</div>
<p className="text-xs text-secondary-500">Failed</p>
</div>
<div>
<div className="flex items-center justify-center gap-1 text-yellow-600 dark:text-yellow-400">
<Clock className="w-4 h-4" />
<span className="font-semibold">{stats.pendingDeliveries}</span>
</div>
<p className="text-xs text-secondary-500">Pending</p>
</div>
<div>
<div className="font-semibold text-secondary-900 dark:text-secondary-100">
{stats.successRate}%
</div>
<p className="text-xs text-secondary-500">Success Rate</p>
</div>
</div>
</div>
)}
{/* Status badge */}
<div className="mt-3 flex items-center justify-between">
<span
className={clsx(
'px-2 py-1 text-xs font-medium rounded-full',
webhook.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400'
: 'bg-secondary-100 text-secondary-600 dark:bg-secondary-700 dark:text-secondary-400'
)}
>
{webhook.isActive ? 'Active' : 'Disabled'}
</span>
{stats?.lastDeliveryAt && (
<span className="text-xs text-secondary-500">
Last delivery: {new Date(stats.lastDeliveryAt).toLocaleDateString()}
</span>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,226 @@
import { useState } from 'react';
import { WebhookDelivery, DeliveryStatus } from '@/services/api';
import {
CheckCircle,
XCircle,
Clock,
RefreshCw,
ChevronDown,
ChevronUp,
RotateCcw,
} from 'lucide-react';
import clsx from 'clsx';
interface WebhookDeliveryListProps {
deliveries: WebhookDelivery[];
isLoading?: boolean;
onRetry: (deliveryId: string) => void;
onLoadMore?: () => void;
hasMore?: boolean;
}
const statusConfig: Record<
DeliveryStatus,
{ icon: typeof CheckCircle; color: string; label: string }
> = {
delivered: {
icon: CheckCircle,
color: 'text-green-600 dark:text-green-400',
label: 'Delivered',
},
failed: {
icon: XCircle,
color: 'text-red-600 dark:text-red-400',
label: 'Failed',
},
pending: {
icon: Clock,
color: 'text-yellow-600 dark:text-yellow-400',
label: 'Pending',
},
retrying: {
icon: RefreshCw,
color: 'text-blue-600 dark:text-blue-400',
label: 'Retrying',
},
};
function DeliveryRow({
delivery,
onRetry,
}: {
delivery: WebhookDelivery;
onRetry: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const config = statusConfig[delivery.status];
const StatusIcon = config.icon;
return (
<div className="border border-secondary-200 dark:border-secondary-700 rounded-lg overflow-hidden">
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-center gap-3">
<StatusIcon className={clsx('w-5 h-5', config.color)} />
<div>
<div className="font-medium text-secondary-900 dark:text-secondary-100">
{delivery.eventType}
</div>
<div className="text-sm text-secondary-500">
{new Date(delivery.createdAt).toLocaleString()}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{delivery.responseStatus && (
<span
className={clsx(
'px-2 py-0.5 text-xs font-medium rounded',
delivery.responseStatus >= 200 && delivery.responseStatus < 300
? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400'
)}
>
{delivery.responseStatus}
</span>
)}
<span
className={clsx(
'px-2 py-0.5 text-xs font-medium rounded',
delivery.status === 'delivered'
? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400'
: delivery.status === 'failed'
? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400'
)}
>
{config.label}
</span>
{expanded ? (
<ChevronUp className="w-4 h-4 text-secondary-400" />
) : (
<ChevronDown className="w-4 h-4 text-secondary-400" />
)}
</div>
</div>
{expanded && (
<div className="border-t border-secondary-200 dark:border-secondary-700 p-3 bg-secondary-50 dark:bg-secondary-900">
<div className="grid grid-cols-2 gap-4 mb-3 text-sm">
<div>
<span className="text-secondary-500">Attempt:</span>{' '}
<span className="text-secondary-900 dark:text-secondary-100">
{delivery.attempt} / {delivery.maxAttempts}
</span>
</div>
{delivery.deliveredAt && (
<div>
<span className="text-secondary-500">Delivered:</span>{' '}
<span className="text-secondary-900 dark:text-secondary-100">
{new Date(delivery.deliveredAt).toLocaleString()}
</span>
</div>
)}
{delivery.nextRetryAt && (
<div>
<span className="text-secondary-500">Next Retry:</span>{' '}
<span className="text-secondary-900 dark:text-secondary-100">
{new Date(delivery.nextRetryAt).toLocaleString()}
</span>
</div>
)}
</div>
{delivery.lastError && (
<div className="mb-3">
<div className="text-sm font-medium text-red-600 dark:text-red-400 mb-1">
Error:
</div>
<div className="text-sm text-secondary-700 dark:text-secondary-300 bg-red-50 dark:bg-red-900/20 p-2 rounded">
{delivery.lastError}
</div>
</div>
)}
<div className="mb-3">
<div className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Payload:
</div>
<pre className="text-xs bg-secondary-100 dark:bg-secondary-800 p-2 rounded overflow-x-auto max-h-40">
{JSON.stringify(delivery.payload, null, 2)}
</pre>
</div>
{delivery.responseBody && (
<div className="mb-3">
<div className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Response:
</div>
<pre className="text-xs bg-secondary-100 dark:bg-secondary-800 p-2 rounded overflow-x-auto max-h-40">
{delivery.responseBody}
</pre>
</div>
)}
{delivery.status === 'failed' && (
<button
onClick={(e) => {
e.stopPropagation();
onRetry(delivery.id);
}}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700"
>
<RotateCcw className="w-4 h-4" />
Retry
</button>
)}
</div>
)}
</div>
);
}
export function WebhookDeliveryList({
deliveries,
isLoading,
onRetry,
onLoadMore,
hasMore,
}: WebhookDeliveryListProps) {
if (isLoading && deliveries.length === 0) {
return (
<div className="text-center py-8 text-secondary-500">
Loading deliveries...
</div>
);
}
if (deliveries.length === 0) {
return (
<div className="text-center py-8 text-secondary-500">
No deliveries yet. Send a test webhook to see delivery history.
</div>
);
}
return (
<div className="space-y-3">
{deliveries.map((delivery) => (
<DeliveryRow key={delivery.id} delivery={delivery} onRetry={onRetry} />
))}
{hasMore && onLoadMore && (
<button
onClick={onLoadMore}
disabled={isLoading}
className="w-full py-2 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
{isLoading ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}

View File

@ -0,0 +1,285 @@
import { useState } from 'react';
import { Webhook, CreateWebhookRequest, UpdateWebhookRequest, WebhookEvent } from '@/services/api';
import { Plus, Trash2, Eye, EyeOff, Copy, Check } from 'lucide-react';
import clsx from 'clsx';
interface WebhookFormProps {
webhook?: Webhook | null;
events: WebhookEvent[];
onSubmit: (data: CreateWebhookRequest | UpdateWebhookRequest) => void;
onCancel: () => void;
isLoading?: boolean;
}
export function WebhookForm({
webhook,
events,
onSubmit,
onCancel,
isLoading,
}: WebhookFormProps) {
const [name, setName] = useState(webhook?.name || '');
const [description, setDescription] = useState(webhook?.description || '');
const [url, setUrl] = useState(webhook?.url || '');
const [selectedEvents, setSelectedEvents] = useState<string[]>(webhook?.events || []);
const [headers, setHeaders] = useState<{ key: string; value: string }[]>(
webhook?.headers
? Object.entries(webhook.headers).map(([key, value]) => ({ key, value }))
: []
);
const [showSecret, setShowSecret] = useState(false);
const [secretCopied, setSecretCopied] = useState(false);
const isEditing = !!webhook;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const headersObj = headers.reduce((acc, { key, value }) => {
if (key.trim()) {
acc[key.trim()] = value;
}
return acc;
}, {} as Record<string, string>);
const data = {
name,
description: description || undefined,
url,
events: selectedEvents,
headers: Object.keys(headersObj).length > 0 ? headersObj : undefined,
};
onSubmit(data);
};
const toggleEvent = (eventName: string) => {
setSelectedEvents((prev) =>
prev.includes(eventName)
? prev.filter((e) => e !== eventName)
: [...prev, eventName]
);
};
const addHeader = () => {
setHeaders((prev) => [...prev, { key: '', value: '' }]);
};
const removeHeader = (index: number) => {
setHeaders((prev) => prev.filter((_, i) => i !== index));
};
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
setHeaders((prev) =>
prev.map((h, i) => (i === index ? { ...h, [field]: value } : h))
);
};
const copySecret = async () => {
if (webhook?.secret) {
await navigator.clipboard.writeText(webhook.secret);
setSecretCopied(true);
setTimeout(() => setSecretCopied(false), 2000);
}
};
const isValid = name.trim() && url.trim() && url.startsWith('https://') && selectedEvents.length > 0;
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Name *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My Webhook"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Description
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional description"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
/>
</div>
{/* URL */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Endpoint URL *
</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/webhook"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500"
required
/>
{url && !url.startsWith('https://') && (
<p className="mt-1 text-sm text-red-600">URL must use HTTPS</p>
)}
</div>
{/* Secret (only for editing) */}
{isEditing && webhook?.secret && (
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Signing Secret
</label>
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<input
type={showSecret ? 'text' : 'password'}
value={webhook.secret}
readOnly
className="w-full px-3 py-2 pr-20 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-secondary-100 font-mono text-sm"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
onClick={() => setShowSecret(!showSecret)}
className="p-1 text-secondary-400 hover:text-secondary-600"
>
{showSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={copySecret}
className="p-1 text-secondary-400 hover:text-secondary-600"
>
{secretCopied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
</div>
</div>
<p className="mt-1 text-xs text-secondary-500">
Use this secret to verify webhook signatures
</p>
</div>
)}
{/* Events */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Events * ({selectedEvents.length} selected)
</label>
<div className="border border-secondary-300 dark:border-secondary-600 rounded-lg p-3 max-h-48 overflow-y-auto">
<div className="space-y-2">
{events.map((event) => (
<label
key={event.name}
className="flex items-start gap-3 p-2 rounded hover:bg-secondary-50 dark:hover:bg-secondary-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedEvents.includes(event.name)}
onChange={() => toggleEvent(event.name)}
className="mt-0.5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
{event.name}
</div>
<div className="text-xs text-secondary-500">{event.description}</div>
</div>
</label>
))}
</div>
</div>
{selectedEvents.length === 0 && (
<p className="mt-1 text-sm text-red-600">Select at least one event</p>
)}
</div>
{/* Custom Headers */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300">
Custom Headers
</label>
<button
type="button"
onClick={addHeader}
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus className="w-4 h-4" />
Add Header
</button>
</div>
{headers.length > 0 && (
<div className="space-y-2">
{headers.map((header, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="text"
value={header.key}
onChange={(e) => updateHeader(index, 'key', e.target.value)}
placeholder="Header name"
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 text-sm"
/>
<input
type="text"
value={header.value}
onChange={(e) => updateHeader(index, 'value', e.target.value)}
placeholder="Value"
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 text-sm"
/>
<button
type="button"
onClick={() => removeHeader(index)}
className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{headers.length === 0 && (
<p className="text-sm text-secondary-500">No custom headers</p>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-secondary-200 dark:border-secondary-700">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
Cancel
</button>
<button
type="submit"
disabled={!isValid || isLoading}
className={clsx(
'px-4 py-2 rounded-lg font-medium',
isValid && !isLoading
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-secondary-200 text-secondary-400 cursor-not-allowed'
)}
>
{isLoading ? 'Saving...' : isEditing ? 'Update Webhook' : 'Create Webhook'}
</button>
</div>
</form>
);
}

View File

@ -0,0 +1,3 @@
export * from './WebhookCard';
export * from './WebhookForm';
export * from './WebhookDeliveryList';

View File

@ -0,0 +1,95 @@
import React, { useState } from 'react';
import { useTestWhatsAppConnection } from '../../hooks/useWhatsApp';
interface WhatsAppTestMessageProps {
disabled?: boolean;
}
export function WhatsAppTestMessage({ disabled }: WhatsAppTestMessageProps) {
const [phoneNumber, setPhoneNumber] = useState('');
const testConnection = useTestWhatsAppConnection();
const handleTest = (e: React.FormEvent) => {
e.preventDefault();
if (phoneNumber) {
testConnection.mutate(phoneNumber);
}
};
return (
<div className="bg-white border rounded-lg p-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Probar Conexion
</h3>
<p className="text-sm text-gray-600 mb-4">
Envia un mensaje de prueba para verificar que la integracion funciona correctamente.
</p>
<form onSubmit={handleTest} className="space-y-4">
<div>
<label
htmlFor="testPhone"
className="block text-sm font-medium text-gray-700"
>
Numero de telefono (E.164)
</label>
<div className="mt-1">
<input
type="text"
id="testPhone"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+521234567890"
pattern="^\+[1-9]\d{1,14}$"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
disabled={disabled || testConnection.isPending}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
Formato E.164: +[codigo pais][numero]
</p>
</div>
<button
type="submit"
disabled={disabled || !phoneNumber || testConnection.isPending}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{testConnection.isPending ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Enviando...
</>
) : (
<>
<svg
className="-ml-1 mr-2 h-4 w-4"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M.057 24l1.687-6.163c-1.041-1.804-1.588-3.849-1.587-5.946.003-6.556 5.338-11.891 11.893-11.891 3.181.001 6.167 1.24 8.413 3.488 2.245 2.248 3.481 5.236 3.48 8.414-.003 6.557-5.338 11.892-11.893 11.892-1.99-.001-3.951-.5-5.688-1.448l-6.305 1.654zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414z" />
</svg>
Enviar mensaje de prueba
</>
)}
</button>
</form>
</div>
);
}

View File

@ -0,0 +1 @@
export * from './WhatsAppTestMessage';

15
src/hooks/index.ts Normal file
View File

@ -0,0 +1,15 @@
export * from './useAuth';
export * from './useData';
export * from './useSuperadmin';
export * from './useOnboarding';
export * from './useAI';
export * from './useStorage';
export * from './useWebhooks';
export * from './useAudit';
export * from './useFeatureFlags';
export * from './usePushNotifications';
export * from './useOAuth';
export * from './useExport';
export * from './useAnalytics';
export * from './useMfa';
export * from './useWhatsApp';

134
src/hooks/useAI.ts Normal file
View File

@ -0,0 +1,134 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { aiApi, ChatRequest, ChatResponse, AIConfig, AIModel, AIUsageStats } from '@/services/api';
import { AxiosError } from 'axios';
interface ApiError {
message: string;
statusCode?: number;
}
// ==================== Query Keys ====================
export const aiQueryKeys = {
all: ['ai'] as const,
config: () => [...aiQueryKeys.all, 'config'] as const,
models: () => [...aiQueryKeys.all, 'models'] as const,
usage: (page?: number, limit?: number) => [...aiQueryKeys.all, 'usage', { page, limit }] as const,
currentUsage: () => [...aiQueryKeys.all, 'current-usage'] as const,
health: () => [...aiQueryKeys.all, 'health'] as const,
};
// ==================== Config Hooks ====================
export function useAIConfig() {
return useQuery({
queryKey: aiQueryKeys.config(),
queryFn: () => aiApi.getConfig(),
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useUpdateAIConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<AIConfig>) => aiApi.updateConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: aiQueryKeys.config() });
toast.success('AI configuration updated');
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to update AI configuration');
},
});
}
// ==================== Models Hook ====================
export function useAIModels() {
return useQuery({
queryKey: aiQueryKeys.models(),
queryFn: () => aiApi.getModels(),
staleTime: 30 * 60 * 1000, // 30 minutes - models don't change often
});
}
// ==================== Chat Hook ====================
export function useAIChat() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: ChatRequest) => aiApi.chat(data),
onSuccess: () => {
// Invalidate usage queries after successful chat
queryClient.invalidateQueries({ queryKey: aiQueryKeys.currentUsage() });
queryClient.invalidateQueries({ queryKey: aiQueryKeys.usage() });
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Failed to get AI response';
toast.error(message);
},
});
}
// ==================== Usage Hooks ====================
export interface AIUsageRecord {
id: string;
model: string;
input_tokens: number;
output_tokens: number;
total_tokens: number;
cost: number;
latency_ms: number;
status: string;
created_at: string;
}
export interface AIUsageResponse {
data: AIUsageRecord[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export function useAIUsage(page = 1, limit = 10) {
return useQuery({
queryKey: aiQueryKeys.usage(page, limit),
queryFn: () => aiApi.getUsage({ page, limit }) as Promise<AIUsageResponse>,
});
}
export function useCurrentAIUsage() {
return useQuery({
queryKey: aiQueryKeys.currentUsage(),
queryFn: () => aiApi.getCurrentUsage(),
refetchInterval: 60000, // Refetch every minute
});
}
// ==================== Health Hook ====================
export interface AIHealthStatus {
status: 'healthy' | 'degraded' | 'unhealthy';
provider: string;
latency_ms: number;
models_available: number;
}
export function useAIHealth() {
return useQuery({
queryKey: aiQueryKeys.health(),
queryFn: () => aiApi.getHealth() as Promise<AIHealthStatus>,
refetchInterval: 5 * 60 * 1000, // Check every 5 minutes
retry: 1,
});
}
// ==================== Re-export types ====================
export type { ChatRequest, ChatResponse, AIConfig, AIModel, AIUsageStats };

192
src/hooks/useAnalytics.ts Normal file
View File

@ -0,0 +1,192 @@
import { useQuery } from '@tanstack/react-query';
import { analyticsApi } from '@/services/api';
// Query keys
export const analyticsKeys = {
all: ['analytics'] as const,
users: (period: string) => [...analyticsKeys.all, 'users', period] as const,
billing: (period: string) => [...analyticsKeys.all, 'billing', period] as const,
usage: (period: string) => [...analyticsKeys.all, 'usage', period] as const,
summary: () => [...analyticsKeys.all, 'summary'] as const,
trends: (period: string) => [...analyticsKeys.all, 'trends', period] as const,
};
// Types
export type AnalyticsPeriod = '7d' | '30d' | '90d' | '1y';
export interface UserMetrics {
totalUsers: number;
activeUsers: number;
newUsers: number;
churnedUsers: number;
growthRate: number;
activeRate: number;
byDay: { date: string; total: number; active: number; new: number }[];
}
export interface BillingMetrics {
mrr: number;
arr: number;
totalRevenue: number;
avgRevenuePerUser: number;
mrrGrowth: number;
churnRate: number;
ltv: number;
byDay: { date: string; revenue: number; mrr: number }[];
}
export interface UsageMetrics {
totalActions: number;
avgActionsPerUser: number;
topFeatures: { feature: string; count: number; percentage: number }[];
byDay: { date: string; actions: number }[];
byHour: { hour: number; actions: number }[];
}
export interface AnalyticsSummary {
users: {
total: number;
active: number;
change: number;
};
revenue: {
mrr: number;
arr: number;
change: number;
};
usage: {
actions: number;
avgPerUser: number;
change: number;
};
engagement: {
rate: number;
sessions: number;
change: number;
};
}
export interface AnalyticsTrends {
users: { date: string; value: number }[];
revenue: { date: string; value: number }[];
actions: { date: string; value: number }[];
}
// Hooks
/**
* Hook to fetch user metrics for a given period
*/
export function useUserMetrics(period: AnalyticsPeriod = '30d') {
return useQuery({
queryKey: analyticsKeys.users(period),
queryFn: () => analyticsApi.getUserMetrics(period),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook to fetch billing metrics for a given period
*/
export function useBillingMetrics(period: AnalyticsPeriod = '30d') {
return useQuery({
queryKey: analyticsKeys.billing(period),
queryFn: () => analyticsApi.getBillingMetrics(period),
staleTime: 5 * 60 * 1000,
});
}
/**
* Hook to fetch usage metrics for a given period
*/
export function useUsageMetrics(period: AnalyticsPeriod = '30d') {
return useQuery({
queryKey: analyticsKeys.usage(period),
queryFn: () => analyticsApi.getUsageMetrics(period),
staleTime: 5 * 60 * 1000,
});
}
/**
* Hook to fetch analytics summary (KPIs)
*/
export function useAnalyticsSummary() {
return useQuery({
queryKey: analyticsKeys.summary(),
queryFn: () => analyticsApi.getSummary(),
staleTime: 5 * 60 * 1000,
});
}
/**
* Hook to fetch analytics trends for charts
*/
export function useAnalyticsTrends(period: AnalyticsPeriod = '30d') {
return useQuery({
queryKey: analyticsKeys.trends(period),
queryFn: () => analyticsApi.getTrends(period),
staleTime: 5 * 60 * 1000,
});
}
// Helper functions
/**
* Get period label for display
*/
export function getPeriodLabel(period: AnalyticsPeriod): string {
const labels: Record<AnalyticsPeriod, string> = {
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
'1y': 'Last year',
};
return labels[period];
}
/**
* Get available periods for selector
*/
export function getAvailablePeriods(): { value: AnalyticsPeriod; label: string }[] {
return [
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: '90d', label: 'Last 90 days' },
{ value: '1y', label: 'Last year' },
];
}
/**
* Format currency value
*/
export function formatCurrency(value: number, compact = false): string {
if (compact && value >= 1000) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
notation: 'compact',
maximumFractionDigits: 1,
}).format(value);
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
}
/**
* Format large numbers compactly
*/
export function formatNumber(value: number, compact = false): string {
if (compact && value >= 1000) {
return new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(value);
}
return new Intl.NumberFormat('en-US').format(value);
}

154
src/hooks/useAudit.ts Normal file
View File

@ -0,0 +1,154 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
auditApi,
QueryAuditLogsParams,
QueryActivityLogsParams,
CreateActivityLogRequest,
AuditAction,
ActivityType,
} from '@/services/api';
// Query keys
const auditKeys = {
all: ['audit'] as const,
logs: () => [...auditKeys.all, 'logs'] as const,
logsQuery: (params?: QueryAuditLogsParams) => [...auditKeys.logs(), params] as const,
log: (id: string) => [...auditKeys.logs(), id] as const,
entityHistory: (entityType: string, entityId: string) =>
[...auditKeys.logs(), 'entity', entityType, entityId] as const,
stats: (days?: number) => [...auditKeys.all, 'stats', days] as const,
activities: () => [...auditKeys.all, 'activities'] as const,
activitiesQuery: (params?: QueryActivityLogsParams) => [...auditKeys.activities(), params] as const,
activitySummary: (days?: number) => [...auditKeys.activities(), 'summary', days] as const,
userActivitySummary: (userId: string, days?: number) =>
[...auditKeys.activities(), 'user', userId, days] as const,
};
// ==================== Audit Logs ====================
export function useAuditLogs(params?: QueryAuditLogsParams) {
return useQuery({
queryKey: auditKeys.logsQuery(params),
queryFn: () => auditApi.queryLogs(params),
});
}
export function useAuditLog(id: string) {
return useQuery({
queryKey: auditKeys.log(id),
queryFn: () => auditApi.getLog(id),
enabled: !!id,
});
}
export function useEntityAuditHistory(entityType: string, entityId: string) {
return useQuery({
queryKey: auditKeys.entityHistory(entityType, entityId),
queryFn: () => auditApi.getEntityHistory(entityType, entityId),
enabled: !!entityType && !!entityId,
});
}
export function useAuditStats(days?: number) {
return useQuery({
queryKey: auditKeys.stats(days),
queryFn: () => auditApi.getStats(days),
});
}
// ==================== Activity Logs ====================
export function useActivityLogs(params?: QueryActivityLogsParams) {
return useQuery({
queryKey: auditKeys.activitiesQuery(params),
queryFn: () => auditApi.queryActivities(params),
});
}
export function useCreateActivityLog() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateActivityLogRequest) => auditApi.createActivity(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: auditKeys.activities() });
},
});
}
export function useActivitySummary(days?: number) {
return useQuery({
queryKey: auditKeys.activitySummary(days),
queryFn: () => auditApi.getActivitySummary(days),
});
}
export function useUserActivitySummary(userId: string, days?: number) {
return useQuery({
queryKey: auditKeys.userActivitySummary(userId, days),
queryFn: () => auditApi.getUserActivitySummary(userId, days),
enabled: !!userId,
});
}
// ==================== Helper functions ====================
export function getAuditActionLabel(action: AuditAction): string {
const labels: Record<AuditAction, string> = {
create: 'Created',
update: 'Updated',
delete: 'Deleted',
read: 'Viewed',
login: 'Logged in',
logout: 'Logged out',
export: 'Exported',
import: 'Imported',
};
return labels[action] || action;
}
export function getAuditActionColor(action: AuditAction): string {
const colors: Record<AuditAction, string> = {
create: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
update: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
delete: 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400',
read: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400',
login: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
logout: 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400',
export: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/20 dark:text-cyan-400',
import: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/20 dark:text-indigo-400',
};
return colors[action] || 'bg-gray-100 text-gray-800';
}
export function getActivityTypeLabel(type: ActivityType): string {
const labels: Record<ActivityType, string> = {
page_view: 'Page View',
feature_use: 'Feature Use',
search: 'Search',
download: 'Download',
upload: 'Upload',
share: 'Share',
invite: 'Invite',
settings_change: 'Settings Change',
subscription_change: 'Subscription Change',
payment: 'Payment',
};
return labels[type] || type;
}
export function getActivityTypeIcon(type: ActivityType): string {
const icons: Record<ActivityType, string> = {
page_view: 'Eye',
feature_use: 'Zap',
search: 'Search',
download: 'Download',
upload: 'Upload',
share: 'Share2',
invite: 'UserPlus',
settings_change: 'Settings',
subscription_change: 'CreditCard',
payment: 'DollarSign',
};
return icons[type] || 'Activity';
}

193
src/hooks/useAuth.ts Normal file
View File

@ -0,0 +1,193 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { authApi, AuthResponse } from '@/services/api';
import { useAuthStore } from '@/stores';
import { AxiosError } from 'axios';
interface ApiError {
message: string;
statusCode?: number;
}
// Query keys
export const authKeys = {
all: ['auth'] as const,
me: () => [...authKeys.all, 'me'] as const,
};
// Get current user hook
export function useCurrentUser() {
const { isAuthenticated, accessToken } = useAuthStore();
return useQuery({
queryKey: authKeys.me(),
queryFn: authApi.me,
enabled: isAuthenticated && !!accessToken,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: false,
});
}
// Login mutation hook
export function useLogin() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { login } = useAuthStore();
return useMutation({
mutationFn: ({ email, password }: { email: string; password: string }) =>
authApi.login(email, password),
onSuccess: (data: AuthResponse) => {
login(
{
id: data.user.id,
email: data.user.email,
first_name: data.user.first_name || '',
last_name: data.user.last_name || '',
role: 'user',
tenant_id: data.user.tenant_id,
},
data.accessToken,
data.refreshToken
);
queryClient.invalidateQueries({ queryKey: authKeys.all });
toast.success('Welcome back!');
navigate('/dashboard');
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Invalid credentials';
toast.error(message);
},
});
}
// Register mutation hook
export function useRegister() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { login } = useAuthStore();
return useMutation({
mutationFn: (data: {
email: string;
password: string;
first_name?: string;
last_name?: string;
phone?: string;
}) => authApi.register(data),
onSuccess: (data: AuthResponse) => {
login(
{
id: data.user.id,
email: data.user.email,
first_name: data.user.first_name || '',
last_name: data.user.last_name || '',
role: 'user',
tenant_id: data.user.tenant_id,
},
data.accessToken,
data.refreshToken
);
queryClient.invalidateQueries({ queryKey: authKeys.all });
toast.success('Account created successfully!');
navigate('/dashboard');
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Registration failed';
toast.error(message);
},
});
}
// Logout mutation hook
export function useLogout() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { logout } = useAuthStore();
return useMutation({
mutationFn: () => authApi.logout(),
onSuccess: () => {
logout();
queryClient.clear();
toast.success('Logged out successfully');
navigate('/auth/login');
},
onError: () => {
// Even if API fails, still logout locally
logout();
queryClient.clear();
navigate('/auth/login');
},
});
}
// Request password reset mutation hook
export function useRequestPasswordReset() {
return useMutation({
mutationFn: (email: string) => authApi.requestPasswordReset(email),
onSuccess: () => {
toast.success('If an account exists with this email, you will receive reset instructions.');
},
onError: () => {
// Don't reveal if email exists or not
toast.success('If an account exists with this email, you will receive reset instructions.');
},
});
}
// Reset password mutation hook
export function useResetPassword() {
const navigate = useNavigate();
return useMutation({
mutationFn: ({ token, password }: { token: string; password: string }) =>
authApi.resetPassword(token, password),
onSuccess: () => {
toast.success('Password reset successfully! Please login with your new password.');
navigate('/auth/login');
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Invalid or expired reset token';
toast.error(message);
},
});
}
// Change password mutation hook
export function useChangePassword() {
return useMutation({
mutationFn: ({
currentPassword,
newPassword,
}: {
currentPassword: string;
newPassword: string;
}) => authApi.changePassword(currentPassword, newPassword),
onSuccess: () => {
toast.success('Password changed successfully!');
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Failed to change password';
toast.error(message);
},
});
}
// Verify email mutation hook
export function useVerifyEmail() {
const navigate = useNavigate();
return useMutation({
mutationFn: (token: string) => authApi.verifyEmail(token),
onSuccess: () => {
toast.success('Email verified successfully!');
navigate('/dashboard');
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Invalid or expired verification token';
toast.error(message);
},
});
}

364
src/hooks/useData.ts Normal file
View File

@ -0,0 +1,364 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { usersApi, billingApi, stripeApi, notificationsApi } from '@/services/api';
import { AxiosError } from 'axios';
interface ApiError {
message: string;
statusCode?: number;
}
// ==================== Query Keys ====================
export const queryKeys = {
users: {
all: ['users'] as const,
list: (page?: number, limit?: number) => [...queryKeys.users.all, 'list', { page, limit }] as const,
detail: (id: string) => [...queryKeys.users.all, 'detail', id] as const,
},
billing: {
all: ['billing'] as const,
subscription: () => [...queryKeys.billing.all, 'subscription'] as const,
subscriptionStatus: () => [...queryKeys.billing.all, 'subscription-status'] as const,
invoices: (page?: number, limit?: number) => [...queryKeys.billing.all, 'invoices', { page, limit }] as const,
invoice: (id: string) => [...queryKeys.billing.all, 'invoice', id] as const,
paymentMethods: () => [...queryKeys.billing.all, 'payment-methods'] as const,
summary: () => [...queryKeys.billing.all, 'summary'] as const,
},
stripe: {
all: ['stripe'] as const,
customer: () => [...queryKeys.stripe.all, 'customer'] as const,
prices: () => [...queryKeys.stripe.all, 'prices'] as const,
paymentMethods: () => [...queryKeys.stripe.all, 'payment-methods'] as const,
},
notifications: {
all: ['notifications'] as const,
list: (page?: number, limit?: number) => [...queryKeys.notifications.all, 'list', { page, limit }] as const,
unreadCount: () => [...queryKeys.notifications.all, 'unread-count'] as const,
preferences: () => [...queryKeys.notifications.all, 'preferences'] as const,
},
dashboard: {
all: ['dashboard'] as const,
stats: () => [...queryKeys.dashboard.all, 'stats'] as const,
},
};
// ==================== Users Hooks ====================
export interface UserListItem {
id: string;
email: string;
first_name: string | null;
last_name: string | null;
status: string;
created_at: string;
last_login_at: string | null;
roles?: { name: string }[];
}
export interface UsersResponse {
data: UserListItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export function useUsers(page = 1, limit = 10) {
return useQuery({
queryKey: queryKeys.users.list(page, limit),
queryFn: () => usersApi.list({ page, limit }) as Promise<UsersResponse>,
});
}
export function useUser(id: string) {
return useQuery({
queryKey: queryKeys.users.detail(id),
queryFn: () => usersApi.get(id),
enabled: !!id,
});
}
export function useInviteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ email, role }: { email: string; role?: string }) =>
usersApi.invite(email, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
toast.success('Invitation sent successfully!');
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to send invitation');
},
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<{ first_name: string; last_name: string }> }) =>
usersApi.update(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(variables.id) });
toast.success('User updated successfully!');
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to update user');
},
});
}
// ==================== Billing Hooks ====================
export interface Subscription {
id: string;
tenant_id: string;
plan_id: string;
status: string;
current_period_start: string;
current_period_end: string;
cancel_at_period_end: boolean;
stripe_subscription_id?: string;
plan?: {
id: string;
name: string;
display_name: string;
price_monthly: number;
price_yearly: number;
billing_cycle: string;
features?: { name: string; included: boolean }[];
};
}
export interface Invoice {
id: string;
invoice_number: string;
status: string;
subtotal: number;
tax: number;
total: number;
currency: string;
issue_date: string;
due_date: string;
paid_at: string | null;
stripe_invoice_id?: string;
}
export interface PaymentMethod {
id: string;
type: string;
card_last_four: string;
card_brand: string;
card_exp_month: number;
card_exp_year: number;
is_default: boolean;
}
export interface BillingSummary {
subscription: Subscription | null;
totalPaid: number;
invoiceCount: number;
upcomingInvoice: {
amount: number;
dueDate: string;
} | null;
}
export function useSubscription() {
return useQuery({
queryKey: queryKeys.billing.subscription(),
queryFn: () => billingApi.getSubscription() as Promise<Subscription | null>,
});
}
export function useSubscriptionStatus() {
return useQuery({
queryKey: queryKeys.billing.subscriptionStatus(),
queryFn: () => billingApi.getSubscriptionStatus(),
});
}
export function useInvoices(page = 1, limit = 10) {
return useQuery({
queryKey: queryKeys.billing.invoices(page, limit),
queryFn: () => billingApi.getInvoices({ page, limit }) as Promise<{ data: Invoice[]; total: number }>,
});
}
export function useInvoice(id: string) {
return useQuery({
queryKey: queryKeys.billing.invoice(id),
queryFn: () => billingApi.getInvoice(id) as Promise<Invoice>,
enabled: !!id,
});
}
export function usePaymentMethods() {
return useQuery({
queryKey: queryKeys.billing.paymentMethods(),
queryFn: () => billingApi.getPaymentMethods() as Promise<PaymentMethod[]>,
});
}
export function useBillingSummary() {
return useQuery({
queryKey: queryKeys.billing.summary(),
queryFn: () => billingApi.getSummary() as Promise<BillingSummary>,
});
}
// ==================== Stripe Hooks ====================
export function useStripeCustomer() {
return useQuery({
queryKey: queryKeys.stripe.customer(),
queryFn: stripeApi.getCustomer,
retry: false,
});
}
export function useStripePrices() {
return useQuery({
queryKey: queryKeys.stripe.prices(),
queryFn: stripeApi.getPrices,
});
}
export function useCreateCheckoutSession() {
return useMutation({
mutationFn: (data: { price_id: string; success_url: string; cancel_url: string }) =>
stripeApi.createCheckoutSession(data),
onSuccess: (data) => {
if (data.url) {
window.location.href = data.url;
}
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to create checkout session');
},
});
}
export function useCreateBillingPortal() {
return useMutation({
mutationFn: (returnUrl: string) => stripeApi.createBillingPortal(returnUrl),
onSuccess: (data) => {
if (data.url) {
window.location.href = data.url;
}
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to open billing portal');
},
});
}
// ==================== Notifications Hooks ====================
export interface Notification {
id: string;
title: string;
message: string;
type: string;
read_at: string | null;
created_at: string;
}
export function useNotifications(page = 1, limit = 10) {
return useQuery({
queryKey: queryKeys.notifications.list(page, limit),
queryFn: () => notificationsApi.list({ page, limit }) as Promise<{ data: Notification[]; total: number }>,
});
}
export function useUnreadNotificationsCount() {
return useQuery({
queryKey: queryKeys.notifications.unreadCount(),
queryFn: () => notificationsApi.getUnreadCount() as Promise<{ count: number }>,
refetchInterval: 30000, // Refetch every 30 seconds
});
}
export function useMarkNotificationAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => notificationsApi.markAsRead(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.notifications.all });
},
});
}
export function useMarkAllNotificationsAsRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => notificationsApi.markAllAsRead(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.notifications.all });
toast.success('All notifications marked as read');
},
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: queryKeys.notifications.preferences(),
queryFn: notificationsApi.getPreferences,
});
}
export function useUpdateNotificationPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (preferences: Record<string, boolean>) =>
notificationsApi.updatePreferences(preferences),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.notifications.preferences() });
toast.success('Preferences updated');
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to update preferences');
},
});
}
// ==================== Dashboard Stats Hook ====================
export interface DashboardStats {
totalUsers: number;
activeUsers: number;
monthlyRevenue: number;
revenueChange: number;
activeSessions: number;
sessionsChange: number;
planName: string;
}
export function useDashboardStats() {
const { data: users } = useUsers(1, 1);
const { data: subscription } = useSubscription();
const { data: billingSummary } = useBillingSummary();
// Compute dashboard stats from available data
const stats: DashboardStats = {
totalUsers: users?.total ?? 0,
activeUsers: users?.total ?? 0, // In real app, filter by status
monthlyRevenue: billingSummary?.totalPaid ?? 0,
revenueChange: 0, // Would need historical data
activeSessions: 0, // Would need session tracking
sessionsChange: 0,
planName: subscription?.plan?.display_name ?? 'Free',
};
return {
data: stats,
isLoading: !users && !subscription,
};
}

139
src/hooks/useExport.ts Normal file
View File

@ -0,0 +1,139 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { reportsApi } from '@/services/api';
import toast from 'react-hot-toast';
export type ReportType = 'users' | 'billing' | 'audit';
export type ExportFormat = 'pdf' | 'excel' | 'csv';
export interface ExportParams {
reportType: ReportType;
format: ExportFormat;
dateFrom?: string;
dateTo?: string;
}
// File extension mapping
const formatExtensions: Record<ExportFormat, string> = {
pdf: 'pdf',
excel: 'xlsx',
csv: 'csv',
};
// Generate filename with timestamp
function generateFilename(reportType: ReportType, format: ExportFormat): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const extension = formatExtensions[format];
return `${reportType}-report-${timestamp}.${extension}`;
}
// Download blob as file
function downloadBlob(blob: Blob, filename: string): void {
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
// Export function based on report type
async function exportReportData(params: ExportParams): Promise<Blob> {
const { reportType, format, dateFrom, dateTo } = params;
const queryParams = { dateFrom, dateTo };
let response;
switch (reportType) {
case 'users':
response = await reportsApi.exportUsers(format, queryParams);
break;
case 'billing':
response = await reportsApi.exportBilling(format, queryParams);
break;
case 'audit':
response = await reportsApi.exportAudit(format, queryParams);
break;
default:
throw new Error(`Unknown report type: ${reportType}`);
}
return response.data;
}
/**
* Hook for exporting reports
* Handles the download of files in PDF, Excel, or CSV format
*/
export function useExportReport() {
const [isExporting, setIsExporting] = useState(false);
const mutation = useMutation({
mutationFn: exportReportData,
onMutate: () => {
setIsExporting(true);
},
onSuccess: (blob, params) => {
const filename = generateFilename(params.reportType, params.format);
downloadBlob(blob, filename);
toast.success(`${params.reportType.charAt(0).toUpperCase() + params.reportType.slice(1)} report exported successfully!`);
},
onError: (error: Error) => {
console.error('Export error:', error);
toast.error(error.message || 'Failed to export report. Please try again.');
},
onSettled: () => {
setIsExporting(false);
},
});
const exportReport = async (params: ExportParams) => {
return mutation.mutateAsync(params);
};
return {
exportReport,
isExporting,
error: mutation.error,
};
}
/**
* Hook for exporting reports with modal integration
* Provides additional state for modal open/close
*/
export function useExportModal(reportType: ReportType) {
const [isModalOpen, setIsModalOpen] = useState(false);
const { exportReport, isExporting, error } = useExportReport();
const openModal = () => setIsModalOpen(true);
const closeModal = () => {
if (!isExporting) {
setIsModalOpen(false);
}
};
const handleExport = async (params: { format: ExportFormat; dateFrom?: string; dateTo?: string }) => {
try {
await exportReport({
reportType,
format: params.format,
dateFrom: params.dateFrom,
dateTo: params.dateTo,
});
closeModal();
} catch {
// Error is handled by the mutation
}
};
return {
isModalOpen,
openModal,
closeModal,
handleExport,
isExporting,
error,
};
}

View File

@ -0,0 +1,225 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
featureFlagsApi,
CreateFlagRequest,
UpdateFlagRequest,
SetTenantFlagRequest,
SetUserFlagRequest,
FlagType,
FlagScope,
} from '@/services/api';
// Query keys
const flagKeys = {
all: ['feature-flags'] as const,
list: () => [...flagKeys.all, 'list'] as const,
detail: (id: string) => [...flagKeys.all, 'detail', id] as const,
tenantOverrides: () => [...flagKeys.all, 'tenant-overrides'] as const,
userOverrides: (userId: string) => [...flagKeys.all, 'user-overrides', userId] as const,
evaluation: () => [...flagKeys.all, 'evaluation'] as const,
evaluationKey: (key: string) => [...flagKeys.evaluation(), key] as const,
check: (key: string) => [...flagKeys.all, 'check', key] as const,
};
// ==================== Flag Management ====================
export function useFeatureFlags() {
return useQuery({
queryKey: flagKeys.list(),
queryFn: () => featureFlagsApi.list(),
});
}
export function useFeatureFlag(id: string) {
return useQuery({
queryKey: flagKeys.detail(id),
queryFn: () => featureFlagsApi.get(id),
enabled: !!id,
});
}
export function useCreateFeatureFlag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateFlagRequest) => featureFlagsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flagKeys.list() });
},
});
}
export function useUpdateFeatureFlag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateFlagRequest }) =>
featureFlagsApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: flagKeys.list() });
queryClient.invalidateQueries({ queryKey: flagKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
},
});
}
export function useDeleteFeatureFlag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => featureFlagsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flagKeys.list() });
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
},
});
}
export function useToggleFeatureFlag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) =>
featureFlagsApi.toggle(id, enabled),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: flagKeys.list() });
queryClient.invalidateQueries({ queryKey: flagKeys.detail(id) });
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
},
});
}
// ==================== Tenant Flags ====================
export function useTenantFlagOverrides() {
return useQuery({
queryKey: flagKeys.tenantOverrides(),
queryFn: () => featureFlagsApi.getTenantOverrides(),
});
}
export function useSetTenantFlagOverride() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: SetTenantFlagRequest) => featureFlagsApi.setTenantOverride(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flagKeys.tenantOverrides() });
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
},
});
}
export function useRemoveTenantFlagOverride() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (flagId: string) => featureFlagsApi.removeTenantOverride(flagId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: flagKeys.tenantOverrides() });
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
},
});
}
// ==================== User Flags ====================
export function useUserFlagOverrides(userId: string) {
return useQuery({
queryKey: flagKeys.userOverrides(userId),
queryFn: () => featureFlagsApi.getUserOverrides(userId),
enabled: !!userId,
});
}
export function useSetUserFlagOverride() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: SetUserFlagRequest) => featureFlagsApi.setUserOverride(data),
onSuccess: (_, { user_id }) => {
queryClient.invalidateQueries({ queryKey: flagKeys.userOverrides(user_id) });
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
},
});
}
export function useRemoveUserFlagOverride() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, flagId }: { userId: string; flagId: string }) =>
featureFlagsApi.removeUserOverride(userId, flagId),
onSuccess: (_, { userId }) => {
queryClient.invalidateQueries({ queryKey: flagKeys.userOverrides(userId) });
queryClient.invalidateQueries({ queryKey: flagKeys.evaluation() });
},
});
}
// ==================== Evaluation ====================
export function useFlagEvaluation(key: string) {
return useQuery({
queryKey: flagKeys.evaluationKey(key),
queryFn: () => featureFlagsApi.evaluate(key),
enabled: !!key,
});
}
export function useAllFlagEvaluations() {
return useQuery({
queryKey: flagKeys.evaluation(),
queryFn: () => featureFlagsApi.evaluateAll(),
});
}
export function useFlagCheck(key: string) {
return useQuery({
queryKey: flagKeys.check(key),
queryFn: () => featureFlagsApi.check(key),
enabled: !!key,
});
}
// ==================== Helper functions ====================
export function getFlagTypeLabel(type: FlagType): string {
const labels: Record<FlagType, string> = {
boolean: 'Boolean',
string: 'String',
number: 'Number',
json: 'JSON',
};
return labels[type] || type;
}
export function getFlagTypeColor(type: FlagType): string {
const colors: Record<FlagType, string> = {
boolean: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
string: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
number: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
json: 'bg-orange-100 text-orange-800 dark:bg-orange-900/20 dark:text-orange-400',
};
return colors[type] || 'bg-gray-100 text-gray-800';
}
export function getFlagScopeLabel(scope: FlagScope): string {
const labels: Record<FlagScope, string> = {
global: 'Global',
tenant: 'Tenant',
user: 'User',
plan: 'Plan',
};
return labels[scope] || scope;
}
export function getFlagScopeColor(scope: FlagScope): string {
const colors: Record<FlagScope, string> = {
global: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400',
tenant: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
user: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
plan: 'bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-400',
};
return colors[scope] || 'bg-gray-100 text-gray-800';
}

149
src/hooks/useMfa.ts Normal file
View File

@ -0,0 +1,149 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { AxiosError } from 'axios';
import api from '@/services/api';
interface ApiError {
message: string;
statusCode?: number;
}
// Types
export interface MfaStatus {
enabled: boolean;
enabledAt?: string;
backupCodesRemaining: number;
}
export interface MfaSetupResponse {
secret: string;
qrCodeDataUrl: string;
backupCodes: string[];
}
export interface VerifyMfaSetupDto {
code: string;
secret: string;
}
export interface DisableMfaDto {
password: string;
code: string;
}
export interface RegenerateBackupCodesDto {
password: string;
code: string;
}
export interface BackupCodesResponse {
backupCodes: string[];
message: string;
}
// Query keys
export const mfaKeys = {
all: ['mfa'] as const,
status: () => [...mfaKeys.all, 'status'] as const,
};
// API functions
const mfaApi = {
getStatus: (): Promise<MfaStatus> =>
api.get('/auth/mfa/status').then((res) => res.data),
setup: (): Promise<MfaSetupResponse> =>
api.post('/auth/mfa/setup').then((res) => res.data),
verifySetup: (dto: VerifyMfaSetupDto): Promise<{ success: boolean; message: string }> =>
api.post('/auth/mfa/verify-setup', dto).then((res) => res.data),
disable: (dto: DisableMfaDto): Promise<{ success: boolean; message: string }> =>
api.post('/auth/mfa/disable', dto).then((res) => res.data),
regenerateBackupCodes: (dto: RegenerateBackupCodesDto): Promise<BackupCodesResponse> =>
api.post('/auth/mfa/backup-codes/regenerate', dto).then((res) => res.data),
};
// Hooks
/**
* Get MFA status for current user
*/
export function useMfaStatus() {
return useQuery({
queryKey: mfaKeys.status(),
queryFn: mfaApi.getStatus,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Initialize MFA setup - returns QR code and secret
*/
export function useSetupMfa() {
return useMutation({
mutationFn: mfaApi.setup,
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Failed to setup MFA';
toast.error(message);
},
});
}
/**
* Verify TOTP code and enable MFA
*/
export function useVerifyMfaSetup() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: mfaApi.verifySetup,
onSuccess: (data) => {
toast.success(data.message || 'MFA enabled successfully!');
queryClient.invalidateQueries({ queryKey: mfaKeys.status() });
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Invalid verification code';
toast.error(message);
},
});
}
/**
* Disable MFA
*/
export function useDisableMfa() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: mfaApi.disable,
onSuccess: (data) => {
toast.success(data.message || 'MFA disabled successfully');
queryClient.invalidateQueries({ queryKey: mfaKeys.status() });
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Failed to disable MFA';
toast.error(message);
},
});
}
/**
* Regenerate backup codes
*/
export function useRegenerateBackupCodes() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: mfaApi.regenerateBackupCodes,
onSuccess: (data) => {
toast.success(data.message || 'New backup codes generated');
queryClient.invalidateQueries({ queryKey: mfaKeys.status() });
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Failed to regenerate backup codes';
toast.error(message);
},
});
}

74
src/hooks/useOAuth.ts Normal file
View File

@ -0,0 +1,74 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { oauthApi, OAuthConnection } from '@/services/api';
import { AxiosError } from 'axios';
interface ApiError {
message: string;
statusCode?: number;
}
// Query keys
export const oauthKeys = {
all: ['oauth'] as const,
connections: () => [...oauthKeys.all, 'connections'] as const,
};
// Get OAuth authorization URL mutation
export function useOAuthUrl() {
return useMutation({
mutationFn: ({ provider, mode }: { provider: string; mode: 'login' | 'register' | 'link' }) =>
oauthApi.getAuthUrl(provider, mode),
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Failed to get authorization URL';
toast.error(message);
},
});
}
// Process OAuth callback mutation
export function useOAuthCallback() {
return useMutation({
mutationFn: ({ provider, code, state, idToken, userData }: {
provider: string;
code: string;
state: string;
idToken?: string; // For Apple OAuth
userData?: string; // For Apple OAuth (first time only)
}) =>
oauthApi.handleCallback(provider, code, state, idToken, userData),
// Don't show toast here - OAuthCallbackPage handles the messaging
});
}
// Get OAuth connections query
export function useOAuthConnections() {
return useQuery({
queryKey: oauthKeys.connections(),
queryFn: oauthApi.getConnections,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Disconnect OAuth provider mutation
export function useDisconnectOAuth() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (provider: string) => oauthApi.disconnect(provider),
onSuccess: (_, provider) => {
queryClient.invalidateQueries({ queryKey: oauthKeys.connections() });
toast.success(`Disconnected ${provider} successfully`);
},
onError: (error: AxiosError<ApiError>) => {
const message = error.response?.data?.message || 'Failed to disconnect provider';
toast.error(message);
},
});
}
// Helper hook to check if a provider is connected
export function useIsProviderConnected(provider: string) {
const { data: connections } = useOAuthConnections();
return connections?.some((c: OAuthConnection) => c.provider === provider) ?? false;
}

296
src/hooks/useOnboarding.ts Normal file
View File

@ -0,0 +1,296 @@
import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import api from '@/services/api';
// ==================== Types ====================
export type OnboardingStep = 'company' | 'invite' | 'plan' | 'complete';
export interface OnboardingState {
currentStep: OnboardingStep;
completedSteps: OnboardingStep[];
companyData: CompanyData | null;
invitedUsers: InvitedUser[];
selectedPlanId: string | null;
}
export interface CompanyData {
name: string;
slug: string;
domain?: string;
logo_url?: string;
industry?: string;
size?: string;
timezone?: string;
}
export interface InvitedUser {
email: string;
role: string;
status: 'pending' | 'sent' | 'error';
}
export interface Plan {
id: string;
name: string;
display_name: string;
description: string;
price_monthly: number;
price_yearly: number;
features: string[];
is_popular?: boolean;
}
// ==================== API Functions ====================
const onboardingApi = {
getStatus: async () => {
const response = await api.get('/onboarding/status');
return response.data;
},
updateCompany: async (data: CompanyData) => {
const response = await api.patch('/tenants/current', data);
return response.data;
},
inviteUsers: async (users: { email: string; role: string }[]) => {
const results = await Promise.allSettled(
users.map((user) => api.post('/users/invite', user))
);
return results;
},
getPlans: async (): Promise<Plan[]> => {
const response = await api.get('/plans');
return response.data;
},
selectPlan: async (planId: string) => {
const response = await api.post('/billing/subscription', { plan_id: planId });
return response.data;
},
completeOnboarding: async () => {
const response = await api.post('/onboarding/complete');
return response.data;
},
};
// ==================== Query Keys ====================
export const onboardingKeys = {
all: ['onboarding'] as const,
status: () => [...onboardingKeys.all, 'status'] as const,
plans: () => [...onboardingKeys.all, 'plans'] as const,
};
// ==================== Hooks ====================
const STEPS: OnboardingStep[] = ['company', 'invite', 'plan', 'complete'];
const STORAGE_KEY = 'onboarding_state';
function loadState(): Partial<OnboardingState> {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
}
function saveState(state: Partial<OnboardingState>) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Ignore storage errors
}
}
function clearState() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore storage errors
}
}
export function useOnboarding() {
const savedState = loadState();
const [state, setState] = useState<OnboardingState>({
currentStep: savedState.currentStep || 'company',
completedSteps: savedState.completedSteps || [],
companyData: savedState.companyData || null,
invitedUsers: savedState.invitedUsers || [],
selectedPlanId: savedState.selectedPlanId || null,
});
// Update state and persist
const updateState = useCallback((updates: Partial<OnboardingState>) => {
setState((prev) => {
const newState = { ...prev, ...updates };
saveState(newState);
return newState;
});
}, []);
// Navigation
const goToStep = useCallback((step: OnboardingStep) => {
updateState({ currentStep: step });
}, [updateState]);
const nextStep = useCallback(() => {
const currentIndex = STEPS.indexOf(state.currentStep);
if (currentIndex < STEPS.length - 1) {
const nextStepName = STEPS[currentIndex + 1];
updateState({
currentStep: nextStepName,
completedSteps: [...new Set([...state.completedSteps, state.currentStep])],
});
}
}, [state.currentStep, state.completedSteps, updateState]);
const prevStep = useCallback(() => {
const currentIndex = STEPS.indexOf(state.currentStep);
if (currentIndex > 0) {
updateState({ currentStep: STEPS[currentIndex - 1] });
}
}, [state.currentStep, updateState]);
const canGoNext = useCallback(() => {
switch (state.currentStep) {
case 'company':
return !!state.companyData?.name && !!state.companyData?.slug;
case 'invite':
return true; // Optional step
case 'plan':
return !!state.selectedPlanId;
case 'complete':
return false;
default:
return false;
}
}, [state]);
const canGoPrev = useCallback(() => {
return STEPS.indexOf(state.currentStep) > 0 && state.currentStep !== 'complete';
}, [state.currentStep]);
const getStepIndex = useCallback(() => {
return STEPS.indexOf(state.currentStep);
}, [state.currentStep]);
const getTotalSteps = useCallback(() => {
return STEPS.length;
}, []);
const isStepCompleted = useCallback((step: OnboardingStep) => {
return state.completedSteps.includes(step);
}, [state.completedSteps]);
// Reset
const resetOnboarding = useCallback(() => {
clearState();
setState({
currentStep: 'company',
completedSteps: [],
companyData: null,
invitedUsers: [],
selectedPlanId: null,
});
}, []);
return {
state,
updateState,
goToStep,
nextStep,
prevStep,
canGoNext,
canGoPrev,
getStepIndex,
getTotalSteps,
isStepCompleted,
resetOnboarding,
steps: STEPS,
};
}
// ==================== Data Hooks ====================
export function usePlans() {
return useQuery({
queryKey: onboardingKeys.plans(),
queryFn: onboardingApi.getPlans,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
export function useUpdateCompany() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: onboardingApi.updateCompany,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenant'] });
toast.success('Company information saved!');
},
onError: () => {
toast.error('Failed to save company information');
},
});
}
export function useInviteUsers() {
return useMutation({
mutationFn: onboardingApi.inviteUsers,
onSuccess: (results) => {
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
if (successful > 0) {
toast.success(`${successful} invitation${successful > 1 ? 's' : ''} sent!`);
}
if (failed > 0) {
toast.error(`${failed} invitation${failed > 1 ? 's' : ''} failed`);
}
},
onError: () => {
toast.error('Failed to send invitations');
},
});
}
export function useSelectPlan() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: onboardingApi.selectPlan,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
toast.success('Plan selected successfully!');
},
onError: () => {
toast.error('Failed to select plan');
},
});
}
export function useCompleteOnboarding() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: onboardingApi.completeOnboarding,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['user'] });
queryClient.invalidateQueries({ queryKey: ['tenant'] });
toast.success('Welcome aboard! Your setup is complete.');
},
onError: () => {
toast.error('Failed to complete onboarding');
},
});
}

View File

@ -0,0 +1,335 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import api from '@/services/api';
// Types
export interface UserDevice {
id: string;
device_type: 'web' | 'mobile' | 'desktop';
device_name: string | null;
browser: string | null;
os: string | null;
is_active: boolean;
last_used_at: string;
created_at: string;
}
export interface RegisterDeviceDto {
deviceToken: string;
deviceType?: 'web' | 'mobile' | 'desktop';
deviceName?: string;
browser?: string;
browserVersion?: string;
os?: string;
osVersion?: string;
}
export interface DeviceStats {
total: number;
active: number;
inactive: number;
byType: {
web: number;
mobile: number;
desktop: number;
};
}
// API functions
const devicesApi = {
getVapidKey: async () => {
const { data } = await api.get('/notifications/devices/vapid-key');
return data as { vapidPublicKey: string | null; isEnabled: boolean };
},
getDevices: async () => {
const { data } = await api.get('/notifications/devices');
return data as UserDevice[];
},
registerDevice: async (dto: RegisterDeviceDto) => {
const { data } = await api.post('/notifications/devices', dto);
return data;
},
unregisterDevice: async (deviceId: string) => {
await api.delete(`/notifications/devices/${deviceId}`);
},
getStats: async () => {
const { data } = await api.get('/notifications/devices/stats');
return data as DeviceStats;
},
};
// Helper: Convert VAPID key to Uint8Array
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Helper: Get device name based on user agent
function getDeviceName(): string {
const ua = navigator.userAgent;
let browser = 'Unknown';
if (ua.includes('Chrome')) browser = 'Chrome';
else if (ua.includes('Firefox')) browser = 'Firefox';
else if (ua.includes('Safari')) browser = 'Safari';
else if (ua.includes('Edge')) browser = 'Edge';
let os = 'Unknown';
if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac')) os = 'macOS';
else if (ua.includes('Linux')) os = 'Linux';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iOS')) os = 'iOS';
return `${browser} on ${os}`;
}
// Helper: Get browser info
function getBrowserInfo() {
const ua = navigator.userAgent;
let browser = 'Unknown';
let browserVersion = '';
if (ua.includes('Chrome')) {
browser = 'Chrome';
const match = ua.match(/Chrome\/(\d+)/);
browserVersion = match ? match[1] : '';
} else if (ua.includes('Firefox')) {
browser = 'Firefox';
const match = ua.match(/Firefox\/(\d+)/);
browserVersion = match ? match[1] : '';
} else if (ua.includes('Safari')) {
browser = 'Safari';
const match = ua.match(/Version\/(\d+)/);
browserVersion = match ? match[1] : '';
} else if (ua.includes('Edge')) {
browser = 'Edge';
const match = ua.match(/Edg\/(\d+)/);
browserVersion = match ? match[1] : '';
}
let os = 'Unknown';
let osVersion = '';
if (ua.includes('Windows NT 10')) {
os = 'Windows';
osVersion = '10/11';
} else if (ua.includes('Mac OS X')) {
os = 'macOS';
const match = ua.match(/Mac OS X (\d+[._]\d+)/);
osVersion = match ? match[1].replace('_', '.') : '';
} else if (ua.includes('Linux')) {
os = 'Linux';
} else if (ua.includes('Android')) {
os = 'Android';
const match = ua.match(/Android (\d+)/);
osVersion = match ? match[1] : '';
}
return { browser, browserVersion, os, osVersion };
}
/**
* Hook for managing push notifications
*/
export function usePushNotifications() {
const queryClient = useQueryClient();
const [permission, setPermission] = useState<NotificationPermission>('default');
const [isSupported, setIsSupported] = useState(false);
const [isSubscribed, setIsSubscribed] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch VAPID key
const { data: vapidData } = useQuery({
queryKey: ['push', 'vapid-key'],
queryFn: devicesApi.getVapidKey,
staleTime: 1000 * 60 * 60, // 1 hour
});
// Register device mutation
const registerDevice = useMutation({
mutationFn: devicesApi.registerDevice,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['devices'] });
setIsSubscribed(true);
},
});
// Unregister device mutation
const unregisterDevice = useMutation({
mutationFn: devicesApi.unregisterDevice,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['devices'] });
},
});
// Check support and permission on mount
useEffect(() => {
const checkSupport = async () => {
const supported =
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window;
setIsSupported(supported);
if (supported) {
setPermission(Notification.permission);
// Check if already subscribed
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
} catch (err) {
console.error('Error checking push subscription:', err);
}
}
};
checkSupport();
}, []);
/**
* Request notification permission and subscribe
*/
const requestPermission = useCallback(async () => {
if (!isSupported || !vapidData?.isEnabled || !vapidData?.vapidPublicKey) {
setError('Push notifications are not available');
return false;
}
setIsLoading(true);
setError(null);
try {
// Request permission
const result = await Notification.requestPermission();
setPermission(result);
if (result !== 'granted') {
setError('Notification permission denied');
return false;
}
// Register service worker if needed
let registration = await navigator.serviceWorker.getRegistration();
if (!registration) {
registration = await navigator.serviceWorker.register('/sw.js');
await navigator.serviceWorker.ready;
}
// Subscribe to push
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidData.vapidPublicKey) as BufferSource,
});
// Get device info
const { browser, browserVersion, os, osVersion } = getBrowserInfo();
// Send to backend
await registerDevice.mutateAsync({
deviceToken: JSON.stringify(subscription),
deviceType: 'web',
deviceName: getDeviceName(),
browser,
browserVersion,
os,
osVersion,
});
setIsSubscribed(true);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to enable push notifications';
setError(message);
console.error('Push subscription error:', err);
return false;
} finally {
setIsLoading(false);
}
}, [isSupported, vapidData, registerDevice]);
/**
* Unsubscribe from push notifications
*/
const unsubscribe = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
}
setIsSubscribed(false);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to unsubscribe';
setError(message);
return false;
} finally {
setIsLoading(false);
}
}, []);
return {
// State
isSupported,
isEnabled: vapidData?.isEnabled ?? false,
permission,
isSubscribed,
isLoading,
error,
// Actions
requestPermission,
unsubscribe,
// Mutations
registerDevice,
unregisterDevice,
};
}
/**
* Hook for managing user devices
*/
export function useDevices() {
return useQuery({
queryKey: ['devices'],
queryFn: devicesApi.getDevices,
});
}
/**
* Hook for device statistics
*/
export function useDeviceStats() {
return useQuery({
queryKey: ['devices', 'stats'],
queryFn: devicesApi.getStats,
});
}
export default usePushNotifications;

105
src/hooks/useStorage.ts Normal file
View File

@ -0,0 +1,105 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { storageApi, StorageFile, FileListResponse, StorageUsage, UpdateFileRequest } from '@/services/api';
// Query keys
export const storageKeys = {
all: ['storage'] as const,
files: () => [...storageKeys.all, 'files'] as const,
filesList: (params?: { page?: number; limit?: number; folder?: string; mimeType?: string; search?: string }) =>
[...storageKeys.files(), params] as const,
file: (id: string) => [...storageKeys.files(), id] as const,
usage: () => [...storageKeys.all, 'usage'] as const,
};
// List files hook
export function useFiles(params?: {
page?: number;
limit?: number;
folder?: string;
mimeType?: string;
search?: string;
}) {
return useQuery<FileListResponse>({
queryKey: storageKeys.filesList(params),
queryFn: () => storageApi.listFiles(params),
});
}
// Get single file hook
export function useFile(id: string) {
return useQuery<StorageFile>({
queryKey: storageKeys.file(id),
queryFn: () => storageApi.getFile(id),
enabled: !!id,
});
}
// Storage usage hook
export function useStorageUsage() {
return useQuery<StorageUsage>({
queryKey: storageKeys.usage(),
queryFn: () => storageApi.getUsage(),
});
}
// Upload file mutation
export function useUploadFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: {
file: File;
folder?: string;
visibility?: 'private' | 'tenant' | 'public';
metadata?: Record<string, any>;
onProgress?: (progress: number) => void;
}) =>
storageApi.uploadFile(params.file, {
folder: params.folder,
visibility: params.visibility,
metadata: params.metadata,
onProgress: params.onProgress,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageKeys.files() });
queryClient.invalidateQueries({ queryKey: storageKeys.usage() });
},
});
}
// Update file mutation
export function useUpdateFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateFileRequest }) =>
storageApi.updateFile(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: storageKeys.file(id) });
queryClient.invalidateQueries({ queryKey: storageKeys.files() });
},
});
}
// Delete file mutation
export function useDeleteFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => storageApi.deleteFile(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageKeys.files() });
queryClient.invalidateQueries({ queryKey: storageKeys.usage() });
},
});
}
// Get download URL
export function useDownloadUrl(id: string) {
return useQuery({
queryKey: [...storageKeys.file(id), 'download'],
queryFn: () => storageApi.getDownloadUrl(id),
enabled: !!id,
staleTime: 1000 * 60 * 50, // 50 minutes (URLs expire in 1 hour)
});
}

284
src/hooks/useSuperadmin.ts Normal file
View File

@ -0,0 +1,284 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { superadminApi } from '@/services/api';
import { AxiosError } from 'axios';
interface ApiError {
message: string;
statusCode?: number;
}
// ==================== Query Keys ====================
export const superadminKeys = {
all: ['superadmin'] as const,
dashboard: () => [...superadminKeys.all, 'dashboard'] as const,
tenants: {
all: () => [...superadminKeys.all, 'tenants'] as const,
list: (params?: TenantListParams) => [...superadminKeys.tenants.all(), 'list', params] as const,
detail: (id: string) => [...superadminKeys.tenants.all(), 'detail', id] as const,
users: (id: string, page?: number, limit?: number) =>
[...superadminKeys.tenants.all(), 'users', id, { page, limit }] as const,
},
metrics: {
all: () => [...superadminKeys.all, 'metrics'] as const,
summary: () => [...superadminKeys.metrics.all(), 'summary'] as const,
tenantGrowth: (months: number) => [...superadminKeys.metrics.all(), 'tenant-growth', months] as const,
userGrowth: (months: number) => [...superadminKeys.metrics.all(), 'user-growth', months] as const,
planDistribution: () => [...superadminKeys.metrics.all(), 'plan-distribution'] as const,
statusDistribution: () => [...superadminKeys.metrics.all(), 'status-distribution'] as const,
topTenants: (limit: number) => [...superadminKeys.metrics.all(), 'top-tenants', limit] as const,
},
};
// ==================== Types ====================
export interface TenantListParams {
page?: number;
limit?: number;
search?: string;
status?: string;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}
export interface Tenant {
id: string;
name: string;
slug: string;
domain: string | null;
logo_url: string | null;
status: 'active' | 'suspended' | 'trial' | 'canceled';
plan_id: string | null;
trial_ends_at: string | null;
settings: Record<string, any> | null;
metadata: Record<string, any> | null;
created_at: string;
updated_at: string;
userCount?: number;
subscription?: {
id: string;
status: string;
plan?: {
id: string;
name: string;
display_name: string;
};
} | null;
}
export interface TenantListResponse {
data: Tenant[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface SuperadminDashboardStats {
totalTenants: number;
activeTenants: number;
trialTenants: number;
suspendedTenants: number;
totalUsers: number;
newTenantsThisMonth: number;
}
export interface CreateTenantData {
name: string;
slug: string;
domain?: string;
logo_url?: string;
plan_id?: string;
status?: string;
}
export interface UpdateTenantData {
name?: string;
domain?: string;
logo_url?: string;
plan_id?: string;
settings?: Record<string, any>;
metadata?: Record<string, any>;
}
export interface UpdateTenantStatusData {
status: string;
reason?: string;
}
// ==================== Dashboard Hooks ====================
export function useSuperadminDashboard() {
return useQuery({
queryKey: superadminKeys.dashboard(),
queryFn: () => superadminApi.getDashboardStats() as Promise<SuperadminDashboardStats>,
});
}
// ==================== Tenant Hooks ====================
export function useTenants(params?: TenantListParams) {
return useQuery({
queryKey: superadminKeys.tenants.list(params),
queryFn: () => superadminApi.listTenants(params) as Promise<TenantListResponse>,
});
}
export function useTenant(id: string) {
return useQuery({
queryKey: superadminKeys.tenants.detail(id),
queryFn: () => superadminApi.getTenant(id) as Promise<Tenant>,
enabled: !!id,
});
}
export function useCreateTenant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTenantData) => superadminApi.createTenant(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.all() });
queryClient.invalidateQueries({ queryKey: superadminKeys.dashboard() });
toast.success('Tenant created successfully!');
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to create tenant');
},
});
}
export function useUpdateTenant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTenantData }) =>
superadminApi.updateTenant(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.all() });
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.detail(variables.id) });
toast.success('Tenant updated successfully!');
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to update tenant');
},
});
}
export function useUpdateTenantStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTenantStatusData }) =>
superadminApi.updateTenantStatus(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.all() });
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.detail(variables.id) });
queryClient.invalidateQueries({ queryKey: superadminKeys.dashboard() });
toast.success('Tenant status updated!');
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to update tenant status');
},
});
}
export function useDeleteTenant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => superadminApi.deleteTenant(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: superadminKeys.tenants.all() });
queryClient.invalidateQueries({ queryKey: superadminKeys.dashboard() });
toast.success('Tenant deleted successfully!');
},
onError: (error: AxiosError<ApiError>) => {
toast.error(error.response?.data?.message || 'Failed to delete tenant');
},
});
}
export function useTenantUsers(tenantId: string, page = 1, limit = 10) {
return useQuery({
queryKey: superadminKeys.tenants.users(tenantId, page, limit),
queryFn: () => superadminApi.getTenantUsers(tenantId, { page, limit }),
enabled: !!tenantId,
});
}
// ==================== Metrics Types ====================
export interface GrowthDataPoint {
month: string;
count: number;
}
export interface DistributionDataPoint {
plan?: string;
status?: string;
count: number;
percentage: number;
}
export interface TopTenant {
id: string;
name: string;
slug: string;
userCount: number;
status: string;
planName: string;
}
export interface MetricsSummary {
tenantGrowth: GrowthDataPoint[];
userGrowth: GrowthDataPoint[];
planDistribution: DistributionDataPoint[];
statusDistribution: DistributionDataPoint[];
topTenants: TopTenant[];
}
// ==================== Metrics Hooks ====================
export function useMetricsSummary() {
return useQuery({
queryKey: superadminKeys.metrics.summary(),
queryFn: () => superadminApi.getMetricsSummary() as Promise<MetricsSummary>,
});
}
export function useTenantGrowth(months = 12) {
return useQuery({
queryKey: superadminKeys.metrics.tenantGrowth(months),
queryFn: () => superadminApi.getTenantGrowth(months) as Promise<GrowthDataPoint[]>,
});
}
export function useUserGrowth(months = 12) {
return useQuery({
queryKey: superadminKeys.metrics.userGrowth(months),
queryFn: () => superadminApi.getUserGrowth(months) as Promise<GrowthDataPoint[]>,
});
}
export function usePlanDistribution() {
return useQuery({
queryKey: superadminKeys.metrics.planDistribution(),
queryFn: () => superadminApi.getPlanDistribution() as Promise<DistributionDataPoint[]>,
});
}
export function useStatusDistribution() {
return useQuery({
queryKey: superadminKeys.metrics.statusDistribution(),
queryFn: () => superadminApi.getStatusDistribution() as Promise<DistributionDataPoint[]>,
});
}
export function useTopTenants(limit = 10) {
return useQuery({
queryKey: superadminKeys.metrics.topTenants(limit),
queryFn: () => superadminApi.getTopTenants(limit) as Promise<TopTenant[]>,
});
}

148
src/hooks/useWebhooks.ts Normal file
View File

@ -0,0 +1,148 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
webhooksApi,
Webhook,
WebhookEvent,
CreateWebhookRequest,
UpdateWebhookRequest,
DeliveryStatus,
PaginatedDeliveries,
} from '@/services/api';
// Query keys
const WEBHOOKS_KEY = ['webhooks'];
const WEBHOOK_KEY = (id: string) => ['webhooks', id];
const WEBHOOK_EVENTS_KEY = ['webhooks', 'events'];
const WEBHOOK_DELIVERIES_KEY = (id: string) => ['webhooks', id, 'deliveries'];
// List all webhooks
export function useWebhooks() {
return useQuery<Webhook[]>({
queryKey: WEBHOOKS_KEY,
queryFn: webhooksApi.list,
});
}
// Get single webhook
export function useWebhook(id: string) {
return useQuery<Webhook>({
queryKey: WEBHOOK_KEY(id),
queryFn: () => webhooksApi.get(id),
enabled: !!id,
});
}
// Get available events
export function useWebhookEvents() {
return useQuery<WebhookEvent[]>({
queryKey: WEBHOOK_EVENTS_KEY,
queryFn: async () => {
const response = await webhooksApi.getEvents();
return response.events;
},
staleTime: 1000 * 60 * 60, // 1 hour - events don't change often
});
}
// Get webhook deliveries
export function useWebhookDeliveries(
webhookId: string,
params?: { status?: DeliveryStatus; eventType?: string; page?: number; limit?: number }
) {
return useQuery<PaginatedDeliveries>({
queryKey: [...WEBHOOK_DELIVERIES_KEY(webhookId), params],
queryFn: () => webhooksApi.getDeliveries(webhookId, params),
enabled: !!webhookId,
});
}
// Create webhook
export function useCreateWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateWebhookRequest) => webhooksApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WEBHOOKS_KEY });
},
});
}
// Update webhook
export function useUpdateWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateWebhookRequest }) =>
webhooksApi.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: WEBHOOKS_KEY });
queryClient.invalidateQueries({ queryKey: WEBHOOK_KEY(id) });
},
});
}
// Delete webhook
export function useDeleteWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => webhooksApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: WEBHOOKS_KEY });
},
});
}
// Toggle webhook active status
export function useToggleWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
webhooksApi.update(id, { isActive }),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: WEBHOOKS_KEY });
queryClient.invalidateQueries({ queryKey: WEBHOOK_KEY(id) });
},
});
}
// Regenerate secret
export function useRegenerateWebhookSecret() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => webhooksApi.regenerateSecret(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: WEBHOOK_KEY(id) });
},
});
}
// Test webhook
export function useTestWebhook() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload?: { eventType?: string; payload?: Record<string, any> } }) =>
webhooksApi.test(id, payload),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: WEBHOOK_DELIVERIES_KEY(id) });
},
});
}
// Retry delivery
export function useRetryDelivery() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ webhookId, deliveryId }: { webhookId: string; deliveryId: string }) =>
webhooksApi.retryDelivery(webhookId, deliveryId),
onSuccess: (_, { webhookId }) => {
queryClient.invalidateQueries({ queryKey: WEBHOOK_DELIVERIES_KEY(webhookId) });
queryClient.invalidateQueries({ queryKey: WEBHOOK_KEY(webhookId) });
},
});
}

126
src/hooks/useWhatsApp.ts Normal file
View File

@ -0,0 +1,126 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { whatsappApi, CreateWhatsAppConfigDto, UpdateWhatsAppConfigDto } from '../services/whatsapp.api';
const QUERY_KEYS = {
config: ['whatsapp', 'config'],
messages: ['whatsapp', 'messages'],
};
export function useWhatsAppConfig() {
return useQuery({
queryKey: QUERY_KEYS.config,
queryFn: whatsappApi.getConfig,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useCreateWhatsAppConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateWhatsAppConfigDto) => whatsappApi.createConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.config });
toast.success('La configuracion de WhatsApp ha sido guardada exitosamente.');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Error al configurar WhatsApp');
},
});
}
export function useUpdateWhatsAppConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateWhatsAppConfigDto) => whatsappApi.updateConfig(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.config });
toast.success('Los cambios han sido guardados.');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Error al actualizar configuracion');
},
});
}
export function useDeleteWhatsAppConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => whatsappApi.deleteConfig(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.config });
toast.success('La integracion de WhatsApp ha sido eliminada.');
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Error al eliminar configuracion');
},
});
}
export function useTestWhatsAppConnection() {
return useMutation({
mutationFn: (phoneNumber: string) => whatsappApi.testConnection(phoneNumber),
onSuccess: (result) => {
if (result.success) {
toast.success('El mensaje de prueba se envio correctamente.');
} else {
toast.error(result.error || 'No se pudo enviar el mensaje de prueba.');
}
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Error al probar conexion');
},
});
}
export function useWhatsAppMessages(params?: {
page?: number;
limit?: number;
phoneNumber?: string;
direction?: 'inbound' | 'outbound';
}) {
return useQuery({
queryKey: [...QUERY_KEYS.messages, params],
queryFn: () => whatsappApi.getMessages(params),
staleTime: 30 * 1000, // 30 seconds
});
}
export function useSendWhatsAppTextMessage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: whatsappApi.sendTextMessage,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.messages });
if (result.success) {
toast.success('El mensaje ha sido enviado exitosamente.');
} else {
toast.error(result.error || 'Error al enviar mensaje');
}
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Error al enviar mensaje');
},
});
}
export function useSendWhatsAppTemplateMessage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: whatsappApi.sendTemplateMessage,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.messages });
if (result.success) {
toast.success('El template ha sido enviado exitosamente.');
}
},
onError: (error: any) => {
toast.error(error.response?.data?.message || 'Error al enviar template');
},
});
}

57
src/index.css Normal file
View File

@ -0,0 +1,57 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
body {
@apply bg-secondary-50 text-secondary-900 dark:bg-secondary-900 dark:text-secondary-50;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-secondary-200 text-secondary-900 hover:bg-secondary-300 focus:ring-secondary-500 dark:bg-secondary-700 dark:text-secondary-100 dark:hover:bg-secondary-600;
}
.btn-danger {
@apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
}
.btn-ghost {
@apply btn bg-transparent hover:bg-secondary-100 dark:hover:bg-secondary-800;
}
.input {
@apply block w-full px-3 py-2 text-sm border border-secondary-300 rounded-lg bg-white placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent dark:bg-secondary-800 dark:border-secondary-600 dark:text-white;
}
.label {
@apply block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1;
}
.card {
@apply bg-white rounded-xl shadow-sm border border-secondary-200 dark:bg-secondary-800 dark:border-secondary-700;
}
.card-header {
@apply px-6 py-4 border-b border-secondary-200 dark:border-secondary-700;
}
.card-body {
@apply p-6;
}
}

View File

@ -0,0 +1,64 @@
import { Outlet } from 'react-router-dom';
export function AuthLayout() {
return (
<div className="min-h-screen flex">
{/* Left side - Branding */}
<div className="hidden lg:flex lg:w-1/2 bg-primary-600 text-white p-12 flex-col justify-between">
<div>
<h1 className="text-3xl font-bold">Template SaaS</h1>
<p className="mt-2 text-primary-100">Multi-tenant Platform</p>
</div>
<div className="space-y-6">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<h3 className="font-semibold">Multi-tenant Architecture</h3>
<p className="text-sm text-primary-200">Complete data isolation per tenant</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div>
<h3 className="font-semibold">Enterprise Security</h3>
<p className="text-sm text-primary-200">RBAC, RLS, and audit logs</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
</div>
<div>
<h3 className="font-semibold">Stripe Integration</h3>
<p className="text-sm text-primary-200">Subscriptions and billing portal</p>
</div>
</div>
</div>
<p className="text-sm text-primary-200">
&copy; {new Date().getFullYear()} Template SaaS. All rights reserved.
</p>
</div>
{/* Right side - Auth forms */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="w-full max-w-md">
<Outlet />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,201 @@
import { Outlet, NavLink } from 'react-router-dom';
import { useAuthStore, useUIStore } from '@/stores';
import { useLogout } from '@/hooks';
import {
LayoutDashboard,
Users,
CreditCard,
Settings,
LogOut,
Menu,
X,
ChevronDown,
Building2,
Shield,
BarChart3,
Bot,
HardDrive,
Webhook,
ClipboardList,
Flag,
MessageSquare,
} from 'lucide-react';
import { useState } from 'react';
import clsx from 'clsx';
import { NotificationBell } from '@/components/notifications';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'AI Assistant', href: '/dashboard/ai', icon: Bot },
{ name: 'Storage', href: '/dashboard/storage', icon: HardDrive },
{ name: 'Webhooks', href: '/dashboard/webhooks', icon: Webhook },
{ name: 'Feature Flags', href: '/dashboard/feature-flags', icon: Flag },
{ name: 'Audit Logs', href: '/dashboard/audit', icon: ClipboardList },
{ name: 'Users', href: '/dashboard/users', icon: Users },
{ name: 'Billing', href: '/dashboard/billing', icon: CreditCard },
{ name: 'Settings', href: '/dashboard/settings', icon: Settings },
{ name: 'WhatsApp', href: '/dashboard/whatsapp', icon: MessageSquare },
];
const superadminNavigation = [
{ name: 'Tenants', href: '/superadmin/tenants', icon: Building2 },
{ name: 'Metrics', href: '/superadmin/metrics', icon: BarChart3 },
];
export function DashboardLayout() {
const { user } = useAuthStore();
const { sidebarOpen, toggleSidebar } = useUIStore();
const [userMenuOpen, setUserMenuOpen] = useState(false);
const logoutMutation = useLogout();
// Check if user is superadmin
const isSuperadmin = user?.role === 'superadmin';
const handleLogout = () => {
setUserMenuOpen(false);
logoutMutation.mutate();
};
return (
<div className="min-h-screen bg-secondary-50 dark:bg-secondary-900">
{/* Sidebar */}
<aside
className={clsx(
'fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-secondary-800 border-r border-secondary-200 dark:border-secondary-700 transform transition-transform duration-200 ease-in-out lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
<div className="flex items-center justify-between h-16 px-6 border-b border-secondary-200 dark:border-secondary-700">
<h1 className="text-xl font-bold text-primary-600">Template SaaS</h1>
<button
onClick={toggleSidebar}
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<X className="w-5 h-5" />
</button>
</div>
<nav className="p-4 space-y-1">
{/* Regular navigation */}
{navigation.map((item) => (
<NavLink
key={item.name}
to={item.href}
end={item.href === '/dashboard'}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400'
: 'text-secondary-600 hover:bg-secondary-100 dark:text-secondary-400 dark:hover:bg-secondary-700'
)
}
>
<item.icon className="w-5 h-5" />
{item.name}
</NavLink>
))}
{/* Superadmin navigation */}
{isSuperadmin && (
<>
<div className="pt-4 mt-4 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-2 px-4 py-2 text-xs font-semibold text-secondary-500 uppercase tracking-wider">
<Shield className="w-4 h-4" />
Superadmin
</div>
</div>
{superadminNavigation.map((item) => (
<NavLink
key={item.name}
to={item.href}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400'
: 'text-secondary-600 hover:bg-secondary-100 dark:text-secondary-400 dark:hover:bg-secondary-700'
)
}
>
<item.icon className="w-5 h-5" />
{item.name}
</NavLink>
))}
</>
)}
</nav>
</aside>
{/* Overlay for mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={toggleSidebar}
/>
)}
{/* Main content */}
<div className={clsx('lg:pl-64 flex flex-col min-h-screen')}>
{/* Top bar */}
<header className="sticky top-0 z-30 h-16 bg-white dark:bg-secondary-800 border-b border-secondary-200 dark:border-secondary-700 px-4 lg:px-6 flex items-center justify-between">
<button
onClick={toggleSidebar}
className="lg:hidden p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-4 ml-auto">
{/* Notifications */}
<NotificationBell />
{/* User menu */}
<div className="relative">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<div className="w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
{user?.first_name?.[0]}{user?.last_name?.[0]}
</span>
</div>
<div className="hidden sm:block text-left">
<p className="text-sm font-medium text-secondary-900 dark:text-secondary-100">
{user?.first_name} {user?.last_name}
</p>
<p className="text-xs text-secondary-500">{user?.role}</p>
</div>
<ChevronDown className="w-4 h-4 text-secondary-400" />
</button>
{userMenuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setUserMenuOpen(false)}
/>
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50">
<button
onClick={handleLogout}
className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<LogOut className="w-4 h-4" />
Sign out
</button>
</div>
</>
)}
</div>
</div>
</header>
{/* Page content */}
<main className="flex-1 p-4 lg:p-6">
<Outlet />
</main>
</div>
</div>
);
}

2
src/layouts/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './AuthLayout';
export * from './DashboardLayout';

34
src/main.tsx Normal file
View File

@ -0,0 +1,34 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#1e293b',
color: '#f1f5f9',
},
}}
/>
</QueryClientProvider>
</StrictMode>
);

View File

@ -0,0 +1,262 @@
import { useState } from 'react';
import {
Users,
DollarSign,
Activity,
TrendingUp,
BarChart3,
Clock,
} from 'lucide-react';
import { MetricCard } from '@/components/analytics/MetricCard';
import { TrendChart } from '@/components/analytics/TrendChart';
import {
useAnalyticsSummary,
useAnalyticsTrends,
useUsageMetrics,
getAvailablePeriods,
getPeriodLabel,
formatCurrency,
formatNumber,
type AnalyticsPeriod,
} from '@/hooks/useAnalytics';
import clsx from 'clsx';
export function AnalyticsDashboardPage() {
const [selectedPeriod, setSelectedPeriod] = useState<AnalyticsPeriod>('30d');
// Fetch data
const { data: summary, isLoading: summaryLoading } = useAnalyticsSummary();
const { data: trends, isLoading: trendsLoading } = useAnalyticsTrends(selectedPeriod);
const { data: usage, isLoading: usageLoading } = useUsageMetrics(selectedPeriod);
const periods = getAvailablePeriods();
return (
<div className="space-y-6">
{/* Header with period selector */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Analytics Dashboard
</h1>
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
Monitor your key business metrics and trends
</p>
</div>
{/* Period selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-secondary-600 dark:text-secondary-400">Period:</span>
<div className="flex bg-secondary-100 dark:bg-secondary-800 rounded-lg p-1">
{periods.map((period) => (
<button
key={period.value}
onClick={() => setSelectedPeriod(period.value)}
className={clsx(
'px-3 py-1.5 text-sm font-medium rounded-md transition-colors',
selectedPeriod === period.value
? 'bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white shadow-sm'
: 'text-secondary-600 dark:text-secondary-400 hover:text-secondary-900 dark:hover:text-white'
)}
>
{period.value}
</button>
))}
</div>
</div>
</div>
{/* KPI Metric Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
title="Active Users"
value={summary?.users.active ?? 0}
change={summary?.users.change}
trend={summary?.users.change !== undefined ? (summary.users.change >= 0 ? 'up' : 'down') : undefined}
icon={Users}
isLoading={summaryLoading}
subtitle={`${summary?.users.total ?? 0} total users`}
/>
<MetricCard
title="Monthly Revenue (MRR)"
value={summary?.revenue.mrr ?? 0}
change={summary?.revenue.change}
trend={summary?.revenue.change !== undefined ? (summary.revenue.change >= 0 ? 'up' : 'down') : undefined}
icon={DollarSign}
isLoading={summaryLoading}
format="currency"
subtitle={`${formatCurrency(summary?.revenue.arr ?? 0)} ARR`}
/>
<MetricCard
title="Total Actions"
value={summary?.usage.actions ?? 0}
change={summary?.usage.change}
trend={summary?.usage.change !== undefined ? (summary.usage.change >= 0 ? 'up' : 'down') : undefined}
icon={Activity}
isLoading={summaryLoading}
subtitle={`${formatNumber(summary?.usage.avgPerUser ?? 0)} avg/user`}
/>
<MetricCard
title="Engagement Rate"
value={summary?.engagement.rate ?? 0}
change={summary?.engagement.change}
trend={summary?.engagement.change !== undefined ? (summary.engagement.change >= 0 ? 'up' : 'down') : undefined}
icon={TrendingUp}
isLoading={summaryLoading}
format="percentage"
subtitle={`${formatNumber(summary?.engagement.sessions ?? 0)} sessions`}
/>
</div>
{/* Trend Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<TrendChart
title="User Growth"
data={trends?.users ?? []}
color="#3b82f6"
isLoading={trendsLoading}
height={320}
/>
<TrendChart
title="Revenue Trend"
data={trends?.revenue ?? []}
color="#10b981"
isLoading={trendsLoading}
height={320}
valueFormatter={(val) => formatCurrency(val, true)}
/>
</div>
{/* Usage Metrics Section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Actions Trend Chart */}
<div className="lg:col-span-2">
<TrendChart
title="Daily Actions"
data={trends?.actions ?? []}
color="#8b5cf6"
isLoading={trendsLoading}
height={280}
/>
</div>
{/* Top Features Table */}
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<div className="flex items-center gap-2 mb-4">
<BarChart3 className="w-5 h-5 text-secondary-500" />
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
Top Features
</h3>
</div>
{usageLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="animate-pulse">
<div className="flex items-center justify-between mb-1">
<div className="h-4 w-24 bg-secondary-200 dark:bg-secondary-700 rounded" />
<div className="h-4 w-12 bg-secondary-200 dark:bg-secondary-700 rounded" />
</div>
<div className="h-2 bg-secondary-200 dark:bg-secondary-700 rounded-full" />
</div>
))}
</div>
) : usage?.topFeatures && usage.topFeatures.length > 0 ? (
<div className="space-y-3">
{usage.topFeatures.slice(0, 5).map((feature, index) => (
<div key={feature.feature}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-secondary-700 dark:text-secondary-300 truncate">
{feature.feature}
</span>
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{formatNumber(feature.count)}
</span>
</div>
<div className="h-2 bg-secondary-200 dark:bg-secondary-700 rounded-full overflow-hidden">
<div
className={clsx(
'h-full rounded-full transition-all duration-500',
index === 0
? 'bg-primary-500'
: index === 1
? 'bg-primary-400'
: index === 2
? 'bg-primary-300'
: 'bg-primary-200'
)}
style={{ width: `${feature.percentage}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-secondary-500 dark:text-secondary-400">
<BarChart3 className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No feature data available</p>
</div>
)}
</div>
</div>
{/* Usage by Hour */}
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<div className="flex items-center gap-2 mb-4">
<Clock className="w-5 h-5 text-secondary-500" />
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
Activity by Hour
</h3>
<span className="text-sm text-secondary-500 dark:text-secondary-400">
({getPeriodLabel(selectedPeriod)})
</span>
</div>
{usageLoading ? (
<div className="animate-pulse flex items-end justify-between h-24 gap-1">
{Array.from({ length: 24 }).map((_, i) => (
<div
key={i}
className="flex-1 bg-secondary-200 dark:bg-secondary-700 rounded-t"
style={{ height: `${Math.random() * 100}%` }}
/>
))}
</div>
) : usage?.byHour && usage.byHour.length > 0 ? (
<div className="flex items-end justify-between h-24 gap-1">
{usage.byHour.map((hourData) => {
const maxActions = Math.max(...usage.byHour.map((h) => h.actions));
const height = maxActions > 0 ? (hourData.actions / maxActions) * 100 : 0;
return (
<div
key={hourData.hour}
className="flex-1 flex flex-col items-center gap-1 group"
title={`${hourData.hour}:00 - ${formatNumber(hourData.actions)} actions`}
>
<div
className="w-full bg-primary-500 dark:bg-primary-400 rounded-t transition-all group-hover:bg-primary-600 dark:group-hover:bg-primary-300"
style={{ height: `${Math.max(height, 2)}%` }}
/>
{hourData.hour % 4 === 0 && (
<span className="text-xs text-secondary-400 dark:text-secondary-500">
{hourData.hour}h
</span>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-secondary-500 dark:text-secondary-400">
<Clock className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No hourly data available</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,407 @@
import React, { useState } from 'react';
import {
useWhatsAppConfig,
useCreateWhatsAppConfig,
useUpdateWhatsAppConfig,
useDeleteWhatsAppConfig,
useWhatsAppMessages,
} from '../../hooks/useWhatsApp';
import { WhatsAppTestMessage } from '../../components/whatsapp/WhatsAppTestMessage';
export function WhatsAppSettings() {
const { data: config, isLoading } = useWhatsAppConfig();
const createConfig = useCreateWhatsAppConfig();
const updateConfig = useUpdateWhatsAppConfig();
const deleteConfig = useDeleteWhatsAppConfig();
const { data: messagesData } = useWhatsAppMessages({ limit: 5 });
const [formData, setFormData] = useState({
phoneNumberId: '',
businessAccountId: '',
accessToken: '',
webhookVerifyToken: '',
dailyMessageLimit: 1000,
});
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === 'number' ? parseInt(value, 10) : value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (config) {
updateConfig.mutate({
phoneNumberId: formData.phoneNumberId || undefined,
businessAccountId: formData.businessAccountId || undefined,
accessToken: formData.accessToken || undefined,
webhookVerifyToken: formData.webhookVerifyToken || undefined,
dailyMessageLimit: formData.dailyMessageLimit,
});
} else {
createConfig.mutate(formData);
}
};
const handleDelete = () => {
deleteConfig.mutate();
setShowDeleteConfirm(false);
};
const handleToggleActive = () => {
if (config) {
updateConfig.mutate({ isActive: !config.isActive });
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600" />
</div>
);
}
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<svg className="h-8 w-8 text-green-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M.057 24l1.687-6.163c-1.041-1.804-1.588-3.849-1.587-5.946.003-6.556 5.338-11.891 11.893-11.891 3.181.001 6.167 1.24 8.413 3.488 2.245 2.248 3.481 5.236 3.48 8.414-.003 6.557-5.338 11.892-11.893 11.892-1.99-.001-3.951-.5-5.688-1.448l-6.305 1.654zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884-.001 2.225.651 3.891 1.746 5.634l-.999 3.648 3.742-.981zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.016-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.709.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414z" />
</svg>
Configuracion de WhatsApp
</h1>
<p className="mt-1 text-sm text-gray-600">
Configura la integracion con WhatsApp Business API para enviar notificaciones.
</p>
</div>
{/* Status Card */}
{config && (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-medium text-gray-900">Estado de la integracion</h2>
<div className="mt-2 flex items-center gap-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
config.isActive
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{config.isActive ? 'Activo' : 'Inactivo'}
</span>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
config.isVerified
? 'bg-blue-100 text-blue-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{config.isVerified ? 'Verificado' : 'Sin verificar'}
</span>
{config.qualityRating && (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
config.qualityRating === 'GREEN'
? 'bg-green-100 text-green-800'
: config.qualityRating === 'YELLOW'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}
>
Calidad: {config.qualityRating}
</span>
)}
</div>
</div>
<button
onClick={handleToggleActive}
className={`px-4 py-2 rounded-md text-sm font-medium ${
config.isActive
? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{config.isActive ? 'Desactivar' : 'Activar'}
</button>
</div>
{config.displayPhoneNumber && (
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-500">Numero:</span>
<span className="ml-2 font-medium">{config.displayPhoneNumber}</span>
</div>
{config.verifiedName && (
<div>
<span className="text-gray-500">Nombre verificado:</span>
<span className="ml-2 font-medium">{config.verifiedName}</span>
</div>
)}
<div>
<span className="text-gray-500">Mensajes hoy:</span>
<span className="ml-2 font-medium">
{config.messagesSentToday} / {config.dailyMessageLimit}
</span>
</div>
</div>
)}
</div>
)}
{/* Configuration Form */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
{config ? 'Actualizar configuracion' : 'Configurar WhatsApp Business API'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="phoneNumberId"
className="block text-sm font-medium text-gray-700"
>
Phone Number ID *
</label>
<input
type="text"
id="phoneNumberId"
name="phoneNumberId"
value={formData.phoneNumberId}
onChange={handleInputChange}
placeholder={config?.phoneNumberId || 'ID del numero de telefono'}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
required={!config}
/>
</div>
<div>
<label
htmlFor="businessAccountId"
className="block text-sm font-medium text-gray-700"
>
Business Account ID *
</label>
<input
type="text"
id="businessAccountId"
name="businessAccountId"
value={formData.businessAccountId}
onChange={handleInputChange}
placeholder={config?.businessAccountId || 'ID de la cuenta de negocio'}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
required={!config}
/>
</div>
</div>
<div>
<label
htmlFor="accessToken"
className="block text-sm font-medium text-gray-700"
>
Access Token *
</label>
<input
type="password"
id="accessToken"
name="accessToken"
value={formData.accessToken}
onChange={handleInputChange}
placeholder={config ? '********' : 'Token de acceso de Meta'}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
required={!config}
/>
<p className="mt-1 text-xs text-gray-500">
Obten el token en{' '}
<a
href="https://developers.facebook.com/apps"
target="_blank"
rel="noopener noreferrer"
className="text-green-600 hover:underline"
>
Meta for Developers
</a>
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor="webhookVerifyToken"
className="block text-sm font-medium text-gray-700"
>
Webhook Verify Token
</label>
<input
type="text"
id="webhookVerifyToken"
name="webhookVerifyToken"
value={formData.webhookVerifyToken}
onChange={handleInputChange}
placeholder="Token para verificacion de webhook"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
<div>
<label
htmlFor="dailyMessageLimit"
className="block text-sm font-medium text-gray-700"
>
Limite diario de mensajes
</label>
<input
type="number"
id="dailyMessageLimit"
name="dailyMessageLimit"
value={formData.dailyMessageLimit}
onChange={handleInputChange}
min={1}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500 sm:text-sm"
/>
</div>
</div>
<div className="flex justify-between pt-4">
<button
type="submit"
disabled={createConfig.isPending || updateConfig.isPending}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
>
{createConfig.isPending || updateConfig.isPending
? 'Guardando...'
: config
? 'Actualizar configuracion'
: 'Guardar configuracion'}
</button>
{config && (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="inline-flex items-center px-4 py-2 border border-red-300 text-sm font-medium rounded-md text-red-700 bg-white hover:bg-red-50"
>
Eliminar integracion
</button>
)}
</div>
</form>
</div>
{/* Test Connection */}
{config && config.isActive && (
<div className="mb-6">
<WhatsAppTestMessage disabled={!config.isVerified} />
</div>
)}
{/* Recent Messages */}
{config && messagesData && messagesData.data.length > 0 && (
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Mensajes recientes
</h2>
<div className="space-y-3">
{messagesData.data.map((message) => (
<div
key={message.id}
className={`flex items-start gap-3 p-3 rounded-lg ${
message.direction === 'outbound' ? 'bg-green-50' : 'bg-gray-50'
}`}
>
<div
className={`flex-shrink-0 h-8 w-8 rounded-full flex items-center justify-center ${
message.direction === 'outbound'
? 'bg-green-200 text-green-700'
: 'bg-gray-200 text-gray-700'
}`}
>
{message.direction === 'outbound' ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900">
{message.phoneNumber}
{message.contactName && (
<span className="text-gray-500 ml-1">({message.contactName})</span>
)}
</p>
<span
className={`text-xs px-2 py-0.5 rounded ${
message.status === 'delivered' || message.status === 'read'
? 'bg-green-100 text-green-700'
: message.status === 'failed'
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{message.status}
</span>
</div>
<p className="text-sm text-gray-600 truncate">
{message.content || message.templateName || '[Media]'}
</p>
<p className="text-xs text-gray-400 mt-1">
{new Date(message.createdAt).toLocaleString()}
</p>
</div>
</div>
))}
</div>
<div className="mt-4 text-center">
<a
href="/admin/whatsapp/messages"
className="text-sm text-green-600 hover:text-green-700"
>
Ver todos los mensajes
</a>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-medium text-gray-900">
Eliminar integracion de WhatsApp
</h3>
<p className="mt-2 text-sm text-gray-600">
Esta seguro de que desea eliminar la integracion de WhatsApp? Esta
accion no se puede deshacer y se perdera toda la configuracion.
</p>
<div className="mt-4 flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
>
Cancelar
</button>
<button
onClick={handleDelete}
disabled={deleteConfig.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50"
>
{deleteConfig.isPending ? 'Eliminando...' : 'Eliminar'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,141 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useRequestPasswordReset } from '@/hooks';
import { Loader2, ArrowLeft, CheckCircle, Mail } from 'lucide-react';
interface ForgotPasswordFormData {
email: string;
}
export function ForgotPasswordPage() {
const [emailSent, setEmailSent] = useState(false);
const resetMutation = useRequestPasswordReset();
const {
register,
handleSubmit,
getValues,
formState: { errors },
} = useForm<ForgotPasswordFormData>();
const onSubmit = (data: ForgotPasswordFormData) => {
resetMutation.mutate(data.email, {
onSuccess: () => setEmailSent(true),
onError: () => setEmailSent(true), // Still show success for security
});
};
if (emailSent) {
return (
<div className="text-center">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
Check your email
</h2>
<p className="text-secondary-600 dark:text-secondary-400 mb-2">
We've sent password reset instructions to:
</p>
<p className="font-medium text-secondary-900 dark:text-white mb-6">
{getValues('email')}
</p>
<div className="bg-secondary-50 dark:bg-secondary-800 rounded-lg p-4 mb-6 text-left">
<div className="flex gap-3">
<Mail className="w-5 h-5 text-secondary-500 mt-0.5" />
<div className="text-sm text-secondary-600 dark:text-secondary-400">
<p className="mb-2">
If you don't see the email, check your spam folder.
</p>
<p>
The link will expire in 1 hour for security reasons.
</p>
</div>
</div>
</div>
<div className="space-y-3">
<button
onClick={() => setEmailSent(false)}
className="btn-secondary w-full"
>
Try a different email
</button>
<Link
to="/auth/login"
className="inline-flex items-center justify-center gap-2 text-primary-600 hover:text-primary-500 font-medium w-full"
>
<ArrowLeft className="w-4 h-4" />
Back to sign in
</Link>
</div>
</div>
);
}
return (
<div>
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white">
Reset your password
</h2>
<p className="mt-2 text-secondary-600 dark:text-secondary-400">
Enter your email and we'll send you reset instructions
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor="email" className="label">
Email address
</label>
<input
id="email"
type="email"
autoComplete="email"
className="input"
placeholder="you@example.com"
disabled={resetMutation.isPending}
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<button
type="submit"
disabled={resetMutation.isPending}
className="btn-primary w-full py-2.5"
>
{resetMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
</>
) : (
'Send reset instructions'
)}
</button>
</form>
<p className="mt-6 text-center">
<Link
to="/auth/login"
className="inline-flex items-center gap-2 text-sm font-medium text-primary-600 hover:text-primary-500"
>
<ArrowLeft className="w-4 h-4" />
Back to sign in
</Link>
</p>
</div>
);
}

View File

@ -0,0 +1,155 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useLogin } from '@/hooks';
import { Eye, EyeOff, Loader2 } from 'lucide-react';
import { OAuthButtons, OAuthSeparator } from '@/components/auth';
interface LoginFormData {
email: string;
password: string;
remember?: boolean;
}
export function LoginPage() {
const [showPassword, setShowPassword] = useState(false);
const loginMutation = useLogin();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>();
const onSubmit = (data: LoginFormData) => {
loginMutation.mutate({
email: data.email,
password: data.password,
});
};
return (
<div>
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white">
Welcome back
</h2>
<p className="mt-2 text-secondary-600 dark:text-secondary-400">
Sign in to your account
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<label htmlFor="email" className="label">
Email address
</label>
<input
id="email"
type="email"
autoComplete="email"
className="input"
placeholder="you@example.com"
disabled={loginMutation.isPending}
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="label">
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
className="input pr-10"
placeholder="Enter your password"
disabled={loginMutation.isPending}
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 hover:text-secondary-600"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-500">{errors.password.message}</p>
)}
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2">
<input
type="checkbox"
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
{...register('remember')}
/>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
Remember me
</span>
</label>
<Link
to="/auth/forgot-password"
className="text-sm font-medium text-primary-600 hover:text-primary-500"
>
Forgot password?
</Link>
</div>
<button
type="submit"
disabled={loginMutation.isPending}
className="btn-primary w-full py-2.5"
>
{loginMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Signing in...
</>
) : (
'Sign in'
)}
</button>
</form>
<OAuthSeparator />
<OAuthButtons mode="login" disabled={loginMutation.isPending} />
<p className="mt-6 text-center text-sm text-secondary-600 dark:text-secondary-400">
Don't have an account?{' '}
<Link
to="/auth/register"
className="font-medium text-primary-600 hover:text-primary-500"
>
Sign up
</Link>
</p>
</div>
);
}

View File

@ -0,0 +1,144 @@
import { useEffect, useState } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { Loader2, CheckCircle, XCircle } from 'lucide-react';
import toast from 'react-hot-toast';
import { useOAuthCallback } from '@/hooks/useOAuth';
import { useAuthStore } from '@/stores';
import { useQueryClient } from '@tanstack/react-query';
type CallbackStatus = 'processing' | 'success' | 'error';
export function OAuthCallbackPage() {
const { provider } = useParams<{ provider: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { login } = useAuthStore();
const [status, setStatus] = useState<CallbackStatus>('processing');
const [errorMessage, setErrorMessage] = useState<string>('');
const oauthCallback = useOAuthCallback();
useEffect(() => {
const processCallback = async () => {
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
// Apple OAuth specific params
const idToken = searchParams.get('id_token') || undefined;
const userData = searchParams.get('user') || undefined;
// Handle OAuth error from provider
if (error) {
setStatus('error');
setErrorMessage(errorDescription || error || 'Authentication failed');
toast.error(errorDescription || 'Authentication failed');
setTimeout(() => navigate('/auth/login'), 3000);
return;
}
// Validate required params
if (!code || !state || !provider) {
setStatus('error');
setErrorMessage('Invalid callback parameters');
toast.error('Invalid callback parameters');
setTimeout(() => navigate('/auth/login'), 3000);
return;
}
try {
const response = await oauthCallback.mutateAsync({
provider,
code,
state,
idToken, // Apple OAuth
userData, // Apple OAuth (first time only)
});
// Success - save tokens and redirect
if (response.accessToken && response.refreshToken && response.user) {
login(
{
id: response.user.id,
email: response.user.email,
first_name: response.user.first_name || '',
last_name: response.user.last_name || '',
role: 'user',
tenant_id: response.user.tenant_id,
},
response.accessToken,
response.refreshToken
);
queryClient.invalidateQueries({ queryKey: ['auth'] });
setStatus('success');
toast.success('Successfully authenticated!');
// Redirect to dashboard after brief success message
setTimeout(() => navigate('/dashboard'), 1500);
} else {
throw new Error('Invalid response from server');
}
} catch (error: any) {
setStatus('error');
const message = error.response?.data?.message || error.message || 'Authentication failed';
setErrorMessage(message);
toast.error(message);
// Redirect to login after error
setTimeout(() => navigate('/auth/login'), 3000);
}
};
processCallback();
}, [provider, searchParams, navigate, login, queryClient, oauthCallback]);
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 dark:bg-secondary-900">
<div className="max-w-md w-full mx-4">
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-lg p-8 text-center">
{status === 'processing' && (
<>
<Loader2 className="w-16 h-16 mx-auto text-primary-600 animate-spin mb-4" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
Completing sign in...
</h2>
<p className="text-secondary-600 dark:text-secondary-400">
Please wait while we verify your credentials with {provider}.
</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="w-16 h-16 mx-auto text-green-500 mb-4" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
Successfully authenticated!
</h2>
<p className="text-secondary-600 dark:text-secondary-400">
Redirecting you to the dashboard...
</p>
</>
)}
{status === 'error' && (
<>
<XCircle className="w-16 h-16 mx-auto text-red-500 mb-4" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white mb-2">
Authentication failed
</h2>
<p className="text-secondary-600 dark:text-secondary-400 mb-4">
{errorMessage}
</p>
<p className="text-sm text-secondary-500 dark:text-secondary-400">
Redirecting to login page...
</p>
</>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,257 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { useRegister } from '@/hooks';
import { Eye, EyeOff, Loader2, CheckCircle, XCircle } from 'lucide-react';
import clsx from 'clsx';
import { OAuthButtons, OAuthSeparator } from '@/components/auth';
interface RegisterFormData {
email: string;
password: string;
confirmPassword: string;
first_name: string;
last_name: string;
}
// Password requirements
const passwordRequirements = [
{ id: 'length', label: 'At least 8 characters', test: (p: string) => p.length >= 8 },
{ id: 'upper', label: 'One uppercase letter', test: (p: string) => /[A-Z]/.test(p) },
{ id: 'lower', label: 'One lowercase letter', test: (p: string) => /[a-z]/.test(p) },
{ id: 'number', label: 'One number', test: (p: string) => /\d/.test(p) },
];
export function RegisterPage() {
const [showPassword, setShowPassword] = useState(false);
const registerMutation = useRegister();
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<RegisterFormData>();
const password = watch('password', '');
const onSubmit = (data: RegisterFormData) => {
registerMutation.mutate({
email: data.email,
password: data.password,
first_name: data.first_name,
last_name: data.last_name,
});
};
const allPasswordRequirementsMet = passwordRequirements.every((req) =>
req.test(password)
);
return (
<div>
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white">
Create your account
</h2>
<p className="mt-2 text-secondary-600 dark:text-secondary-400">
Start your 14-day free trial
</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="first_name" className="label">
First name
</label>
<input
id="first_name"
type="text"
className="input"
placeholder="John"
disabled={registerMutation.isPending}
{...register('first_name', { required: 'First name is required' })}
/>
{errors.first_name && (
<p className="mt-1 text-sm text-red-500">{errors.first_name.message}</p>
)}
</div>
<div>
<label htmlFor="last_name" className="label">
Last name
</label>
<input
id="last_name"
type="text"
className="input"
placeholder="Doe"
disabled={registerMutation.isPending}
{...register('last_name', { required: 'Last name is required' })}
/>
{errors.last_name && (
<p className="mt-1 text-sm text-red-500">{errors.last_name.message}</p>
)}
</div>
</div>
<div>
<label htmlFor="email" className="label">
Email address
</label>
<input
id="email"
type="email"
autoComplete="email"
className="input"
placeholder="you@example.com"
disabled={registerMutation.isPending}
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="password" className="label">
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
className="input pr-10"
placeholder="Create a password"
disabled={registerMutation.isPending}
{...register('password', {
required: 'Password is required',
validate: () =>
allPasswordRequirementsMet || 'Password does not meet requirements',
})}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-secondary-400 hover:text-secondary-600"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
{/* Password requirements */}
{password && (
<div className="mt-2 space-y-1">
{passwordRequirements.map((req) => {
const met = req.test(password);
return (
<div
key={req.id}
className={clsx(
'flex items-center gap-2 text-xs',
met ? 'text-green-600' : 'text-secondary-500'
)}
>
{met ? (
<CheckCircle className="w-3.5 h-3.5" />
) : (
<XCircle className="w-3.5 h-3.5" />
)}
{req.label}
</div>
);
})}
</div>
)}
{errors.password && !password && (
<p className="mt-1 text-sm text-red-500">{errors.password.message}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword" className="label">
Confirm password
</label>
<input
id="confirmPassword"
type="password"
className="input"
placeholder="Confirm your password"
disabled={registerMutation.isPending}
{...register('confirmPassword', {
required: 'Please confirm your password',
validate: (value) =>
value === password || 'Passwords do not match',
})}
/>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">{errors.confirmPassword.message}</p>
)}
</div>
<div className="flex items-start gap-2">
<input
id="terms"
type="checkbox"
className="mt-1 w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
required
/>
<label
htmlFor="terms"
className="text-sm text-secondary-600 dark:text-secondary-400"
>
I agree to the{' '}
<a href="#" className="text-primary-600 hover:underline">
Terms of Service
</a>{' '}
and{' '}
<a href="#" className="text-primary-600 hover:underline">
Privacy Policy
</a>
</label>
</div>
<button
type="submit"
disabled={registerMutation.isPending}
className="btn-primary w-full py-2.5"
>
{registerMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating account...
</>
) : (
'Create account'
)}
</button>
</form>
<OAuthSeparator />
<OAuthButtons mode="register" disabled={registerMutation.isPending} />
<p className="mt-6 text-center text-sm text-secondary-600 dark:text-secondary-400">
Already have an account?{' '}
<Link
to="/auth/login"
className="font-medium text-primary-600 hover:text-primary-500"
>
Sign in
</Link>
</p>
</div>
);
}

4
src/pages/auth/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from './LoginPage';
export * from './RegisterPage';
export * from './ForgotPasswordPage';
export * from './OAuthCallbackPage';

View File

@ -0,0 +1,107 @@
import { Bot, Sparkles, TrendingUp } from 'lucide-react';
import { AIChat } from '@/components/ai';
import { useCurrentAIUsage, useAIConfig } from '@/hooks/useAI';
export function AIPage() {
const { data: usage } = useCurrentAIUsage();
const { data: config } = useAIConfig();
const formatNumber = (num: number) => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
return num.toString();
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
AI Assistant
</h1>
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
Chat with your AI assistant powered by {config?.provider || 'OpenRouter'}
</p>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<Bot className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<div>
<p className="text-sm text-secondary-500">Requests This Month</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{usage?.request_count?.toLocaleString() || 0}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
<Sparkles className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-secondary-500">Tokens Used</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{formatNumber(usage?.total_tokens || 0)}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-lg flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<p className="text-sm text-secondary-500">Avg Response Time</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{Math.round(usage?.avg_latency_ms || 0)}ms
</p>
</div>
</div>
</div>
</div>
{/* Chat Interface */}
<AIChat
className="h-[600px]"
showModelSelector={config?.allow_custom_prompts}
placeholder="Ask me anything..."
/>
{/* Tips */}
<div className="card p-4">
<h3 className="font-medium text-secondary-900 dark:text-white mb-3">
Tips for better results
</h3>
<ul className="space-y-2 text-sm text-secondary-600 dark:text-secondary-400">
<li className="flex items-start gap-2">
<span className="text-primary-500"></span>
Be specific and provide context for your questions
</li>
<li className="flex items-start gap-2">
<span className="text-primary-500"></span>
Break complex tasks into smaller, focused requests
</li>
<li className="flex items-start gap-2">
<span className="text-primary-500"></span>
Use follow-up questions to refine responses
</li>
<li className="flex items-start gap-2">
<span className="text-primary-500"></span>
Clear the chat to start fresh conversations
</li>
</ul>
</div>
</div>
);
}

View File

@ -0,0 +1,276 @@
import { useState, useMemo } from 'react';
import { QueryAuditLogsParams } from '@/services/api';
import {
useAuditLogs,
useAuditStats,
useActivityLogs,
useActivitySummary,
} from '@/hooks/useAudit';
import {
AuditLogRow,
AuditStatsCard,
AuditFilters,
ActivityTimeline,
} from '@/components/audit';
import { ExportButton } from '@/components/common';
import {
ClipboardList,
Activity,
ChevronLeft,
ChevronRight,
Loader2,
} from 'lucide-react';
import clsx from 'clsx';
type TabType = 'logs' | 'activity';
export function AuditLogsPage() {
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [filters, setFilters] = useState<QueryAuditLogsParams>({
page: 1,
limit: 20,
});
// Queries
const { data: auditLogsData, isLoading: logsLoading } = useAuditLogs(filters);
const { data: auditStats, isLoading: statsLoading } = useAuditStats(7);
const { data: activitiesData, isLoading: activitiesLoading } = useActivityLogs({
page: 1,
limit: 50,
});
const { data: activitySummary, isLoading: summaryLoading } = useActivitySummary(30);
// Extract unique entity types from stats for filters
const entityTypes = useMemo(() => {
if (!auditStats?.by_entity_type) return [];
return Object.keys(auditStats.by_entity_type);
}, [auditStats]);
const handlePageChange = (newPage: number) => {
setFilters((prev) => ({ ...prev, page: newPage }));
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
Audit Logs
</h1>
<p className="text-secondary-500 mt-1">
Track and monitor all system activities and changes
</p>
</div>
<ExportButton reportType="audit" />
</div>
{/* Stats Card */}
<AuditStatsCard stats={auditStats!} isLoading={statsLoading} />
{/* Tabs */}
<div className="flex items-center gap-4 border-b border-secondary-200 dark:border-secondary-700">
<button
onClick={() => setActiveTab('logs')}
className={clsx(
'flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === 'logs'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300'
)}
>
<ClipboardList className="w-4 h-4" />
Audit Logs
{auditLogsData?.total !== undefined && (
<span className="px-2 py-0.5 bg-secondary-100 dark:bg-secondary-700 rounded-full text-xs">
{auditLogsData.total}
</span>
)}
</button>
<button
onClick={() => setActiveTab('activity')}
className={clsx(
'flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === 'activity'
? 'border-primary-600 text-primary-600'
: 'border-transparent text-secondary-500 hover:text-secondary-700 dark:hover:text-secondary-300'
)}
>
<Activity className="w-4 h-4" />
Activity
{activitiesData?.total !== undefined && (
<span className="px-2 py-0.5 bg-secondary-100 dark:bg-secondary-700 rounded-full text-xs">
{activitiesData.total}
</span>
)}
</button>
</div>
{/* Content */}
{activeTab === 'logs' ? (
<div className="space-y-4">
{/* Filters */}
<AuditFilters
filters={filters}
onFiltersChange={setFilters}
entityTypes={entityTypes}
/>
{/* Logs list */}
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700">
{logsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-primary-600 animate-spin" />
</div>
) : !auditLogsData?.items?.length ? (
<div className="text-center py-12">
<ClipboardList className="w-12 h-12 mx-auto text-secondary-400 mb-3" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-1">
No audit logs found
</h3>
<p className="text-secondary-500">
{Object.keys(filters).filter((k) => k !== 'page' && k !== 'limit').length > 0
? 'Try adjusting your filters'
: 'Audit logs will appear here as actions are performed'}
</p>
</div>
) : (
<>
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{auditLogsData.items.map((log) => (
<AuditLogRow key={log.id} log={log} />
))}
</div>
{/* Pagination */}
{auditLogsData.totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-200 dark:border-secondary-700">
<div className="text-sm text-secondary-500">
Showing {(auditLogsData.page - 1) * auditLogsData.limit + 1} -{' '}
{Math.min(
auditLogsData.page * auditLogsData.limit,
auditLogsData.total
)}{' '}
of {auditLogsData.total}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(auditLogsData.page - 1)}
disabled={auditLogsData.page === 1}
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm text-secondary-700 dark:text-secondary-300">
Page {auditLogsData.page} of {auditLogsData.totalPages}
</span>
<button
onClick={() => handlePageChange(auditLogsData.page + 1)}
disabled={auditLogsData.page === auditLogsData.totalPages}
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Activity timeline */}
<div className="lg:col-span-2 bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
Recent Activity
</h3>
<ActivityTimeline
activities={activitiesData?.items || []}
isLoading={activitiesLoading}
/>
</div>
{/* Activity summary */}
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100 mb-4">
Activity Summary
</h3>
{summaryLoading ? (
<div className="animate-pulse space-y-3">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-8 bg-secondary-200 dark:bg-secondary-700 rounded"
/>
))}
</div>
) : activitySummary ? (
<div className="space-y-4">
<div className="p-3 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
<p className="text-sm text-primary-600 dark:text-primary-400 font-medium">
Total Activities (30 days)
</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{activitySummary.total_activities.toLocaleString()}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
By Type
</h4>
<div className="space-y-2">
{Object.entries(activitySummary.by_type)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([type, count]) => (
<div
key={type}
className="flex items-center justify-between text-sm"
>
<span className="text-secondary-600 dark:text-secondary-400 capitalize">
{type.replace(/_/g, ' ')}
</span>
<span className="font-medium text-secondary-900 dark:text-secondary-100">
{count.toLocaleString()}
</span>
</div>
))}
</div>
</div>
{activitySummary.by_day && activitySummary.by_day.length > 0 && (
<div>
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Daily Trend
</h4>
<div className="flex items-end justify-between h-16 gap-0.5">
{activitySummary.by_day.slice(-14).map((day) => {
const maxCount = Math.max(
...activitySummary.by_day.map((d) => d.count)
);
const height =
maxCount > 0 ? (day.count / maxCount) * 100 : 0;
return (
<div
key={day.date}
className="flex-1 bg-primary-500 dark:bg-primary-400 rounded-t"
style={{ height: `${Math.max(height, 4)}%` }}
title={`${day.date}: ${day.count}`}
/>
);
})}
</div>
</div>
)}
</div>
) : (
<p className="text-secondary-500">No activity data available</p>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,360 @@
import {
useSubscription,
useInvoices,
usePaymentMethods,
useStripePrices,
useCreateBillingPortal,
useCreateCheckoutSession,
} from '@/hooks';
import {
CreditCard,
CheckCircle,
ExternalLink,
Loader2,
Download,
AlertCircle,
} from 'lucide-react';
import clsx from 'clsx';
export function BillingPage() {
const { data: subscription, isLoading: subscriptionLoading } = useSubscription();
const { data: invoicesData, isLoading: invoicesLoading } = useInvoices(1, 10);
const { data: paymentMethods, isLoading: paymentMethodsLoading } = usePaymentMethods();
const { data: prices } = useStripePrices();
const billingPortalMutation = useCreateBillingPortal();
const checkoutMutation = useCreateCheckoutSession();
// Format currency
const formatCurrency = (amount: number, currency = 'USD') => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase(),
}).format(amount / 100); // Assuming cents
};
// Format date
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
// Handle manage billing button click
const handleManageBilling = () => {
billingPortalMutation.mutate(window.location.href);
};
// Handle upgrade plan
const handleUpgrade = (priceId: string) => {
checkoutMutation.mutate({
price_id: priceId,
success_url: `${window.location.origin}/dashboard/billing?success=true`,
cancel_url: `${window.location.origin}/dashboard/billing?canceled=true`,
});
};
// Get default payment method
const defaultPaymentMethod = paymentMethods?.find((pm: any) => pm.is_default) || paymentMethods?.[0];
// Plans data (could come from API in the future)
const plans = [
{
name: 'Starter',
price: '$29',
period: '/month',
priceId: prices?.starter?.monthly || 'price_starter_monthly',
features: ['Up to 5 users', '10GB storage', 'Email support', 'Basic analytics'],
},
{
name: 'Professional',
price: '$79',
period: '/month',
priceId: prices?.professional?.monthly || 'price_professional_monthly',
features: ['Up to 25 users', '100GB storage', 'Priority support', 'Advanced analytics', 'API access'],
},
{
name: 'Enterprise',
price: '$199',
period: '/month',
priceId: prices?.enterprise?.monthly || 'price_enterprise_monthly',
features: ['Unlimited users', 'Unlimited storage', '24/7 support', 'Custom analytics', 'Dedicated manager'],
},
];
// Determine current plan
const currentPlanName = subscription?.plan?.name?.toLowerCase() || 'free';
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Billing & Plans
</h1>
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
Manage your subscription and billing information
</p>
</div>
{/* Current Plan */}
<div className="card">
<div className="card-header">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Current Plan
</h2>
</div>
<div className="card-body">
{subscriptionLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
</div>
) : subscription ? (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="text-xl font-bold text-secondary-900 dark:text-white">
{subscription.plan?.display_name || subscription.plan?.name || 'Unknown Plan'}
</span>
<span
className={clsx(
'px-2 py-0.5 text-xs font-medium rounded-full',
subscription.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: subscription.status === 'trialing'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
)}
>
{subscription.status}
</span>
</div>
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
{subscription.plan?.price_monthly
? formatCurrency(subscription.plan.price_monthly)
: 'Free'}
/month
{subscription.current_period_end && (
<span className="ml-2">
· Renews on {formatDate(subscription.current_period_end)}
</span>
)}
</p>
{subscription.cancel_at_period_end && (
<p className="mt-2 text-sm text-yellow-600 dark:text-yellow-400 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
Cancels at end of billing period
</p>
)}
</div>
<button
onClick={handleManageBilling}
disabled={billingPortalMutation.isPending}
className="btn-secondary"
>
{billingPortalMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<ExternalLink className="w-4 h-4 mr-2" />
)}
Manage in Stripe
</button>
</div>
) : (
<div className="text-center py-4">
<p className="text-secondary-600 dark:text-secondary-400">
No active subscription. Choose a plan below to get started.
</p>
</div>
)}
</div>
</div>
{/* Available Plans */}
<div>
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Available Plans
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{plans.map((plan) => {
const isCurrent = currentPlanName === plan.name.toLowerCase();
return (
<div
key={plan.name}
className={clsx('card', isCurrent && 'ring-2 ring-primary-500')}
>
<div className="card-body">
{isCurrent && (
<span className="inline-block px-2 py-0.5 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 text-xs font-medium rounded-full mb-2">
Current Plan
</span>
)}
<h3 className="text-xl font-bold text-secondary-900 dark:text-white">
{plan.name}
</h3>
<div className="mt-2">
<span className="text-3xl font-bold text-secondary-900 dark:text-white">
{plan.price}
</span>
<span className="text-secondary-500">{plan.period}</span>
</div>
<ul className="mt-4 space-y-3">
{plan.features.map((feature) => (
<li
key={feature}
className="flex items-center gap-2 text-sm text-secondary-600 dark:text-secondary-400"
>
<CheckCircle className="w-4 h-4 text-green-500 flex-shrink-0" />
{feature}
</li>
))}
</ul>
<button
onClick={() => !isCurrent && handleUpgrade(plan.priceId)}
disabled={isCurrent || checkoutMutation.isPending}
className={clsx('mt-6 w-full', isCurrent ? 'btn-secondary' : 'btn-primary')}
>
{checkoutMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : isCurrent ? (
'Current Plan'
) : (
'Upgrade'
)}
</button>
</div>
</div>
);
})}
</div>
</div>
{/* Payment Method */}
<div className="card">
<div className="card-header">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Payment Method
</h2>
</div>
<div className="card-body">
{paymentMethodsLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
</div>
) : defaultPaymentMethod ? (
<div className="flex items-center gap-4 p-4 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
<div className="w-12 h-8 bg-gradient-to-r from-blue-600 to-blue-400 rounded flex items-center justify-center">
<CreditCard className="w-6 h-6 text-white" />
</div>
<div className="flex-1">
<p className="font-medium text-secondary-900 dark:text-white">
{defaultPaymentMethod.card_brand?.charAt(0).toUpperCase() +
defaultPaymentMethod.card_brand?.slice(1) || 'Card'}{' '}
ending in {defaultPaymentMethod.card_last_four}
</p>
<p className="text-sm text-secondary-500">
Expires {defaultPaymentMethod.card_exp_month}/{defaultPaymentMethod.card_exp_year}
</p>
</div>
<button
onClick={handleManageBilling}
disabled={billingPortalMutation.isPending}
className="btn-ghost text-sm"
>
Update
</button>
</div>
) : (
<div className="text-center py-4">
<p className="text-secondary-600 dark:text-secondary-400 mb-4">
No payment method on file
</p>
<button onClick={handleManageBilling} className="btn-secondary">
Add Payment Method
</button>
</div>
)}
</div>
</div>
{/* Billing History */}
<div className="card">
<div className="card-header">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Billing History
</h2>
</div>
{invoicesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
</div>
) : invoicesData?.data && invoicesData.data.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-secondary-200 dark:border-secondary-700">
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
Date
</th>
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
Invoice
</th>
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
Amount
</th>
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
Status
</th>
<th className="py-3 px-6"></th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-200 dark:divide-secondary-700">
{invoicesData.data.map((invoice: any) => (
<tr key={invoice.id}>
<td className="py-3 px-6 text-sm text-secondary-900 dark:text-secondary-100">
{formatDate(invoice.issue_date)}
</td>
<td className="py-3 px-6 text-sm text-secondary-600 dark:text-secondary-400">
{invoice.invoice_number}
</td>
<td className="py-3 px-6 text-sm text-secondary-900 dark:text-secondary-100">
{formatCurrency(invoice.total, invoice.currency)}
</td>
<td className="py-3 px-6">
<span
className={clsx(
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
invoice.status === 'paid'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: invoice.status === 'open'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: invoice.status === 'void'
? 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
)}
>
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
</span>
</td>
<td className="py-3 px-6 text-right">
<button className="text-sm text-primary-600 hover:text-primary-500 flex items-center gap-1">
<Download className="w-4 h-4" />
PDF
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="py-8 text-center text-secondary-500 dark:text-secondary-400">
<CreditCard className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No invoices yet</p>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,228 @@
import { Link } from 'react-router-dom';
import { useAuthStore } from '@/stores';
import { useUsers, useSubscription, useBillingSummary, useUnreadNotificationsCount } from '@/hooks';
import { Users, CreditCard, Activity, TrendingUp, Bell, Loader2 } from 'lucide-react';
export function DashboardPage() {
const { user } = useAuthStore();
const { data: usersData, isLoading: usersLoading } = useUsers(1, 10);
const { data: subscription, isLoading: subscriptionLoading } = useSubscription();
const { data: billingSummary, isLoading: billingLoading } = useBillingSummary();
const { data: unreadCount } = useUnreadNotificationsCount();
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount / 100); // Assuming cents
};
const stats = [
{
name: 'Total Users',
value: usersLoading ? '...' : (usersData?.total ?? 0).toString(),
icon: Users,
change: '+12%',
changeType: 'positive' as const,
},
{
name: 'Monthly Revenue',
value: billingLoading ? '...' : formatCurrency(billingSummary?.totalPaid ?? 0),
icon: CreditCard,
change: '+8%',
changeType: 'positive' as const,
},
{
name: 'Current Plan',
value: subscriptionLoading ? '...' : (subscription?.plan?.display_name ?? 'Free'),
icon: TrendingUp,
change: subscription?.status === 'active' ? 'Active' : (subscription?.status ?? 'N/A'),
changeType: subscription?.status === 'active' ? 'positive' as const : 'neutral' as const,
},
{
name: 'Notifications',
value: (unreadCount?.count ?? 0).toString(),
icon: Bell,
change: unreadCount?.count ? 'Unread' : 'All read',
changeType: unreadCount?.count ? 'neutral' as const : 'positive' as const,
},
];
return (
<div className="space-y-6">
{/* Welcome header */}
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Welcome back, {user?.first_name || 'User'}!
</h1>
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
Here's what's happening with your business today.
</p>
</div>
{/* Stats grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((stat) => (
<div key={stat.name} className="card card-body">
<div className="flex items-center justify-between">
<div className="w-10 h-10 bg-primary-50 dark:bg-primary-900/30 rounded-lg flex items-center justify-center">
<stat.icon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<span
className={`text-sm font-medium ${
stat.changeType === 'positive'
? 'text-green-600 dark:text-green-400'
: 'text-secondary-500 dark:text-secondary-400'
}`}
>
{stat.change}
</span>
</div>
<div className="mt-4">
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
{stat.value}
</p>
<p className="text-sm text-secondary-600 dark:text-secondary-400">
{stat.name}
</p>
</div>
</div>
))}
</div>
{/* Quick actions */}
<div className="card">
<div className="card-header">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Quick Actions
</h2>
</div>
<div className="card-body">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Link to="/dashboard/users" className="btn-secondary justify-center py-3">
<Users className="w-5 h-5 mr-2" />
Manage Team
</Link>
<Link to="/dashboard/billing" className="btn-secondary justify-center py-3">
<CreditCard className="w-5 h-5 mr-2" />
Manage Billing
</Link>
<Link to="/dashboard/settings" className="btn-secondary justify-center py-3">
<Activity className="w-5 h-5 mr-2" />
Settings
</Link>
</div>
</div>
</div>
{/* Recent users */}
<div className="card">
<div className="card-header flex items-center justify-between">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Recent Team Members
</h2>
<Link
to="/dashboard/users"
className="text-sm text-primary-600 hover:text-primary-500"
>
View all
</Link>
</div>
<div className="card-body">
{usersLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary-600" />
</div>
) : usersData?.data && usersData.data.length > 0 ? (
<div className="space-y-3">
{usersData.data.slice(0, 5).map((u) => (
<div
key={u.id}
className="flex items-center gap-3 p-3 rounded-lg bg-secondary-50 dark:bg-secondary-700/30"
>
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
{(u.first_name?.[0] || u.email[0]).toUpperCase()}
{(u.last_name?.[0] || '').toUpperCase()}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-secondary-900 dark:text-white truncate">
{u.first_name && u.last_name
? `${u.first_name} ${u.last_name}`
: u.email}
</p>
<p className="text-sm text-secondary-500 truncate">{u.email}</p>
</div>
<span
className={`text-xs font-medium px-2 py-1 rounded-full ${
u.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}`}
>
{u.status}
</span>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-secondary-500 dark:text-secondary-400">
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No team members yet</p>
</div>
)}
</div>
</div>
{/* Subscription info */}
{subscription && (
<div className="card">
<div className="card-header flex items-center justify-between">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Subscription
</h2>
<Link
to="/dashboard/billing"
className="text-sm text-primary-600 hover:text-primary-500"
>
Manage
</Link>
</div>
<div className="card-body">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="text-xl font-bold text-secondary-900 dark:text-white">
{subscription.plan?.display_name || 'Unknown Plan'}
</span>
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full ${
subscription.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
}`}
>
{subscription.status}
</span>
</div>
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
{subscription.plan?.price_monthly
? formatCurrency(subscription.plan.price_monthly)
: 'Free'}
/month
{subscription.current_period_end && (
<span className="ml-2">
· Renews {new Date(subscription.current_period_end).toLocaleDateString()}
</span>
)}
</p>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,389 @@
import { useState, useMemo } from 'react';
import { FeatureFlag, CreateFlagRequest, UpdateFlagRequest } from '@/services/api';
import {
useFeatureFlags,
useCreateFeatureFlag,
useUpdateFeatureFlag,
useDeleteFeatureFlag,
useToggleFeatureFlag,
useTenantFlagOverrides,
useSetTenantFlagOverride,
useRemoveTenantFlagOverride,
} from '@/hooks/useFeatureFlags';
import {
FeatureFlagCard,
FeatureFlagForm,
TenantOverridesPanel,
} from '@/components/feature-flags';
import {
Flag,
Plus,
ArrowLeft,
AlertTriangle,
Loader2,
Search,
ToggleLeft,
Settings2,
} from 'lucide-react';
import clsx from 'clsx';
type ViewMode = 'list' | 'create' | 'edit' | 'overrides';
export function FeatureFlagsPage() {
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [selectedFlag, setSelectedFlag] = useState<FeatureFlag | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<FeatureFlag | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<string>('');
const [showEnabledOnly, setShowEnabledOnly] = useState(false);
// Queries
const { data: flags = [], isLoading: flagsLoading } = useFeatureFlags();
const { data: tenantOverrides = [] } = useTenantFlagOverrides();
// Mutations
const createMutation = useCreateFeatureFlag();
const updateMutation = useUpdateFeatureFlag();
const deleteMutation = useDeleteFeatureFlag();
const toggleMutation = useToggleFeatureFlag();
const setOverrideMutation = useSetTenantFlagOverride();
const removeOverrideMutation = useRemoveTenantFlagOverride();
// Get unique categories
const categories = useMemo(() => {
const cats = new Set<string>();
flags.forEach((flag) => {
if (flag.category) cats.add(flag.category);
});
return Array.from(cats).sort();
}, [flags]);
// Filter flags
const filteredFlags = useMemo(() => {
return flags.filter((flag) => {
const matchesSearch =
!searchQuery ||
flag.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
flag.key.toLowerCase().includes(searchQuery.toLowerCase());
const matchesCategory = !categoryFilter || flag.category === categoryFilter;
const matchesEnabled = !showEnabledOnly || flag.is_enabled;
return matchesSearch && matchesCategory && matchesEnabled;
});
}, [flags, searchQuery, categoryFilter, showEnabledOnly]);
const handleCreate = async (data: CreateFlagRequest) => {
try {
await createMutation.mutateAsync(data);
setViewMode('list');
} catch (error) {
console.error('Failed to create flag:', error);
}
};
const handleUpdate = async (data: UpdateFlagRequest) => {
if (!selectedFlag) return;
try {
await updateMutation.mutateAsync({ id: selectedFlag.id, data });
setViewMode('list');
setSelectedFlag(null);
} catch (error) {
console.error('Failed to update flag:', error);
}
};
const handleDelete = async () => {
if (!deleteConfirm) return;
try {
await deleteMutation.mutateAsync(deleteConfirm.id);
setDeleteConfirm(null);
} catch (error) {
console.error('Failed to delete flag:', error);
}
};
const handleToggle = async (flag: FeatureFlag) => {
try {
await toggleMutation.mutateAsync({ id: flag.id, enabled: !flag.is_enabled });
} catch (error) {
console.error('Failed to toggle flag:', error);
}
};
const handleAddOverride = async (flagId: string, isEnabled: boolean, value?: any) => {
try {
await setOverrideMutation.mutateAsync({
flag_id: flagId,
is_enabled: isEnabled,
value,
});
} catch (error) {
console.error('Failed to add override:', error);
}
};
const handleRemoveOverride = async (flagId: string) => {
try {
await removeOverrideMutation.mutateAsync(flagId);
} catch (error) {
console.error('Failed to remove override:', error);
}
};
// Render list view
if (viewMode === 'list') {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
Feature Flags
</h1>
<p className="text-secondary-500 mt-1">
Manage feature flags and rollouts
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setViewMode('overrides')}
className="flex items-center gap-2 px-4 py-2 text-secondary-700 dark:text-secondary-300 border border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<Settings2 className="w-4 h-4" />
Tenant Overrides
{tenantOverrides.length > 0 && (
<span className="px-1.5 py-0.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400 text-xs rounded-full">
{tenantOverrides.length}
</span>
)}
</button>
<button
onClick={() => setViewMode('create')}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
<Plus className="w-4 h-4" />
New Flag
</button>
</div>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px] max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" />
<input
type="text"
placeholder="Search flags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100"
/>
</div>
</div>
{categories.length > 0 && (
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 rounded-lg border border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-secondary-100"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
)}
<button
onClick={() => setShowEnabledOnly(!showEnabledOnly)}
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg border',
showEnabledOnly
? 'bg-green-50 border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-800 dark:text-green-400'
: 'border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700'
)}
>
<ToggleLeft className="w-4 h-4" />
Enabled Only
</button>
</div>
{/* Flags grid */}
{flagsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-primary-600 animate-spin" />
</div>
) : filteredFlags.length === 0 ? (
<div className="text-center py-12">
<Flag className="w-12 h-12 mx-auto text-secondary-400 mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-2">
{flags.length === 0 ? 'No feature flags yet' : 'No matching flags'}
</h3>
<p className="text-secondary-500 mb-4">
{flags.length === 0
? 'Create your first feature flag to start managing features'
: 'Try adjusting your search or filters'}
</p>
{flags.length === 0 && (
<button
onClick={() => setViewMode('create')}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Create Flag
</button>
)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredFlags.map((flag) => (
<FeatureFlagCard
key={flag.id}
flag={flag}
onEdit={(f) => {
setSelectedFlag(f);
setViewMode('edit');
}}
onDelete={setDeleteConfirm}
onToggle={handleToggle}
isToggling={toggleMutation.isPending}
/>
))}
</div>
)}
{/* Delete confirmation modal */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-red-100 dark:bg-red-900/20 rounded-full">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
Delete Feature Flag
</h3>
</div>
<p className="text-secondary-600 dark:text-secondary-400 mb-2">
Are you sure you want to delete "{deleteConfirm.name}"?
</p>
<p className="text-sm text-secondary-500 mb-6">
This will remove the flag globally and all tenant/user overrides will be lost.
</p>
<div className="flex items-center justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Render create view
if (viewMode === 'create') {
return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => setViewMode('list')}
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
</button>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
Create Feature Flag
</h1>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<FeatureFlagForm
onSubmit={(data) => handleCreate(data as CreateFlagRequest)}
onCancel={() => setViewMode('list')}
isLoading={createMutation.isPending}
/>
</div>
</div>
);
}
// Render edit view
if (viewMode === 'edit' && selectedFlag) {
return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => {
setViewMode('list');
setSelectedFlag(null);
}}
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
</button>
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
Edit Feature Flag
</h1>
<code className="text-sm text-secondary-500">{selectedFlag.key}</code>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<FeatureFlagForm
flag={selectedFlag}
onSubmit={(data) => handleUpdate(data as UpdateFlagRequest)}
onCancel={() => {
setViewMode('list');
setSelectedFlag(null);
}}
isLoading={updateMutation.isPending}
/>
</div>
</div>
);
}
// Render overrides view
if (viewMode === 'overrides') {
return (
<div className="max-w-3xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => setViewMode('list')}
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
</button>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
Tenant Overrides
</h1>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<TenantOverridesPanel
flags={flags}
overrides={tenantOverrides}
onAdd={handleAddOverride}
onRemove={handleRemoveOverride}
isLoading={setOverrideMutation.isPending || removeOverrideMutation.isPending}
/>
</div>
</div>
);
}
return null;
}

View File

@ -0,0 +1,198 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useAuthStore, useUIStore } from '@/stores';
import toast from 'react-hot-toast';
import { Loader2, User, Building, Moon, Sun, Monitor } from 'lucide-react';
interface ProfileFormData {
first_name: string;
last_name: string;
email: string;
}
export function SettingsPage() {
const { user } = useAuthStore();
const { theme, setTheme } = useUIStore();
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ProfileFormData>({
defaultValues: {
first_name: user?.first_name || '',
last_name: user?.last_name || '',
email: user?.email || '',
},
});
const onSubmit = async (_data: ProfileFormData) => {
try {
setIsLoading(true);
// API call would go here
await new Promise((resolve) => setTimeout(resolve, 1000));
toast.success('Profile updated successfully!');
} catch {
toast.error('Failed to update profile');
} finally {
setIsLoading(false);
}
};
const themes = [
{ value: 'light', label: 'Light', icon: Sun },
{ value: 'dark', label: 'Dark', icon: Moon },
{ value: 'system', label: 'System', icon: Monitor },
] as const;
return (
<div className="space-y-6 max-w-3xl">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Settings
</h1>
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
Manage your account settings and preferences
</p>
</div>
{/* Profile Settings */}
<div className="card">
<div className="card-header flex items-center gap-3">
<User className="w-5 h-5 text-secondary-500" />
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Profile Information
</h2>
</div>
<div className="card-body">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label htmlFor="first_name" className="label">
First name
</label>
<input
id="first_name"
type="text"
className="input"
{...register('first_name', { required: 'First name is required' })}
/>
{errors.first_name && (
<p className="mt-1 text-sm text-red-500">{errors.first_name.message}</p>
)}
</div>
<div>
<label htmlFor="last_name" className="label">
Last name
</label>
<input
id="last_name"
type="text"
className="input"
{...register('last_name', { required: 'Last name is required' })}
/>
{errors.last_name && (
<p className="mt-1 text-sm text-red-500">{errors.last_name.message}</p>
)}
</div>
</div>
<div>
<label htmlFor="email" className="label">
Email address
</label>
<input
id="email"
type="email"
className="input"
disabled
{...register('email')}
/>
<p className="mt-1 text-sm text-secondary-500">
Contact support to change your email address
</p>
</div>
<div className="pt-4">
<button type="submit" disabled={isLoading} className="btn-primary">
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
'Save changes'
)}
</button>
</div>
</form>
</div>
</div>
{/* Organization Settings */}
<div className="card">
<div className="card-header flex items-center gap-3">
<Building className="w-5 h-5 text-secondary-500" />
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Organization
</h2>
</div>
<div className="card-body">
<div className="flex items-center justify-between p-4 bg-secondary-50 dark:bg-secondary-700/50 rounded-lg">
<div>
<p className="font-medium text-secondary-900 dark:text-white">
Tenant ID
</p>
<p className="text-sm text-secondary-500 dark:text-secondary-400 font-mono">
{user?.tenant_id || 'N/A'}
</p>
</div>
</div>
</div>
</div>
{/* Appearance Settings */}
<div className="card">
<div className="card-header">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Appearance
</h2>
</div>
<div className="card-body">
<div className="grid grid-cols-3 gap-4">
{themes.map((t) => (
<button
key={t.value}
onClick={() => setTheme(t.value)}
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
theme === t.value
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-secondary-200 dark:border-secondary-700 hover:border-secondary-300 dark:hover:border-secondary-600'
}`}
>
<t.icon
className={`w-6 h-6 ${
theme === t.value
? 'text-primary-600 dark:text-primary-400'
: 'text-secondary-500'
}`}
/>
<span
className={`text-sm font-medium ${
theme === t.value
? 'text-primary-600 dark:text-primary-400'
: 'text-secondary-700 dark:text-secondary-300'
}`}
>
{t.label}
</span>
</button>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,91 @@
import { useState } from 'react';
import { Upload, X } from 'lucide-react';
import { FileUpload, FileList, StorageUsageCard } from '@/components/storage';
import { StorageFile } from '@/services/api';
export function StoragePage() {
const [showUpload, setShowUpload] = useState(false);
const [previewFile, setPreviewFile] = useState<StorageFile | null>(null);
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
<p className="text-gray-600 mt-1">
Manage your files and documents
</p>
</div>
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
<Upload className="w-5 h-5" />
Upload File
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Main content */}
<div className="lg:col-span-3">
<div className="bg-white rounded-lg border p-6">
<FileList onPreview={setPreviewFile} />
</div>
</div>
{/* Sidebar */}
<div className="lg:col-span-1">
<StorageUsageCard />
</div>
</div>
{/* Upload Modal */}
{showUpload && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Upload File</h2>
<button
onClick={() => setShowUpload(false)}
className="p-2 hover:bg-gray-100 rounded-lg"
>
<X className="w-5 h-5" />
</button>
</div>
<FileUpload
onSuccess={() => {
setShowUpload(false);
}}
/>
</div>
</div>
)}
{/* Image Preview Modal */}
{previewFile && previewFile.mimeType.startsWith('image/') && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
onClick={() => setPreviewFile(null)}
>
<div className="relative max-w-4xl max-h-full">
<button
onClick={() => setPreviewFile(null)}
className="absolute -top-10 right-0 text-white hover:text-gray-300"
>
<X className="w-6 h-6" />
</button>
<img
src={previewFile.thumbnails?.large || previewFile.thumbnails?.medium || ''}
alt={previewFile.originalName}
className="max-w-full max-h-[80vh] object-contain rounded-lg"
/>
<div className="text-white text-center mt-4">
<p className="font-medium">{previewFile.originalName}</p>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,384 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { useUsers, useInviteUser, UserListItem } from '@/hooks';
import { ExportButton } from '@/components/common';
import {
Plus,
Search,
MoreVertical,
Mail,
Shield,
Trash2,
Loader2,
X,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import clsx from 'clsx';
interface InviteFormData {
email: string;
role: string;
}
export function UsersPage() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [showInviteModal, setShowInviteModal] = useState(false);
const limit = 10;
const { data: usersData, isLoading, isError, refetch } = useUsers(page, limit);
const inviteMutation = useInviteUser();
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<InviteFormData>({
defaultValues: { role: 'member' },
});
// Filter users by search (client-side for now)
const filteredUsers = usersData?.data?.filter(
(user: UserListItem) =>
user.email.toLowerCase().includes(search.toLowerCase()) ||
(user.first_name?.toLowerCase() || '').includes(search.toLowerCase()) ||
(user.last_name?.toLowerCase() || '').includes(search.toLowerCase())
) ?? [];
const totalPages = usersData?.totalPages ?? 1;
const handleInvite = (data: InviteFormData) => {
inviteMutation.mutate(data, {
onSuccess: () => {
setShowInviteModal(false);
reset();
refetch();
},
});
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Never';
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hours ago`;
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString();
};
const getUserInitials = (user: UserListItem) => {
if (user.first_name && user.last_name) {
return `${user.first_name[0]}${user.last_name[0]}`.toUpperCase();
}
return user.email[0].toUpperCase();
};
const getUserDisplayName = (user: UserListItem) => {
if (user.first_name && user.last_name) {
return `${user.first_name} ${user.last_name}`;
}
return user.email.split('@')[0];
};
const getUserRole = (user: UserListItem) => {
return user.roles?.[0]?.name || 'Member';
};
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Team Members
</h1>
<p className="text-secondary-600 dark:text-secondary-400 mt-1">
Manage your team and their permissions
{usersData?.total !== undefined && (
<span className="ml-2">({usersData.total} total)</span>
)}
</p>
</div>
<div className="flex items-center gap-3">
<ExportButton reportType="users" />
<button
onClick={() => setShowInviteModal(true)}
className="btn-primary"
>
<Plus className="w-4 h-4 mr-2" />
Invite Member
</button>
</div>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="text"
placeholder="Search by name or email..."
className="input pl-10"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Users table */}
<div className="card overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-600" />
</div>
) : isError ? (
<div className="py-12 text-center">
<p className="text-red-500 mb-4">Failed to load users</p>
<button onClick={() => refetch()} className="btn-secondary">
Try again
</button>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-secondary-50 dark:bg-secondary-700/50">
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
User
</th>
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
Role
</th>
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
Status
</th>
<th className="text-left py-3 px-6 text-sm font-medium text-secondary-500 dark:text-secondary-400">
Last Active
</th>
<th className="py-3 px-6 w-12"></th>
</tr>
</thead>
<tbody className="divide-y divide-secondary-200 dark:divide-secondary-700">
{filteredUsers.map((user: UserListItem) => (
<tr
key={user.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700/30"
>
<td className="py-4 px-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
<span className="text-sm font-medium text-primary-600 dark:text-primary-400">
{getUserInitials(user)}
</span>
</div>
<div>
<p className="font-medium text-secondary-900 dark:text-white">
{getUserDisplayName(user)}
</p>
<p className="text-sm text-secondary-500">{user.email}</p>
</div>
</div>
</td>
<td className="py-4 px-6">
<span
className={clsx(
'inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full',
getUserRole(user).toLowerCase() === 'admin' ||
getUserRole(user).toLowerCase() === 'owner'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-300'
)}
>
{(getUserRole(user).toLowerCase() === 'admin' ||
getUserRole(user).toLowerCase() === 'owner') && (
<Shield className="w-3 h-3" />
)}
{getUserRole(user)}
</span>
</td>
<td className="py-4 px-6">
<span
className={clsx(
'inline-flex px-2 py-0.5 text-xs font-medium rounded-full',
user.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: user.status === 'pending'
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
)}
>
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</span>
</td>
<td className="py-4 px-6 text-sm text-secondary-600 dark:text-secondary-400">
{formatDate(user.last_login_at)}
</td>
<td className="py-4 px-6">
<div className="relative">
<button
onClick={() =>
setOpenMenu(openMenu === user.id ? null : user.id)
}
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<MoreVertical className="w-4 h-4 text-secondary-500" />
</button>
{openMenu === user.id && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setOpenMenu(null)}
/>
<div className="absolute right-0 mt-1 w-48 bg-white dark:bg-secondary-800 rounded-lg shadow-lg border border-secondary-200 dark:border-secondary-700 py-1 z-50">
<button className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700">
<Mail className="w-4 h-4" />
Send email
</button>
<button className="w-full flex items-center gap-2 px-4 py-2 text-sm text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700">
<Shield className="w-4 h-4" />
Change role
</button>
<hr className="my-1 border-secondary-200 dark:border-secondary-700" />
<button className="w-full flex items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-secondary-100 dark:hover:bg-secondary-700">
<Trash2 className="w-4 h-4" />
Remove
</button>
</div>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredUsers.length === 0 && (
<div className="py-12 text-center text-secondary-500 dark:text-secondary-400">
{search
? 'No users found matching your search.'
: 'No team members yet. Invite someone to get started!'}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary-200 dark:border-secondary-700">
<p className="text-sm text-secondary-600 dark:text-secondary-400">
Page {page} of {totalPages}
</p>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="btn-secondary px-3 py-1.5 disabled:opacity-50"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="btn-secondary px-3 py-1.5 disabled:opacity-50"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</>
)}
</div>
{/* Invite Modal */}
{showInviteModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="fixed inset-0 bg-black/50"
onClick={() => setShowInviteModal(false)}
/>
<div className="relative bg-white dark:bg-secondary-800 rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
Invite Team Member
</h2>
<button
onClick={() => setShowInviteModal(false)}
className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
>
<X className="w-5 h-5 text-secondary-500" />
</button>
</div>
<form onSubmit={handleSubmit(handleInvite)} className="space-y-4">
<div>
<label htmlFor="email" className="label">
Email address
</label>
<input
id="email"
type="email"
className="input"
placeholder="colleague@company.com"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<div>
<label htmlFor="role" className="label">
Role
</label>
<select id="role" className="input" {...register('role')}>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => setShowInviteModal(false)}
className="btn-secondary flex-1"
>
Cancel
</button>
<button
type="submit"
disabled={inviteMutation.isPending}
className="btn-primary flex-1"
>
{inviteMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
</>
) : (
'Send Invitation'
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,308 @@
import { useState } from 'react';
import { Webhook } from '@/services/api';
import {
useWebhooks,
useWebhookEvents,
useWebhookDeliveries,
useCreateWebhook,
useUpdateWebhook,
useDeleteWebhook,
useToggleWebhook,
useTestWebhook,
useRetryDelivery,
} from '@/hooks';
import { WebhookCard, WebhookForm, WebhookDeliveryList } from '@/components/webhooks';
import { Plus, ArrowLeft, Webhook as WebhookIcon, AlertTriangle } from 'lucide-react';
type ViewMode = 'list' | 'create' | 'edit' | 'deliveries';
export function WebhooksPage() {
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Webhook | null>(null);
// Queries
const { data: webhooks = [], isLoading: webhooksLoading } = useWebhooks();
const { data: events = [] } = useWebhookEvents();
const { data: deliveriesData, isLoading: deliveriesLoading } = useWebhookDeliveries(
selectedWebhook?.id || '',
{ limit: 20 }
);
// Mutations
const createMutation = useCreateWebhook();
const updateMutation = useUpdateWebhook();
const deleteMutation = useDeleteWebhook();
const toggleMutation = useToggleWebhook();
const testMutation = useTestWebhook();
const retryMutation = useRetryDelivery();
const handleCreate = async (data: any) => {
try {
await createMutation.mutateAsync(data);
setViewMode('list');
} catch (error) {
console.error('Failed to create webhook:', error);
}
};
const handleUpdate = async (data: any) => {
if (!selectedWebhook) return;
try {
await updateMutation.mutateAsync({ id: selectedWebhook.id, data });
setViewMode('list');
setSelectedWebhook(null);
} catch (error) {
console.error('Failed to update webhook:', error);
}
};
const handleDelete = async () => {
if (!deleteConfirm) return;
try {
await deleteMutation.mutateAsync(deleteConfirm.id);
setDeleteConfirm(null);
} catch (error) {
console.error('Failed to delete webhook:', error);
}
};
const handleToggle = async (webhook: Webhook) => {
try {
await toggleMutation.mutateAsync({ id: webhook.id, isActive: !webhook.isActive });
} catch (error) {
console.error('Failed to toggle webhook:', error);
}
};
const handleTest = async (webhook: Webhook) => {
try {
await testMutation.mutateAsync({ id: webhook.id });
setSelectedWebhook(webhook);
setViewMode('deliveries');
} catch (error) {
console.error('Failed to test webhook:', error);
}
};
const handleRetry = async (deliveryId: string) => {
if (!selectedWebhook) return;
try {
await retryMutation.mutateAsync({ webhookId: selectedWebhook.id, deliveryId });
} catch (error) {
console.error('Failed to retry delivery:', error);
}
};
// Render list view
if (viewMode === 'list') {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
Webhooks
</h1>
<p className="text-secondary-500 mt-1">
Manage outbound webhooks for your integrations
</p>
</div>
<button
onClick={() => setViewMode('create')}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
<Plus className="w-4 h-4" />
Add Webhook
</button>
</div>
{webhooksLoading ? (
<div className="text-center py-12 text-secondary-500">Loading webhooks...</div>
) : webhooks.length === 0 ? (
<div className="text-center py-12">
<WebhookIcon className="w-12 h-12 mx-auto text-secondary-400 mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-secondary-100 mb-2">
No webhooks configured
</h3>
<p className="text-secondary-500 mb-4">
Create your first webhook to start receiving event notifications
</p>
<button
onClick={() => setViewMode('create')}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Create Webhook
</button>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{webhooks.map((webhook) => (
<WebhookCard
key={webhook.id}
webhook={webhook}
onEdit={(w) => {
setSelectedWebhook(w);
setViewMode('edit');
}}
onDelete={setDeleteConfirm}
onTest={handleTest}
onToggle={handleToggle}
onViewDeliveries={(w) => {
setSelectedWebhook(w);
setViewMode('deliveries');
}}
/>
))}
</div>
)}
{/* Delete confirmation modal */}
{deleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-red-100 dark:bg-red-900/20 rounded-full">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
Delete Webhook
</h3>
</div>
<p className="text-secondary-600 dark:text-secondary-400 mb-6">
Are you sure you want to delete "{deleteConfirm.name}"? This action cannot be
undone and all delivery history will be lost.
</p>
<div className="flex items-center justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="px-4 py-2 text-secondary-700 dark:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Render create view
if (viewMode === 'create') {
return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => setViewMode('list')}
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
</button>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
Create Webhook
</h1>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<WebhookForm
events={events}
onSubmit={handleCreate}
onCancel={() => setViewMode('list')}
isLoading={createMutation.isPending}
/>
</div>
</div>
);
}
// Render edit view
if (viewMode === 'edit' && selectedWebhook) {
return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => {
setViewMode('list');
setSelectedWebhook(null);
}}
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
</button>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
Edit Webhook
</h1>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<WebhookForm
webhook={selectedWebhook}
events={events}
onSubmit={handleUpdate}
onCancel={() => {
setViewMode('list');
setSelectedWebhook(null);
}}
isLoading={updateMutation.isPending}
/>
</div>
</div>
);
}
// Render deliveries view
if (viewMode === 'deliveries' && selectedWebhook) {
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => {
setViewMode('list');
setSelectedWebhook(null);
}}
className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
>
<ArrowLeft className="w-5 h-5 text-secondary-600 dark:text-secondary-400" />
</button>
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
{selectedWebhook.name}
</h1>
<p className="text-secondary-500">{selectedWebhook.url}</p>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
Delivery History
</h2>
<button
onClick={() => handleTest(selectedWebhook)}
disabled={testMutation.isPending}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{testMutation.isPending ? 'Sending...' : 'Send Test'}
</button>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<WebhookDeliveryList
deliveries={deliveriesData?.items || []}
isLoading={deliveriesLoading}
onRetry={handleRetry}
hasMore={(deliveriesData?.page || 1) < (deliveriesData?.totalPages || 1)}
/>
</div>
</div>
);
}
return null;
}

View File

@ -0,0 +1,9 @@
export * from './DashboardPage';
export * from './SettingsPage';
export * from './BillingPage';
export * from './UsersPage';
export * from './AIPage';
export * from './StoragePage';
export * from './WebhooksPage';
export * from './AuditLogsPage';
export * from './FeatureFlagsPage';

View File

@ -0,0 +1,233 @@
import { useCallback } from 'react';
import { Check, Building2, Users, CreditCard, Rocket } from 'lucide-react';
import clsx from 'clsx';
import {
useOnboarding,
usePlans,
useUpdateCompany,
useInviteUsers,
useCompleteOnboarding,
type OnboardingStep,
type CompanyData,
type InvitedUser,
} from '@/hooks/useOnboarding';
import { CompanyStep, InviteStep, PlanStep, CompleteStep } from './steps';
const STEP_CONFIG: Record<OnboardingStep, { icon: typeof Building2; label: string; color: string }> = {
company: { icon: Building2, label: 'Company', color: 'primary' },
invite: { icon: Users, label: 'Team', color: 'green' },
plan: { icon: CreditCard, label: 'Plan', color: 'purple' },
complete: { icon: Rocket, label: 'Launch', color: 'amber' },
};
export function OnboardingPage() {
const {
state,
updateState,
nextStep,
prevStep,
canGoNext,
canGoPrev,
getStepIndex,
getTotalSteps,
isStepCompleted,
steps,
} = useOnboarding();
const { data: plans, isLoading: plansLoading } = usePlans();
const updateCompanyMutation = useUpdateCompany();
const inviteUsersMutation = useInviteUsers();
const completeOnboardingMutation = useCompleteOnboarding();
// Handlers
const handleCompanyUpdate = useCallback((data: CompanyData) => {
updateState({ companyData: data });
}, [updateState]);
const handleInvitedUsersUpdate = useCallback((users: InvitedUser[]) => {
updateState({ invitedUsers: users });
}, [updateState]);
const handleSendInvites = useCallback(async (users: { email: string; role: string }[]) => {
await inviteUsersMutation.mutateAsync(users);
}, [inviteUsersMutation]);
const handlePlanSelect = useCallback((planId: string) => {
updateState({ selectedPlanId: planId });
}, [updateState]);
const handleComplete = useCallback(async () => {
// Save company data if changed
if (state.companyData) {
await updateCompanyMutation.mutateAsync(state.companyData);
}
// Complete onboarding
await completeOnboardingMutation.mutateAsync();
}, [state.companyData, updateCompanyMutation, completeOnboardingMutation]);
const handleNext = useCallback(async () => {
// Save progress before moving to next step
if (state.currentStep === 'company' && state.companyData) {
try {
await updateCompanyMutation.mutateAsync(state.companyData);
} catch {
// Continue anyway, data is saved locally
}
}
nextStep();
}, [state.currentStep, state.companyData, updateCompanyMutation, nextStep]);
const currentStepIndex = getStepIndex();
const totalSteps = getTotalSteps();
const progress = ((currentStepIndex + 1) / totalSteps) * 100;
return (
<div className="min-h-screen bg-gradient-to-br from-secondary-50 to-secondary-100 dark:from-secondary-900 dark:to-secondary-800">
{/* Header */}
<header className="bg-white dark:bg-secondary-800 border-b border-secondary-200 dark:border-secondary-700">
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-primary-600">Template SaaS</h1>
<div className="text-sm text-secondary-500">
Step {currentStepIndex + 1} of {totalSteps}
</div>
</div>
</header>
{/* Progress bar */}
<div className="bg-white dark:bg-secondary-800 border-b border-secondary-200 dark:border-secondary-700">
<div className="max-w-5xl mx-auto px-4">
{/* Step indicators */}
<div className="flex items-center justify-between py-6">
{steps.map((step, index) => {
const config = STEP_CONFIG[step];
const Icon = config.icon;
const isActive = state.currentStep === step;
const isCompleted = isStepCompleted(step) || index < currentStepIndex;
const isPast = index < currentStepIndex;
return (
<div key={step} className="flex items-center flex-1">
{/* Step circle */}
<div className="flex flex-col items-center">
<div
className={clsx(
'w-12 h-12 rounded-full flex items-center justify-center transition-all',
isCompleted && 'bg-green-500 text-white',
isActive && !isCompleted && 'bg-primary-600 text-white ring-4 ring-primary-100 dark:ring-primary-900',
!isActive && !isCompleted && 'bg-secondary-200 dark:bg-secondary-700 text-secondary-500'
)}
>
{isCompleted && !isActive ? (
<Check className="w-6 h-6" />
) : (
<Icon className="w-6 h-6" />
)}
</div>
<span
className={clsx(
'mt-2 text-sm font-medium',
isActive
? 'text-primary-600 dark:text-primary-400'
: 'text-secondary-500'
)}
>
{config.label}
</span>
</div>
{/* Connector line */}
{index < steps.length - 1 && (
<div
className={clsx(
'flex-1 h-1 mx-4 rounded-full transition-colors',
isPast ? 'bg-green-500' : 'bg-secondary-200 dark:bg-secondary-700'
)}
/>
)}
</div>
);
})}
</div>
{/* Progress bar */}
<div className="h-1 bg-secondary-200 dark:bg-secondary-700 rounded-full overflow-hidden mb-4">
<div
className="h-full bg-primary-600 transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
{/* Content */}
<main className="max-w-4xl mx-auto px-4 py-8">
<div className="bg-white dark:bg-secondary-800 rounded-2xl shadow-xl p-8">
{/* Step content */}
{state.currentStep === 'company' && (
<CompanyStep
data={state.companyData}
onUpdate={handleCompanyUpdate}
/>
)}
{state.currentStep === 'invite' && (
<InviteStep
invitedUsers={state.invitedUsers}
onUpdate={handleInvitedUsersUpdate}
onSendInvites={handleSendInvites}
isSending={inviteUsersMutation.isPending}
/>
)}
{state.currentStep === 'plan' && (
<PlanStep
plans={plans || []}
selectedPlanId={state.selectedPlanId}
onSelect={handlePlanSelect}
isLoading={plansLoading}
/>
)}
{state.currentStep === 'complete' && (
<CompleteStep
state={state}
onComplete={handleComplete}
isCompleting={completeOnboardingMutation.isPending || updateCompanyMutation.isPending}
/>
)}
{/* Navigation buttons */}
{state.currentStep !== 'complete' && (
<div className="flex items-center justify-between mt-8 pt-6 border-t border-secondary-200 dark:border-secondary-700">
<button
onClick={prevStep}
disabled={!canGoPrev()}
className={clsx(
'px-6 py-3 rounded-lg font-medium transition-colors',
canGoPrev()
? 'text-secondary-600 hover:bg-secondary-100 dark:text-secondary-400 dark:hover:bg-secondary-700'
: 'text-secondary-300 dark:text-secondary-600 cursor-not-allowed'
)}
>
Back
</button>
<button
onClick={handleNext}
disabled={!canGoNext() && state.currentStep !== 'invite'}
className={clsx(
'px-8 py-3 rounded-lg font-medium transition-colors',
canGoNext() || state.currentStep === 'invite'
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-secondary-200 text-secondary-400 cursor-not-allowed'
)}
>
{state.currentStep === 'plan' ? 'Complete Setup' : 'Continue'}
</button>
</div>
)}
</div>
</main>
</div>
);
}

View File

@ -0,0 +1 @@
export { OnboardingPage } from './OnboardingPage';

View File

@ -0,0 +1,233 @@
import { useState, useEffect } from 'react';
import { Building2, Globe, Image, Users, Clock } from 'lucide-react';
import type { CompanyData } from '@/hooks/useOnboarding';
interface CompanyStepProps {
data: CompanyData | null;
onUpdate: (data: CompanyData) => void;
}
const INDUSTRIES = [
'Technology',
'Healthcare',
'Finance',
'Education',
'Retail',
'Manufacturing',
'Consulting',
'Media',
'Real Estate',
'Other',
];
const COMPANY_SIZES = [
{ value: '1-10', label: '1-10 employees' },
{ value: '11-50', label: '11-50 employees' },
{ value: '51-200', label: '51-200 employees' },
{ value: '201-500', label: '201-500 employees' },
{ value: '500+', label: '500+ employees' },
];
const TIMEZONES = [
{ value: 'America/New_York', label: 'Eastern Time (ET)' },
{ value: 'America/Chicago', label: 'Central Time (CT)' },
{ value: 'America/Denver', label: 'Mountain Time (MT)' },
{ value: 'America/Los_Angeles', label: 'Pacific Time (PT)' },
{ value: 'America/Mexico_City', label: 'Mexico City' },
{ value: 'Europe/London', label: 'London (GMT)' },
{ value: 'Europe/Madrid', label: 'Madrid (CET)' },
{ value: 'UTC', label: 'UTC' },
];
function generateSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 50);
}
export function CompanyStep({ data, onUpdate }: CompanyStepProps) {
const [formData, setFormData] = useState<CompanyData>({
name: data?.name || '',
slug: data?.slug || '',
domain: data?.domain || '',
logo_url: data?.logo_url || '',
industry: data?.industry || '',
size: data?.size || '',
timezone: data?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
});
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
// Auto-generate slug from name
useEffect(() => {
if (!slugManuallyEdited && formData.name) {
setFormData((prev) => ({ ...prev, slug: generateSlug(formData.name) }));
}
}, [formData.name, slugManuallyEdited]);
// Notify parent of changes
useEffect(() => {
onUpdate(formData);
}, [formData, onUpdate]);
const handleChange = (field: keyof CompanyData, value: string) => {
if (field === 'slug') {
setSlugManuallyEdited(true);
}
setFormData((prev) => ({ ...prev, [field]: value }));
};
return (
<div className="space-y-6">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary-100 dark:bg-primary-900/30 rounded-full mb-4">
<Building2 className="w-8 h-8 text-primary-600 dark:text-primary-400" />
</div>
<h2 className="text-2xl font-bold text-secondary-900 dark:text-secondary-100">
Tell us about your company
</h2>
<p className="text-secondary-500 mt-2">
This information helps us customize your experience
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Company Name */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Company Name *
</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Acme Corporation"
className="w-full pl-10 pr-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
{/* Slug */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Workspace URL *
</label>
<div className="flex items-center">
<span className="px-3 py-3 bg-secondary-100 dark:bg-secondary-700 border border-r-0 border-secondary-300 dark:border-secondary-600 rounded-l-lg text-secondary-500 text-sm">
app.example.com/
</span>
<input
type="text"
value={formData.slug}
onChange={(e) => handleChange('slug', generateSlug(e.target.value))}
placeholder="acme"
className="flex-1 px-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-r-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<p className="text-xs text-secondary-500 mt-1">
Only lowercase letters, numbers, and hyphens
</p>
</div>
{/* Domain */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Company Domain
</label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="text"
value={formData.domain}
onChange={(e) => handleChange('domain', e.target.value)}
placeholder="acme.com"
className="w-full pl-10 pr-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
{/* Logo URL */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Logo URL
</label>
<div className="relative">
<Image className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-secondary-400" />
<input
type="url"
value={formData.logo_url}
onChange={(e) => handleChange('logo_url', e.target.value)}
placeholder="https://example.com/logo.png"
className="w-full pl-10 pr-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<p className="text-xs text-secondary-500 mt-1">
You can upload a logo later in settings
</p>
</div>
{/* Industry */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Industry
</label>
<select
value={formData.industry}
onChange={(e) => handleChange('industry', e.target.value)}
className="w-full px-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="">Select industry</option>
{INDUSTRIES.map((industry) => (
<option key={industry} value={industry}>
{industry}
</option>
))}
</select>
</div>
{/* Company Size */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<Users className="inline w-4 h-4 mr-1" />
Company Size
</label>
<select
value={formData.size}
onChange={(e) => handleChange('size', e.target.value)}
className="w-full px-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
<option value="">Select size</option>
{COMPANY_SIZES.map((size) => (
<option key={size.value} value={size.value}>
{size.label}
</option>
))}
</select>
</div>
{/* Timezone */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
<Clock className="inline w-4 h-4 mr-1" />
Timezone
</label>
<select
value={formData.timezone}
onChange={(e) => handleChange('timezone', e.target.value)}
className="w-full px-4 py-3 border border-secondary-300 dark:border-secondary-600 rounded-lg bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
>
{TIMEZONES.map((tz) => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
))}
</select>
</div>
</div>
</div>
);
}

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