BusyBee3333 4e6467ffb0 Add CRESync CRM application with Setup page
- 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>
2026-01-14 17:30:55 -05:00

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&apos;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>
);
}