- Build complete Next.js CRM for commercial real estate - Add authentication with JWT sessions and role-based access - Add GoHighLevel API integration for contacts, conversations, opportunities - Add AI-powered Control Center with tool calling - Add Setup page with onboarding checklist (/setup) - Add sidebar navigation with Setup menu item - Fix type errors in onboarding API, GHL services, and control center tools - Add Prisma schema with SQLite for local development - Add UI components with clay morphism design system Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1128 lines
38 KiB
TypeScript
1128 lines
38 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import {
|
|
Shield,
|
|
Users,
|
|
Settings,
|
|
BarChart2,
|
|
DollarSign,
|
|
AlertTriangle,
|
|
Clock,
|
|
Search,
|
|
ChevronUp,
|
|
ChevronDown,
|
|
CheckCircle,
|
|
XCircle,
|
|
Bell,
|
|
Eye,
|
|
EyeOff,
|
|
Check,
|
|
X,
|
|
Loader2,
|
|
Save,
|
|
RefreshCw,
|
|
UserPlus,
|
|
} from 'lucide-react';
|
|
import { api } from '@/lib/api/client';
|
|
import { Role, AdminDashboardStats, AdminUserView, SystemSettings } from '@/types';
|
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
|
import { PERMISSIONS } from '@/lib/auth/roles';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
type TabType = 'overview' | 'users' | 'settings';
|
|
type SortField = 'name' | 'email' | 'brokerage' | 'gci' | 'createdAt' | 'setupCompletion';
|
|
type SortDirection = 'asc' | 'desc';
|
|
type FilterType = 'all' | 'highGCI' | 'incompleteSetup' | 'completeSetup' | 'pendingDFY';
|
|
|
|
interface RecentSignup {
|
|
id: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
createdAt: Date;
|
|
gciRange?: string;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helper Functions
|
|
// =============================================================================
|
|
|
|
const parseGCI = (gciString?: string): number => {
|
|
if (!gciString) return 0;
|
|
const cleaned = gciString.replace(/[$,]/g, '');
|
|
return parseFloat(cleaned) || 0;
|
|
};
|
|
|
|
const isHighGCI = (gciRange?: string): boolean => {
|
|
return parseGCI(gciRange) >= 100000;
|
|
};
|
|
|
|
const getSetupCompletionCount = (status?: AdminUserView['setupStatus']): number => {
|
|
if (!status) return 0;
|
|
return [
|
|
status.smsConfigured,
|
|
status.emailConfigured,
|
|
status.contactsImported,
|
|
status.campaignsSetup
|
|
].filter(Boolean).length;
|
|
};
|
|
|
|
const hasIncompleteSetup = (status?: AdminUserView['setupStatus']): boolean => {
|
|
return getSetupCompletionCount(status) < 4;
|
|
};
|
|
|
|
// =============================================================================
|
|
// Tab Navigation Component
|
|
// =============================================================================
|
|
|
|
function TabNavigation({
|
|
activeTab,
|
|
onTabChange
|
|
}: {
|
|
activeTab: TabType;
|
|
onTabChange: (tab: TabType) => void;
|
|
}) {
|
|
const tabs = [
|
|
{ id: 'overview' as TabType, label: 'Overview', icon: BarChart2 },
|
|
{ id: 'users' as TabType, label: 'Users', icon: Users },
|
|
{ id: 'settings' as TabType, label: 'Settings', icon: Settings },
|
|
];
|
|
|
|
return (
|
|
<div className="flex gap-2 mb-6">
|
|
{tabs.map((tab) => {
|
|
const Icon = tab.icon;
|
|
const isActive = activeTab === tab.id;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => onTabChange(tab.id)}
|
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium transition-all ${
|
|
isActive
|
|
? 'bg-primary-600 text-white shadow-[4px_4px_8px_rgba(0,0,0,0.15),-2px_-2px_6px_rgba(255,255,255,0.8)]'
|
|
: 'bg-white text-slate-600 hover:bg-slate-100 shadow-[4px_4px_8px_rgba(0,0,0,0.1),-2px_-2px_6px_rgba(255,255,255,0.9)]'
|
|
}`}
|
|
>
|
|
<Icon size={18} />
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Stats Card Component
|
|
// =============================================================================
|
|
|
|
function StatCard({
|
|
icon,
|
|
label,
|
|
value,
|
|
iconBgClass = 'bg-primary-100',
|
|
iconColorClass = 'text-primary-600',
|
|
isLoading = false
|
|
}: {
|
|
icon: React.ReactNode;
|
|
label: string;
|
|
value: string | number;
|
|
iconBgClass?: string;
|
|
iconColorClass?: string;
|
|
isLoading?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="clay-card">
|
|
<div className="flex items-center gap-4">
|
|
<div className={`${iconBgClass} ${iconColorClass} p-4 rounded-2xl`}>
|
|
{icon}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-slate-500">{label}</p>
|
|
{isLoading ? (
|
|
<div className="h-8 w-16 bg-slate-200 rounded animate-pulse" />
|
|
) : (
|
|
<p className="text-2xl font-bold text-slate-900">{value}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Overview Tab Component
|
|
// =============================================================================
|
|
|
|
function OverviewTab({
|
|
stats,
|
|
recentSignups,
|
|
isLoading
|
|
}: {
|
|
stats: AdminDashboardStats | null;
|
|
recentSignups: RecentSignup[];
|
|
isLoading: boolean;
|
|
}) {
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<StatCard
|
|
icon={<Users size={24} />}
|
|
label="Total Users"
|
|
value={stats?.totalUsers ?? 0}
|
|
iconBgClass="bg-primary-100"
|
|
iconColorClass="text-primary-600"
|
|
isLoading={isLoading}
|
|
/>
|
|
<StatCard
|
|
icon={<DollarSign size={24} />}
|
|
label="High GCI Users ($100K+)"
|
|
value={stats?.highGCIUsers ?? 0}
|
|
iconBgClass="bg-yellow-100"
|
|
iconColorClass="text-yellow-600"
|
|
isLoading={isLoading}
|
|
/>
|
|
<StatCard
|
|
icon={<AlertTriangle size={24} />}
|
|
label="Incomplete Setup"
|
|
value={stats?.incompleteSetup ?? 0}
|
|
iconBgClass="bg-red-100"
|
|
iconColorClass="text-red-600"
|
|
isLoading={isLoading}
|
|
/>
|
|
<StatCard
|
|
icon={<Clock size={24} />}
|
|
label="Pending DFY"
|
|
value={stats?.dfyRequestsPending ?? 0}
|
|
iconBgClass="bg-purple-100"
|
|
iconColorClass="text-purple-600"
|
|
isLoading={isLoading}
|
|
/>
|
|
</div>
|
|
|
|
{/* Recent Signups */}
|
|
<div className="clay-card">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-slate-900">Recent Signups</h3>
|
|
<span className="text-sm text-slate-500">Last 7 days</span>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="space-y-3">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="flex items-center gap-4 p-3 rounded-xl bg-slate-50">
|
|
<div className="w-10 h-10 rounded-full bg-slate-200 animate-pulse" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-4 w-32 bg-slate-200 rounded animate-pulse" />
|
|
<div className="h-3 w-48 bg-slate-200 rounded animate-pulse" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : recentSignups.length === 0 ? (
|
|
<div className="text-center py-8">
|
|
<UserPlus className="mx-auto text-slate-300 mb-3" size={48} />
|
|
<p className="text-slate-500">No recent signups</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{recentSignups.map((user) => (
|
|
<div
|
|
key={user.id}
|
|
className="flex items-center justify-between p-3 rounded-xl bg-slate-50 hover:bg-slate-100 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 font-semibold">
|
|
{user.firstName?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-slate-900">
|
|
{user.firstName} {user.lastName}
|
|
</p>
|
|
<p className="text-sm text-slate-500">{user.email}</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-sm text-slate-600">
|
|
{new Date(user.createdAt).toLocaleDateString()}
|
|
</p>
|
|
{user.gciRange && isHighGCI(user.gciRange) && (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700">
|
|
High GCI
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Users Tab Component
|
|
// =============================================================================
|
|
|
|
function UsersTab({
|
|
users,
|
|
isLoading,
|
|
onRefresh
|
|
}: {
|
|
users: AdminUserView[];
|
|
isLoading: boolean;
|
|
onRefresh: () => void;
|
|
}) {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [sortField, setSortField] = useState<SortField>('createdAt');
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
|
const [filterType, setFilterType] = useState<FilterType>('all');
|
|
|
|
const handleSort = (field: SortField) => {
|
|
if (sortField === field) {
|
|
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
|
} else {
|
|
setSortField(field);
|
|
setSortDirection('asc');
|
|
}
|
|
};
|
|
|
|
const filteredAndSortedUsers = useMemo(() => {
|
|
let result = [...users];
|
|
|
|
// Apply search filter
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
result = result.filter(
|
|
(user) =>
|
|
user.firstName?.toLowerCase().includes(query) ||
|
|
user.lastName?.toLowerCase().includes(query) ||
|
|
user.email.toLowerCase().includes(query) ||
|
|
user.brokerage?.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
// Apply type filter
|
|
switch (filterType) {
|
|
case 'highGCI':
|
|
result = result.filter((user) => isHighGCI(user.gciRange));
|
|
break;
|
|
case 'incompleteSetup':
|
|
result = result.filter((user) => hasIncompleteSetup(user.setupStatus));
|
|
break;
|
|
case 'completeSetup':
|
|
result = result.filter((user) => !hasIncompleteSetup(user.setupStatus));
|
|
break;
|
|
}
|
|
|
|
// Apply sorting
|
|
result.sort((a, b) => {
|
|
let comparison = 0;
|
|
|
|
switch (sortField) {
|
|
case 'name':
|
|
comparison = `${a.firstName} ${a.lastName}`.localeCompare(
|
|
`${b.firstName} ${b.lastName}`
|
|
);
|
|
break;
|
|
case 'email':
|
|
comparison = a.email.localeCompare(b.email);
|
|
break;
|
|
case 'brokerage':
|
|
comparison = (a.brokerage || '').localeCompare(b.brokerage || '');
|
|
break;
|
|
case 'gci':
|
|
comparison = parseGCI(a.gciRange) - parseGCI(b.gciRange);
|
|
break;
|
|
case 'createdAt':
|
|
comparison =
|
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
break;
|
|
case 'setupCompletion':
|
|
comparison =
|
|
getSetupCompletionCount(a.setupStatus) -
|
|
getSetupCompletionCount(b.setupStatus);
|
|
break;
|
|
}
|
|
|
|
return sortDirection === 'asc' ? comparison : -comparison;
|
|
});
|
|
|
|
return result;
|
|
}, [users, searchQuery, sortField, sortDirection, filterType]);
|
|
|
|
const SortIcon = ({ field }: { field: SortField }) => {
|
|
if (sortField !== field) return null;
|
|
return sortDirection === 'asc' ? (
|
|
<ChevronUp size={16} className="inline ml-1" />
|
|
) : (
|
|
<ChevronDown size={16} className="inline ml-1" />
|
|
);
|
|
};
|
|
|
|
const StatusBadge = ({
|
|
configured,
|
|
label
|
|
}: {
|
|
configured: boolean;
|
|
label: string;
|
|
}) => (
|
|
<span
|
|
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
|
configured
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-red-100 text-red-700'
|
|
}`}
|
|
>
|
|
{configured ? <CheckCircle size={12} /> : <XCircle size={12} />}
|
|
{label}
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Filters and Search */}
|
|
<div className="clay-card">
|
|
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
|
|
{/* Search */}
|
|
<div className="relative flex-1 max-w-md">
|
|
<Search
|
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400"
|
|
size={20}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name, email, or brokerage..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-3 rounded-xl bg-slate-50 border-none shadow-inner focus:ring-2 focus:ring-primary-500 outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Filter Buttons */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
<button
|
|
onClick={() => setFilterType('all')}
|
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
|
filterType === 'all'
|
|
? 'bg-primary-600 text-white'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
All
|
|
</button>
|
|
<button
|
|
onClick={() => setFilterType('highGCI')}
|
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
|
|
filterType === 'highGCI'
|
|
? 'bg-yellow-500 text-white'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
<DollarSign size={14} /> High GCI
|
|
</button>
|
|
<button
|
|
onClick={() => setFilterType('incompleteSetup')}
|
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
|
|
filterType === 'incompleteSetup'
|
|
? 'bg-red-500 text-white'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
<AlertTriangle size={14} /> Incomplete
|
|
</button>
|
|
<button
|
|
onClick={() => setFilterType('completeSetup')}
|
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
|
|
filterType === 'completeSetup'
|
|
? 'bg-green-500 text-white'
|
|
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
<CheckCircle size={14} /> Complete
|
|
</button>
|
|
<button
|
|
onClick={onRefresh}
|
|
className="px-4 py-2 rounded-xl text-sm font-medium transition-all bg-slate-100 text-slate-600 hover:bg-slate-200 flex items-center gap-1"
|
|
>
|
|
<RefreshCw size={14} /> Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Users Table */}
|
|
<div className="clay-card overflow-hidden">
|
|
{isLoading ? (
|
|
<div className="space-y-4 p-4">
|
|
{[1, 2, 3, 4, 5].map((i) => (
|
|
<div key={i} className="flex items-center gap-4">
|
|
<div className="w-10 h-10 rounded-full bg-slate-200 animate-pulse" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-4 w-48 bg-slate-200 rounded animate-pulse" />
|
|
<div className="h-3 w-32 bg-slate-200 rounded animate-pulse" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-slate-200">
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-slate-600 cursor-pointer hover:text-primary-600"
|
|
onClick={() => handleSort('name')}
|
|
>
|
|
Name <SortIcon field="name" />
|
|
</th>
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-slate-600 cursor-pointer hover:text-primary-600"
|
|
onClick={() => handleSort('email')}
|
|
>
|
|
Email <SortIcon field="email" />
|
|
</th>
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-slate-600 cursor-pointer hover:text-primary-600"
|
|
onClick={() => handleSort('brokerage')}
|
|
>
|
|
Brokerage <SortIcon field="brokerage" />
|
|
</th>
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-slate-600 cursor-pointer hover:text-primary-600"
|
|
onClick={() => handleSort('gci')}
|
|
>
|
|
GCI Range <SortIcon field="gci" />
|
|
</th>
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-slate-600 cursor-pointer hover:text-primary-600"
|
|
onClick={() => handleSort('setupCompletion')}
|
|
>
|
|
Setup Status <SortIcon field="setupCompletion" />
|
|
</th>
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-slate-600 cursor-pointer hover:text-primary-600"
|
|
onClick={() => handleSort('createdAt')}
|
|
>
|
|
Joined <SortIcon field="createdAt" />
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredAndSortedUsers.map((user) => {
|
|
const userIsHighGCI = isHighGCI(user.gciRange);
|
|
const userHasIncompleteSetup = hasIncompleteSetup(user.setupStatus);
|
|
|
|
return (
|
|
<tr
|
|
key={user.id}
|
|
className={`border-b border-slate-100 transition-colors hover:bg-slate-50 ${
|
|
userIsHighGCI ? 'bg-yellow-50/50' : ''
|
|
} ${
|
|
userHasIncompleteSetup && !userIsHighGCI
|
|
? 'bg-red-50/30'
|
|
: ''
|
|
}`}
|
|
>
|
|
<td className="py-4 px-4">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center text-primary-600 text-sm font-medium">
|
|
{user.firstName?.[0]?.toUpperCase() ||
|
|
user.email[0].toUpperCase()}
|
|
</div>
|
|
<span className="font-medium text-slate-800">
|
|
{user.firstName} {user.lastName}
|
|
</span>
|
|
{userIsHighGCI && (
|
|
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 text-xs rounded-full font-medium">
|
|
High GCI
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-4 px-4 text-slate-600">{user.email}</td>
|
|
<td className="py-4 px-4 text-slate-600">
|
|
{user.brokerage || (
|
|
<span className="text-slate-400 italic">Not set</span>
|
|
)}
|
|
</td>
|
|
<td className="py-4 px-4">
|
|
<span
|
|
className={`font-semibold ${
|
|
userIsHighGCI ? 'text-yellow-600' : 'text-slate-800'
|
|
}`}
|
|
>
|
|
{user.gciRange || 'N/A'}
|
|
</span>
|
|
</td>
|
|
<td className="py-4 px-4">
|
|
<div className="flex flex-wrap gap-1">
|
|
<StatusBadge
|
|
configured={user.setupStatus?.smsConfigured ?? false}
|
|
label="SMS"
|
|
/>
|
|
<StatusBadge
|
|
configured={user.setupStatus?.emailConfigured ?? false}
|
|
label="Email"
|
|
/>
|
|
<StatusBadge
|
|
configured={user.setupStatus?.contactsImported ?? false}
|
|
label="Contacts"
|
|
/>
|
|
<StatusBadge
|
|
configured={user.setupStatus?.campaignsSetup ?? false}
|
|
label="Campaigns"
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td className="py-4 px-4 text-slate-600">
|
|
{new Date(user.createdAt).toLocaleDateString()}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
|
|
{filteredAndSortedUsers.length === 0 && (
|
|
<div className="text-center py-12 text-slate-500">
|
|
<Users size={48} className="mx-auto mb-4 text-slate-300" />
|
|
<p className="text-lg font-medium">No users found</p>
|
|
<p className="text-sm mt-1">
|
|
Try adjusting your search or filter criteria
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Results Summary */}
|
|
<div className="text-center text-slate-500 text-sm">
|
|
Showing {filteredAndSortedUsers.length} of {users.length} users
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Settings Tab Component
|
|
// =============================================================================
|
|
|
|
function SettingsTab({
|
|
initialSettings,
|
|
onSave,
|
|
onTestGHL,
|
|
onTestStripe,
|
|
isLoading
|
|
}: {
|
|
initialSettings: Record<string, string>;
|
|
onSave: (settings: Record<string, string>) => Promise<void>;
|
|
onTestGHL: () => Promise<{ success: boolean; error?: string }>;
|
|
onTestStripe: () => Promise<{ success: boolean; error?: string }>;
|
|
isLoading: boolean;
|
|
}) {
|
|
const [settings, setSettings] = useState(initialSettings);
|
|
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
|
const [saving, setSaving] = useState(false);
|
|
const [testingGHL, setTestingGHL] = useState(false);
|
|
const [testingStripe, setTestingStripe] = useState(false);
|
|
const [ghlStatus, setGhlStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
|
const [stripeStatus, setStripeStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
|
|
|
useEffect(() => {
|
|
setSettings(initialSettings);
|
|
}, [initialSettings]);
|
|
|
|
const handleChange = (key: string, value: string) => {
|
|
setSettings((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await onSave(settings);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleTestGHL = async () => {
|
|
setTestingGHL(true);
|
|
setGhlStatus('idle');
|
|
try {
|
|
const result = await onTestGHL();
|
|
setGhlStatus(result.success ? 'success' : 'error');
|
|
} finally {
|
|
setTestingGHL(false);
|
|
}
|
|
};
|
|
|
|
const handleTestStripe = async () => {
|
|
setTestingStripe(true);
|
|
setStripeStatus('idle');
|
|
try {
|
|
const result = await onTestStripe();
|
|
setStripeStatus(result.success ? 'success' : 'error');
|
|
} finally {
|
|
setTestingStripe(false);
|
|
}
|
|
};
|
|
|
|
const toggleSecret = (key: string) => {
|
|
setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
};
|
|
|
|
const renderSecretInput = (key: string, label: string, placeholder: string) => (
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">{label}</label>
|
|
<div className="relative">
|
|
<input
|
|
type={showSecrets[key] ? 'text' : 'password'}
|
|
value={settings[key] || ''}
|
|
onChange={(e) => handleChange(key, e.target.value)}
|
|
placeholder={placeholder}
|
|
className="w-full p-3 pr-10 rounded-xl bg-slate-50 border-none shadow-inner focus:ring-2 focus:ring-primary-500 outline-none"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleSecret(key)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
|
>
|
|
{showSecrets[key] ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const renderTextInput = (key: string, label: string, placeholder: string) => (
|
|
<div className="space-y-2">
|
|
<label className="block text-sm font-medium text-slate-700">{label}</label>
|
|
<input
|
|
type="text"
|
|
value={settings[key] || ''}
|
|
onChange={(e) => handleChange(key, e.target.value)}
|
|
placeholder={placeholder}
|
|
className="w-full p-3 rounded-xl bg-slate-50 border-none shadow-inner focus:ring-2 focus:ring-primary-500 outline-none"
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="clay-card">
|
|
<div className="h-6 w-48 bg-slate-200 rounded animate-pulse mb-6" />
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<div className="h-4 w-24 bg-slate-200 rounded animate-pulse" />
|
|
<div className="h-12 bg-slate-200 rounded-xl animate-pulse" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="h-4 w-24 bg-slate-200 rounded animate-pulse" />
|
|
<div className="h-12 bg-slate-200 rounded-xl animate-pulse" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{/* GHL Configuration */}
|
|
<div className="clay-card">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-lg font-bold text-slate-900">
|
|
GoHighLevel Configuration
|
|
</h3>
|
|
<button
|
|
onClick={handleTestGHL}
|
|
disabled={testingGHL}
|
|
className="btn-secondary flex items-center gap-2"
|
|
>
|
|
{testingGHL ? (
|
|
<Loader2 className="animate-spin" size={16} />
|
|
) : ghlStatus === 'success' ? (
|
|
<Check className="text-green-600" size={16} />
|
|
) : ghlStatus === 'error' ? (
|
|
<X className="text-red-600" size={16} />
|
|
) : null}
|
|
Test Connection
|
|
</button>
|
|
</div>
|
|
<p className="text-sm text-slate-500 mb-4">
|
|
Configure your GHL Private Integration OAuth credentials. Create a Private Integration in GHL under Settings → Integrations → Private Integrations.
|
|
</p>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{renderTextInput(
|
|
'ghlClientId',
|
|
'Client ID',
|
|
'OAuth Client ID from Private Integration'
|
|
)}
|
|
{renderSecretInput(
|
|
'ghlClientSecret',
|
|
'Client Secret',
|
|
'OAuth Client Secret from Private Integration'
|
|
)}
|
|
{renderSecretInput(
|
|
'ghlAccessToken',
|
|
'Access Token',
|
|
'OAuth Access Token (from authorization)'
|
|
)}
|
|
{renderSecretInput(
|
|
'ghlRefreshToken',
|
|
'Refresh Token',
|
|
'OAuth Refresh Token (for auto-renewal)'
|
|
)}
|
|
{renderTextInput(
|
|
'ghlLocationId',
|
|
'Location ID',
|
|
'Your GHL Location/Sub-account ID'
|
|
)}
|
|
{renderSecretInput(
|
|
'ghlWebhookSecret',
|
|
'Webhook Secret',
|
|
'Secret for validating webhooks (optional)'
|
|
)}
|
|
</div>
|
|
|
|
{/* Legacy/Agency Settings (collapsible or secondary) */}
|
|
<details className="mt-6">
|
|
<summary className="text-sm font-medium text-slate-600 cursor-pointer hover:text-slate-800">
|
|
Agency Settings (for user provisioning)
|
|
</summary>
|
|
<div className="grid gap-4 md:grid-cols-2 mt-4 pt-4 border-t border-slate-200">
|
|
{renderSecretInput(
|
|
'ghlAgencyApiKey',
|
|
'Agency API Key',
|
|
'For creating sub-accounts'
|
|
)}
|
|
{renderTextInput('ghlAgencyId', 'Agency ID', 'Your GHL Agency/Company ID')}
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
{/* Tag Configuration */}
|
|
<div className="clay-card">
|
|
<h3 className="text-lg font-bold text-slate-900 mb-6">Tag Configuration</h3>
|
|
<p className="text-sm text-slate-500 mb-4">
|
|
These tags will be applied to contacts in the owner's GHL account to
|
|
trigger automations.
|
|
</p>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
{renderTextInput('tagHighGCI', 'High GCI Tag', 'e.g., high-gci-lead')}
|
|
{renderTextInput(
|
|
'tagOnboardingComplete',
|
|
'Onboarding Complete Tag',
|
|
'e.g., onboarding-done'
|
|
)}
|
|
{renderTextInput(
|
|
'tagDFYRequested',
|
|
'DFY Requested Tag',
|
|
'e.g., dfy-requested'
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stripe Configuration */}
|
|
<div className="clay-card">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-lg font-bold text-slate-900">Stripe Configuration</h3>
|
|
<button
|
|
onClick={handleTestStripe}
|
|
disabled={testingStripe}
|
|
className="btn-secondary flex items-center gap-2"
|
|
>
|
|
{testingStripe ? (
|
|
<Loader2 className="animate-spin" size={16} />
|
|
) : stripeStatus === 'success' ? (
|
|
<Check className="text-green-600" size={16} />
|
|
) : stripeStatus === 'error' ? (
|
|
<X className="text-red-600" size={16} />
|
|
) : null}
|
|
Test Connection
|
|
</button>
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{renderSecretInput(
|
|
'stripeSecretKey',
|
|
'Stripe Secret Key',
|
|
'sk_live_... or sk_test_...'
|
|
)}
|
|
{renderSecretInput(
|
|
'stripeWebhookSecret',
|
|
'Stripe Webhook Secret',
|
|
'whsec_...'
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* DFY Pricing Configuration */}
|
|
<div className="clay-card">
|
|
<h3 className="text-lg font-bold text-slate-900 mb-6">
|
|
DFY Pricing (in cents)
|
|
</h3>
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
{renderTextInput(
|
|
'dfyPriceFullSetup',
|
|
'Full Setup Price',
|
|
'e.g., 29700 for $297'
|
|
)}
|
|
{renderTextInput(
|
|
'dfyPriceSmsSetup',
|
|
'SMS Setup Price',
|
|
'e.g., 9900 for $99'
|
|
)}
|
|
{renderTextInput(
|
|
'dfyPriceEmailSetup',
|
|
'Email Setup Price',
|
|
'e.g., 9900 for $99'
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Calendly Links */}
|
|
<div className="clay-card">
|
|
<h3 className="text-lg font-bold text-slate-900 mb-6">Calendly Links</h3>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{renderTextInput(
|
|
'calendlyCoachingLink',
|
|
'Coaching Calendly Link',
|
|
'https://calendly.com/...'
|
|
)}
|
|
{renderTextInput(
|
|
'calendlyTeamLink',
|
|
'Join Team Calendly Link',
|
|
'https://calendly.com/...'
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notifications */}
|
|
<div className="clay-card">
|
|
<h3 className="text-lg font-bold text-slate-900 mb-6">Notifications</h3>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{renderTextInput(
|
|
'notificationEmail',
|
|
'Notification Email',
|
|
'Email for high-GCI alerts'
|
|
)}
|
|
{renderTextInput(
|
|
'slackWebhookUrl',
|
|
'Slack Webhook URL',
|
|
'Optional: Slack notifications'
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ClickUp Integration */}
|
|
<div className="clay-card">
|
|
<h3 className="text-lg font-bold text-slate-900 mb-6">
|
|
ClickUp Integration (for DFY tasks)
|
|
</h3>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{renderSecretInput(
|
|
'clickupApiKey',
|
|
'ClickUp API Key',
|
|
'Enter your ClickUp API key'
|
|
)}
|
|
{renderTextInput('clickupListId', 'ClickUp List ID', 'List ID for DFY tasks')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* AI Configuration */}
|
|
<div className="clay-card">
|
|
<h3 className="text-lg font-bold text-slate-900 mb-6">
|
|
AI Configuration
|
|
</h3>
|
|
<p className="text-sm text-slate-500 mb-4">
|
|
Configure AI service API keys for intelligent features and automations.
|
|
</p>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{renderSecretInput(
|
|
'claudeApiKey',
|
|
'Claude API Key',
|
|
'sk-ant-... (Anthropic API key)'
|
|
)}
|
|
{renderSecretInput(
|
|
'openaiApiKey',
|
|
'OpenAI API Key',
|
|
'sk-... (OpenAI API key)'
|
|
)}
|
|
{renderTextInput(
|
|
'mcpServerUrl',
|
|
'MCP Server URL',
|
|
'https://your-mcp-server.com'
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save Button */}
|
|
<div className="flex justify-end">
|
|
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
|
|
{saving ? (
|
|
<Loader2 className="animate-spin" size={16} />
|
|
) : (
|
|
<Save size={16} />
|
|
)}
|
|
Save Settings
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Main Admin Page Component
|
|
// =============================================================================
|
|
|
|
export default function AdminPage() {
|
|
const router = useRouter();
|
|
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
// Data states
|
|
const [stats, setStats] = useState<AdminDashboardStats | null>(null);
|
|
const [users, setUsers] = useState<AdminUserView[]>([]);
|
|
const [recentSignups, setRecentSignups] = useState<RecentSignup[]>([]);
|
|
const [settings, setSettings] = useState<Record<string, string>>({});
|
|
|
|
// Fetch data
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const [statsResponse, usersResponse, settingsResponse] = await Promise.all([
|
|
api.admin.getStats(),
|
|
api.admin.getUsers({ limit: 100 }),
|
|
api.admin.getSettings(),
|
|
]);
|
|
|
|
if (statsResponse?.stats) {
|
|
setStats(statsResponse.stats);
|
|
}
|
|
|
|
if (usersResponse?.users) {
|
|
setUsers(usersResponse.users);
|
|
// Extract recent signups (last 7 days)
|
|
const sevenDaysAgo = new Date();
|
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
const recent = usersResponse.users
|
|
.filter((u: AdminUserView) => new Date(u.createdAt) >= sevenDaysAgo)
|
|
.sort((a: AdminUserView, b: AdminUserView) =>
|
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
)
|
|
.slice(0, 5)
|
|
.map((u: AdminUserView) => ({
|
|
id: u.id,
|
|
firstName: u.firstName || '',
|
|
lastName: u.lastName || '',
|
|
email: u.email,
|
|
createdAt: u.createdAt,
|
|
gciRange: u.gciRange,
|
|
}));
|
|
setRecentSignups(recent);
|
|
}
|
|
|
|
if (settingsResponse?.settings) {
|
|
// Convert settings to string record for form
|
|
const settingsRecord: Record<string, string> = {};
|
|
Object.entries(settingsResponse.settings).forEach(([key, value]) => {
|
|
settingsRecord[key] = value?.toString() || '';
|
|
});
|
|
setSettings(settingsRecord);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching admin data:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, []);
|
|
|
|
const handleSaveSettings = async (newSettings: Record<string, string>) => {
|
|
await api.admin.updateSettings(newSettings);
|
|
setSettings(newSettings);
|
|
};
|
|
|
|
const handleTestGHL = async () => {
|
|
return api.admin.testConnection('ghl');
|
|
};
|
|
|
|
const handleTestStripe = async () => {
|
|
return api.admin.testConnection('stripe');
|
|
};
|
|
|
|
const handleRefreshUsers = async () => {
|
|
try {
|
|
const usersResponse = await api.admin.getUsers({ limit: 100 });
|
|
if (usersResponse?.users) {
|
|
setUsers(usersResponse.users);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error refreshing users:', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ProtectedRoute requireAdmin redirectTo="/dashboard">
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-3 bg-primary-100 rounded-xl">
|
|
<Shield className="text-primary-600" size={24} />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-2xl font-bold text-slate-900">Admin Settings</h2>
|
|
<p className="text-slate-600">
|
|
System configuration and user management
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Navigation */}
|
|
<TabNavigation activeTab={activeTab} onTabChange={setActiveTab} />
|
|
|
|
{/* Tab Content */}
|
|
{activeTab === 'overview' && (
|
|
<OverviewTab
|
|
stats={stats}
|
|
recentSignups={recentSignups}
|
|
isLoading={isLoading}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'users' && (
|
|
<UsersTab
|
|
users={users}
|
|
isLoading={isLoading}
|
|
onRefresh={handleRefreshUsers}
|
|
/>
|
|
)}
|
|
|
|
{activeTab === 'settings' && (
|
|
<SettingsTab
|
|
initialSettings={settings}
|
|
onSave={handleSaveSettings}
|
|
onTestGHL={handleTestGHL}
|
|
onTestStripe={handleTestStripe}
|
|
isLoading={isLoading}
|
|
/>
|
|
)}
|
|
</div>
|
|
</ProtectedRoute>
|
|
);
|
|
}
|