+
+
+
+
+
+
+
+ {webhook.name}
+
+
+ {webhook.url}
+
+
+
+
+
+
setMenuOpen(!menuOpen)}
+ className="p-1 rounded hover:bg-secondary-100 dark:hover:bg-secondary-700"
+ >
+
+
+
+ {menuOpen && (
+ <>
+
setMenuOpen(false)}
+ />
+
+
{
+ 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"
+ >
+
+ Edit
+
+
{
+ 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"
+ >
+
+ Send Test
+
+
{
+ 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"
+ >
+
+ View Deliveries
+
+
{
+ 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"
+ >
+
+ {webhook.isActive ? 'Disable' : 'Enable'}
+
+
+
{
+ 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"
+ >
+
+ Delete
+
+
+ >
+ )}
+
+
+
+ {/* Events */}
+
+ {webhook.events.slice(0, 3).map((event) => (
+
+ {event}
+
+ ))}
+ {webhook.events.length > 3 && (
+
+ +{webhook.events.length - 3} more
+
+ )}
+
+
+ {/* Stats */}
+ {stats && (
+
+
+
+
+
+ {stats.successfulDeliveries}
+
+
Delivered
+
+
+
+
+ {stats.failedDeliveries}
+
+
Failed
+
+
+
+
+ {stats.pendingDeliveries}
+
+
Pending
+
+
+
+ {stats.successRate}%
+
+
Success Rate
+
+
+
+ )}
+
+ {/* Status badge */}
+
+
+ {webhook.isActive ? 'Active' : 'Disabled'}
+
+ {stats?.lastDeliveryAt && (
+
+ Last delivery: {new Date(stats.lastDeliveryAt).toLocaleDateString()}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/webhooks/WebhookDeliveryList.tsx b/src/components/webhooks/WebhookDeliveryList.tsx
new file mode 100644
index 0000000..ac015c7
--- /dev/null
+++ b/src/components/webhooks/WebhookDeliveryList.tsx
@@ -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 (
+
+
setExpanded(!expanded)}
+ >
+
+
+
+
+ {delivery.eventType}
+
+
+ {new Date(delivery.createdAt).toLocaleString()}
+
+
+
+
+
+ {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}
+
+ )}
+
+ {config.label}
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+
+ {expanded && (
+
+
+
+ Attempt: {' '}
+
+ {delivery.attempt} / {delivery.maxAttempts}
+
+
+ {delivery.deliveredAt && (
+
+ Delivered: {' '}
+
+ {new Date(delivery.deliveredAt).toLocaleString()}
+
+
+ )}
+ {delivery.nextRetryAt && (
+
+ Next Retry: {' '}
+
+ {new Date(delivery.nextRetryAt).toLocaleString()}
+
+
+ )}
+
+
+ {delivery.lastError && (
+
+
+ Error:
+
+
+ {delivery.lastError}
+
+
+ )}
+
+
+
+ Payload:
+
+
+ {JSON.stringify(delivery.payload, null, 2)}
+
+
+
+ {delivery.responseBody && (
+
+
+ Response:
+
+
+ {delivery.responseBody}
+
+
+ )}
+
+ {delivery.status === 'failed' && (
+
{
+ 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"
+ >
+
+ Retry
+
+ )}
+
+ )}
+
+ );
+}
+
+export function WebhookDeliveryList({
+ deliveries,
+ isLoading,
+ onRetry,
+ onLoadMore,
+ hasMore,
+}: WebhookDeliveryListProps) {
+ if (isLoading && deliveries.length === 0) {
+ return (
+
+ Loading deliveries...
+
+ );
+ }
+
+ if (deliveries.length === 0) {
+ return (
+
+ No deliveries yet. Send a test webhook to see delivery history.
+
+ );
+ }
+
+ return (
+
+ {deliveries.map((delivery) => (
+
+ ))}
+
+ {hasMore && onLoadMore && (
+
+ {isLoading ? 'Loading...' : 'Load More'}
+
+ )}
+
+ );
+}
diff --git a/src/components/webhooks/WebhookForm.tsx b/src/components/webhooks/WebhookForm.tsx
new file mode 100644
index 0000000..736b18e
--- /dev/null
+++ b/src/components/webhooks/WebhookForm.tsx
@@ -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
(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);
+
+ 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 (
+
+ );
+}
diff --git a/src/components/webhooks/index.ts b/src/components/webhooks/index.ts
new file mode 100644
index 0000000..e1ef2a2
--- /dev/null
+++ b/src/components/webhooks/index.ts
@@ -0,0 +1,3 @@
+export * from './WebhookCard';
+export * from './WebhookForm';
+export * from './WebhookDeliveryList';
diff --git a/src/components/whatsapp/WhatsAppTestMessage.tsx b/src/components/whatsapp/WhatsAppTestMessage.tsx
new file mode 100644
index 0000000..239caa2
--- /dev/null
+++ b/src/components/whatsapp/WhatsAppTestMessage.tsx
@@ -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 (
+
+
+ Probar Conexion
+
+
+ Envia un mensaje de prueba para verificar que la integracion funciona correctamente.
+
+
+
+ );
+}
diff --git a/src/components/whatsapp/index.ts b/src/components/whatsapp/index.ts
new file mode 100644
index 0000000..1a74dbf
--- /dev/null
+++ b/src/components/whatsapp/index.ts
@@ -0,0 +1 @@
+export * from './WhatsAppTestMessage';
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 0000000..f2d5518
--- /dev/null
+++ b/src/hooks/index.ts
@@ -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';
diff --git a/src/hooks/useAI.ts b/src/hooks/useAI.ts
new file mode 100644
index 0000000..8264cd4
--- /dev/null
+++ b/src/hooks/useAI.ts
@@ -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) => aiApi.updateConfig(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: aiQueryKeys.config() });
+ toast.success('AI configuration updated');
+ },
+ onError: (error: AxiosError) => {
+ 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) => {
+ 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,
+ });
+}
+
+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,
+ refetchInterval: 5 * 60 * 1000, // Check every 5 minutes
+ retry: 1,
+ });
+}
+
+// ==================== Re-export types ====================
+
+export type { ChatRequest, ChatResponse, AIConfig, AIModel, AIUsageStats };
diff --git a/src/hooks/useAnalytics.ts b/src/hooks/useAnalytics.ts
new file mode 100644
index 0000000..a728f6f
--- /dev/null
+++ b/src/hooks/useAnalytics.ts
@@ -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 = {
+ '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);
+}
diff --git a/src/hooks/useAudit.ts b/src/hooks/useAudit.ts
new file mode 100644
index 0000000..2d1db3f
--- /dev/null
+++ b/src/hooks/useAudit.ts
@@ -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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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';
+}
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
new file mode 100644
index 0000000..544c983
--- /dev/null
+++ b/src/hooks/useAuth.ts
@@ -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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ const message = error.response?.data?.message || 'Invalid or expired verification token';
+ toast.error(message);
+ },
+ });
+}
diff --git a/src/hooks/useData.ts b/src/hooks/useData.ts
new file mode 100644
index 0000000..348f57a
--- /dev/null
+++ b/src/hooks/useData.ts
@@ -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,
+ });
+}
+
+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) => {
+ 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) => {
+ 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,
+ });
+}
+
+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,
+ enabled: !!id,
+ });
+}
+
+export function usePaymentMethods() {
+ return useQuery({
+ queryKey: queryKeys.billing.paymentMethods(),
+ queryFn: () => billingApi.getPaymentMethods() as Promise,
+ });
+}
+
+export function useBillingSummary() {
+ return useQuery({
+ queryKey: queryKeys.billing.summary(),
+ queryFn: () => billingApi.getSummary() as Promise,
+ });
+}
+
+// ==================== 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) => {
+ 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) => {
+ 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) =>
+ notificationsApi.updatePreferences(preferences),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.notifications.preferences() });
+ toast.success('Preferences updated');
+ },
+ onError: (error: AxiosError) => {
+ 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,
+ };
+}
diff --git a/src/hooks/useExport.ts b/src/hooks/useExport.ts
new file mode 100644
index 0000000..c2df287
--- /dev/null
+++ b/src/hooks/useExport.ts
@@ -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 = {
+ 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 {
+ 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,
+ };
+}
diff --git a/src/hooks/useFeatureFlags.ts b/src/hooks/useFeatureFlags.ts
new file mode 100644
index 0000000..1448b97
--- /dev/null
+++ b/src/hooks/useFeatureFlags.ts
@@ -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 = {
+ boolean: 'Boolean',
+ string: 'String',
+ number: 'Number',
+ json: 'JSON',
+ };
+ return labels[type] || type;
+}
+
+export function getFlagTypeColor(type: FlagType): string {
+ const colors: Record = {
+ 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 = {
+ global: 'Global',
+ tenant: 'Tenant',
+ user: 'User',
+ plan: 'Plan',
+ };
+ return labels[scope] || scope;
+}
+
+export function getFlagScopeColor(scope: FlagScope): string {
+ const colors: Record = {
+ 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';
+}
diff --git a/src/hooks/useMfa.ts b/src/hooks/useMfa.ts
new file mode 100644
index 0000000..12b562c
--- /dev/null
+++ b/src/hooks/useMfa.ts
@@ -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 =>
+ api.get('/auth/mfa/status').then((res) => res.data),
+
+ setup: (): Promise =>
+ 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 =>
+ 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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ const message = error.response?.data?.message || 'Failed to regenerate backup codes';
+ toast.error(message);
+ },
+ });
+}
diff --git a/src/hooks/useOAuth.ts b/src/hooks/useOAuth.ts
new file mode 100644
index 0000000..593ff9c
--- /dev/null
+++ b/src/hooks/useOAuth.ts
@@ -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) => {
+ 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) => {
+ 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;
+}
diff --git a/src/hooks/useOnboarding.ts b/src/hooks/useOnboarding.ts
new file mode 100644
index 0000000..a437719
--- /dev/null
+++ b/src/hooks/useOnboarding.ts
@@ -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 => {
+ 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 {
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ return saved ? JSON.parse(saved) : {};
+ } catch {
+ return {};
+ }
+}
+
+function saveState(state: Partial) {
+ 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({
+ 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) => {
+ 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');
+ },
+ });
+}
diff --git a/src/hooks/usePushNotifications.ts b/src/hooks/usePushNotifications.ts
new file mode 100644
index 0000000..bc01ac2
--- /dev/null
+++ b/src/hooks/usePushNotifications.ts
@@ -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('default');
+ const [isSupported, setIsSupported] = useState(false);
+ const [isSubscribed, setIsSubscribed] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(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;
diff --git a/src/hooks/useStorage.ts b/src/hooks/useStorage.ts
new file mode 100644
index 0000000..9ed275e
--- /dev/null
+++ b/src/hooks/useStorage.ts
@@ -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({
+ queryKey: storageKeys.filesList(params),
+ queryFn: () => storageApi.listFiles(params),
+ });
+}
+
+// Get single file hook
+export function useFile(id: string) {
+ return useQuery({
+ queryKey: storageKeys.file(id),
+ queryFn: () => storageApi.getFile(id),
+ enabled: !!id,
+ });
+}
+
+// Storage usage hook
+export function useStorageUsage() {
+ return useQuery({
+ 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;
+ 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)
+ });
+}
diff --git a/src/hooks/useSuperadmin.ts b/src/hooks/useSuperadmin.ts
new file mode 100644
index 0000000..3af6584
--- /dev/null
+++ b/src/hooks/useSuperadmin.ts
@@ -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 | null;
+ metadata: Record | 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;
+ metadata?: Record;
+}
+
+export interface UpdateTenantStatusData {
+ status: string;
+ reason?: string;
+}
+
+// ==================== Dashboard Hooks ====================
+
+export function useSuperadminDashboard() {
+ return useQuery({
+ queryKey: superadminKeys.dashboard(),
+ queryFn: () => superadminApi.getDashboardStats() as Promise,
+ });
+}
+
+// ==================== Tenant Hooks ====================
+
+export function useTenants(params?: TenantListParams) {
+ return useQuery({
+ queryKey: superadminKeys.tenants.list(params),
+ queryFn: () => superadminApi.listTenants(params) as Promise,
+ });
+}
+
+export function useTenant(id: string) {
+ return useQuery({
+ queryKey: superadminKeys.tenants.detail(id),
+ queryFn: () => superadminApi.getTenant(id) as Promise,
+ 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) => {
+ 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) => {
+ 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) => {
+ 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) => {
+ 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,
+ });
+}
+
+export function useTenantGrowth(months = 12) {
+ return useQuery({
+ queryKey: superadminKeys.metrics.tenantGrowth(months),
+ queryFn: () => superadminApi.getTenantGrowth(months) as Promise,
+ });
+}
+
+export function useUserGrowth(months = 12) {
+ return useQuery({
+ queryKey: superadminKeys.metrics.userGrowth(months),
+ queryFn: () => superadminApi.getUserGrowth(months) as Promise,
+ });
+}
+
+export function usePlanDistribution() {
+ return useQuery({
+ queryKey: superadminKeys.metrics.planDistribution(),
+ queryFn: () => superadminApi.getPlanDistribution() as Promise,
+ });
+}
+
+export function useStatusDistribution() {
+ return useQuery({
+ queryKey: superadminKeys.metrics.statusDistribution(),
+ queryFn: () => superadminApi.getStatusDistribution() as Promise,
+ });
+}
+
+export function useTopTenants(limit = 10) {
+ return useQuery({
+ queryKey: superadminKeys.metrics.topTenants(limit),
+ queryFn: () => superadminApi.getTopTenants(limit) as Promise,
+ });
+}
diff --git a/src/hooks/useWebhooks.ts b/src/hooks/useWebhooks.ts
new file mode 100644
index 0000000..d80bac3
--- /dev/null
+++ b/src/hooks/useWebhooks.ts
@@ -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({
+ queryKey: WEBHOOKS_KEY,
+ queryFn: webhooksApi.list,
+ });
+}
+
+// Get single webhook
+export function useWebhook(id: string) {
+ return useQuery({
+ queryKey: WEBHOOK_KEY(id),
+ queryFn: () => webhooksApi.get(id),
+ enabled: !!id,
+ });
+}
+
+// Get available events
+export function useWebhookEvents() {
+ return useQuery({
+ 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({
+ 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 } }) =>
+ 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) });
+ },
+ });
+}
diff --git a/src/hooks/useWhatsApp.ts b/src/hooks/useWhatsApp.ts
new file mode 100644
index 0000000..a0fb95e
--- /dev/null
+++ b/src/hooks/useWhatsApp.ts
@@ -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');
+ },
+ });
+}
diff --git a/src/index.css b/src/index.css
new file mode 100644
index 0000000..a156dc6
--- /dev/null
+++ b/src/index.css
@@ -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;
+ }
+}
diff --git a/src/layouts/AuthLayout.tsx b/src/layouts/AuthLayout.tsx
new file mode 100644
index 0000000..44a7a98
--- /dev/null
+++ b/src/layouts/AuthLayout.tsx
@@ -0,0 +1,64 @@
+import { Outlet } from 'react-router-dom';
+
+export function AuthLayout() {
+ return (
+
+ {/* Left side - Branding */}
+
+
+
Template SaaS
+
Multi-tenant Platform
+
+
+
+
+
+
+
Multi-tenant Architecture
+
Complete data isolation per tenant
+
+
+
+
+
+
+
Enterprise Security
+
RBAC, RLS, and audit logs
+
+
+
+
+
+
+
Stripe Integration
+
Subscriptions and billing portal
+
+
+
+
+
+ © {new Date().getFullYear()} Template SaaS. All rights reserved.
+
+
+
+ {/* Right side - Auth forms */}
+
+
+ );
+}
diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/DashboardLayout.tsx
new file mode 100644
index 0000000..96e8eaa
--- /dev/null
+++ b/src/layouts/DashboardLayout.tsx
@@ -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 (
+
+ {/* Sidebar */}
+
+
+
Template SaaS
+
+
+
+
+
+
+ {/* Regular navigation */}
+ {navigation.map((item) => (
+
+ 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.name}
+
+ ))}
+
+ {/* Superadmin navigation */}
+ {isSuperadmin && (
+ <>
+
+ {superadminNavigation.map((item) => (
+
+ 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.name}
+
+ ))}
+ >
+ )}
+
+
+
+ {/* Overlay for mobile */}
+ {sidebarOpen && (
+
+ )}
+
+ {/* Main content */}
+
+ );
+}
diff --git a/src/layouts/index.ts b/src/layouts/index.ts
new file mode 100644
index 0000000..a3acefd
--- /dev/null
+++ b/src/layouts/index.ts
@@ -0,0 +1,2 @@
+export * from './AuthLayout';
+export * from './DashboardLayout';
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..91b2ed3
--- /dev/null
+++ b/src/main.tsx
@@ -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(
+
+
+
+
+
+
+);
diff --git a/src/pages/admin/AnalyticsDashboardPage.tsx b/src/pages/admin/AnalyticsDashboardPage.tsx
new file mode 100644
index 0000000..4dace5e
--- /dev/null
+++ b/src/pages/admin/AnalyticsDashboardPage.tsx
@@ -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
('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 (
+
+ {/* Header with period selector */}
+
+
+
+ Analytics Dashboard
+
+
+ Monitor your key business metrics and trends
+
+
+
+ {/* Period selector */}
+
+
Period:
+
+ {periods.map((period) => (
+ 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}
+
+ ))}
+
+
+
+
+ {/* KPI Metric Cards Grid */}
+
+ = 0 ? 'up' : 'down') : undefined}
+ icon={Users}
+ isLoading={summaryLoading}
+ subtitle={`${summary?.users.total ?? 0} total users`}
+ />
+
+ = 0 ? 'up' : 'down') : undefined}
+ icon={DollarSign}
+ isLoading={summaryLoading}
+ format="currency"
+ subtitle={`${formatCurrency(summary?.revenue.arr ?? 0)} ARR`}
+ />
+
+ = 0 ? 'up' : 'down') : undefined}
+ icon={Activity}
+ isLoading={summaryLoading}
+ subtitle={`${formatNumber(summary?.usage.avgPerUser ?? 0)} avg/user`}
+ />
+
+ = 0 ? 'up' : 'down') : undefined}
+ icon={TrendingUp}
+ isLoading={summaryLoading}
+ format="percentage"
+ subtitle={`${formatNumber(summary?.engagement.sessions ?? 0)} sessions`}
+ />
+
+
+ {/* Trend Charts */}
+
+
+
+ formatCurrency(val, true)}
+ />
+
+
+ {/* Usage Metrics Section */}
+
+ {/* Actions Trend Chart */}
+
+
+
+
+ {/* Top Features Table */}
+
+
+
+
+ Top Features
+
+
+
+ {usageLoading ? (
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+ ) : usage?.topFeatures && usage.topFeatures.length > 0 ? (
+
+ {usage.topFeatures.slice(0, 5).map((feature, index) => (
+
+
+
+ {feature.feature}
+
+
+ {formatNumber(feature.count)}
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
No feature data available
+
+ )}
+
+
+
+ {/* Usage by Hour */}
+
+
+
+
+ Activity by Hour
+
+
+ ({getPeriodLabel(selectedPeriod)})
+
+
+
+ {usageLoading ? (
+
+ {Array.from({ length: 24 }).map((_, i) => (
+
+ ))}
+
+ ) : usage?.byHour && usage.byHour.length > 0 ? (
+
+ {usage.byHour.map((hourData) => {
+ const maxActions = Math.max(...usage.byHour.map((h) => h.actions));
+ const height = maxActions > 0 ? (hourData.actions / maxActions) * 100 : 0;
+ return (
+
+
+ {hourData.hour % 4 === 0 && (
+
+ {hourData.hour}h
+
+ )}
+
+ );
+ })}
+
+ ) : (
+
+
+
No hourly data available
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/admin/WhatsAppSettings.tsx b/src/pages/admin/WhatsAppSettings.tsx
new file mode 100644
index 0000000..11b682e
--- /dev/null
+++ b/src/pages/admin/WhatsAppSettings.tsx
@@ -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) => {
+ 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 (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ Configuracion de WhatsApp
+
+
+ Configura la integracion con WhatsApp Business API para enviar notificaciones.
+
+
+
+ {/* Status Card */}
+ {config && (
+
+
+
+
Estado de la integracion
+
+
+ {config.isActive ? 'Activo' : 'Inactivo'}
+
+
+ {config.isVerified ? 'Verificado' : 'Sin verificar'}
+
+ {config.qualityRating && (
+
+ Calidad: {config.qualityRating}
+
+ )}
+
+
+
+ {config.isActive ? 'Desactivar' : 'Activar'}
+
+
+
+ {config.displayPhoneNumber && (
+
+
+ Numero:
+ {config.displayPhoneNumber}
+
+ {config.verifiedName && (
+
+ Nombre verificado:
+ {config.verifiedName}
+
+ )}
+
+ Mensajes hoy:
+
+ {config.messagesSentToday} / {config.dailyMessageLimit}
+
+
+
+ )}
+
+ )}
+
+ {/* Configuration Form */}
+
+
+ {config ? 'Actualizar configuracion' : 'Configurar WhatsApp Business API'}
+
+
+
+
+ {/* Test Connection */}
+ {config && config.isActive && (
+
+
+
+ )}
+
+ {/* Recent Messages */}
+ {config && messagesData && messagesData.data.length > 0 && (
+
+
+ Mensajes recientes
+
+
+ {messagesData.data.map((message) => (
+
+
+ {message.direction === 'outbound' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {message.phoneNumber}
+ {message.contactName && (
+ ({message.contactName})
+ )}
+
+
+ {message.status}
+
+
+
+ {message.content || message.templateName || '[Media]'}
+
+
+ {new Date(message.createdAt).toLocaleString()}
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteConfirm && (
+
+
+
+ Eliminar integracion de WhatsApp
+
+
+ Esta seguro de que desea eliminar la integracion de WhatsApp? Esta
+ accion no se puede deshacer y se perdera toda la configuracion.
+
+
+ setShowDeleteConfirm(false)}
+ className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
+ >
+ Cancelar
+
+
+ {deleteConfig.isPending ? 'Eliminando...' : 'Eliminar'}
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/auth/ForgotPasswordPage.tsx b/src/pages/auth/ForgotPasswordPage.tsx
new file mode 100644
index 0000000..bba9bb3
--- /dev/null
+++ b/src/pages/auth/ForgotPasswordPage.tsx
@@ -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();
+
+ const onSubmit = (data: ForgotPasswordFormData) => {
+ resetMutation.mutate(data.email, {
+ onSuccess: () => setEmailSent(true),
+ onError: () => setEmailSent(true), // Still show success for security
+ });
+ };
+
+ if (emailSent) {
+ return (
+
+
+
+
+
+ Check your email
+
+
+ We've sent password reset instructions to:
+
+
+ {getValues('email')}
+
+
+
+
+
+
+
+ If you don't see the email, check your spam folder.
+
+
+ The link will expire in 1 hour for security reasons.
+
+
+
+
+
+
+
setEmailSent(false)}
+ className="btn-secondary w-full"
+ >
+ Try a different email
+
+
+
+ Back to sign in
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Reset your password
+
+
+ Enter your email and we'll send you reset instructions
+
+
+
+
+
+
+
+
+ Back to sign in
+
+
+
+ );
+}
diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx
new file mode 100644
index 0000000..911bbca
--- /dev/null
+++ b/src/pages/auth/LoginPage.tsx
@@ -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();
+
+ const onSubmit = (data: LoginFormData) => {
+ loginMutation.mutate({
+ email: data.email,
+ password: data.password,
+ });
+ };
+
+ return (
+
+
+
+ Welcome back
+
+
+ Sign in to your account
+
+
+
+
+
+
+
+
+
+ Don't have an account?{' '}
+
+ Sign up
+
+
+
+ );
+}
diff --git a/src/pages/auth/OAuthCallbackPage.tsx b/src/pages/auth/OAuthCallbackPage.tsx
new file mode 100644
index 0000000..bf3235f
--- /dev/null
+++ b/src/pages/auth/OAuthCallbackPage.tsx
@@ -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('processing');
+ const [errorMessage, setErrorMessage] = useState('');
+
+ 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 (
+
+
+
+ {status === 'processing' && (
+ <>
+
+
+ Completing sign in...
+
+
+ Please wait while we verify your credentials with {provider}.
+
+ >
+ )}
+
+ {status === 'success' && (
+ <>
+
+
+ Successfully authenticated!
+
+
+ Redirecting you to the dashboard...
+
+ >
+ )}
+
+ {status === 'error' && (
+ <>
+
+
+ Authentication failed
+
+
+ {errorMessage}
+
+
+ Redirecting to login page...
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/auth/RegisterPage.tsx b/src/pages/auth/RegisterPage.tsx
new file mode 100644
index 0000000..1b0464e
--- /dev/null
+++ b/src/pages/auth/RegisterPage.tsx
@@ -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();
+
+ 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 (
+
+
+
+ Create your account
+
+
+ Start your 14-day free trial
+
+
+
+
+
+
+
+
+
+ Already have an account?{' '}
+
+ Sign in
+
+
+
+ );
+}
diff --git a/src/pages/auth/index.ts b/src/pages/auth/index.ts
new file mode 100644
index 0000000..1e0554a
--- /dev/null
+++ b/src/pages/auth/index.ts
@@ -0,0 +1,4 @@
+export * from './LoginPage';
+export * from './RegisterPage';
+export * from './ForgotPasswordPage';
+export * from './OAuthCallbackPage';
diff --git a/src/pages/dashboard/AIPage.tsx b/src/pages/dashboard/AIPage.tsx
new file mode 100644
index 0000000..c24238a
--- /dev/null
+++ b/src/pages/dashboard/AIPage.tsx
@@ -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 (
+
+ {/* Header */}
+
+
+
+ AI Assistant
+
+
+ Chat with your AI assistant powered by {config?.provider || 'OpenRouter'}
+
+
+
+
+ {/* Stats */}
+
+
+
+
+
+
+
+
Requests This Month
+
+ {usage?.request_count?.toLocaleString() || 0}
+
+
+
+
+
+
+
+
+
+
+
+
Tokens Used
+
+ {formatNumber(usage?.total_tokens || 0)}
+
+
+
+
+
+
+
+
+
+
+
+
Avg Response Time
+
+ {Math.round(usage?.avg_latency_ms || 0)}ms
+
+
+
+
+
+
+ {/* Chat Interface */}
+
+
+ {/* Tips */}
+
+
+ Tips for better results
+
+
+
+ •
+ Be specific and provide context for your questions
+
+
+ •
+ Break complex tasks into smaller, focused requests
+
+
+ •
+ Use follow-up questions to refine responses
+
+
+ •
+ Clear the chat to start fresh conversations
+
+
+
+
+ );
+}
diff --git a/src/pages/dashboard/AuditLogsPage.tsx b/src/pages/dashboard/AuditLogsPage.tsx
new file mode 100644
index 0000000..ebdf394
--- /dev/null
+++ b/src/pages/dashboard/AuditLogsPage.tsx
@@ -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('logs');
+ const [filters, setFilters] = useState({
+ 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 (
+
+ {/* Header */}
+
+
+
+ Audit Logs
+
+
+ Track and monitor all system activities and changes
+
+
+
+
+
+ {/* Stats Card */}
+
+
+ {/* Tabs */}
+
+
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'
+ )}
+ >
+
+ Audit Logs
+ {auditLogsData?.total !== undefined && (
+
+ {auditLogsData.total}
+
+ )}
+
+
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
+ {activitiesData?.total !== undefined && (
+
+ {activitiesData.total}
+
+ )}
+
+
+
+ {/* Content */}
+ {activeTab === 'logs' ? (
+
+ {/* Filters */}
+
+
+ {/* Logs list */}
+
+ {logsLoading ? (
+
+
+
+ ) : !auditLogsData?.items?.length ? (
+
+
+
+ No audit logs found
+
+
+ {Object.keys(filters).filter((k) => k !== 'page' && k !== 'limit').length > 0
+ ? 'Try adjusting your filters'
+ : 'Audit logs will appear here as actions are performed'}
+
+
+ ) : (
+ <>
+
+ {auditLogsData.items.map((log) => (
+
+ ))}
+
+
+ {/* Pagination */}
+ {auditLogsData.totalPages > 1 && (
+
+
+ Showing {(auditLogsData.page - 1) * auditLogsData.limit + 1} -{' '}
+ {Math.min(
+ auditLogsData.page * auditLogsData.limit,
+ auditLogsData.total
+ )}{' '}
+ of {auditLogsData.total}
+
+
+ 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"
+ >
+
+
+
+ Page {auditLogsData.page} of {auditLogsData.totalPages}
+
+ 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"
+ >
+
+
+
+
+ )}
+ >
+ )}
+
+
+ ) : (
+
+ {/* Activity timeline */}
+
+
+ Recent Activity
+
+
+
+
+ {/* Activity summary */}
+
+
+ Activity Summary
+
+ {summaryLoading ? (
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+ ) : activitySummary ? (
+
+
+
+ Total Activities (30 days)
+
+
+ {activitySummary.total_activities.toLocaleString()}
+
+
+
+
+
+ By Type
+
+
+ {Object.entries(activitySummary.by_type)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 5)
+ .map(([type, count]) => (
+
+
+ {type.replace(/_/g, ' ')}
+
+
+ {count.toLocaleString()}
+
+
+ ))}
+
+
+
+ {activitySummary.by_day && activitySummary.by_day.length > 0 && (
+
+
+ Daily Trend
+
+
+ {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 (
+
+ );
+ })}
+
+
+ )}
+
+ ) : (
+
No activity data available
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/dashboard/BillingPage.tsx b/src/pages/dashboard/BillingPage.tsx
new file mode 100644
index 0000000..35e3148
--- /dev/null
+++ b/src/pages/dashboard/BillingPage.tsx
@@ -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 (
+
+
+
+ Billing & Plans
+
+
+ Manage your subscription and billing information
+
+
+
+ {/* Current Plan */}
+
+
+
+ Current Plan
+
+
+
+ {subscriptionLoading ? (
+
+
+
+ ) : subscription ? (
+
+
+
+
+ {subscription.plan?.display_name || subscription.plan?.name || 'Unknown Plan'}
+
+
+ {subscription.status}
+
+
+
+ {subscription.plan?.price_monthly
+ ? formatCurrency(subscription.plan.price_monthly)
+ : 'Free'}
+ /month
+ {subscription.current_period_end && (
+
+ · Renews on {formatDate(subscription.current_period_end)}
+
+ )}
+
+ {subscription.cancel_at_period_end && (
+
+
+ Cancels at end of billing period
+
+ )}
+
+
+ {billingPortalMutation.isPending ? (
+
+ ) : (
+
+ )}
+ Manage in Stripe
+
+
+ ) : (
+
+
+ No active subscription. Choose a plan below to get started.
+
+
+ )}
+
+
+
+ {/* Available Plans */}
+
+
+ Available Plans
+
+
+ {plans.map((plan) => {
+ const isCurrent = currentPlanName === plan.name.toLowerCase();
+ return (
+
+
+ {isCurrent && (
+
+ Current Plan
+
+ )}
+
+ {plan.name}
+
+
+
+ {plan.price}
+
+ {plan.period}
+
+
+ {plan.features.map((feature) => (
+
+
+ {feature}
+
+ ))}
+
+
!isCurrent && handleUpgrade(plan.priceId)}
+ disabled={isCurrent || checkoutMutation.isPending}
+ className={clsx('mt-6 w-full', isCurrent ? 'btn-secondary' : 'btn-primary')}
+ >
+ {checkoutMutation.isPending ? (
+
+ ) : isCurrent ? (
+ 'Current Plan'
+ ) : (
+ 'Upgrade'
+ )}
+
+
+
+ );
+ })}
+
+
+
+ {/* Payment Method */}
+
+
+
+ Payment Method
+
+
+
+ {paymentMethodsLoading ? (
+
+
+
+ ) : defaultPaymentMethod ? (
+
+
+
+
+
+
+ {defaultPaymentMethod.card_brand?.charAt(0).toUpperCase() +
+ defaultPaymentMethod.card_brand?.slice(1) || 'Card'}{' '}
+ ending in {defaultPaymentMethod.card_last_four}
+
+
+ Expires {defaultPaymentMethod.card_exp_month}/{defaultPaymentMethod.card_exp_year}
+
+
+
+ Update
+
+
+ ) : (
+
+
+ No payment method on file
+
+
+ Add Payment Method
+
+
+ )}
+
+
+
+ {/* Billing History */}
+
+
+
+ Billing History
+
+
+ {invoicesLoading ? (
+
+
+
+ ) : invoicesData?.data && invoicesData.data.length > 0 ? (
+
+
+
+
+
+ Date
+
+
+ Invoice
+
+
+ Amount
+
+
+ Status
+
+
+
+
+
+ {invoicesData.data.map((invoice: any) => (
+
+
+ {formatDate(invoice.issue_date)}
+
+
+ {invoice.invoice_number}
+
+
+ {formatCurrency(invoice.total, invoice.currency)}
+
+
+
+ {invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
+
+
+
+
+
+ PDF
+
+
+
+ ))}
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx
new file mode 100644
index 0000000..c2ca430
--- /dev/null
+++ b/src/pages/dashboard/DashboardPage.tsx
@@ -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 (
+
+ {/* Welcome header */}
+
+
+ Welcome back, {user?.first_name || 'User'}!
+
+
+ Here's what's happening with your business today.
+
+
+
+ {/* Stats grid */}
+
+ {stats.map((stat) => (
+
+
+
+
+
+
+ {stat.change}
+
+
+
+
+ {stat.value}
+
+
+ {stat.name}
+
+
+
+ ))}
+
+
+ {/* Quick actions */}
+
+
+
+ Quick Actions
+
+
+
+
+
+
+ Manage Team
+
+
+
+ Manage Billing
+
+
+
+ Settings
+
+
+
+
+
+ {/* Recent users */}
+
+
+
+ Recent Team Members
+
+
+ View all
+
+
+
+ {usersLoading ? (
+
+
+
+ ) : usersData?.data && usersData.data.length > 0 ? (
+
+ {usersData.data.slice(0, 5).map((u) => (
+
+
+
+ {(u.first_name?.[0] || u.email[0]).toUpperCase()}
+ {(u.last_name?.[0] || '').toUpperCase()}
+
+
+
+
+ {u.first_name && u.last_name
+ ? `${u.first_name} ${u.last_name}`
+ : u.email}
+
+
{u.email}
+
+
+ {u.status}
+
+
+ ))}
+
+ ) : (
+
+
+
No team members yet
+
+ )}
+
+
+
+ {/* Subscription info */}
+ {subscription && (
+
+
+
+ Subscription
+
+
+ Manage
+
+
+
+
+
+
+
+ {subscription.plan?.display_name || 'Unknown Plan'}
+
+
+ {subscription.status}
+
+
+
+ {subscription.plan?.price_monthly
+ ? formatCurrency(subscription.plan.price_monthly)
+ : 'Free'}
+ /month
+ {subscription.current_period_end && (
+
+ · Renews {new Date(subscription.current_period_end).toLocaleDateString()}
+
+ )}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/dashboard/FeatureFlagsPage.tsx b/src/pages/dashboard/FeatureFlagsPage.tsx
new file mode 100644
index 0000000..8c68b79
--- /dev/null
+++ b/src/pages/dashboard/FeatureFlagsPage.tsx
@@ -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('list');
+ const [selectedFlag, setSelectedFlag] = useState(null);
+ const [deleteConfirm, setDeleteConfirm] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [categoryFilter, setCategoryFilter] = useState('');
+ 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();
+ 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 (
+
+
+
+
+ Feature Flags
+
+
+ Manage feature flags and rollouts
+
+
+
+
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"
+ >
+
+ Tenant Overrides
+ {tenantOverrides.length > 0 && (
+
+ {tenantOverrides.length}
+
+ )}
+
+
setViewMode('create')}
+ className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
+ >
+
+ New Flag
+
+
+
+
+ {/* Filters */}
+
+
+
+
+ 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"
+ />
+
+
+
+ {categories.length > 0 && (
+
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"
+ >
+ All Categories
+ {categories.map((cat) => (
+
+ {cat}
+
+ ))}
+
+ )}
+
+
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'
+ )}
+ >
+
+ Enabled Only
+
+
+
+ {/* Flags grid */}
+ {flagsLoading ? (
+
+
+
+ ) : filteredFlags.length === 0 ? (
+
+
+
+ {flags.length === 0 ? 'No feature flags yet' : 'No matching flags'}
+
+
+ {flags.length === 0
+ ? 'Create your first feature flag to start managing features'
+ : 'Try adjusting your search or filters'}
+
+ {flags.length === 0 && (
+
setViewMode('create')}
+ className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
+ >
+ Create Flag
+
+ )}
+
+ ) : (
+
+ {filteredFlags.map((flag) => (
+ {
+ setSelectedFlag(f);
+ setViewMode('edit');
+ }}
+ onDelete={setDeleteConfirm}
+ onToggle={handleToggle}
+ isToggling={toggleMutation.isPending}
+ />
+ ))}
+
+ )}
+
+ {/* Delete confirmation modal */}
+ {deleteConfirm && (
+
+
+
+
+
+ Delete Feature Flag
+
+
+
+ Are you sure you want to delete "{deleteConfirm.name}"?
+
+
+ This will remove the flag globally and all tenant/user overrides will be lost.
+
+
+ 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
+
+
+ {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
+
+
+
+
+ )}
+
+ );
+ }
+
+ // Render create view
+ if (viewMode === 'create') {
+ return (
+
+
+
setViewMode('list')}
+ className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
+ >
+
+
+
+ Create Feature Flag
+
+
+
+
+ handleCreate(data as CreateFlagRequest)}
+ onCancel={() => setViewMode('list')}
+ isLoading={createMutation.isPending}
+ />
+
+
+ );
+ }
+
+ // Render edit view
+ if (viewMode === 'edit' && selectedFlag) {
+ return (
+
+
+
{
+ setViewMode('list');
+ setSelectedFlag(null);
+ }}
+ className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
+ >
+
+
+
+
+ Edit Feature Flag
+
+ {selectedFlag.key}
+
+
+
+
+ handleUpdate(data as UpdateFlagRequest)}
+ onCancel={() => {
+ setViewMode('list');
+ setSelectedFlag(null);
+ }}
+ isLoading={updateMutation.isPending}
+ />
+
+
+ );
+ }
+
+ // Render overrides view
+ if (viewMode === 'overrides') {
+ return (
+
+
+
setViewMode('list')}
+ className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
+ >
+
+
+
+ Tenant Overrides
+
+
+
+
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/pages/dashboard/SettingsPage.tsx b/src/pages/dashboard/SettingsPage.tsx
new file mode 100644
index 0000000..31c474a
--- /dev/null
+++ b/src/pages/dashboard/SettingsPage.tsx
@@ -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({
+ 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 (
+
+
+
+ Settings
+
+
+ Manage your account settings and preferences
+
+
+
+ {/* Profile Settings */}
+
+
+
+
+ Profile Information
+
+
+
+
+
+ {/* Organization Settings */}
+
+
+
+
+ Organization
+
+
+
+
+
+
+ Tenant ID
+
+
+ {user?.tenant_id || 'N/A'}
+
+
+
+
+
+
+ {/* Appearance Settings */}
+
+
+
+ Appearance
+
+
+
+
+ {themes.map((t) => (
+ 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.label}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/pages/dashboard/StoragePage.tsx b/src/pages/dashboard/StoragePage.tsx
new file mode 100644
index 0000000..5d7554a
--- /dev/null
+++ b/src/pages/dashboard/StoragePage.tsx
@@ -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(null);
+
+ return (
+
+ {/* Header */}
+
+
+
File Storage
+
+ Manage your files and documents
+
+
+
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 File
+
+
+
+
+ {/* Main content */}
+
+
+ {/* Sidebar */}
+
+
+
+
+
+ {/* Upload Modal */}
+ {showUpload && (
+
+
+
+
Upload File
+ setShowUpload(false)}
+ className="p-2 hover:bg-gray-100 rounded-lg"
+ >
+
+
+
+
{
+ setShowUpload(false);
+ }}
+ />
+
+
+ )}
+
+ {/* Image Preview Modal */}
+ {previewFile && previewFile.mimeType.startsWith('image/') && (
+
setPreviewFile(null)}
+ >
+
+
setPreviewFile(null)}
+ className="absolute -top-10 right-0 text-white hover:text-gray-300"
+ >
+
+
+
+
+
{previewFile.originalName}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/dashboard/UsersPage.tsx b/src/pages/dashboard/UsersPage.tsx
new file mode 100644
index 0000000..b532cf0
--- /dev/null
+++ b/src/pages/dashboard/UsersPage.tsx
@@ -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(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({
+ 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 (
+
+
+
+
+ Team Members
+
+
+ Manage your team and their permissions
+ {usersData?.total !== undefined && (
+ ({usersData.total} total)
+ )}
+
+
+
+
+
setShowInviteModal(true)}
+ className="btn-primary"
+ >
+
+ Invite Member
+
+
+
+
+ {/* Search */}
+
+
+ setSearch(e.target.value)}
+ />
+
+
+ {/* Users table */}
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+
Failed to load users
+
refetch()} className="btn-secondary">
+ Try again
+
+
+ ) : (
+ <>
+
+
+
+
+
+ User
+
+
+ Role
+
+
+ Status
+
+
+ Last Active
+
+
+
+
+
+ {filteredUsers.map((user: UserListItem) => (
+
+
+
+
+
+ {getUserInitials(user)}
+
+
+
+
+ {getUserDisplayName(user)}
+
+
{user.email}
+
+
+
+
+
+ {(getUserRole(user).toLowerCase() === 'admin' ||
+ getUserRole(user).toLowerCase() === 'owner') && (
+
+ )}
+ {getUserRole(user)}
+
+
+
+
+ {user.status.charAt(0).toUpperCase() + user.status.slice(1)}
+
+
+
+ {formatDate(user.last_login_at)}
+
+
+
+
+ setOpenMenu(openMenu === user.id ? null : user.id)
+ }
+ className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
+ >
+
+
+
+ {openMenu === user.id && (
+ <>
+
setOpenMenu(null)}
+ />
+
+
+
+ Send email
+
+
+
+ Change role
+
+
+
+
+ Remove
+
+
+ >
+ )}
+
+
+
+ ))}
+
+
+
+
+ {filteredUsers.length === 0 && (
+
+ {search
+ ? 'No users found matching your search.'
+ : 'No team members yet. Invite someone to get started!'}
+
+ )}
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+ Page {page} of {totalPages}
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ className="btn-secondary px-3 py-1.5 disabled:opacity-50"
+ >
+
+
+ setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page === totalPages}
+ className="btn-secondary px-3 py-1.5 disabled:opacity-50"
+ >
+
+
+
+
+ )}
+ >
+ )}
+
+
+ {/* Invite Modal */}
+ {showInviteModal && (
+
+
setShowInviteModal(false)}
+ />
+
+
+
+ Invite Team Member
+
+ setShowInviteModal(false)}
+ className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
+ >
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/dashboard/WebhooksPage.tsx b/src/pages/dashboard/WebhooksPage.tsx
new file mode 100644
index 0000000..b3781ef
--- /dev/null
+++ b/src/pages/dashboard/WebhooksPage.tsx
@@ -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
('list');
+ const [selectedWebhook, setSelectedWebhook] = useState(null);
+ const [deleteConfirm, setDeleteConfirm] = useState(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 (
+
+
+
+
+ Webhooks
+
+
+ Manage outbound webhooks for your integrations
+
+
+
setViewMode('create')}
+ className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
+ >
+
+ Add Webhook
+
+
+
+ {webhooksLoading ? (
+
Loading webhooks...
+ ) : webhooks.length === 0 ? (
+
+
+
+ No webhooks configured
+
+
+ Create your first webhook to start receiving event notifications
+
+
setViewMode('create')}
+ className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
+ >
+ Create Webhook
+
+
+ ) : (
+
+ {webhooks.map((webhook) => (
+ {
+ setSelectedWebhook(w);
+ setViewMode('edit');
+ }}
+ onDelete={setDeleteConfirm}
+ onTest={handleTest}
+ onToggle={handleToggle}
+ onViewDeliveries={(w) => {
+ setSelectedWebhook(w);
+ setViewMode('deliveries');
+ }}
+ />
+ ))}
+
+ )}
+
+ {/* Delete confirmation modal */}
+ {deleteConfirm && (
+
+
+
+
+ Are you sure you want to delete "{deleteConfirm.name}"? This action cannot be
+ undone and all delivery history will be lost.
+
+
+ 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
+
+
+ {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
+
+
+
+
+ )}
+
+ );
+ }
+
+ // Render create view
+ if (viewMode === 'create') {
+ return (
+
+
+
setViewMode('list')}
+ className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
+ >
+
+
+
+ Create Webhook
+
+
+
+
+ setViewMode('list')}
+ isLoading={createMutation.isPending}
+ />
+
+
+ );
+ }
+
+ // Render edit view
+ if (viewMode === 'edit' && selectedWebhook) {
+ return (
+
+
+
{
+ setViewMode('list');
+ setSelectedWebhook(null);
+ }}
+ className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
+ >
+
+
+
+ Edit Webhook
+
+
+
+
+ {
+ setViewMode('list');
+ setSelectedWebhook(null);
+ }}
+ isLoading={updateMutation.isPending}
+ />
+
+
+ );
+ }
+
+ // Render deliveries view
+ if (viewMode === 'deliveries' && selectedWebhook) {
+ return (
+
+
+
{
+ setViewMode('list');
+ setSelectedWebhook(null);
+ }}
+ className="p-2 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-lg"
+ >
+
+
+
+
+ {selectedWebhook.name}
+
+
{selectedWebhook.url}
+
+
+
+
+
+ Delivery History
+
+ 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'}
+
+
+
+
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/pages/dashboard/index.ts b/src/pages/dashboard/index.ts
new file mode 100644
index 0000000..d910187
--- /dev/null
+++ b/src/pages/dashboard/index.ts
@@ -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';
diff --git a/src/pages/onboarding/OnboardingPage.tsx b/src/pages/onboarding/OnboardingPage.tsx
new file mode 100644
index 0000000..3ef3562
--- /dev/null
+++ b/src/pages/onboarding/OnboardingPage.tsx
@@ -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 = {
+ 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 (
+
+ {/* Header */}
+
+
+ {/* Progress bar */}
+
+
+ {/* Step indicators */}
+
+ {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 (
+
+ {/* Step circle */}
+
+
+ {isCompleted && !isActive ? (
+
+ ) : (
+
+ )}
+
+
+ {config.label}
+
+
+
+ {/* Connector line */}
+ {index < steps.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+
+ {/* Progress bar */}
+
+
+
+
+ {/* Content */}
+
+
+ {/* Step content */}
+ {state.currentStep === 'company' && (
+
+ )}
+
+ {state.currentStep === 'invite' && (
+
+ )}
+
+ {state.currentStep === 'plan' && (
+
+ )}
+
+ {state.currentStep === 'complete' && (
+
+ )}
+
+ {/* Navigation buttons */}
+ {state.currentStep !== 'complete' && (
+
+
+ Back
+
+
+
+ {state.currentStep === 'plan' ? 'Complete Setup' : 'Continue'}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/onboarding/index.ts b/src/pages/onboarding/index.ts
new file mode 100644
index 0000000..c920c83
--- /dev/null
+++ b/src/pages/onboarding/index.ts
@@ -0,0 +1 @@
+export { OnboardingPage } from './OnboardingPage';
diff --git a/src/pages/onboarding/steps/CompanyStep.tsx b/src/pages/onboarding/steps/CompanyStep.tsx
new file mode 100644
index 0000000..78c4f87
--- /dev/null
+++ b/src/pages/onboarding/steps/CompanyStep.tsx
@@ -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({
+ 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 (
+
+
+
+
+
+
+ Tell us about your company
+
+
+ This information helps us customize your experience
+
+
+
+
+ {/* Company Name */}
+
+
+ Company Name *
+
+
+
+ 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"
+ />
+
+
+
+ {/* Slug */}
+
+
+ Workspace URL *
+
+
+
+ app.example.com/
+
+ 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"
+ />
+
+
+ Only lowercase letters, numbers, and hyphens
+
+
+
+ {/* Domain */}
+
+
+ Company Domain
+
+
+
+ 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"
+ />
+
+
+
+ {/* Logo URL */}
+
+
+ Logo URL
+
+
+
+ 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"
+ />
+
+
+ You can upload a logo later in settings
+
+
+
+ {/* Industry */}
+
+
+ Industry
+
+ 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"
+ >
+ Select industry
+ {INDUSTRIES.map((industry) => (
+
+ {industry}
+
+ ))}
+
+
+
+ {/* Company Size */}
+
+
+
+ Company Size
+
+ 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"
+ >
+ Select size
+ {COMPANY_SIZES.map((size) => (
+
+ {size.label}
+
+ ))}
+
+
+
+ {/* Timezone */}
+
+
+
+ Timezone
+
+ 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) => (
+
+ {tz.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/pages/onboarding/steps/CompleteStep.tsx b/src/pages/onboarding/steps/CompleteStep.tsx
new file mode 100644
index 0000000..4d19c14
--- /dev/null
+++ b/src/pages/onboarding/steps/CompleteStep.tsx
@@ -0,0 +1,164 @@
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { CheckCircle, Rocket, ArrowRight, Loader2, PartyPopper } from 'lucide-react';
+import clsx from 'clsx';
+import type { OnboardingState } from '@/hooks/useOnboarding';
+
+interface CompleteStepProps {
+ state: OnboardingState;
+ onComplete: () => Promise;
+ isCompleting: boolean;
+}
+
+export function CompleteStep({ state, onComplete, isCompleting }: CompleteStepProps) {
+ const navigate = useNavigate();
+ const [isComplete, setIsComplete] = useState(false);
+ const [countdown, setCountdown] = useState(5);
+
+ useEffect(() => {
+ // Auto-complete on mount
+ if (!isComplete && !isCompleting) {
+ onComplete().then(() => setIsComplete(true));
+ }
+ }, []);
+
+ // Countdown to redirect
+ useEffect(() => {
+ if (!isComplete) return;
+
+ const timer = setInterval(() => {
+ setCountdown((prev) => {
+ if (prev <= 1) {
+ clearInterval(timer);
+ navigate('/dashboard');
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, [isComplete, navigate]);
+
+ const summaryItems = [
+ {
+ label: 'Company',
+ value: state.companyData?.name || 'Not set',
+ done: !!state.companyData?.name,
+ },
+ {
+ label: 'Workspace URL',
+ value: state.companyData?.slug ? `app.example.com/${state.companyData.slug}` : 'Not set',
+ done: !!state.companyData?.slug,
+ },
+ {
+ label: 'Team members invited',
+ value: `${state.invitedUsers.filter((u) => u.status === 'sent').length} invited`,
+ done: state.invitedUsers.length > 0,
+ },
+ {
+ label: 'Plan selected',
+ value: state.selectedPlanId || 'Free plan',
+ done: !!state.selectedPlanId,
+ },
+ ];
+
+ return (
+
+
+ {isCompleting ? (
+ <>
+
+
+
+
+ Setting up your workspace...
+
+
+ Just a moment while we prepare everything for you
+
+ >
+ ) : (
+ <>
+
+
+ You're all set!
+
+
+ Welcome to your new workspace
+
+ >
+ )}
+
+
+ {/* Summary */}
+
+
+ Setup Summary
+
+
+ {summaryItems.map((item, index) => (
+
+
+
+ {item.done ? (
+
+ ) : (
+
+ )}
+
+
{item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+
+
+ {/* Next steps */}
+ {isComplete && (
+
+
+
+
+
+
+
Ready to explore?
+
+ Your dashboard is waiting. Start building something amazing!
+
+
+
navigate('/dashboard')}
+ className="flex items-center gap-2 px-6 py-3 bg-white text-primary-600 rounded-lg font-medium hover:bg-white/90 transition-colors"
+ >
+ Go to Dashboard
+
+
+
+
+ )}
+
+ {/* Auto-redirect notice */}
+ {isComplete && countdown > 0 && (
+
+ Redirecting to dashboard in {countdown} second{countdown !== 1 ? 's' : ''}...
+
+ )}
+
+ );
+}
diff --git a/src/pages/onboarding/steps/InviteStep.tsx b/src/pages/onboarding/steps/InviteStep.tsx
new file mode 100644
index 0000000..e5bab71
--- /dev/null
+++ b/src/pages/onboarding/steps/InviteStep.tsx
@@ -0,0 +1,225 @@
+import { useState } from 'react';
+import { Users, Mail, Plus, X, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
+import clsx from 'clsx';
+import type { InvitedUser } from '@/hooks/useOnboarding';
+
+interface InviteStepProps {
+ invitedUsers: InvitedUser[];
+ onUpdate: (users: InvitedUser[]) => void;
+ onSendInvites: (users: { email: string; role: string }[]) => Promise;
+ isSending: boolean;
+}
+
+const ROLES = [
+ { value: 'admin', label: 'Admin', description: 'Full access to all features' },
+ { value: 'member', label: 'Member', description: 'Standard access' },
+ { value: 'viewer', label: 'Viewer', description: 'Read-only access' },
+];
+
+function isValidEmail(email: string): boolean {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+
+export function InviteStep({ invitedUsers, onUpdate, onSendInvites, isSending }: InviteStepProps) {
+ const [newEmail, setNewEmail] = useState('');
+ const [newRole, setNewRole] = useState('member');
+ const [emailError, setEmailError] = useState('');
+
+ const handleAddUser = () => {
+ const email = newEmail.trim().toLowerCase();
+
+ if (!email) {
+ setEmailError('Please enter an email address');
+ return;
+ }
+
+ if (!isValidEmail(email)) {
+ setEmailError('Please enter a valid email address');
+ return;
+ }
+
+ if (invitedUsers.some((u) => u.email === email)) {
+ setEmailError('This email has already been added');
+ return;
+ }
+
+ onUpdate([...invitedUsers, { email, role: newRole, status: 'pending' }]);
+ setNewEmail('');
+ setNewRole('member');
+ setEmailError('');
+ };
+
+ const handleRemoveUser = (email: string) => {
+ onUpdate(invitedUsers.filter((u) => u.email !== email));
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ handleAddUser();
+ }
+ };
+
+ const handleSendInvites = async () => {
+ const pendingUsers = invitedUsers.filter((u) => u.status === 'pending');
+ if (pendingUsers.length === 0) return;
+
+ await onSendInvites(pendingUsers.map((u) => ({ email: u.email, role: u.role })));
+
+ // Mark all as sent (in real app, this would be based on API response)
+ onUpdate(
+ invitedUsers.map((u) => (u.status === 'pending' ? { ...u, status: 'sent' as const } : u))
+ );
+ };
+
+ const pendingCount = invitedUsers.filter((u) => u.status === 'pending').length;
+
+ return (
+
+
+
+
+
+
+ Invite your team
+
+
+ Collaborate with your colleagues. You can always invite more later.
+
+
+
+ {/* Add user form */}
+
+
+
+
+
+ {
+ setNewEmail(e.target.value);
+ setEmailError('');
+ }}
+ onKeyPress={handleKeyPress}
+ placeholder="colleague@company.com"
+ className={clsx(
+ 'w-full pl-10 pr-4 py-3 border 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',
+ emailError
+ ? 'border-red-500'
+ : 'border-secondary-300 dark:border-secondary-600'
+ )}
+ />
+
+ {emailError && (
+
{emailError}
+ )}
+
+
setNewRole(e.target.value)}
+ className="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"
+ >
+ {ROLES.map((role) => (
+
+ {role.label}
+
+ ))}
+
+
+
+ Add
+
+
+
+
+ {/* Invited users list */}
+ {invitedUsers.length > 0 && (
+
+
+ Team members to invite ({invitedUsers.length})
+
+
+ {invitedUsers.map((user) => (
+
+
+
+
+
+
+
+ {user.email}
+
+
{user.role}
+
+
+
+ {user.status === 'sent' && (
+
+
+ Sent
+
+ )}
+ {user.status === 'error' && (
+
+
+ Failed
+
+ )}
+ {user.status === 'pending' && (
+
handleRemoveUser(user.email)}
+ className="p-2 text-secondary-400 hover:text-red-500 transition-colors"
+ >
+
+
+ )}
+
+
+ ))}
+
+
+ {pendingCount > 0 && (
+
+ {isSending ? (
+ <>
+
+ Sending...
+ >
+ ) : (
+ <>
+
+ Send {pendingCount} Invitation{pendingCount > 1 ? 's' : ''}
+ >
+ )}
+
+ )}
+
+ )}
+
+ {/* Empty state */}
+ {invitedUsers.length === 0 && (
+
+
+
No team members added yet
+
Add emails above to invite your team
+
+ )}
+
+ {/* Skip note */}
+
+ This step is optional. You can invite team members later from Settings.
+
+
+ );
+}
diff --git a/src/pages/onboarding/steps/PlanStep.tsx b/src/pages/onboarding/steps/PlanStep.tsx
new file mode 100644
index 0000000..87e06c6
--- /dev/null
+++ b/src/pages/onboarding/steps/PlanStep.tsx
@@ -0,0 +1,223 @@
+import { useState } from 'react';
+import { CreditCard, Check, Loader2, Sparkles } from 'lucide-react';
+import clsx from 'clsx';
+import type { Plan } from '@/hooks/useOnboarding';
+
+interface PlanStepProps {
+ plans: Plan[];
+ selectedPlanId: string | null;
+ onSelect: (planId: string) => void;
+ isLoading: boolean;
+}
+
+export function PlanStep({ plans, selectedPlanId, onSelect, isLoading }: PlanStepProps) {
+ const [billingPeriod, setBillingPeriod] = useState<'monthly' | 'yearly'>('monthly');
+
+ // Mock plans if none provided
+ const displayPlans: Plan[] = plans.length > 0 ? plans : [
+ {
+ id: 'free',
+ name: 'free',
+ display_name: 'Free',
+ description: 'Perfect for getting started',
+ price_monthly: 0,
+ price_yearly: 0,
+ features: ['Up to 3 users', 'Basic features', 'Community support', '1 GB storage'],
+ },
+ {
+ id: 'starter',
+ name: 'starter',
+ display_name: 'Starter',
+ description: 'For small teams',
+ price_monthly: 29,
+ price_yearly: 290,
+ features: ['Up to 10 users', 'All basic features', 'Email support', '10 GB storage', 'API access'],
+ },
+ {
+ id: 'professional',
+ name: 'professional',
+ display_name: 'Professional',
+ description: 'For growing businesses',
+ price_monthly: 79,
+ price_yearly: 790,
+ features: ['Up to 50 users', 'All starter features', 'Priority support', '100 GB storage', 'Advanced analytics', 'Custom integrations'],
+ is_popular: true,
+ },
+ {
+ id: 'enterprise',
+ name: 'enterprise',
+ display_name: 'Enterprise',
+ description: 'For large organizations',
+ price_monthly: 199,
+ price_yearly: 1990,
+ features: ['Unlimited users', 'All professional features', 'Dedicated support', 'Unlimited storage', 'SSO/SAML', 'Custom contracts', 'SLA guarantee'],
+ },
+ ];
+
+ const getPrice = (plan: Plan) => {
+ return billingPeriod === 'monthly' ? plan.price_monthly : plan.price_yearly;
+ };
+
+ const getSavings = (plan: Plan) => {
+ if (plan.price_monthly === 0) return 0;
+ const yearlyTotal = plan.price_monthly * 12;
+ const savings = yearlyTotal - plan.price_yearly;
+ return Math.round((savings / yearlyTotal) * 100);
+ };
+
+ return (
+
+
+
+
+
+
+ Choose your plan
+
+
+ Select the plan that best fits your needs. You can upgrade anytime.
+
+
+
+ {/* Billing toggle */}
+
+
+ Monthly
+
+ setBillingPeriod(billingPeriod === 'monthly' ? 'yearly' : 'monthly')}
+ className={clsx(
+ 'relative w-14 h-7 rounded-full transition-colors',
+ billingPeriod === 'yearly'
+ ? 'bg-primary-600'
+ : 'bg-secondary-300 dark:bg-secondary-600'
+ )}
+ >
+
+
+
+ Yearly
+
+ {billingPeriod === 'yearly' && (
+
+ Save up to 17%
+
+ )}
+
+
+ {/* Plans grid */}
+ {isLoading ? (
+
+
+
+ ) : (
+
+ {displayPlans.map((plan) => {
+ const isSelected = selectedPlanId === plan.id;
+ const price = getPrice(plan);
+ const savings = getSavings(plan);
+
+ return (
+
onSelect(plan.id)}
+ className={clsx(
+ 'relative flex flex-col p-6 rounded-xl border-2 cursor-pointer transition-all',
+ isSelected
+ ? 'border-primary-500 bg-primary-50/50 dark:bg-primary-900/10'
+ : 'border-secondary-200 dark:border-secondary-700 hover:border-primary-300 dark:hover:border-primary-700',
+ plan.is_popular && 'ring-2 ring-purple-500 ring-offset-2 dark:ring-offset-secondary-900'
+ )}
+ >
+ {/* Popular badge */}
+ {plan.is_popular && (
+
+
+
+ Most Popular
+
+
+ )}
+
+ {/* Plan header */}
+
+
+ {plan.display_name}
+
+
{plan.description}
+
+
+ {/* Price */}
+
+
+
+ ${price}
+
+ {price > 0 && (
+
+ /{billingPeriod === 'monthly' ? 'mo' : 'yr'}
+
+ )}
+
+ {billingPeriod === 'yearly' && savings > 0 && (
+
+ Save {savings}% vs monthly
+
+ )}
+
+
+ {/* Features */}
+
+ {plan.features.map((feature, index) => (
+
+
+
+ {feature}
+
+
+ ))}
+
+
+ {/* Select button */}
+
+ {isSelected ? 'Selected' : 'Select Plan'}
+
+
+ );
+ })}
+
+ )}
+
+ {/* Note */}
+
+ All plans include a 14-day free trial. No credit card required.
+
+
+ );
+}
diff --git a/src/pages/onboarding/steps/index.ts b/src/pages/onboarding/steps/index.ts
new file mode 100644
index 0000000..dca8ae5
--- /dev/null
+++ b/src/pages/onboarding/steps/index.ts
@@ -0,0 +1,4 @@
+export { CompanyStep } from './CompanyStep';
+export { InviteStep } from './InviteStep';
+export { PlanStep } from './PlanStep';
+export { CompleteStep } from './CompleteStep';
diff --git a/src/pages/settings/GeneralSettings.tsx b/src/pages/settings/GeneralSettings.tsx
new file mode 100644
index 0000000..558be45
--- /dev/null
+++ b/src/pages/settings/GeneralSettings.tsx
@@ -0,0 +1,189 @@
+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 GeneralSettings() {
+ const { user } = useAuthStore();
+ const { theme, setTheme } = useUIStore();
+ const [isLoading, setIsLoading] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ 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 (
+
+ {/* Profile Settings */}
+
+
+
+
+ Profile Information
+
+
+
+
+
+ {/* Organization Settings */}
+
+
+
+
+ Organization
+
+
+
+
+
+
+ Tenant ID
+
+
+ {user?.tenant_id || 'N/A'}
+
+
+
+
+
+
+ {/* Appearance Settings */}
+
+
+
+ Appearance
+
+
+
+
+ {themes.map((t) => (
+ 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.label}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/pages/settings/NotificationSettings.tsx b/src/pages/settings/NotificationSettings.tsx
new file mode 100644
index 0000000..3483bb4
--- /dev/null
+++ b/src/pages/settings/NotificationSettings.tsx
@@ -0,0 +1,186 @@
+import { Bell, Mail, Smartphone, MessageSquare, Loader2 } from 'lucide-react';
+import { useNotificationPreferences, useUpdateNotificationPreferences } from '@/hooks/useData';
+
+interface NotificationChannel {
+ key: string;
+ label: string;
+ description: string;
+ icon: typeof Bell;
+}
+
+const channels: NotificationChannel[] = [
+ {
+ key: 'email_enabled',
+ label: 'Email Notifications',
+ description: 'Receive notifications via email',
+ icon: Mail,
+ },
+ {
+ key: 'push_enabled',
+ label: 'Push Notifications',
+ description: 'Receive push notifications on your devices',
+ icon: Smartphone,
+ },
+ {
+ key: 'in_app_enabled',
+ label: 'In-App Notifications',
+ description: 'See notifications within the application',
+ icon: Bell,
+ },
+ {
+ key: 'sms_enabled',
+ label: 'SMS Notifications',
+ description: 'Receive important alerts via SMS',
+ icon: MessageSquare,
+ },
+];
+
+interface NotificationCategory {
+ key: string;
+ label: string;
+ description: string;
+}
+
+const categories: NotificationCategory[] = [
+ {
+ key: 'security_alerts',
+ label: 'Security Alerts',
+ description: 'Login attempts, password changes, and security updates',
+ },
+ {
+ key: 'product_updates',
+ label: 'Product Updates',
+ description: 'New features, improvements, and announcements',
+ },
+ {
+ key: 'marketing_emails',
+ label: 'Marketing Emails',
+ description: 'Tips, offers, and promotional content',
+ },
+];
+
+export function NotificationSettings() {
+ const { data: preferences, isLoading } = useNotificationPreferences();
+ const updatePreferences = useUpdateNotificationPreferences();
+
+ const handleToggle = (key: string) => {
+ if (!preferences) return;
+
+ const currentValue = (preferences as Record)[key] ?? false;
+ updatePreferences.mutate({ [key]: !currentValue });
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const prefs = (preferences || {}) as Record;
+
+ return (
+
+ {/* Notification Channels */}
+
+
+
+
+
+ Notification Channels
+
+
+ Choose how you want to receive notifications
+
+
+
+
+
+ {channels.map((channel) => (
+
+
+
+
+
+
+
+ {channel.label}
+
+
+ {channel.description}
+
+
+
+
handleToggle(channel.key)}
+ disabled={updatePreferences.isPending}
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
+ prefs[channel.key]
+ ? 'bg-primary-600'
+ : 'bg-secondary-300 dark:bg-secondary-600'
+ }`}
+ >
+
+
+
+ ))}
+
+
+
+
+ {/* Notification Categories */}
+
+
+
+ Notification Categories
+
+
+ Control which types of notifications you receive
+
+
+
+
+ {categories.map((category) => (
+
+
+
+ {category.label}
+
+
+ {category.description}
+
+
+
handleToggle(category.key)}
+ disabled={updatePreferences.isPending}
+ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
+ prefs[category.key]
+ ? 'bg-primary-600'
+ : 'bg-secondary-300 dark:bg-secondary-600'
+ }`}
+ >
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/pages/settings/SecuritySettings.tsx b/src/pages/settings/SecuritySettings.tsx
new file mode 100644
index 0000000..04b2b39
--- /dev/null
+++ b/src/pages/settings/SecuritySettings.tsx
@@ -0,0 +1,629 @@
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import {
+ Shield,
+ Key,
+ Smartphone,
+ AlertTriangle,
+ Loader2,
+ Eye,
+ EyeOff,
+ Check,
+ Copy,
+ RefreshCw,
+ X,
+} from 'lucide-react';
+import { QRCodeSVG } from 'qrcode.react';
+import { useChangePassword } from '@/hooks/useAuth';
+import {
+ useMfaStatus,
+ useSetupMfa,
+ useVerifyMfaSetup,
+ useDisableMfa,
+ useRegenerateBackupCodes,
+} from '@/hooks/useMfa';
+import toast from 'react-hot-toast';
+
+interface PasswordFormData {
+ current_password: string;
+ new_password: string;
+ confirm_password: string;
+}
+
+interface MfaSetupFormData {
+ code: string;
+}
+
+interface DisableMfaFormData {
+ password: string;
+ code: string;
+}
+
+export function SecuritySettings() {
+ const [showCurrentPassword, setShowCurrentPassword] = useState(false);
+ const [showNewPassword, setShowNewPassword] = useState(false);
+ const [showMfaSetup, setShowMfaSetup] = useState(false);
+ const [showDisableMfa, setShowDisableMfa] = useState(false);
+ const [showBackupCodes, setShowBackupCodes] = useState(false);
+ const [setupData, setSetupData] = useState<{
+ secret: string;
+ qrCodeDataUrl: string;
+ backupCodes: string[];
+ } | null>(null);
+ const [newBackupCodes, setNewBackupCodes] = useState(null);
+
+ const changePassword = useChangePassword();
+ const { data: mfaStatus, isLoading: mfaStatusLoading } = useMfaStatus();
+ const setupMfa = useSetupMfa();
+ const verifyMfaSetup = useVerifyMfaSetup();
+ const disableMfa = useDisableMfa();
+ const regenerateBackupCodes = useRegenerateBackupCodes();
+
+ const {
+ register: registerPassword,
+ handleSubmit: handleSubmitPassword,
+ watch,
+ reset: resetPassword,
+ formState: { errors: passwordErrors },
+ } = useForm();
+
+ const {
+ register: registerMfaSetup,
+ handleSubmit: handleSubmitMfaSetup,
+ reset: resetMfaSetup,
+ formState: { errors: mfaSetupErrors },
+ } = useForm();
+
+ const {
+ register: registerDisableMfa,
+ handleSubmit: handleSubmitDisableMfa,
+ reset: resetDisableMfa,
+ formState: { errors: disableMfaErrors },
+ } = useForm();
+
+ const newPassword = watch('new_password');
+
+ const onPasswordSubmit = async (data: PasswordFormData) => {
+ try {
+ await changePassword.mutateAsync({
+ currentPassword: data.current_password,
+ newPassword: data.new_password,
+ });
+ resetPassword();
+ } catch {
+ // Error handled by mutation
+ }
+ };
+
+ const handleStartMfaSetup = async () => {
+ try {
+ const data = await setupMfa.mutateAsync();
+ setSetupData(data);
+ setShowMfaSetup(true);
+ } catch {
+ // Error handled by mutation
+ }
+ };
+
+ const onMfaSetupSubmit = async (data: MfaSetupFormData) => {
+ if (!setupData) return;
+
+ try {
+ await verifyMfaSetup.mutateAsync({
+ code: data.code,
+ secret: setupData.secret,
+ });
+ setShowMfaSetup(false);
+ setShowBackupCodes(true);
+ resetMfaSetup();
+ } catch {
+ // Error handled by mutation
+ }
+ };
+
+ const onDisableMfaSubmit = async (data: DisableMfaFormData) => {
+ try {
+ await disableMfa.mutateAsync(data);
+ setShowDisableMfa(false);
+ resetDisableMfa();
+ } catch {
+ // Error handled by mutation
+ }
+ };
+
+ const handleRegenerateBackupCodes = async () => {
+ const password = prompt('Enter your password:');
+ const code = prompt('Enter your TOTP code:');
+ if (!password || !code) return;
+
+ try {
+ const result = await regenerateBackupCodes.mutateAsync({ password, code });
+ setNewBackupCodes(result.backupCodes);
+ setShowBackupCodes(true);
+ } catch {
+ // Error handled by mutation
+ }
+ };
+
+ const copyBackupCodes = (codes: string[]) => {
+ navigator.clipboard.writeText(codes.join('\n'));
+ toast.success('Backup codes copied to clipboard');
+ };
+
+ return (
+
+ {/* Change Password */}
+
+
+
+
+
+ Change Password
+
+
+ Update your password to keep your account secure
+
+
+
+
+
+
+ {/* Two-Factor Authentication */}
+
+
+
+
+
+ Two-Factor Authentication
+
+
+ Add an extra layer of security to your account
+
+
+
+
+ {mfaStatusLoading ? (
+
+
+
+ ) : mfaStatus?.enabled ? (
+ // MFA is enabled
+
+
+
+
+
+
+
+
+ 2FA is enabled
+
+
+ {mfaStatus.backupCodesRemaining} backup codes remaining
+
+
+
+
+
+ {regenerateBackupCodes.isPending ? (
+
+ ) : (
+
+ )}
+ New Codes
+
+ setShowDisableMfa(true)}
+ className="btn-secondary text-sm text-red-600 hover:text-red-700"
+ >
+ Disable
+
+
+
+
+ {/* Disable MFA Modal */}
+ {showDisableMfa && (
+
+
+
+
+ Disable Two-Factor Authentication
+
+ setShowDisableMfa(false)}
+ className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
+ >
+
+
+
+
+
+
+ )}
+
+ ) : (
+ // MFA is not enabled
+
+
+
+
+
+
+
+ 2FA is not enabled
+
+
+ Protect your account with two-factor authentication
+
+
+
+
+ {setupMfa.isPending ? (
+
+ ) : (
+ 'Enable 2FA'
+ )}
+
+
+ )}
+
+ {/* MFA Setup Modal */}
+ {showMfaSetup && setupData && (
+
+
+
+
+ Set Up Two-Factor Authentication
+
+ setShowMfaSetup(false)}
+ className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
+ >
+
+
+
+
+
+
+
+ Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
+
+
+
+
+
+
+
+
+ Or enter this code manually:
+
+
+ {setupData.secret}
+ {
+ navigator.clipboard.writeText(setupData.secret);
+ toast.success('Secret copied');
+ }}
+ className="p-1.5 hover:bg-secondary-200 dark:hover:bg-secondary-600 rounded"
+ >
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Backup Codes Modal */}
+ {showBackupCodes && (setupData?.backupCodes || newBackupCodes) && (
+
+
+
+
+ Save Your Backup Codes
+
+
+
+
+
+
+ Important: Save these backup codes in a secure place. Each code can only be used once.
+
+
+
+
+ {(newBackupCodes || setupData?.backupCodes)?.map((code, index) => (
+
+ {code}
+
+ ))}
+
+
+
+ copyBackupCodes(newBackupCodes || setupData?.backupCodes || [])}
+ className="btn-secondary flex-1"
+ >
+
+ Copy All
+
+ {
+ setShowBackupCodes(false);
+ setSetupData(null);
+ setNewBackupCodes(null);
+ }}
+ className="btn-primary flex-1"
+ >
+ I've Saved My Codes
+
+
+
+
+
+ )}
+
+
+
+ {/* Active Sessions */}
+
+
+
+
+
+ Active Sessions
+
+
+ Manage your active sessions across devices
+
+
+
+
+
+
+
+
+ Current Session
+
+
+ This device - Last active now
+
+
+
+ Active
+
+
+
+
+
+
+ Sign out of all other sessions
+
+
+
+
+
+ {/* Danger Zone */}
+
+
+
+
+
+ Danger Zone
+
+
+ Irreversible and destructive actions
+
+
+
+
+
+
+
+ Delete Account
+
+
+ Permanently delete your account and all associated data
+
+
+
+ Delete Account
+
+
+
+
+
+ );
+}
diff --git a/src/pages/settings/SettingsPage.tsx b/src/pages/settings/SettingsPage.tsx
new file mode 100644
index 0000000..b33f78c
--- /dev/null
+++ b/src/pages/settings/SettingsPage.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react';
+import { Settings, User, Bell, Shield, Bot } from 'lucide-react';
+import clsx from 'clsx';
+import { GeneralSettings } from './GeneralSettings';
+import { NotificationSettings } from './NotificationSettings';
+import { SecuritySettings } from './SecuritySettings';
+import { AISettings } from '@/components/ai';
+
+type TabKey = 'general' | 'notifications' | 'security' | 'ai';
+
+interface Tab {
+ key: TabKey;
+ label: string;
+ icon: typeof Settings;
+ component: React.ComponentType;
+}
+
+const tabs: Tab[] = [
+ {
+ key: 'general',
+ label: 'General',
+ icon: User,
+ component: GeneralSettings,
+ },
+ {
+ key: 'notifications',
+ label: 'Notifications',
+ icon: Bell,
+ component: NotificationSettings,
+ },
+ {
+ key: 'security',
+ label: 'Security',
+ icon: Shield,
+ component: SecuritySettings,
+ },
+ {
+ key: 'ai',
+ label: 'AI',
+ icon: Bot,
+ component: AISettings,
+ },
+];
+
+export function SettingsPage() {
+ const [activeTab, setActiveTab] = useState('general');
+
+ const ActiveComponent = tabs.find((t) => t.key === activeTab)?.component || GeneralSettings;
+
+ return (
+
+ {/* Header */}
+
+
+ Settings
+
+
+ Manage your account settings and preferences
+
+
+
+ {/* Tabs */}
+
+
+ {tabs.map((tab) => (
+ setActiveTab(tab.key)}
+ className={clsx(
+ 'flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors',
+ activeTab === tab.key
+ ? 'border-primary-500 text-primary-600 dark:text-primary-400'
+ : 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:hover:text-secondary-300'
+ )}
+ >
+
+ {tab.label}
+
+ ))}
+
+
+
+ {/* Tab Content */}
+
+
+ );
+}
diff --git a/src/pages/settings/index.ts b/src/pages/settings/index.ts
new file mode 100644
index 0000000..d541e19
--- /dev/null
+++ b/src/pages/settings/index.ts
@@ -0,0 +1,4 @@
+export { SettingsPage } from './SettingsPage';
+export { GeneralSettings } from './GeneralSettings';
+export { NotificationSettings } from './NotificationSettings';
+export { SecuritySettings } from './SecuritySettings';
diff --git a/src/pages/superadmin/MetricsPage.tsx b/src/pages/superadmin/MetricsPage.tsx
new file mode 100644
index 0000000..2cb7a31
--- /dev/null
+++ b/src/pages/superadmin/MetricsPage.tsx
@@ -0,0 +1,453 @@
+import { useState } from 'react';
+import { Link } from 'react-router-dom';
+import {
+ TrendingUp,
+ Users,
+ Building2,
+ PieChart,
+ BarChart3,
+ Download,
+ RefreshCw,
+ ChevronRight,
+ Crown,
+} from 'lucide-react';
+import clsx from 'clsx';
+import { useMetricsSummary, useSuperadminDashboard } from '@/hooks/useSuperadmin';
+
+// Simple bar chart component using CSS
+function BarChart({
+ data,
+ labelKey,
+ valueKey,
+ color = 'bg-primary-500',
+ height = 200,
+}: {
+ data: Record[];
+ labelKey: string;
+ valueKey: string;
+ color?: string;
+ height?: number;
+}) {
+ const maxValue = Math.max(...data.map((d) => d[valueKey]), 1);
+
+ return (
+
+ {data.map((item, index) => {
+ const barHeight = (item[valueKey] / maxValue) * 100;
+ return (
+
+
+ {item[valueKey]}
+
+
+
+ {item[labelKey]}
+
+
+ );
+ })}
+
+ );
+}
+
+// Simple donut chart using CSS conic-gradient
+function DonutChart({
+ data,
+ labelKey,
+ valueKey,
+ colors,
+}: {
+ data: Record[];
+ labelKey: string;
+ valueKey: string;
+ colors: string[];
+}) {
+ const total = data.reduce((sum, d) => sum + d[valueKey], 0);
+ let currentDegree = 0;
+
+ const gradientParts = data.map((item, index) => {
+ const percentage = total > 0 ? (item[valueKey] / total) * 360 : 0;
+ const start = currentDegree;
+ currentDegree += percentage;
+ return `${colors[index % colors.length]} ${start}deg ${currentDegree}deg`;
+ });
+
+ const gradient = `conic-gradient(${gradientParts.join(', ')})`;
+
+ return (
+
+
+
+ {data.map((item, index) => (
+
+
+
+ {item[labelKey]}
+
+
+ {item[valueKey]} ({item.percentage || 0}%)
+
+
+ ))}
+
+
+ );
+}
+
+// Status badge component
+function StatusBadge({ status }: { status: string }) {
+ const colors: Record = {
+ active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
+ Active: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
+ trial: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
+ Trial: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
+ suspended: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
+ Suspended: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
+ canceled: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
+ Canceled: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
+ };
+
+ return (
+
+ {status}
+
+ );
+}
+
+export function MetricsPage() {
+ const [timeRange, setTimeRange] = useState<6 | 12>(12);
+ const { data: metrics, isLoading, refetch, isRefetching } = useMetricsSummary();
+ const { data: dashboardStats } = useSuperadminDashboard();
+
+ const planColors = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#8b5cf6'];
+ const statusColors = ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444'];
+
+ const handleExport = () => {
+ if (!metrics) return;
+
+ const csvData = [
+ ['Metric Type', 'Label', 'Value', 'Percentage'],
+ ...metrics.tenantGrowth.map((d) => ['Tenant Growth', d.month, d.count, '']),
+ ...metrics.userGrowth.map((d) => ['User Growth', d.month, d.count, '']),
+ ...metrics.planDistribution.map((d) => ['Plan Distribution', d.plan || '', d.count, `${d.percentage}%`]),
+ ...metrics.statusDistribution.map((d) => ['Status Distribution', d.status || '', d.count, `${d.percentage}%`]),
+ ];
+
+ const csv = csvData.map((row) => row.join(',')).join('\n');
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `metrics-${new Date().toISOString().split('T')[0]}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ // Filter growth data based on time range
+ const filteredTenantGrowth = metrics?.tenantGrowth.slice(-timeRange) || [];
+ const filteredUserGrowth = metrics?.userGrowth.slice(-timeRange) || [];
+
+ return (
+
+ {/* Header */}
+
+
+
+ Platform Metrics
+
+
+ Analytics and insights for your SaaS platform
+
+
+
+ setTimeRange(Number(e.target.value) as 6 | 12)}
+ className="px-3 py-2 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-lg text-sm focus:ring-2 focus:ring-primary-500"
+ >
+ Last 6 months
+ Last 12 months
+
+ refetch()}
+ disabled={isRefetching}
+ className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-lg text-sm hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
+ >
+
+ Refresh
+
+
+
+ Export CSV
+
+
+
+
+ {/* Quick Stats */}
+ {dashboardStats && (
+
+
+
+
+
+
+
+
+ {dashboardStats.totalTenants}
+
+
Total Tenants
+
+
+
+
+
+
+
+
+
+
+ {dashboardStats.totalUsers}
+
+
Total Users
+
+
+
+
+
+
+
+
+
+
+ {dashboardStats.newTenantsThisMonth}
+
+
New This Month
+
+
+
+
+
+
+
+
+
+
+ {dashboardStats.activeTenants}
+
+
Active Tenants
+
+
+
+
+ )}
+
+ {isLoading ? (
+
+ ) : metrics ? (
+
+ {/* Tenant Growth Chart */}
+
+
+
+
+
+
+
+ Tenant Growth
+
+
New tenants per month
+
+
+ {filteredTenantGrowth.length > 0 ? (
+
+ ) : (
+
+ No data available
+
+ )}
+
+
+ {/* User Growth Chart */}
+
+
+
+
+
+
+
+ User Growth
+
+
New users per month
+
+
+ {filteredUserGrowth.length > 0 ? (
+
+ ) : (
+
+ No data available
+
+ )}
+
+
+ {/* Plan Distribution */}
+
+
+
+
+
+ Plan Distribution
+
+
Tenants by subscription plan
+
+
+ {metrics.planDistribution.length > 0 ? (
+
+ ) : (
+
+ No data available
+
+ )}
+
+
+ {/* Status Distribution */}
+
+
+
+
+
+ Status Distribution
+
+
Tenants by status
+
+
+ {metrics.statusDistribution.length > 0 ? (
+
+ ) : (
+
+ No data available
+
+ )}
+
+
+ ) : (
+
+ Failed to load metrics data
+
+ )}
+
+ {/* Top Tenants */}
+ {metrics && metrics.topTenants.length > 0 && (
+
+
+
+
+
+
+
+
+
+ Top Tenants
+
+
By user count
+
+
+
+ View all
+
+
+
+
+
+ {metrics.topTenants.map((tenant, index) => (
+
+
+
2 && 'bg-secondary-100 text-secondary-600 dark:bg-secondary-700 dark:text-secondary-400'
+ )}
+ >
+ {index + 1}
+
+
+
+ {tenant.name}
+
+
{tenant.slug}
+
+
+
+
+
+ {tenant.userCount} users
+
+
{tenant.planName}
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/superadmin/TenantDetailPage.tsx b/src/pages/superadmin/TenantDetailPage.tsx
new file mode 100644
index 0000000..1c42dcd
--- /dev/null
+++ b/src/pages/superadmin/TenantDetailPage.tsx
@@ -0,0 +1,443 @@
+import { useState } from 'react';
+import { useParams, Link, useNavigate } from 'react-router-dom';
+import { useForm } from 'react-hook-form';
+import {
+ useTenant,
+ useTenantUsers,
+ useUpdateTenant,
+ useUpdateTenantStatus,
+ useDeleteTenant,
+ UpdateTenantData,
+} from '@/hooks';
+import {
+ ArrowLeft,
+ Building2,
+ Users,
+ CreditCard,
+ Calendar,
+ Globe,
+ Edit2,
+ Save,
+ X,
+ Loader2,
+ Trash2,
+ Pause,
+ Play,
+ AlertTriangle,
+} from 'lucide-react';
+import clsx from 'clsx';
+
+export function TenantDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const [isEditing, setIsEditing] = useState(false);
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+
+ const { data: tenant, isLoading, isError } = useTenant(id!);
+ const { data: usersData } = useTenantUsers(id!, 1, 5);
+ const updateMutation = useUpdateTenant();
+ const updateStatusMutation = useUpdateTenantStatus();
+ const deleteMutation = useDeleteTenant();
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors },
+ } = useForm();
+
+ const handleSave = (data: UpdateTenantData) => {
+ updateMutation.mutate(
+ { id: id!, data },
+ {
+ onSuccess: () => {
+ setIsEditing(false);
+ },
+ }
+ );
+ };
+
+ const handleStatusToggle = () => {
+ if (!tenant) return;
+ const newStatus = tenant.status === 'active' || tenant.status === 'trial' ? 'suspended' : 'active';
+ updateStatusMutation.mutate({ id: id!, data: { status: newStatus } });
+ };
+
+ const handleDelete = () => {
+ deleteMutation.mutate(id!, {
+ onSuccess: () => navigate('/superadmin/tenants'),
+ });
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ };
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'active':
+ return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
+ case 'trial':
+ return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
+ case 'suspended':
+ return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
+ case 'canceled':
+ return 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-400';
+ default:
+ return 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-400';
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !tenant) {
+ return (
+
+
+
+ Tenant not found
+
+
+ Back to Tenants
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {tenant.logo_url ? (
+
+ ) : (
+
+ )}
+
+
+
+ {tenant.name}
+
+
{tenant.slug}
+
+
+
+
+
+
+ {tenant.status.charAt(0).toUpperCase() + tenant.status.slice(1)}
+
+
+ {updateStatusMutation.isPending ? (
+
+ ) : tenant.status === 'suspended' ? (
+ <>
+
+ Activate
+ >
+ ) : (
+ <>
+
+ Suspend
+ >
+ )}
+
+
+
+
+ {/* Stats Cards */}
+
+
+
+
+
+
+ {tenant.userCount ?? 0}
+
+
Users
+
+
+
+
+
+
+
+
+ {tenant.subscription?.plan?.display_name || 'No Plan'}
+
+
Current Plan
+
+
+
+
+
+
+
+
+ {new Date(tenant.created_at).toLocaleDateString()}
+
+
Created
+
+
+
+
+
+
+
+
+ {tenant.domain || `${tenant.slug}.platform.com`}
+
+
Domain
+
+
+
+
+
+ {/* Details */}
+
+ {/* Tenant Info */}
+
+
+
+ Tenant Information
+
+ {!isEditing ? (
+ {
+ reset({
+ name: tenant.name,
+ domain: tenant.domain || '',
+ logo_url: tenant.logo_url || '',
+ });
+ setIsEditing(true);
+ }}
+ className="btn-ghost text-sm"
+ >
+
+ Edit
+
+ ) : (
+ setIsEditing(false)} className="btn-ghost text-sm">
+
+ Cancel
+
+ )}
+
+
+ {isEditing ? (
+
+ ) : (
+
+
+
Name
+ {tenant.name}
+
+
+
Slug
+ {tenant.slug}
+
+
+
Domain
+
+ {tenant.domain || 'Not configured'}
+
+
+
+
Created
+
+ {formatDate(tenant.created_at)}
+
+
+ {tenant.trial_ends_at && (
+
+
Trial Ends
+
+ {formatDate(tenant.trial_ends_at)}
+
+
+ )}
+
+ )}
+
+
+
+ {/* Recent Users */}
+
+
+
+ Recent Users
+
+
+
+ {usersData?.data && usersData.data.length > 0 ? (
+
+ {usersData.data.map((user: any) => (
+
+
+
+ {(user.first_name?.[0] || user.email[0]).toUpperCase()}
+
+
+
+
+ {user.first_name && user.last_name
+ ? `${user.first_name} ${user.last_name}`
+ : user.email}
+
+
{user.email}
+
+
+ {user.status}
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Danger Zone */}
+
+
+
Danger Zone
+
+
+
+
+
Delete Tenant
+
+ Permanently delete this tenant and all associated data.
+
+
+
setShowDeleteModal(true)}
+ className="btn-ghost text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
+ >
+
+ Delete
+
+
+
+
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteModal && (
+
+
setShowDeleteModal(false)} />
+
+
+
+
+
+ Delete Tenant
+
+
+ This action cannot be undone
+
+
+
+
+ Are you sure you want to delete {tenant.name} ? This will permanently
+ remove the tenant and all associated data.
+
+
+ setShowDeleteModal(false)} className="btn-secondary flex-1">
+ Cancel
+
+
+ {deleteMutation.isPending ? (
+
+ ) : (
+
+ )}
+ Delete Tenant
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/superadmin/TenantsPage.tsx b/src/pages/superadmin/TenantsPage.tsx
new file mode 100644
index 0000000..44d56ca
--- /dev/null
+++ b/src/pages/superadmin/TenantsPage.tsx
@@ -0,0 +1,507 @@
+import { useState } from 'react';
+import { Link } from 'react-router-dom';
+import { useForm } from 'react-hook-form';
+import {
+ useTenants,
+ useCreateTenant,
+ useUpdateTenantStatus,
+ useSuperadminDashboard,
+ Tenant,
+ CreateTenantData,
+} from '@/hooks';
+import {
+ Plus,
+ Search,
+ Building2,
+ Users,
+ TrendingUp,
+ MoreVertical,
+ Eye,
+ Pause,
+ Play,
+ Loader2,
+ X,
+ ChevronLeft,
+ ChevronRight,
+ Filter,
+} from 'lucide-react';
+import clsx from 'clsx';
+
+export function TenantsPage() {
+ const [search, setSearch] = useState('');
+ const [statusFilter, setStatusFilter] = useState
('');
+ const [page, setPage] = useState(1);
+ const [openMenu, setOpenMenu] = useState(null);
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showStatusModal, setShowStatusModal] = useState<{ id: string; currentStatus: string } | null>(null);
+ const limit = 10;
+
+ const { data: stats, isLoading: statsLoading } = useSuperadminDashboard();
+ const { data: tenantsData, isLoading, isError, refetch } = useTenants({
+ page,
+ limit,
+ search: search || undefined,
+ status: statusFilter || undefined,
+ });
+ const createMutation = useCreateTenant();
+ const updateStatusMutation = useUpdateTenantStatus();
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors },
+ } = useForm({
+ defaultValues: { status: 'trial' },
+ });
+
+ const handleCreate = (data: CreateTenantData) => {
+ createMutation.mutate(data, {
+ onSuccess: () => {
+ setShowCreateModal(false);
+ reset();
+ },
+ });
+ };
+
+ const handleStatusChange = (newStatus: string) => {
+ if (showStatusModal) {
+ updateStatusMutation.mutate(
+ { id: showStatusModal.id, data: { status: newStatus } },
+ { onSuccess: () => setShowStatusModal(null) }
+ );
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ const getStatusColor = (status: string) => {
+ switch (status) {
+ case 'active':
+ return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
+ case 'trial':
+ return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
+ case 'suspended':
+ return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
+ case 'canceled':
+ return 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-400';
+ default:
+ return 'bg-secondary-100 text-secondary-700 dark:bg-secondary-700 dark:text-secondary-400';
+ }
+ };
+
+ const totalPages = tenantsData?.totalPages ?? 1;
+
+ return (
+
+
+
+
+ Tenant Management
+
+
+ Manage all tenants in the platform
+
+
+
setShowCreateModal(true)} className="btn-primary">
+
+ Create Tenant
+
+
+
+ {/* Stats */}
+
+ {[
+ { label: 'Total Tenants', value: stats?.totalTenants ?? 0, icon: Building2 },
+ { label: 'Active', value: stats?.activeTenants ?? 0, icon: Play },
+ { label: 'Trial', value: stats?.trialTenants ?? 0, icon: TrendingUp },
+ { label: 'Total Users', value: stats?.totalUsers ?? 0, icon: Users },
+ ].map((stat) => (
+
+
+
+
+
+
+
+ {statsLoading ? '...' : stat.value}
+
+
{stat.label}
+
+
+
+ ))}
+
+
+ {/* Filters */}
+
+
+
+ {
+ setSearch(e.target.value);
+ setPage(1);
+ }}
+ />
+
+
+
+ {
+ setStatusFilter(e.target.value);
+ setPage(1);
+ }}
+ >
+ All Status
+ Active
+ Trial
+ Suspended
+ Canceled
+
+
+
+
+ {/* Tenants Table */}
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+
Failed to load tenants
+
refetch()} className="btn-secondary">
+ Try again
+
+
+ ) : (
+ <>
+
+
+
+
+
+ Tenant
+
+
+ Status
+
+
+ Plan
+
+
+ Users
+
+
+ Created
+
+
+
+
+
+ {tenantsData?.data?.map((tenant: Tenant) => (
+
+
+
+
+ {tenant.logo_url ? (
+
+ ) : (
+
+ )}
+
+
+
+ {tenant.name}
+
+
{tenant.slug}
+
+
+
+
+
+ {tenant.status.charAt(0).toUpperCase() + tenant.status.slice(1)}
+
+
+
+ {tenant.subscription?.plan?.display_name || 'No Plan'}
+
+
+
+
+ {tenant.userCount ?? 0}
+
+
+
+ {formatDate(tenant.created_at)}
+
+
+
+
setOpenMenu(openMenu === tenant.id ? null : tenant.id)}
+ className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
+ >
+
+
+
+ {openMenu === tenant.id && (
+ <>
+
setOpenMenu(null)}
+ />
+
+
+
+ View Details
+
+ {tenant.status === 'active' || tenant.status === 'trial' ? (
+
{
+ setOpenMenu(null);
+ setShowStatusModal({ id: tenant.id, currentStatus: tenant.status });
+ }}
+ className="w-full flex items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-secondary-100 dark:hover:bg-secondary-700"
+ >
+
+ Suspend
+
+ ) : (
+
{
+ setOpenMenu(null);
+ setShowStatusModal({ id: tenant.id, currentStatus: tenant.status });
+ }}
+ className="w-full flex items-center gap-2 px-4 py-2 text-sm text-green-600 hover:bg-secondary-100 dark:hover:bg-secondary-700"
+ >
+
+ Activate
+
+ )}
+
+ >
+ )}
+
+
+
+ ))}
+
+
+
+
+ {tenantsData?.data?.length === 0 && (
+
+ )}
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+ Page {page} of {totalPages} ({tenantsData?.total} total)
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ className="btn-secondary px-3 py-1.5 disabled:opacity-50"
+ >
+
+
+ setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page === totalPages}
+ className="btn-secondary px-3 py-1.5 disabled:opacity-50"
+ >
+
+
+
+
+ )}
+ >
+ )}
+
+
+ {/* Create Modal */}
+ {showCreateModal && (
+
+
setShowCreateModal(false)} />
+
+
+
+ Create New Tenant
+
+ setShowCreateModal(false)}
+ className="p-2 rounded-lg hover:bg-secondary-100 dark:hover:bg-secondary-700"
+ >
+
+
+
+
+
+
+
+ )}
+
+ {/* Status Change Modal */}
+ {showStatusModal && (
+
+
setShowStatusModal(null)} />
+
+
+ Change Tenant Status
+
+
+ Current status: {showStatusModal.currentStatus}
+
+
+ {['active', 'trial', 'suspended', 'canceled'].map((status) => (
+ handleStatusChange(status)}
+ disabled={status === showStatusModal.currentStatus || updateStatusMutation.isPending}
+ className={clsx(
+ 'w-full text-left px-4 py-3 rounded-lg border transition-colors',
+ status === showStatusModal.currentStatus
+ ? 'bg-secondary-100 dark:bg-secondary-700 border-secondary-200 dark:border-secondary-600 opacity-50'
+ : 'border-secondary-200 dark:border-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-700'
+ )}
+ >
+
+ {status.charAt(0).toUpperCase() + status.slice(1)}
+
+
+ ))}
+
+
setShowStatusModal(null)}
+ className="btn-secondary w-full mt-4"
+ >
+ Cancel
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/superadmin/index.ts b/src/pages/superadmin/index.ts
new file mode 100644
index 0000000..1f345d1
--- /dev/null
+++ b/src/pages/superadmin/index.ts
@@ -0,0 +1,3 @@
+export { TenantsPage } from './TenantsPage';
+export { TenantDetailPage } from './TenantDetailPage';
+export { MetricsPage } from './MetricsPage';
diff --git a/src/router/index.tsx b/src/router/index.tsx
new file mode 100644
index 0000000..63fd8d6
--- /dev/null
+++ b/src/router/index.tsx
@@ -0,0 +1,167 @@
+import { lazy, Suspense } from 'react';
+import { Routes, Route, Navigate } from 'react-router-dom';
+import { useAuthStore } from '@/stores/auth.store';
+
+// Layouts - kept as static imports since they're used on every page
+import { AuthLayout } from '@/layouts/AuthLayout';
+import { DashboardLayout } from '@/layouts/DashboardLayout';
+
+// Loading fallback component
+function PageLoader() {
+ return (
+
+ );
+}
+
+// Lazy loaded pages - Auth
+const LoginPage = lazy(() => import('@/pages/auth/LoginPage').then(m => ({ default: m.LoginPage })));
+const RegisterPage = lazy(() => import('@/pages/auth/RegisterPage').then(m => ({ default: m.RegisterPage })));
+const ForgotPasswordPage = lazy(() => import('@/pages/auth/ForgotPasswordPage').then(m => ({ default: m.ForgotPasswordPage })));
+const OAuthCallbackPage = lazy(() => import('@/pages/auth/OAuthCallbackPage').then(m => ({ default: m.OAuthCallbackPage })));
+
+// Lazy loaded pages - Dashboard
+const DashboardPage = lazy(() => import('@/pages/dashboard/DashboardPage').then(m => ({ default: m.DashboardPage })));
+const BillingPage = lazy(() => import('@/pages/dashboard/BillingPage').then(m => ({ default: m.BillingPage })));
+const UsersPage = lazy(() => import('@/pages/dashboard/UsersPage').then(m => ({ default: m.UsersPage })));
+const AIPage = lazy(() => import('@/pages/dashboard/AIPage').then(m => ({ default: m.AIPage })));
+const StoragePage = lazy(() => import('@/pages/dashboard/StoragePage').then(m => ({ default: m.StoragePage })));
+const WebhooksPage = lazy(() => import('@/pages/dashboard/WebhooksPage').then(m => ({ default: m.WebhooksPage })));
+const AuditLogsPage = lazy(() => import('@/pages/dashboard/AuditLogsPage').then(m => ({ default: m.AuditLogsPage })));
+const FeatureFlagsPage = lazy(() => import('@/pages/dashboard/FeatureFlagsPage').then(m => ({ default: m.FeatureFlagsPage })));
+
+// Lazy loaded pages - Admin
+const WhatsAppSettings = lazy(() => import('@/pages/admin/WhatsAppSettings').then(m => ({ default: m.WhatsAppSettings })));
+const AnalyticsDashboardPage = lazy(() => import('@/pages/admin/AnalyticsDashboardPage').then(m => ({ default: m.AnalyticsDashboardPage })));
+
+// Lazy loaded pages - Settings
+const SettingsPage = lazy(() => import('@/pages/settings').then(m => ({ default: m.SettingsPage })));
+
+// Lazy loaded pages - Superadmin
+const TenantsPage = lazy(() => import('@/pages/superadmin').then(m => ({ default: m.TenantsPage })));
+const TenantDetailPage = lazy(() => import('@/pages/superadmin').then(m => ({ default: m.TenantDetailPage })));
+const MetricsPage = lazy(() => import('@/pages/superadmin').then(m => ({ default: m.MetricsPage })));
+
+// Lazy loaded pages - Onboarding
+const OnboardingPage = lazy(() => import('@/pages/onboarding').then(m => ({ default: m.OnboardingPage })));
+
+// Protected Route wrapper
+function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
+
+ if (!isAuthenticated) {
+ return
;
+ }
+
+ return <>{children}>;
+}
+
+// Superadmin Route wrapper (requires superadmin role)
+function SuperadminRoute({ children }: { children: React.ReactNode }) {
+ const { isAuthenticated, user } = useAuthStore();
+
+ if (!isAuthenticated) {
+ return
;
+ }
+
+ // Check if user has superadmin role
+ // In a real app, this would check against the user's roles from the API
+ if (user?.role !== 'superadmin') {
+ return
;
+ }
+
+ return <>{children}>;
+}
+
+// Guest Route wrapper (redirect to dashboard if already logged in)
+function GuestRoute({ children }: { children: React.ReactNode }) {
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
+
+ if (isAuthenticated) {
+ return
;
+ }
+
+ return <>{children}>;
+}
+
+// Suspense wrapper for lazy loaded pages
+function SuspensePage({ children }: { children: React.ReactNode }) {
+ return
}>{children};
+}
+
+export function AppRouter() {
+ return (
+
+ {/* Public routes */}
+ } />
+
+ {/* Auth routes */}
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+
+
+ {/* OAuth callback route - outside GuestRoute since user may be authenticated after callback */}
+ } />
+
+ {/* Dashboard routes */}
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* Superadmin routes */}
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+
+
+ {/* Onboarding route */}
+
+
+
+ }
+ />
+
+ {/* 404 */}
+ } />
+
+ );
+}
diff --git a/src/services/api.ts b/src/services/api.ts
new file mode 100644
index 0000000..db6ceff
--- /dev/null
+++ b/src/services/api.ts
@@ -0,0 +1,1343 @@
+import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
+import { useAuthStore } from '@/stores';
+
+const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1';
+
+// Get tenant ID from subdomain or environment
+function getTenantId(): string {
+ // In production, extract from subdomain (e.g., acme.template-saas.com)
+ const hostname = window.location.hostname;
+ const parts = hostname.split('.');
+
+ // If we have a subdomain (not localhost or IP)
+ if (parts.length >= 3 && !hostname.includes('localhost')) {
+ return parts[0]; // Return subdomain as tenant slug
+ }
+
+ // For development, use environment variable or default
+ return import.meta.env.VITE_TENANT_ID || 'default-tenant';
+}
+
+// Create axios instance
+const api: AxiosInstance = axios.create({
+ baseURL: API_BASE_URL,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ timeout: 30000,
+});
+
+// Request interceptor to add auth token and tenant ID
+api.interceptors.request.use(
+ (config: InternalAxiosRequestConfig) => {
+ // Add tenant ID header
+ config.headers['x-tenant-id'] = getTenantId();
+
+ // Add auth token if available
+ const token = useAuthStore.getState().accessToken;
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+
+ return config;
+ },
+ (error) => Promise.reject(error)
+);
+
+// Response interceptor for error handling and token refresh
+api.interceptors.response.use(
+ (response) => response,
+ async (error: AxiosError) => {
+ const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
+
+ // If 401 and not already retried, try to refresh token
+ if (error.response?.status === 401 && !originalRequest._retry) {
+ originalRequest._retry = true;
+
+ try {
+ const refreshToken = useAuthStore.getState().refreshToken;
+ if (refreshToken) {
+ const response = await axios.post(
+ `${API_BASE_URL}/auth/refresh`,
+ { refreshToken },
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-tenant-id': getTenantId(),
+ },
+ }
+ );
+
+ const { accessToken, refreshToken: newRefreshToken } = response.data;
+ useAuthStore.getState().setTokens(accessToken, newRefreshToken);
+
+ originalRequest.headers.Authorization = `Bearer ${accessToken}`;
+ return api(originalRequest);
+ }
+ } catch (refreshError) {
+ // Refresh failed, logout user
+ useAuthStore.getState().logout();
+ window.location.href = '/auth/login';
+ return Promise.reject(refreshError);
+ }
+ }
+
+ return Promise.reject(error);
+ }
+);
+
+// Types
+export interface User {
+ id: string;
+ email: string;
+ first_name: string | null;
+ last_name: string | null;
+ tenant_id: string;
+ status: string;
+ email_verified: boolean;
+ created_at: string;
+}
+
+export interface AuthResponse {
+ user: User;
+ accessToken: string;
+ refreshToken: string;
+}
+
+export interface ApiError {
+ message: string;
+ statusCode: number;
+ error?: string;
+}
+
+// Auth API
+export const authApi = {
+ login: async (email: string, password: string): Promise
=> {
+ const response = await api.post('/auth/login', { email, password });
+ return response.data;
+ },
+
+ register: async (data: {
+ email: string;
+ password: string;
+ first_name?: string;
+ last_name?: string;
+ phone?: string;
+ }): Promise => {
+ const response = await api.post('/auth/register', data);
+ return response.data;
+ },
+
+ logout: async (sessionToken?: string): Promise<{ message: string }> => {
+ const response = await api.post('/auth/logout', { sessionToken });
+ return response.data;
+ },
+
+ logoutAll: async (): Promise<{ message: string }> => {
+ const response = await api.post('/auth/logout-all');
+ return response.data;
+ },
+
+ refresh: async (refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> => {
+ const response = await api.post('/auth/refresh', { refreshToken });
+ return response.data;
+ },
+
+ requestPasswordReset: async (email: string): Promise<{ message: string }> => {
+ const response = await api.post('/auth/password/request-reset', { email });
+ return response.data;
+ },
+
+ resetPassword: async (token: string, password: string): Promise<{ message: string }> => {
+ const response = await api.post('/auth/password/reset', { token, password });
+ return response.data;
+ },
+
+ changePassword: async (
+ currentPassword: string,
+ newPassword: string
+ ): Promise<{ message: string }> => {
+ const response = await api.post('/auth/password/change', {
+ currentPassword,
+ newPassword,
+ });
+ return response.data;
+ },
+
+ verifyEmail: async (token: string): Promise<{ message: string }> => {
+ const response = await api.post('/auth/verify-email', { token });
+ return response.data;
+ },
+
+ me: async (): Promise => {
+ const response = await api.get('/auth/me');
+ return response.data;
+ },
+};
+
+// Users API
+export const usersApi = {
+ list: async (params?: { page?: number; limit?: number }) => {
+ const response = await api.get('/users', { params });
+ return response.data;
+ },
+
+ get: async (id: string) => {
+ const response = await api.get(`/users/${id}`);
+ return response.data;
+ },
+
+ update: async (id: string, data: Partial<{ first_name: string; last_name: string }>) => {
+ const response = await api.patch(`/users/${id}`, data);
+ return response.data;
+ },
+
+ invite: async (email: string, role?: string) => {
+ const response = await api.post('/users/invite', { email, role });
+ return response.data;
+ },
+};
+
+// Billing API
+export const billingApi = {
+ getSubscription: async () => {
+ const response = await api.get('/billing/subscription');
+ return response.data;
+ },
+
+ getSubscriptionStatus: async () => {
+ const response = await api.get('/billing/subscription/status');
+ return response.data;
+ },
+
+ getInvoices: async (params?: { page?: number; limit?: number }) => {
+ const response = await api.get('/billing/invoices', { params });
+ return response.data;
+ },
+
+ getInvoice: async (id: string) => {
+ const response = await api.get(`/billing/invoices/${id}`);
+ return response.data;
+ },
+
+ getPaymentMethods: async () => {
+ const response = await api.get('/billing/payment-methods');
+ return response.data;
+ },
+
+ getSummary: async () => {
+ const response = await api.get('/billing/summary');
+ return response.data;
+ },
+};
+
+// Stripe API
+export const stripeApi = {
+ createCheckoutSession: async (data: {
+ price_id: string;
+ success_url: string;
+ cancel_url: string;
+ }) => {
+ const response = await api.post('/stripe/checkout-session', data);
+ return response.data;
+ },
+
+ createBillingPortal: async (return_url: string) => {
+ const response = await api.post('/stripe/billing-portal', { return_url });
+ return response.data;
+ },
+
+ getCustomer: async () => {
+ const response = await api.get('/stripe/customer');
+ return response.data;
+ },
+
+ createCustomer: async (data: { email: string; name?: string }) => {
+ const response = await api.post('/stripe/customer', data);
+ return response.data;
+ },
+
+ getPrices: async () => {
+ const response = await api.get('/stripe/prices');
+ return response.data;
+ },
+
+ getPaymentMethods: async () => {
+ const response = await api.get('/stripe/payment-methods');
+ return response.data;
+ },
+
+ createSetupIntent: async () => {
+ const response = await api.post('/stripe/setup-intent');
+ return response.data;
+ },
+};
+
+// Notifications API
+export const notificationsApi = {
+ list: async (params?: { page?: number; limit?: number }) => {
+ const response = await api.get('/notifications', { params });
+ return response.data;
+ },
+
+ markAsRead: async (id: string) => {
+ const response = await api.patch(`/notifications/${id}/read`);
+ return response.data;
+ },
+
+ markAllAsRead: async () => {
+ const response = await api.post('/notifications/read-all');
+ return response.data;
+ },
+
+ getUnreadCount: async () => {
+ const response = await api.get('/notifications/unread-count');
+ return response.data;
+ },
+
+ getPreferences: async () => {
+ const response = await api.get('/notifications/preferences');
+ return response.data;
+ },
+
+ updatePreferences: async (preferences: Record) => {
+ const response = await api.patch('/notifications/preferences', preferences);
+ return response.data;
+ },
+};
+
+// Health API
+export const healthApi = {
+ check: async () => {
+ const response = await api.get('/health');
+ return response.data;
+ },
+
+ live: async () => {
+ const response = await api.get('/health/live');
+ return response.data;
+ },
+
+ ready: async () => {
+ const response = await api.get('/health/ready');
+ return response.data;
+ },
+};
+
+// Superadmin API
+export const superadminApi = {
+ // Dashboard
+ getDashboardStats: async () => {
+ const response = await api.get('/superadmin/dashboard/stats');
+ return response.data;
+ },
+
+ // Tenants
+ listTenants: async (params?: {
+ page?: number;
+ limit?: number;
+ search?: string;
+ status?: string;
+ sortBy?: string;
+ sortOrder?: 'ASC' | 'DESC';
+ }) => {
+ const response = await api.get('/superadmin/tenants', { params });
+ return response.data;
+ },
+
+ getTenant: async (id: string) => {
+ const response = await api.get(`/superadmin/tenants/${id}`);
+ return response.data;
+ },
+
+ createTenant: async (data: {
+ name: string;
+ slug: string;
+ domain?: string;
+ logo_url?: string;
+ plan_id?: string;
+ status?: string;
+ }) => {
+ const response = await api.post('/superadmin/tenants', data);
+ return response.data;
+ },
+
+ updateTenant: async (id: string, data: {
+ name?: string;
+ domain?: string;
+ logo_url?: string;
+ plan_id?: string;
+ settings?: Record;
+ metadata?: Record;
+ }) => {
+ const response = await api.patch(`/superadmin/tenants/${id}`, data);
+ return response.data;
+ },
+
+ updateTenantStatus: async (id: string, data: {
+ status: string;
+ reason?: string;
+ }) => {
+ const response = await api.patch(`/superadmin/tenants/${id}/status`, data);
+ return response.data;
+ },
+
+ deleteTenant: async (id: string) => {
+ await api.delete(`/superadmin/tenants/${id}`);
+ },
+
+ getTenantUsers: async (id: string, params?: { page?: number; limit?: number }) => {
+ const response = await api.get(`/superadmin/tenants/${id}/users`, { params });
+ return response.data;
+ },
+
+ // Metrics
+ getMetricsSummary: async () => {
+ const response = await api.get('/superadmin/metrics');
+ return response.data;
+ },
+
+ getTenantGrowth: async (months = 12) => {
+ const response = await api.get('/superadmin/metrics/tenant-growth', { params: { months } });
+ return response.data;
+ },
+
+ getUserGrowth: async (months = 12) => {
+ const response = await api.get('/superadmin/metrics/user-growth', { params: { months } });
+ return response.data;
+ },
+
+ getPlanDistribution: async () => {
+ const response = await api.get('/superadmin/metrics/plan-distribution');
+ return response.data;
+ },
+
+ getStatusDistribution: async () => {
+ const response = await api.get('/superadmin/metrics/status-distribution');
+ return response.data;
+ },
+
+ getTopTenants: async (limit = 10) => {
+ const response = await api.get('/superadmin/metrics/top-tenants', { params: { limit } });
+ return response.data;
+ },
+};
+
+// AI API
+export interface ChatMessage {
+ role: 'system' | 'user' | 'assistant';
+ content: string;
+}
+
+export interface ChatRequest {
+ messages: ChatMessage[];
+ model?: string;
+ temperature?: number;
+ max_tokens?: number;
+}
+
+export interface ChatResponse {
+ id: string;
+ model: string;
+ choices: {
+ index: number;
+ message: ChatMessage;
+ finish_reason: string;
+ }[];
+ usage: {
+ prompt_tokens: number;
+ completion_tokens: number;
+ total_tokens: number;
+ };
+}
+
+export interface AIConfig {
+ id: string;
+ tenant_id: string;
+ provider: string;
+ default_model: string;
+ fallback_model?: string;
+ temperature: number;
+ max_tokens: number;
+ system_prompt?: string;
+ is_enabled: boolean;
+ allow_custom_prompts: boolean;
+ log_conversations: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface AIModel {
+ id: string;
+ name: string;
+ provider: string;
+ context_length: number;
+ pricing: {
+ prompt: number;
+ completion: number;
+ };
+}
+
+export interface AIUsageStats {
+ request_count: number;
+ total_input_tokens: number;
+ total_output_tokens: number;
+ total_tokens: number;
+ total_cost: number;
+ avg_latency_ms: number;
+}
+
+export const aiApi = {
+ chat: async (data: ChatRequest): Promise => {
+ const response = await api.post('/ai/chat', data);
+ return response.data;
+ },
+
+ getModels: async (): Promise => {
+ const response = await api.get('/ai/models');
+ return response.data;
+ },
+
+ getConfig: async (): Promise => {
+ const response = await api.get('/ai/config');
+ return response.data;
+ },
+
+ updateConfig: async (data: Partial): Promise => {
+ const response = await api.patch('/ai/config', data);
+ return response.data;
+ },
+
+ getUsage: async (params?: { page?: number; limit?: number }) => {
+ const response = await api.get('/ai/usage', { params });
+ return response.data;
+ },
+
+ getCurrentUsage: async (): Promise => {
+ const response = await api.get('/ai/usage/current');
+ return response.data;
+ },
+
+ getHealth: async () => {
+ const response = await api.get('/ai/health');
+ return response.data;
+ },
+};
+
+// Storage API
+export interface StorageFile {
+ id: string;
+ filename: string;
+ originalName: string;
+ mimeType: string;
+ sizeBytes: number;
+ folder: string;
+ visibility: 'private' | 'tenant' | 'public';
+ thumbnails: Record;
+ metadata: Record;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface PresignedUrlResponse {
+ uploadId: string;
+ url: string;
+ expiresAt: string;
+ maxSize: number;
+}
+
+export interface FileListResponse {
+ data: StorageFile[];
+ total: number;
+ page: number;
+ limit: number;
+ totalPages: number;
+}
+
+export interface DownloadUrlResponse {
+ url: string;
+ expiresAt: string;
+}
+
+export interface StorageUsage {
+ totalFiles: number;
+ totalBytes: number;
+ maxBytes: number | null;
+ maxFileSize: number | null;
+ usagePercent: number;
+ filesByFolder: Record;
+}
+
+export interface GetUploadUrlRequest {
+ filename: string;
+ mimeType: string;
+ sizeBytes: number;
+ folder?: string;
+ visibility?: 'private' | 'tenant' | 'public';
+}
+
+export interface UpdateFileRequest {
+ folder?: string;
+ visibility?: 'private' | 'tenant' | 'public';
+ metadata?: Record;
+}
+
+export const storageApi = {
+ getUploadUrl: async (data: GetUploadUrlRequest): Promise => {
+ const response = await api.post('/storage/upload-url', data);
+ return response.data;
+ },
+
+ confirmUpload: async (uploadId: string, metadata?: Record): Promise => {
+ const response = await api.post('/storage/confirm', { uploadId, metadata });
+ return response.data;
+ },
+
+ listFiles: async (params?: {
+ page?: number;
+ limit?: number;
+ folder?: string;
+ mimeType?: string;
+ search?: string;
+ }): Promise => {
+ const response = await api.get('/storage/files', { params });
+ return response.data;
+ },
+
+ getFile: async (id: string): Promise => {
+ const response = await api.get(`/storage/files/${id}`);
+ return response.data;
+ },
+
+ getDownloadUrl: async (id: string): Promise => {
+ const response = await api.get(`/storage/files/${id}/download`);
+ return response.data;
+ },
+
+ updateFile: async (id: string, data: UpdateFileRequest): Promise => {
+ const response = await api.patch(`/storage/files/${id}`, data);
+ return response.data;
+ },
+
+ deleteFile: async (id: string): Promise => {
+ await api.delete(`/storage/files/${id}`);
+ },
+
+ getUsage: async (): Promise => {
+ const response = await api.get('/storage/usage');
+ return response.data;
+ },
+
+ // Upload file directly (combines getUploadUrl, PUT to S3, confirmUpload)
+ uploadFile: async (
+ file: File,
+ options?: {
+ folder?: string;
+ visibility?: 'private' | 'tenant' | 'public';
+ metadata?: Record;
+ onProgress?: (progress: number) => void;
+ }
+ ): Promise => {
+ // 1. Get presigned URL
+ const presigned = await storageApi.getUploadUrl({
+ filename: file.name,
+ mimeType: file.type,
+ sizeBytes: file.size,
+ folder: options?.folder,
+ visibility: options?.visibility,
+ });
+
+ // 2. Upload to S3 using presigned URL
+ await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open('PUT', presigned.url, true);
+ xhr.setRequestHeader('Content-Type', file.type);
+
+ if (options?.onProgress) {
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable) {
+ options.onProgress!(Math.round((e.loaded / e.total) * 100));
+ }
+ };
+ }
+
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve();
+ } else {
+ reject(new Error(`Upload failed: ${xhr.statusText}`));
+ }
+ };
+
+ xhr.onerror = () => reject(new Error('Upload failed'));
+ xhr.send(file);
+ });
+
+ // 3. Confirm upload
+ return storageApi.confirmUpload(presigned.uploadId, options?.metadata);
+ },
+};
+
+// Audit API
+export type AuditAction = 'create' | 'update' | 'delete' | 'read' | 'login' | 'logout' | 'export' | 'import';
+
+export type ActivityType =
+ | 'page_view'
+ | 'feature_use'
+ | 'search'
+ | 'download'
+ | 'upload'
+ | 'share'
+ | 'invite'
+ | 'settings_change'
+ | 'subscription_change'
+ | 'payment';
+
+export interface AuditLog {
+ id: string;
+ tenant_id: string;
+ user_id: string | null;
+ action: AuditAction;
+ entity_type: string;
+ entity_id: string | null;
+ old_values: Record | null;
+ new_values: Record | null;
+ changed_fields: string[] | null;
+ ip_address: string | null;
+ user_agent: string | null;
+ endpoint: string | null;
+ http_method: string | null;
+ response_status: number | null;
+ duration_ms: number | null;
+ description: string | null;
+ metadata: Record | null;
+ created_at: string;
+ // Joined user info
+ user?: {
+ id: string;
+ email: string;
+ first_name: string | null;
+ last_name: string | null;
+ };
+}
+
+export interface ActivityLog {
+ id: string;
+ tenant_id: string;
+ user_id: string;
+ activity_type: ActivityType;
+ resource_type: string | null;
+ resource_id: string | null;
+ description: string | null;
+ metadata: Record | null;
+ ip_address: string | null;
+ user_agent: string | null;
+ session_id: string | null;
+ created_at: string;
+ // Joined user info
+ user?: {
+ id: string;
+ email: string;
+ first_name: string | null;
+ last_name: string | null;
+ };
+}
+
+export interface QueryAuditLogsParams {
+ user_id?: string;
+ action?: AuditAction;
+ entity_type?: string;
+ entity_id?: string;
+ from_date?: string;
+ to_date?: string;
+ page?: number;
+ limit?: number;
+}
+
+export interface QueryActivityLogsParams {
+ user_id?: string;
+ activity_type?: ActivityType;
+ resource_type?: string;
+ from_date?: string;
+ to_date?: string;
+ page?: number;
+ limit?: number;
+}
+
+export interface CreateActivityLogRequest {
+ activity_type: ActivityType;
+ resource_type?: string;
+ resource_id?: string;
+ description?: string;
+ metadata?: Record;
+}
+
+export interface PaginatedAuditLogs {
+ items: AuditLog[];
+ total: number;
+ page: number;
+ limit: number;
+ totalPages: number;
+}
+
+export interface PaginatedActivityLogs {
+ items: ActivityLog[];
+ total: number;
+ page: number;
+ limit: number;
+ totalPages: number;
+}
+
+export interface AuditStats {
+ total_logs: number;
+ by_action: Record;
+ by_entity_type: Record;
+ by_day: { date: string; count: number }[];
+ top_users: { user_id: string; count: number; user?: { email: string; first_name: string | null } }[];
+}
+
+export interface ActivitySummary {
+ total_activities: number;
+ by_type: Record;
+ by_day: { date: string; count: number }[];
+ recent_activities: ActivityLog[];
+}
+
+export const auditApi = {
+ // Audit Logs
+ queryLogs: async (params?: QueryAuditLogsParams): Promise => {
+ const response = await api.get('/audit/logs', { params });
+ return response.data;
+ },
+
+ getLog: async (id: string): Promise => {
+ const response = await api.get(`/audit/logs/${id}`);
+ return response.data;
+ },
+
+ getEntityHistory: async (entityType: string, entityId: string): Promise => {
+ const response = await api.get(`/audit/entity/${entityType}/${entityId}`);
+ return response.data;
+ },
+
+ getStats: async (days?: number): Promise => {
+ const response = await api.get('/audit/stats', { params: { days } });
+ return response.data;
+ },
+
+ // Activity Logs
+ queryActivities: async (params?: QueryActivityLogsParams): Promise => {
+ const response = await api.get('/audit/activities', { params });
+ return response.data;
+ },
+
+ createActivity: async (data: CreateActivityLogRequest): Promise => {
+ const response = await api.post('/audit/activities', data);
+ return response.data;
+ },
+
+ getActivitySummary: async (days?: number): Promise => {
+ const response = await api.get('/audit/activities/summary', { params: { days } });
+ return response.data;
+ },
+
+ getUserActivitySummary: async (userId: string, days?: number): Promise => {
+ const response = await api.get(`/audit/activities/user/${userId}`, { params: { days } });
+ return response.data;
+ },
+};
+
+// Webhooks API
+export type DeliveryStatus = 'pending' | 'delivered' | 'failed' | 'retrying';
+
+export interface Webhook {
+ id: string;
+ name: string;
+ description: string | null;
+ url: string;
+ events: string[];
+ headers: Record;
+ isActive: boolean;
+ createdAt: string;
+ updatedAt: string;
+ secret?: string;
+ stats?: WebhookStats;
+}
+
+export interface WebhookStats {
+ totalDeliveries: number;
+ successfulDeliveries: number;
+ failedDeliveries: number;
+ pendingDeliveries: number;
+ successRate: number;
+ lastDeliveryAt: string | null;
+}
+
+export interface WebhookDelivery {
+ id: string;
+ webhookId: string;
+ eventType: string;
+ payload: Record;
+ status: DeliveryStatus;
+ responseStatus: number | null;
+ responseBody: string | null;
+ attempt: number;
+ maxAttempts: number;
+ nextRetryAt: string | null;
+ lastError: string | null;
+ createdAt: string;
+ deliveredAt: string | null;
+}
+
+export interface WebhookEvent {
+ name: string;
+ description: string;
+}
+
+export interface CreateWebhookRequest {
+ name: string;
+ description?: string;
+ url: string;
+ events: string[];
+ headers?: Record;
+}
+
+export interface UpdateWebhookRequest {
+ name?: string;
+ description?: string;
+ url?: string;
+ events?: string[];
+ headers?: Record;
+ isActive?: boolean;
+}
+
+export interface PaginatedDeliveries {
+ items: WebhookDelivery[];
+ total: number;
+ page: number;
+ limit: number;
+ totalPages: number;
+}
+
+export const webhooksApi = {
+ // Get available events
+ getEvents: async (): Promise<{ events: WebhookEvent[] }> => {
+ const response = await api.get<{ events: WebhookEvent[] }>('/webhooks/events');
+ return response.data;
+ },
+
+ // List all webhooks
+ list: async (): Promise => {
+ const response = await api.get('/webhooks');
+ return response.data;
+ },
+
+ // Get single webhook
+ get: async (id: string): Promise => {
+ const response = await api.get(`/webhooks/${id}`);
+ return response.data;
+ },
+
+ // Create webhook
+ create: async (data: CreateWebhookRequest): Promise => {
+ const response = await api.post('/webhooks', data);
+ return response.data;
+ },
+
+ // Update webhook
+ update: async (id: string, data: UpdateWebhookRequest): Promise => {
+ const response = await api.put(`/webhooks/${id}`, data);
+ return response.data;
+ },
+
+ // Delete webhook
+ delete: async (id: string): Promise => {
+ await api.delete(`/webhooks/${id}`);
+ },
+
+ // Regenerate secret
+ regenerateSecret: async (id: string): Promise<{ secret: string }> => {
+ const response = await api.post<{ secret: string }>(`/webhooks/${id}/regenerate-secret`);
+ return response.data;
+ },
+
+ // Test webhook
+ test: async (id: string, payload?: { eventType?: string; payload?: Record }): Promise => {
+ const response = await api.post(`/webhooks/${id}/test`, payload || {});
+ return response.data;
+ },
+
+ // Get deliveries
+ getDeliveries: async (
+ webhookId: string,
+ params?: { status?: DeliveryStatus; eventType?: string; page?: number; limit?: number }
+ ): Promise => {
+ const response = await api.get(`/webhooks/${webhookId}/deliveries`, { params });
+ return response.data;
+ },
+
+ // Retry delivery
+ retryDelivery: async (webhookId: string, deliveryId: string): Promise => {
+ const response = await api.post(`/webhooks/${webhookId}/deliveries/${deliveryId}/retry`);
+ return response.data;
+ },
+};
+
+// Feature Flags API
+export type FlagType = 'boolean' | 'string' | 'number' | 'json';
+export type FlagScope = 'global' | 'tenant' | 'user' | 'plan';
+
+export interface FeatureFlag {
+ id: string;
+ key: string;
+ name: string;
+ description: string | null;
+ flag_type: FlagType;
+ scope: FlagScope;
+ default_value: any;
+ is_enabled: boolean;
+ targeting_rules: Record | null;
+ rollout_percentage: number | null;
+ category: string | null;
+ metadata: Record | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface TenantFlag {
+ id: string;
+ tenant_id: string;
+ flag_id: string;
+ flag?: FeatureFlag;
+ is_enabled: boolean;
+ value: any;
+ metadata: Record | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface UserFlag {
+ id: string;
+ user_id: string;
+ flag_id: string;
+ flag?: FeatureFlag;
+ is_enabled: boolean;
+ value: any;
+ metadata: Record | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface CreateFlagRequest {
+ key: string;
+ name: string;
+ description?: string;
+ flag_type?: FlagType;
+ scope?: FlagScope;
+ default_value?: any;
+ is_enabled?: boolean;
+ targeting_rules?: Record;
+ rollout_percentage?: number;
+ category?: string;
+ metadata?: Record;
+}
+
+export interface UpdateFlagRequest {
+ name?: string;
+ description?: string;
+ flag_type?: FlagType;
+ scope?: FlagScope;
+ default_value?: any;
+ is_enabled?: boolean;
+ targeting_rules?: Record;
+ rollout_percentage?: number;
+ category?: string;
+ metadata?: Record;
+}
+
+export interface SetTenantFlagRequest {
+ flag_id: string;
+ is_enabled?: boolean;
+ value?: any;
+ metadata?: Record;
+}
+
+export interface SetUserFlagRequest {
+ flag_id: string;
+ user_id: string;
+ is_enabled?: boolean;
+ value?: any;
+ metadata?: Record;
+}
+
+export interface FlagEvaluation {
+ key: string;
+ enabled: boolean;
+ value: any;
+ source: 'default' | 'tenant' | 'user' | 'targeting';
+}
+
+export const featureFlagsApi = {
+ // Flag Management
+ list: async (): Promise => {
+ const response = await api.get('/feature-flags');
+ return response.data;
+ },
+
+ get: async (id: string): Promise => {
+ const response = await api.get(`/feature-flags/${id}`);
+ return response.data;
+ },
+
+ create: async (data: CreateFlagRequest): Promise => {
+ const response = await api.post('/feature-flags', data);
+ return response.data;
+ },
+
+ update: async (id: string, data: UpdateFlagRequest): Promise