templates #11

Closed
kayela wants to merge 47 commits from templates into main
8 changed files with 1267 additions and 32 deletions
Showing only changes of commit a6c2475092 - Show all commits

View File

@@ -40,7 +40,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
try {
const response = await api.get('/admin/users');
const pending = response.data.filter(u =>
['pending_validation', 'pre_validated'].includes(u.status)
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
);
setPendingCount(pending.length);
} catch (error) {

View File

@@ -141,13 +141,35 @@ const Navbar = () => {
>
Gallery
</Link>
<Link
to="/members/newsletters"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Documents
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity flex items-center gap-1 bg-transparent border-none cursor-pointer"
style={{ fontFamily: "'Poppins', sans-serif" }}>
Documents
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-white min-w-[220px]">
<DropdownMenuItem asChild>
<Link to="/members/newsletters" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
style={{ fontFamily: "'Poppins', sans-serif" }}>
Newsletters
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/members/financials" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
style={{ fontFamily: "'Poppins', sans-serif" }}>
Financials
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/members/bylaws" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
style={{ fontFamily: "'Poppins', sans-serif" }}>
Bylaws
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
to="/profile"
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
@@ -297,14 +319,36 @@ const Navbar = () => {
Gallery
</Link>
<Link
to="/members/newsletters"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Documents
</Link>
{/* Documents Section */}
<div className="space-y-1">
<p className="px-4 py-2 text-white/70 text-sm font-semibold uppercase tracking-wider" style={{ fontFamily: "'Poppins', sans-serif" }}>
Documents
</p>
<Link
to="/members/newsletters"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Newsletters
</Link>
<Link
to="/members/financials"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Financials
</Link>
<Link
to="/members/bylaws"
onClick={() => setIsMobileMenuOpen(false)}
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
style={{ fontFamily: "'Poppins', sans-serif" }}
>
Bylaws
</Link>
</div>
<Link
to="/profile"

View File

@@ -0,0 +1,987 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Badge } from './ui/badge';
import { Checkbox } from './ui/checkbox';
import { Input } from './ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Progress } from './ui/progress';
import { Alert, AlertDescription } from './ui/alert';
import { toast } from 'sonner';
import api from '../utils/api';
import {
Upload,
FileCheck,
CheckCircle,
Eye,
Play,
CheckCircle2,
AlertCircle,
AlertTriangle,
Trash2,
FileDown,
Users,
ChevronLeft,
ChevronRight,
Loader2
} from 'lucide-react';
/**
* WordPress Import Wizard Component
*
* A comprehensive 6-step wizard for importing WordPress users to LOAF platform.
* Features:
* - CSV upload and analysis
* - Interactive status review and adjustment
* - Preview before import
* - Real-time import progress
* - Full rollback capability
* - Error reporting
*/
export default function WordPressImportWizard({ open, onOpenChange, onSuccess }) {
// Wizard state
const [currentStep, setCurrentStep] = useState(1);
const [importJobId, setImportJobId] = useState(null);
// Data state
const [uploadedFile, setUploadedFile] = useState(null);
const [analysisResult, setAnalysisResult] = useState(null);
const [previewData, setPreviewData] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
// Override state
const [statusOverrides, setStatusOverrides] = useState({});
const [selectedRows, setSelectedRows] = useState(new Set());
// Import execution state
const [importing, setImporting] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [importResults, setImportResults] = useState(null);
// UI state
const [uploading, setUploading] = useState(false);
const [loading, setLoading] = useState(false);
// Step definitions
const steps = [
{ number: 1, title: 'Upload CSV', icon: Upload },
{ number: 2, title: 'Field Mapping', icon: FileCheck },
{ number: 3, title: 'Review Status', icon: CheckCircle },
{ number: 4, title: 'Preview', icon: Eye },
{ number: 5, title: 'Execute', icon: Play },
{ number: 6, title: 'Results', icon: CheckCircle2 }
];
// Reset wizard state when dialog opens/closes
useEffect(() => {
if (!open) {
setTimeout(() => {
setCurrentStep(1);
setImportJobId(null);
setUploadedFile(null);
setAnalysisResult(null);
setPreviewData([]);
setStatusOverrides({});
setSelectedRows(new Set());
setImporting(false);
setImportProgress(0);
setImportResults(null);
}, 300); // Wait for dialog close animation
}
}, [open]);
// ============================================================================
// Step Navigation
// ============================================================================
const canProceed = () => {
switch (currentStep) {
case 1:
return uploadedFile && analysisResult;
case 2:
return true; // Field mapping auto-detected
case 3:
return true; // Status review is optional
case 4:
return true; // Preview is informational
case 5:
return !importing;
case 6:
return true;
default:
return false;
}
};
const handleNext = () => {
if (currentStep < 6 && canProceed()) {
if (currentStep === 3) {
// Load preview data when moving from step 3 to 4
loadPreviewData(1);
}
setCurrentStep(currentStep + 1);
}
};
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
// ============================================================================
// Step 1: Upload CSV
// ============================================================================
const handleFileSelect = (e) => {
const file = e.target.files[0];
if (file) {
if (!file.name.endsWith('.csv')) {
toast.error('Please upload a CSV file');
return;
}
setUploadedFile(file);
}
};
const handleUpload = async () => {
if (!uploadedFile) return;
setUploading(true);
const formData = new FormData();
formData.append('file', uploadedFile);
try {
const response = await api.post('/admin/import/upload-csv', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setImportJobId(response.data.import_job_id);
setAnalysisResult(response.data);
toast.success(`CSV analyzed: ${response.data.valid_rows} valid rows, ${response.data.warnings} warnings`);
// Auto-advance to next step
setTimeout(() => setCurrentStep(2), 500);
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to upload CSV');
} finally {
setUploading(false);
}
};
// ============================================================================
// Step 3: Review & Adjust Status
// ============================================================================
const loadPreviewData = async (page = 1) => {
if (!importJobId) return;
setLoading(true);
try {
const response = await api.get(`/admin/import/${importJobId}/preview`, {
params: { page, page_size: 50 }
});
setPreviewData(response.data.rows);
setCurrentPage(response.data.page);
setTotalPages(response.data.total_pages);
} catch (error) {
toast.error('Failed to load preview data');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (currentStep === 3 && importJobId && previewData.length === 0) {
loadPreviewData(1);
}
}, [currentStep, importJobId]);
const handleStatusOverride = (rowNum, status) => {
setStatusOverrides(prev => ({
...prev,
[rowNum]: { status }
}));
};
const handleBulkStatusChange = (status) => {
const newOverrides = { ...statusOverrides };
selectedRows.forEach(rowNum => {
newOverrides[rowNum] = { status };
});
setStatusOverrides(newOverrides);
toast.success(`Updated ${selectedRows.size} users to ${status}`);
};
const toggleRowSelection = (rowNum) => {
setSelectedRows(prev => {
const newSet = new Set(prev);
if (newSet.has(rowNum)) {
newSet.delete(rowNum);
} else {
newSet.add(rowNum);
}
return newSet;
});
};
const toggleSelectAll = () => {
if (selectedRows.size === previewData.length) {
setSelectedRows(new Set());
} else {
setSelectedRows(new Set(previewData.map(row => row.row_number)));
}
};
// ============================================================================
// Step 5: Execute Import
// ============================================================================
const handleExecuteImport = async () => {
setImporting(true);
setCurrentStep(5);
try {
// Start import
const response = await api.post(`/admin/import/${importJobId}/execute`, {
overrides: statusOverrides,
options: {
send_password_emails: true,
skip_errors: true
}
});
setImportResults(response.data);
toast.success(`Import completed: ${response.data.successful_rows} users imported`);
// Move to results step
setCurrentStep(6);
} catch (error) {
toast.error(error.response?.data?.detail || 'Import failed');
} finally {
setImporting(false);
}
};
// Poll for import progress
useEffect(() => {
if (currentStep === 5 && importing && importJobId) {
const interval = setInterval(async () => {
try {
const response = await api.get(`/admin/import/${importJobId}/status`);
setImportProgress(response.data.progress_percent);
} catch (error) {
console.error('Failed to fetch import status:', error);
}
}, 1000);
return () => clearInterval(interval);
}
}, [currentStep, importing, importJobId]);
// ============================================================================
// Step 6: Rollback
// ============================================================================
const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false);
const [confirmText, setConfirmText] = useState('');
const handleRollback = async () => {
try {
await api.post(`/admin/import/${importJobId}/rollback`, { confirm: true });
toast.success(`Rolled back ${importResults.successful_rows} users`);
onOpenChange(false);
if (onSuccess) onSuccess();
} catch (error) {
toast.error(error.response?.data?.detail || 'Rollback failed');
}
};
const handleDownloadErrors = async () => {
try {
const response = await api.get(`/admin/import/${importJobId}/errors/download`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `import_errors_${importJobId}.csv`);
document.body.appendChild(link);
link.click();
link.remove();
toast.success('Error report downloaded');
} catch (error) {
toast.error('Failed to download error report');
}
};
// ============================================================================
// Status Badge Component
// ============================================================================
const StatusBadge = ({ status }) => {
const colors = {
active: 'bg-green-100 text-green-800 border-green-300',
pre_validated: 'bg-blue-100 text-blue-800 border-blue-300',
payment_pending: 'bg-yellow-100 text-yellow-800 border-yellow-300',
inactive: 'bg-gray-100 text-gray-800 border-gray-300'
};
return (
<Badge variant="outline" className={colors[status] || 'bg-gray-100 text-gray-800'}>
{status.replace('_', ' ')}
</Badge>
);
};
// ============================================================================
// Render Step Content
// ============================================================================
const renderStepContent = () => {
switch (currentStep) {
case 1:
return <Step1Upload />;
case 2:
return <Step2FieldMapping />;
case 3:
return <Step3ReviewStatus />;
case 4:
return <Step4Preview />;
case 5:
return <Step5Execute />;
case 6:
return <Step6Results />;
default:
return null;
}
};
// ============================================================================
// Step 1: Upload CSV
// ============================================================================
const Step1Upload = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-2">Upload WordPress CSV Export</h3>
<p className="text-sm text-[#664fa3]">
Select the WordPress user export CSV file. The file will be analyzed for data quality issues.
</p>
</div>
<Card className="p-6 border-2 border-dashed border-[#ddd8eb] bg-[#f9f5ff]">
<div className="flex flex-col items-center gap-4">
<Upload className="h-12 w-12 text-[#664fa3]" />
<div className="text-center">
<Input
type="file"
accept=".csv"
onChange={handleFileSelect}
className="max-w-xs"
/>
{uploadedFile && (
<p className="text-sm text-[#664fa3] mt-2">
Selected: {uploadedFile.name}
</p>
)}
</div>
</div>
</Card>
{uploadedFile && !analysisResult && (
<Button
onClick={handleUpload}
disabled={uploading}
className="w-full bg-[#664fa3] hover:bg-[#422268]"
>
{uploading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyzing CSV...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Upload and Analyze
</>
)}
</Button>
)}
{analysisResult && (
<Card className="p-6 bg-green-50 border-green-200">
<h4 className="font-semibold text-green-900 mb-4">Analysis Complete</h4>
<div className="grid md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-green-700">Total Rows</p>
<p className="text-2xl font-semibold text-green-900">{analysisResult.total_rows}</p>
</div>
<div>
<p className="text-sm text-green-700">Valid Rows</p>
<p className="text-2xl font-semibold text-green-900">{analysisResult.valid_rows}</p>
</div>
<div>
<p className="text-sm text-yellow-700">Warnings</p>
<p className="text-2xl font-semibold text-yellow-900">{analysisResult.warnings}</p>
</div>
<div>
<p className="text-sm text-red-700">Errors</p>
<p className="text-2xl font-semibold text-red-900">{analysisResult.errors}</p>
</div>
</div>
{analysisResult.data_quality && (
<div className="mt-4 pt-4 border-t border-green-300">
<h5 className="text-sm font-semibold text-green-900 mb-2">Data Quality Issues:</h5>
<ul className="text-sm text-green-800 space-y-1">
{analysisResult.data_quality.invalid_dob > 0 && (
<li> {analysisResult.data_quality.invalid_dob} invalid dates of birth</li>
)}
{analysisResult.data_quality.missing_phone > 0 && (
<li> {analysisResult.data_quality.missing_phone} missing phone numbers</li>
)}
{analysisResult.data_quality.duplicate_email > 0 && (
<li> {analysisResult.data_quality.duplicate_email} duplicate emails</li>
)}
</ul>
</div>
)}
</Card>
)}
</div>
);
// ============================================================================
// Step 2: Field Mapping
// ============================================================================
const Step2FieldMapping = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-2">Field Mapping</h3>
<p className="text-sm text-[#664fa3]">
WordPress fields have been automatically mapped to LOAF platform fields.
</p>
</div>
<Card className="p-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>WordPress Field</TableHead>
<TableHead></TableHead>
<TableHead>LOAF Field</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-mono text-sm">user_email</TableCell>
<TableCell></TableCell>
<TableCell className="font-mono text-sm">email</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono text-sm">first_name</TableCell>
<TableCell></TableCell>
<TableCell className="font-mono text-sm">first_name</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono text-sm">last_name</TableCell>
<TableCell></TableCell>
<TableCell className="font-mono text-sm">last_name</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono text-sm">cell_phone</TableCell>
<TableCell></TableCell>
<TableCell className="font-mono text-sm">phone</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono text-sm">date_of_birth</TableCell>
<TableCell></TableCell>
<TableCell className="font-mono text-sm">date_of_birth</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-mono text-sm">wp_capabilities</TableCell>
<TableCell></TableCell>
<TableCell className="font-mono text-sm">role + status</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
<Alert className="bg-blue-50 border-blue-200">
<AlertCircle className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-blue-800">
WordPress roles will be automatically converted:
<ul className="mt-2 ml-4 space-y-1">
<li> <code>loaf_admin</code> admin (active)</li>
<li> <code>loaf_treasure</code> finance (active)</li>
<li> <code>administrator</code> superadmin (active)</li>
<li> <code>pms_subscription_plan_63</code> member</li>
</ul>
</AlertDescription>
</Alert>
</div>
);
// ============================================================================
// Step 3: Review & Adjust Status (KEY FEATURE)
// ============================================================================
const Step3ReviewStatus = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-2">Review & Adjust User Status</h3>
<p className="text-sm text-[#664fa3]">
Review suggested status mappings and override as needed before import.
</p>
</div>
{/* Bulk edit toolbar */}
<Card className="p-4 bg-[#f9f5ff] border-[#ddd8eb]">
<div className="flex items-center gap-4">
<Checkbox
checked={selectedRows.size === previewData.length && previewData.length > 0}
onCheckedChange={toggleSelectAll}
/>
<span className="text-sm text-[#664fa3] font-medium">
{selectedRows.size > 0 ? `${selectedRows.size} selected` : 'Select all'}
</span>
{selectedRows.size > 0 && (
<Select onValueChange={handleBulkStatusChange}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Change status to..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
)}
</div>
</Card>
{/* Data table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-[#664fa3]" />
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-[#f9f5ff]">
<TableHead className="w-12">
<Checkbox checked={false} />
</TableHead>
<TableHead>Row</TableHead>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>WP Role</TableHead>
<TableHead>Suggested Status</TableHead>
<TableHead>Override Status</TableHead>
<TableHead>Issues</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{previewData.map((row) => (
<TableRow key={row.row_number}>
<TableCell>
<Checkbox
checked={selectedRows.has(row.row_number)}
onCheckedChange={() => toggleRowSelection(row.row_number)}
/>
</TableCell>
<TableCell className="font-mono text-sm">{row.row_number}</TableCell>
<TableCell className="text-sm">{row.email}</TableCell>
<TableCell className="text-sm">
{row.first_name} {row.last_name}
</TableCell>
<TableCell>
<Badge className="bg-[#ddd8eb] text-[#422268]">
{row.wordpress_roles?.join(', ') || 'N/A'}
</Badge>
</TableCell>
<TableCell>
<StatusBadge status={row.suggested_status} />
</TableCell>
<TableCell>
<Select
value={statusOverrides[row.row_number]?.status || row.suggested_status}
onValueChange={(value) => handleStatusOverride(row.row_number, value)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell>
{row.warnings?.map((warning, idx) => (
<Badge
key={idx}
variant="outline"
className="text-orange-600 border-orange-300 mr-1"
>
<AlertCircle className="h-3 w-3 mr-1" />
{warning}
</Badge>
))}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-[#664fa3]">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => loadPreviewData(currentPage - 1)}
disabled={currentPage === 1 || loading}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => loadPreviewData(currentPage + 1)}
disabled={currentPage === totalPages || loading}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
// ============================================================================
// Step 4: Preview
// ============================================================================
const Step4Preview = () => {
const overrideCount = Object.keys(statusOverrides).length;
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-2">Import Preview</h3>
<p className="text-sm text-[#664fa3]">
Review the final import settings before execution.
</p>
</div>
<div className="grid md:grid-cols-3 gap-4">
<Card className="p-6">
<p className="text-sm text-[#664fa3]">Total Users</p>
<p className="text-3xl font-semibold text-[#422268]">{analysisResult?.total_rows}</p>
</Card>
<Card className="p-6">
<p className="text-sm text-[#664fa3]">Status Overrides</p>
<p className="text-3xl font-semibold text-[#422268]">{overrideCount}</p>
</Card>
<Card className="p-6">
<p className="text-sm text-[#664fa3]">Expected Imports</p>
<p className="text-3xl font-semibold text-[#422268]">{analysisResult?.valid_rows}</p>
</Card>
</div>
<Card className="p-6">
<h4 className="font-semibold text-[#422268] mb-4">Import Options</h4>
<div className="space-y-3">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<span className="text-sm text-[#664fa3]">Send password reset emails to all imported users</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<span className="text-sm text-[#664fa3]">Skip rows with errors and continue import</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<span className="text-sm text-[#664fa3]">Full rollback capability available after import</span>
</div>
</div>
</Card>
{overrideCount > 0 && (
<Alert className="bg-yellow-50 border-yellow-200">
<AlertCircle className="h-4 w-4 text-yellow-600" />
<AlertDescription className="text-yellow-800">
You have overridden {overrideCount} user status{overrideCount > 1 ? 'es' : ''}.
These will be applied during import.
</AlertDescription>
</Alert>
)}
</div>
);
};
// ============================================================================
// Step 5: Execute
// ============================================================================
const Step5Execute = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-2">
{importing ? 'Import in Progress...' : 'Ready to Import'}
</h3>
<p className="text-sm text-[#664fa3]">
{importing
? 'Please wait while users are imported. This may take a few minutes.'
: 'Click "Start Import" to begin importing users.'}
</p>
</div>
{importing && (
<div className="space-y-4">
<Progress value={importProgress} className="w-full" />
<p className="text-center text-sm text-[#664fa3]">
{importProgress.toFixed(1)}% complete
</p>
</div>
)}
{!importing && !importResults && (
<Button
onClick={handleExecuteImport}
className="w-full bg-[#664fa3] hover:bg-[#422268] py-6 text-lg"
>
<Play className="mr-2 h-5 w-5" />
Start Import
</Button>
)}
</div>
);
// ============================================================================
// Step 6: Results & Rollback
// ============================================================================
const Step6Results = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-[#422268] mb-2">Import Complete</h3>
<p className="text-sm text-[#664fa3]">
Review the import results and download error reports if needed.
</p>
</div>
{/* Stats cards */}
<div className="grid md:grid-cols-3 gap-4">
<Card className="p-6 bg-green-50 border-green-200">
<p className="text-sm text-green-700">Successful Imports</p>
<p className="text-4xl font-semibold text-green-900">{importResults?.successful_rows || 0}</p>
</Card>
<Card className="p-6 bg-red-50 border-red-200">
<p className="text-sm text-red-700">Failed Imports</p>
<p className="text-4xl font-semibold text-red-900">{importResults?.failed_rows || 0}</p>
</Card>
<Card className="p-6 bg-blue-50 border-blue-200">
<p className="text-sm text-blue-700">Password Emails Sent</p>
<p className="text-4xl font-semibold text-blue-900">{importResults?.password_emails_queued || 0}</p>
</Card>
</div>
{/* Action buttons */}
<div className="flex gap-4 justify-between flex-wrap">
<div className="flex gap-4 flex-wrap">
{importResults?.failed_rows > 0 && (
<Button onClick={handleDownloadErrors} variant="outline">
<FileDown className="h-4 w-4 mr-2" />
Download Error Report
</Button>
)}
<Button
onClick={() => {
onOpenChange(false);
if (onSuccess) onSuccess();
}}
variant="outline"
>
<Users className="h-4 w-4 mr-2" />
View Imported Members
</Button>
</div>
{/* Rollback button (prominent, red) */}
{importResults?.successful_rows > 0 && (
<Button
onClick={() => setRollbackConfirmOpen(true)}
variant="destructive"
className="bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-2" />
Rollback Import ({importResults.successful_rows} users)
</Button>
)}
</div>
{/* Rollback confirmation dialog */}
<Dialog open={rollbackConfirmOpen} onOpenChange={setRollbackConfirmOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-3 bg-red-100 rounded-full">
<AlertTriangle className="h-6 w-6 text-red-600" />
</div>
<DialogTitle className="text-2xl font-semibold text-[#422268]">
Confirm Rollback
</DialogTitle>
</div>
<DialogDescription className="text-[#664fa3]">
This will permanently delete{' '}
<strong>{importResults?.successful_rows} users</strong> that were imported.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 my-4">
<p className="text-sm text-red-800 font-medium mb-2">
Type "DELETE {importResults?.successful_rows} USERS" to confirm:
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
className="mt-2"
placeholder={`DELETE ${importResults?.successful_rows} USERS`}
/>
</div>
<DialogFooter>
<Button onClick={() => setRollbackConfirmOpen(false)} variant="outline">
Cancel
</Button>
<Button
onClick={handleRollback}
disabled={confirmText !== `DELETE ${importResults?.successful_rows} USERS`}
className="bg-red-600 hover:bg-red-700"
>
Yes, Delete {importResults?.successful_rows} Users
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
// ============================================================================
// Main Render
// ============================================================================
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-semibold text-[#422268]">
WordPress Import Wizard
</DialogTitle>
<DialogDescription className="text-[#664fa3]">
Import WordPress users with interactive status review and full rollback capability
</DialogDescription>
</DialogHeader>
{/* Step indicator */}
<div className="flex items-center justify-between mb-6 px-4">
{steps.map((step, index) => {
const StepIcon = step.icon;
const isCompleted = currentStep > step.number;
const isCurrent = currentStep === step.number;
return (
<React.Fragment key={step.number}>
<div className="flex flex-col items-center">
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center
${isCurrent ? 'bg-[#664fa3] text-white' : ''}
${isCompleted ? 'bg-green-600 text-white' : ''}
${!isCurrent && !isCompleted ? 'bg-gray-200 text-gray-600' : ''}
`}
>
{isCompleted ? (
<CheckCircle className="h-5 w-5" />
) : (
<StepIcon className="h-5 w-5" />
)}
</div>
<p className={`text-xs mt-1 ${isCurrent ? 'font-semibold text-[#422268]' : 'text-gray-600'}`}>
{step.title}
</p>
</div>
{index < steps.length - 1 && (
<div className={`flex-1 h-0.5 mx-2 ${isCompleted ? 'bg-green-600' : 'bg-gray-300'}`} />
)}
</React.Fragment>
);
})}
</div>
{/* Step content */}
<div className="py-6">
{renderStepContent()}
</div>
{/* Navigation footer */}
<DialogFooter className="flex justify-between items-center">
<Button
variant="outline"
onClick={handleBack}
disabled={currentStep === 1 || importing}
>
<ChevronLeft className="h-4 w-4 mr-2" />
Back
</Button>
{currentStep < 5 && (
<Button
onClick={handleNext}
disabled={!canProceed()}
className="bg-[#664fa3] hover:bg-[#422268]"
>
Next
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
)}
{currentStep === 6 && (
<Button
onClick={() => {
onOpenChange(false);
if (onSuccess) onSuccess();
}}
className="bg-[#664fa3] hover:bg-[#422268]"
>
Close
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -60,8 +60,13 @@ export const AuthProvider = ({ children }) => {
setToken(access_token);
setUser(userData);
// Fetch user permissions
await fetchPermissions(access_token);
// Fetch user permissions (don't let this fail the login)
try {
await fetchPermissions(access_token);
} catch (error) {
console.error('Failed to fetch permissions during login, will retry later:', error);
// Don't throw - permissions can be fetched later if needed
}
return userData;
};

View File

@@ -15,9 +15,12 @@ const Dashboard = () => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [resendLoading, setResendLoading] = useState(false);
const [eventActivity, setEventActivity] = useState(null);
const [activityLoading, setActivityLoading] = useState(true);
useEffect(() => {
fetchUpcomingEvents();
fetchEventActivity();
}, []);
const fetchUpcomingEvents = async () => {
@@ -32,6 +35,17 @@ const Dashboard = () => {
}
};
const fetchEventActivity = async () => {
try {
const response = await api.get('/members/event-activity');
setEventActivity(response.data);
} catch (error) {
console.error('Failed to fetch event activity:', error);
} finally {
setActivityLoading(false);
}
};
const handleResendVerification = async () => {
setResendLoading(true);
try {
@@ -298,6 +312,156 @@ const Dashboard = () => {
</div>
</Card>
)}
{/* Event Activity Section */}
<div className="mt-12">
<div className="flex justify-between items-center mb-6">
<h2 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
My Event Activity
</h2>
</div>
{activityLoading ? (
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event activity...</p>
) : eventActivity ? (
<div className="space-y-8">
{/* Stats Cards */}
<div className="grid md:grid-cols-2 gap-6">
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
<div className="flex items-center gap-4">
<div className="bg-[#DDD8EB]/20 p-4 rounded-lg">
<Calendar className="h-8 w-8 text-[#664fa3]" />
</div>
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{eventActivity.total_rsvps}
</p>
</div>
</div>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
<div className="flex items-center gap-4">
<div className="bg-[#81B29A]/20 p-4 rounded-lg">
<CheckCircle className="h-8 w-8 text-[#81B29A]" />
</div>
<div>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Events Attended</p>
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{eventActivity.total_attended}
</p>
</div>
</div>
</Card>
</div>
{/* Upcoming RSVP'd Events */}
{eventActivity.upcoming_events && eventActivity.upcoming_events.length > 0 && (
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Upcoming Events (RSVP'd)
</h3>
<div className="space-y-3">
{eventActivity.upcoming_events.map((event) => (
<Link to={`/events/${event.id}`} key={event.id}>
<div className="p-4 border border-[#ddd8eb] rounded-xl hover:border-[#664fa3] hover:shadow-md transition-all">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h4 className="font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p>
</div>
<Badge className={
event.rsvp_status === 'yes' ? 'bg-[#81B29A] text-white' :
event.rsvp_status === 'maybe' ? 'bg-orange-100 text-orange-700' :
'bg-gray-200 text-gray-700'
}>
{event.rsvp_status === 'yes' ? 'Going' :
event.rsvp_status === 'maybe' ? 'Maybe' : 'Not Going'}
</Badge>
</div>
</div>
</Link>
))}
</div>
</Card>
)}
{/* Past Events & Attendance */}
{eventActivity.past_events && eventActivity.past_events.length > 0 && (
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Past Events
</h3>
<div className="space-y-3">
{eventActivity.past_events.slice(0, 5).map((event) => (
<div key={event.id} className="p-4 border border-[#ddd8eb] rounded-xl">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h4 className="font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<Badge className={event.attended ? 'bg-[#81B29A] text-white' : 'bg-gray-200 text-gray-700'}>
{event.attended ? 'Attended' : 'Did not attend'}
</Badge>
{event.attended && event.attended_at && (
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Checked in: {new Date(event.attended_at).toLocaleDateString()}
</p>
)}
</div>
</div>
</div>
))}
</div>
{eventActivity.past_events.length > 5 && (
<p className="text-sm text-center text-[#664fa3] mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Showing 5 of {eventActivity.past_events.length} past events
</p>
)}
</Card>
)}
{/* No Events Message */}
{(!eventActivity.upcoming_events || eventActivity.upcoming_events.length === 0) &&
(!eventActivity.past_events || eventActivity.past_events.length === 0) && (
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb]">
<div className="text-center">
<Calendar className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
No Event Activity Yet
</h3>
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Browse upcoming events and RSVP to start building your event history!
</p>
<Link to="/events">
<Button className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6">
<Calendar className="h-4 w-4 mr-2" />
Browse Events
</Button>
</Link>
</div>
</Card>
)}
</div>
) : (
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb]">
<div className="text-center">
<AlertCircle className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Failed to load event activity. Please try refreshing the page.
</p>
</div>
</Card>
)}
</div>
</div>
<MemberFooter />
</div>

View File

@@ -19,7 +19,7 @@ import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog';
import ImportMembersDialog from '../../components/ImportMembersDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard';
const AdminMembers = () => {
const navigate = useNavigate();
@@ -569,7 +569,7 @@ const AdminMembers = () => {
onSuccess={fetchMembers}
/>
<ImportMembersDialog
<WordPressImportWizard
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
onSuccess={fetchMembers}

View File

@@ -74,7 +74,7 @@ const AdminValidations = () => {
try {
const response = await api.get('/admin/users');
const pending = response.data.filter(user =>
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(user.status)
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending', 'rejected'].includes(user.status)
);
setPendingUsers(pending);
} catch (error) {
@@ -218,12 +218,28 @@ const AdminValidations = () => {
}
};
const handleReactivateUser = async (user) => {
setActionLoading(user.id);
try {
await api.put(`/admin/users/${user.id}/status`, {
status: 'pending_validation'
});
toast.success(`${user.first_name} ${user.last_name} has been reactivated and moved to pending validation`);
fetchPendingUsers();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to reactivate user');
} finally {
setActionLoading(null);
}
};
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' }
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
rejected: { label: 'Rejected', className: 'bg-red-100 text-red-700' }
};
const statusConfig = config[status];
@@ -302,6 +318,12 @@ const AdminValidations = () => {
{pendingUsers.filter(u => u.status === 'payment_pending').length}
</p>
</div>
<div>
<p className="text-sm text-red-600 mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Rejected</p>
<p className="text-3xl font-semibold text-red-800" style={{ fontFamily: "'Inter', sans-serif" }}>
{pendingUsers.filter(u => u.status === 'rejected').length}
</p>
</div>
</div>
</Card>
@@ -327,6 +349,8 @@ const AdminValidations = () => {
<SelectItem value="pending_email">Awaiting Email</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent>
</Select>
</div>
@@ -384,7 +408,16 @@ const AdminValidations = () => {
</TableCell>
<TableCell>
<div className="flex gap-2">
{user.status === 'pending_email' ? (
{user.status === 'rejected' ? (
<Button
onClick={() => handleReactivateUser(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
>
{actionLoading === user.id ? 'Reactivating...' : 'Reactivate'}
</Button>
) : user.status === 'pending_email' ? (
<>
<Button
onClick={() => handleBypassAndValidateRequest(user)}

View File

@@ -140,15 +140,17 @@ const MembersDirectory = () => {
)}
{/* Member Since */}
<div className="flex items-center justify-center gap-2 mb-4">
<Calendar className="h-4 w-4 text-[#664fa3]" />
<span className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Member since {new Date(member.member_since || member.created_at).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric'
})}
</span>
</div>
{member.created_at && (
<div className="flex items-center justify-center gap-2 mb-4">
<Calendar className="h-4 w-4 text-[#664fa3]" />
<span className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Member since {new Date(member.created_at).toLocaleDateString('en-US', {
month: 'long',
year: 'numeric'
})}
</span>
</div>
)}
{/* Contact Information */}
<div className="space-y-3 mb-4">