diff --git a/.env.development b/.env.development index 6ec835a..3886372 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,3 @@ REACT_APP_BACKEND_URL=http://localhost:8000 -REACT_APP_BASENAME=/membership -PUBLIC_URL=/membership +REACT_APP_BASENAME=/ +PUBLIC_URL=/ diff --git a/src/App.js b/src/App.js index 98278c8..3b51ce0 100644 --- a/src/App.js +++ b/src/App.js @@ -48,6 +48,7 @@ import AdminNewsletters from './pages/admin/AdminNewsletters'; import AdminFinancials from './pages/admin/AdminFinancials'; import AdminBylaws from './pages/admin/AdminBylaws'; import AdminRegistrationBuilder from './pages/admin/AdminRegistrationBuilder'; +import AdminDirectorySettings from './pages/admin/AdminDirectorySettings'; import History from './pages/History'; import MissionValues from './pages/MissionValues'; import BoardOfDirectors from './pages/BoardOfDirectors'; @@ -319,6 +320,8 @@ function App() { } /> } /> } /> + } /> + } /> {/* 404 - Catch all undefined routes */} diff --git a/src/components/ComprehensiveImportWizard.js b/src/components/ComprehensiveImportWizard.js new file mode 100644 index 0000000..d1b985e --- /dev/null +++ b/src/components/ComprehensiveImportWizard.js @@ -0,0 +1,1138 @@ +import React, { useState, useCallback } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Card } from './ui/card'; +import { Label } from './ui/label'; +import { Checkbox } from './ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from './ui/table'; +import { + Upload, + FileSpreadsheet, + Users, + CreditCard, + Receipt, + CheckCircle2, + XCircle, + AlertTriangle, + Loader2, + ChevronLeft, + ChevronRight, + Download, + RotateCcw, + Mail, + MailX, + Info, +} from 'lucide-react'; +import { toast } from 'sonner'; +import api from '../utils/api'; + +const STEPS = [ + { id: 'upload', title: 'Upload Files', icon: Upload }, + { id: 'preview', title: 'Preview Data', icon: FileSpreadsheet }, + { id: 'options', title: 'Import Options', icon: Users }, + { id: 'execute', title: 'Execute Import', icon: CheckCircle2 }, +]; + +const STATUS_COLORS = { + active: 'bg-green-100 text-green-800', + pre_validated: 'bg-blue-100 text-blue-800', + payment_pending: 'bg-yellow-100 text-yellow-800', + pending_validation: 'bg-purple-100 text-purple-800', + inactive: 'bg-gray-100 text-gray-800', + expired: 'bg-red-100 text-red-800', +}; + +const ROLE_COLORS = { + superadmin: 'bg-red-100 text-red-800', + admin: 'bg-orange-100 text-orange-800', + finance: 'bg-blue-100 text-blue-800', + member: 'bg-green-100 text-green-800', + guest: 'bg-gray-100 text-gray-800', +}; + +/** + * ComprehensiveImportWizard - Multi-file WordPress import wizard + * + * Supports importing: + * - WordPress Users CSV (required) + * - PMS Members CSV (optional) - subscription data + * - PMS Payments CSV (optional) - payment history + */ +const ComprehensiveImportWizard = ({ open, onOpenChange, onSuccess }) => { + const [currentStep, setCurrentStep] = useState(0); + const [loading, setLoading] = useState(false); + const [importJobId, setImportJobId] = useState(null); + + // File upload state + const [usersFile, setUsersFile] = useState(null); + const [membersFile, setMembersFile] = useState(null); + const [paymentsFile, setPaymentsFile] = useState(null); + + // Preview state + const [uploadSummary, setUploadSummary] = useState(null); + const [previewData, setPreviewData] = useState([]); + const [previewPage, setPreviewPage] = useState(1); + const [previewTotalPages, setPreviewTotalPages] = useState(1); + const [filterErrors, setFilterErrors] = useState(false); + const [filterWarnings, setFilterWarnings] = useState(false); + + // Override state + const [overrides, setOverrides] = useState({}); + + // Import options + const [options, setOptions] = useState({ + skip_notifications: true, + send_welcome_emails: false, + send_password_emails: false, + import_subscriptions: true, + import_payment_history: true, + skip_errors: true, + }); + + // Results state + const [importResults, setImportResults] = useState(null); + + // Reset wizard state + const resetWizard = useCallback(() => { + setCurrentStep(0); + setLoading(false); + setImportJobId(null); + setUsersFile(null); + setMembersFile(null); + setPaymentsFile(null); + setUploadSummary(null); + setPreviewData([]); + setPreviewPage(1); + setPreviewTotalPages(1); + setFilterErrors(false); + setFilterWarnings(false); + setOverrides({}); + setOptions({ + skip_notifications: true, + send_welcome_emails: false, + send_password_emails: false, + import_subscriptions: true, + import_payment_history: true, + skip_errors: true, + }); + setImportResults(null); + }, []); + + // Handle dialog close + const handleClose = () => { + if (loading) return; + resetWizard(); + onOpenChange(false); + }; + + // Step 1: Upload files + const handleUpload = async () => { + if (!usersFile) { + toast.error('Please select a WordPress Users CSV file'); + return; + } + + setLoading(true); + try { + const formData = new FormData(); + formData.append('users_file', usersFile); + if (membersFile) { + formData.append('members_file', membersFile); + } + if (paymentsFile) { + formData.append('payments_file', paymentsFile); + } + + const response = await api.post('/admin/import/comprehensive/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + timeout: 120000, // 2 minutes for large file uploads + R2 storage + }); + + setImportJobId(response.data.import_job_id); + setUploadSummary(response.data); + + // Fetch preview data + await fetchPreview(response.data.import_job_id, 1); + + toast.success('Files uploaded and analyzed successfully'); + setCurrentStep(1); + } catch (error) { + console.error('[ComprehensiveImport] Upload failed:', error); + const message = error.response?.data?.detail || + error.message || + 'Failed to upload files'; + toast.error(message); + } finally { + setLoading(false); + } + }; + + // Fetch preview data + const fetchPreview = async (jobId, page) => { + try { + const response = await api.get(`/admin/import/comprehensive/${jobId}/preview`, { + params: { + page, + page_size: 20, + filter_errors: filterErrors, + filter_warnings: filterWarnings, + }, + }); + + setPreviewData(response.data.rows); + setPreviewPage(response.data.page); + setPreviewTotalPages(response.data.total_pages); + } catch (error) { + toast.error('Failed to load preview data'); + } + }; + + // Handle page change + const handlePageChange = async (newPage) => { + if (newPage < 1 || newPage > previewTotalPages) return; + setLoading(true); + await fetchPreview(importJobId, newPage); + setLoading(false); + }; + + // Handle filter change + const handleFilterChange = async () => { + if (!importJobId) return; + setLoading(true); + await fetchPreview(importJobId, 1); + setLoading(false); + }; + + // Handle override change + const handleOverride = (rowNum, field, value) => { + setOverrides((prev) => ({ + ...prev, + [rowNum]: { + ...prev[rowNum], + [field]: value, + }, + })); + }; + + // Execute import + const handleExecute = async () => { + if (!importJobId) return; + + setLoading(true); + try { + const response = await api.post(`/admin/import/comprehensive/${importJobId}/execute`, { + overrides, + options, + }); + + setImportResults(response.data); + toast.success( + `Import completed: ${response.data.successful_rows} users imported` + ); + setCurrentStep(3); + } catch (error) { + const message = error.response?.data?.detail || 'Import failed'; + toast.error(message); + } finally { + setLoading(false); + } + }; + + // Render step content + const renderStepContent = () => { + switch (currentStep) { + case 0: + return renderUploadStep(); + case 1: + return renderPreviewStep(); + case 2: + return renderOptionsStep(); + case 3: + return renderResultsStep(); + default: + return null; + } + }; + + // Step 1: Upload + const renderUploadStep = () => ( +
+
+

+ Upload your WordPress export CSV files. The Users file is required, + while Members and Payments files are optional but recommended for + complete data migration. +

+
+ + {/* Users File (Required) */} + +
+
+ +
+
+ +

+ User profiles, contact info, directory settings, newsletter + preferences +

+ setUsersFile(e.target.files[0])} + className="block w-full text-sm text-brand-purple file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-purple file:text-white hover:file:bg-[var(--purple-ink)] cursor-pointer" + /> + {usersFile && ( +

+ + {usersFile.name} +

+ )} +
+
+
+ + {/* Members File (Optional) */} + +
+
+ +
+
+ +

+ Subscription plans, start/end dates, membership status +

+ setMembersFile(e.target.files[0])} + className="block w-full text-sm text-brand-purple file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-[var(--lavender-500)] file:text-brand-purple hover:file:bg-[var(--lavender-300)] cursor-pointer" + /> + {membersFile && ( +

+ + {membersFile.name} +

+ )} +
+
+
+ + {/* Payments File (Optional) */} + +
+
+ +
+
+ +

+ Payment history, transaction records, amounts paid +

+ setPaymentsFile(e.target.files[0])} + className="block w-full text-sm text-brand-purple file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-[var(--lavender-500)] file:text-brand-purple hover:file:bg-[var(--lavender-300)] cursor-pointer" + /> + {paymentsFile && ( +

+ + {paymentsFile.name} +

+ )} +
+
+
+
+ ); + + // Step 2: Preview + const renderPreviewStep = () => ( +
+ {/* Summary Cards */} + {uploadSummary && ( +
+ +
+ + + Users + +
+
+ {uploadSummary.users?.valid || 0} + + {' '} + / {uploadSummary.users?.total || 0} + +
+

+ {uploadSummary.users?.warnings || 0} warnings,{' '} + {uploadSummary.users?.errors || 0} errors +

+
+ + +
+ + + Subscriptions + +
+
+ {uploadSummary.members?.matched || 0} + + {' '} + / {uploadSummary.members?.total || 0} + +
+

+ {uploadSummary.members?.unmatched || 0} unmatched +

+
+ + +
+ + + Payments + +
+
+ {uploadSummary.payments?.matched || 0} +
+

+ $ + {( + (uploadSummary.payments?.total_amount_cents || 0) / 100 + ).toLocaleString()}{' '} + total +

+
+
+ )} + + {/* Filters */} +
+
+ { + setFilterErrors(checked); + setTimeout(handleFilterChange, 0); + }} + /> + +
+
+ { + setFilterWarnings(checked); + setTimeout(handleFilterChange, 0); + }} + /> + +
+
+ + {/* Preview Table */} +
+ + + + + Row + + + Email + + + Name + + + Role + + + Status + + + Subscription + + + Issues + + + + + {previewData.map((row) => ( + 0 + ? 'bg-red-50' + : row.warnings?.length > 0 + ? 'bg-yellow-50' + : '' + } + > + + {row.row_number} + + {row.email} + + {row.first_name} {row.last_name} + + + + + + + + + {row.has_subscription ? ( + + Yes + + ) : ( + + No + + )} + + +
+ {row.errors?.length > 0 && ( + + + + )} + {row.warnings?.length > 0 && ( + + + + )} + {!row.errors?.length && !row.warnings?.length && ( + + )} +
+
+
+ ))} +
+
+
+ + {/* Pagination */} +
+

+ Page {previewPage} of {previewTotalPages} +

+
+ + +
+
+
+ ); + + // Step 3: Options + const renderOptionsStep = () => ( +
+ {/* Notification Options */} + +

+ + Email Notifications +

+ +
+
+ + setOptions((prev) => ({ + ...prev, + skip_notifications: checked, + send_welcome_emails: checked ? false : prev.send_welcome_emails, + send_password_emails: checked + ? false + : prev.send_password_emails, + })) + } + /> +
+ +

+ No emails will be sent to imported users. You can send a bulk + password reset later from the admin panel. +

+
+
+ + {!options.skip_notifications && ( + <> +
+ + setOptions((prev) => ({ + ...prev, + send_welcome_emails: checked, + })) + } + /> + +
+ +
+ + setOptions((prev) => ({ + ...prev, + send_password_emails: checked, + })) + } + /> + +
+ + )} +
+
+ + {/* Data Import Options */} + +

+ + Data Import Options +

+ +
+
+ + setOptions((prev) => ({ + ...prev, + import_subscriptions: checked, + })) + } + /> +
+ +

+ Create subscription records from Members CSV data +

+
+
+ +
+ + setOptions((prev) => ({ + ...prev, + import_payment_history: checked, + })) + } + /> +
+ +

+ Include payment history from Payments CSV in subscription notes +

+
+
+ +
+ + setOptions((prev) => ({ + ...prev, + skip_errors: checked, + })) + } + /> +
+ +

+ Skip rows with errors and continue importing valid rows +

+
+
+
+
+ + {/* Summary */} + +
+ +
+

Import Summary

+

+ Ready to import {uploadSummary?.users?.valid || 0} users + {options.import_subscriptions && + uploadSummary?.members?.matched > 0 && + ` with ${uploadSummary.members.matched} subscriptions`} + . All imported users will have{' '} + + force_password_change + {' '} + enabled. +

+
+
+
+
+ ); + + // Step 4: Results + const renderResultsStep = () => ( +
+ {importResults && ( + <> + {/* Success Banner */} +
+ {importResults.failed_rows === 0 ? ( + + ) : ( + + )} +

+ {importResults.failed_rows === 0 + ? 'Import Completed Successfully!' + : 'Import Completed with Some Errors'} +

+
+ + {/* Results Grid */} +
+ +

+ {importResults.successful_rows} +

+

Users Imported

+
+ +

+ {importResults.failed_rows} +

+

Failed

+
+ +

+ {importResults.created_subscriptions} +

+

Subscriptions

+
+ +

+ {importResults.emails_sent} +

+

Emails Sent

+
+
+ + {/* Error List */} + {importResults.errors?.length > 0 && ( + +

+ Import Errors ({importResults.errors.length}) +

+
+ {importResults.errors.slice(0, 20).map((error, idx) => ( +
+ + Row {error.row}: + {' '} + {error.error} + {error.email && ( + ({error.email}) + )} +
+ ))} + {importResults.errors.length > 20 && ( +

+ ...and {importResults.errors.length - 20} more errors +

+ )} +
+
+ )} + + {/* Next Steps */} + +

+ Next Steps +

+
    +
  • + Review imported users in the Members management page +
  • + {options.skip_notifications && ( +
  • + Send bulk password reset emails when ready +
  • + )} +
  • + Use the Import Jobs page to rollback if needed +
  • +
+
+ + )} +
+ ); + + return ( + + + + + WordPress Import Wizard + + + Import users, subscriptions, and payment history from WordPress + + + + {/* Step Indicator */} +
+ {STEPS.map((step, idx) => { + const StepIcon = step.icon; + const isActive = idx === currentStep; + const isCompleted = idx < currentStep; + + return ( + +
+
+ {isCompleted ? ( + + ) : ( + + )} +
+ + {step.title} + +
+ {idx < STEPS.length - 1 && ( +
+ )} + + ); + })} +
+ + {/* Step Content */} +
{renderStepContent()}
+ + + {/* Back Button */} + {currentStep > 0 && currentStep < 3 && ( + + )} + + {/* Cancel Button */} + {currentStep < 3 && ( + + )} + + {/* Action Buttons */} + {currentStep === 0 && ( + + )} + + {currentStep === 1 && ( + + )} + + {currentStep === 2 && ( + + )} + + {currentStep === 3 && ( + + )} + + +
+ ); +}; + +export default ComprehensiveImportWizard; diff --git a/src/components/IdleSessionWarning.js b/src/components/IdleSessionWarning.js index adf450c..138594e 100644 --- a/src/components/IdleSessionWarning.js +++ b/src/components/IdleSessionWarning.js @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; +import api from '../utils/api'; import logger from '../utils/logger'; import { Dialog, @@ -20,6 +21,7 @@ import { AlertTriangle, RefreshCw } from 'lucide-react'; * - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min) * - Auto-logout on expiration * - "Stay Logged In" extends session + * - Checks session validity when tab becomes visible after being hidden */ const IdleSessionWarning = () => { const { user, logout, refreshUser } = useAuth(); @@ -33,11 +35,13 @@ const IdleSessionWarning = () => { const [showWarning, setShowWarning] = useState(false); const [timeRemaining, setTimeRemaining] = useState(60); // seconds const [isExtending, setIsExtending] = useState(false); + const [isCheckingSession, setIsCheckingSession] = useState(false); const activityTimeoutRef = useRef(null); const warningTimeoutRef = useRef(null); const countdownIntervalRef = useRef(null); const lastActivityRef = useRef(Date.now()); + const lastVisibilityCheckRef = useRef(Date.now()); // Reset activity timer const resetActivityTimer = useCallback(() => { @@ -120,6 +124,83 @@ const IdleSessionWarning = () => { } }; + // Check if session is still valid (called when tab becomes visible) + const checkSessionValidity = useCallback(async () => { + if (!user || isCheckingSession) return; + + const token = localStorage.getItem('token'); + if (!token) { + logger.log('[IdleSessionWarning] No token found on visibility change'); + handleSessionExpired(); + return; + } + + setIsCheckingSession(true); + try { + // Make a lightweight API call to verify token is still valid + await api.get('/auth/me'); + logger.log('[IdleSessionWarning] Session still valid after visibility change'); + + // Reset the activity timer since user is back + resetActivityTimer(); + } catch (error) { + logger.error('[IdleSessionWarning] Session invalid on visibility change:', error); + + // If 401, the interceptor will handle redirect + // For other errors, still redirect to be safe + if (error.response?.status !== 401) { + handleSessionExpired(); + } + } finally { + setIsCheckingSession(false); + } + }, [user, isCheckingSession, handleSessionExpired, resetActivityTimer]); + + // Handle visibility change (when user returns to tab after being away) + useEffect(() => { + if (!user) return; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + const now = Date.now(); + const timeSinceLastCheck = now - lastVisibilityCheckRef.current; + + // Only check if tab was hidden for more than 1 minute + // This prevents unnecessary API calls for quick tab switches + if (timeSinceLastCheck > 60 * 1000) { + logger.log('[IdleSessionWarning] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds'); + checkSessionValidity(); + } + + lastVisibilityCheckRef.current = now; + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [user, checkSessionValidity]); + + // Listen for session expired event from API interceptor + useEffect(() => { + const handleAuthSessionExpired = () => { + logger.log('[IdleSessionWarning] Received auth:session-expired event'); + // Clear all timers + if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current); + if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current); + if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current); + setShowWarning(false); + }; + + window.addEventListener('auth:session-expired', handleAuthSessionExpired); + + return () => { + window.removeEventListener('auth:session-expired', handleAuthSessionExpired); + }; + }, []); + // Track user activity useEffect(() => { if (!user) return; diff --git a/src/components/MemberCard.js b/src/components/MemberCard.js index c61dcce..71feb7b 100644 --- a/src/components/MemberCard.js +++ b/src/components/MemberCard.js @@ -3,6 +3,7 @@ import { Card } from './ui/card'; import { Button } from './ui/button'; import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react'; import MemberBadge from './MemberBadge'; +import useDirectoryConfig from '../hooks/use-directory-config'; // Helper function to get initials const getInitials = (firstName, lastName) => { @@ -20,6 +21,7 @@ const getSocialMediaLink = (url) => { const MemberCard = ({ member, onViewProfile, tiers }) => { const memberSince = member.member_since || member.created_at; + const { isFieldEnabled } = useDirectoryConfig(); return ( {/* Member Tier Badge */} @@ -48,7 +50,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => { {/* Partner Name */} - {member.directory_partner_name && ( + {isFieldEnabled('directory_partner_name') && member.directory_partner_name && (
@@ -58,7 +60,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => { )} {/* Bio */} - {member.directory_bio && ( + {isFieldEnabled('directory_bio') && member.directory_bio && (

{member.directory_bio}

@@ -79,7 +81,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => { {/* Contact Information */}
- {member.directory_email && ( + {isFieldEnabled('directory_email') && member.directory_email && ( )} - {member.directory_phone && ( + {isFieldEnabled('directory_phone') && member.directory_phone && ( )} - {member.directory_address && ( + {isFieldEnabled('directory_address') && member.directory_address && (
@@ -116,7 +118,7 @@ const MemberCard = ({ member, onViewProfile, tiers }) => {
{/* Social Media Links */} - {(member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && ( + {isFieldEnabled('social_media') && (member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
{member.social_media_facebook && ( diff --git a/src/components/PaymentMethodsSection.js b/src/components/PaymentMethodsSection.js index 5b67c35..b2bed3f 100644 --- a/src/components/PaymentMethodsSection.js +++ b/src/components/PaymentMethodsSection.js @@ -1,18 +1,15 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { loadStripe } from '@stripe/stripe-js'; import { Elements } from '@stripe/react-stripe-js'; import { Card } from './ui/card'; import { Button } from './ui/button'; import { CreditCard, Plus, Loader2, AlertCircle } from 'lucide-react'; import { toast } from 'sonner'; import api from '../utils/api'; +import useStripeConfig from '../hooks/use-stripe-config'; import PaymentMethodCard from './PaymentMethodCard'; import AddPaymentMethodDialog from './AddPaymentMethodDialog'; import ConfirmationDialog from './ConfirmationDialog'; -// Initialize Stripe with publishable key from environment -const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY); - /** * PaymentMethodsSection - Manages user payment methods * @@ -28,6 +25,9 @@ const PaymentMethodsSection = () => { const [actionLoading, setActionLoading] = useState(false); const [error, setError] = useState(null); + // Get Stripe configuration from API + const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig(); + // Dialog states const [addDialogOpen, setAddDialogOpen] = useState(false); const [clientSecret, setClientSecret] = useState(null); diff --git a/src/components/SettingsSidebar.js b/src/components/SettingsSidebar.js index 2c25883..fc08858 100644 --- a/src/components/SettingsSidebar.js +++ b/src/components/SettingsSidebar.js @@ -1,12 +1,12 @@ import React from 'react'; import { NavLink, useLocation } from 'react-router-dom'; -import { CreditCard, Shield, Star, Palette, FileEdit } from 'lucide-react'; +import { CreditCard, Shield, Star, Palette, FileEdit, BookUser } from 'lucide-react'; const settingsItems = [ { label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard }, { label: 'Permissions', path: '/admin/settings/permissions', icon: Shield }, { label: 'Theme', path: '/admin/settings/theme', icon: Palette }, - + { label: 'Directory', path: '/admin/settings/directory', icon: BookUser }, ]; const SettingsTabs = () => { diff --git a/src/components/TemplateImportWizard.js b/src/components/TemplateImportWizard.js new file mode 100644 index 0000000..c468456 --- /dev/null +++ b/src/components/TemplateImportWizard.js @@ -0,0 +1,1084 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Card } from './ui/card'; +import { Label } from './ui/label'; +import { Checkbox } from './ui/checkbox'; +import { Badge } from './ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from './ui/table'; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from './ui/tabs'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from './ui/accordion'; +import { + Upload, + Download, + FileSpreadsheet, + Users, + CreditCard, + Heart, + Receipt, + ClipboardList, + CheckCircle2, + XCircle, + AlertTriangle, + Loader2, + ChevronLeft, + ChevronRight, + Info, + FileDown, + ExternalLink, +} from 'lucide-react'; +import { toast } from 'sonner'; +import api from '../utils/api'; + +const STEPS = [ + { id: 'templates', title: 'Download Templates', icon: Download }, + { id: 'upload', title: 'Upload Files', icon: Upload }, + { id: 'preview', title: 'Preview Data', icon: FileSpreadsheet }, + { id: 'options', title: 'Import Options', icon: Users }, + { id: 'execute', title: 'Execute Import', icon: CheckCircle2 }, +]; + +const FILE_TYPES = { + users: { name: 'Users', icon: Users, color: 'text-blue-600', required: true }, + subscriptions: { name: 'Subscriptions', icon: CreditCard, color: 'text-green-600', required: false }, + donations: { name: 'Donations', icon: Heart, color: 'text-pink-600', required: false }, + payments: { name: 'Payments', icon: Receipt, color: 'text-purple-600', required: false }, + registration_data: { name: 'Custom Fields', icon: ClipboardList, color: 'text-orange-600', required: false }, +}; + +const STATUS_COLORS = { + active: 'bg-green-100 text-green-800', + inactive: 'bg-gray-100 text-gray-800', + pending_email: 'bg-yellow-100 text-yellow-800', + pending_validation: 'bg-purple-100 text-purple-800', + pre_validated: 'bg-blue-100 text-blue-800', + payment_pending: 'bg-orange-100 text-orange-800', + canceled: 'bg-red-100 text-red-800', + expired: 'bg-red-100 text-red-800', +}; + +/** + * TemplateImportWizard - Template-based CSV import wizard + * + * Provides standardized CSV templates for importing: + * - Users (required) + * - Subscriptions (optional) + * - Donations (optional) + * - Payments (optional) + * - Custom Registration Fields (optional) + */ +const TemplateImportWizard = ({ open, onOpenChange, onSuccess }) => { + const [currentStep, setCurrentStep] = useState(0); + const [loading, setLoading] = useState(false); + const [importJobId, setImportJobId] = useState(null); + + // Templates state + const [templates, setTemplates] = useState([]); + const [templatesLoading, setTemplatesLoading] = useState(false); + + // File upload state + const [files, setFiles] = useState({ + users: null, + subscriptions: null, + donations: null, + payments: null, + registration_data: null, + }); + + // Upload/validation results + const [uploadSummary, setUploadSummary] = useState(null); + + // Preview state + const [previewTab, setPreviewTab] = useState('users'); + const [previewData, setPreviewData] = useState({}); + const [previewPage, setPreviewPage] = useState(1); + const [previewTotalPages, setPreviewTotalPages] = useState(1); + + // Import options + const [options, setOptions] = useState({ + skip_notifications: true, + update_existing: false, + import_subscriptions: true, + import_donations: true, + import_payments: true, + import_registration_data: true, + skip_errors: true, + }); + + // Results state + const [importResults, setImportResults] = useState(null); + + // Fetch templates on mount + useEffect(() => { + if (open && templates.length === 0) { + fetchTemplates(); + } + }, [open]); + + const fetchTemplates = async () => { + setTemplatesLoading(true); + try { + const response = await api.get('/admin/import/templates'); + setTemplates(response.data); + } catch (error) { + toast.error('Failed to load templates'); + } finally { + setTemplatesLoading(false); + } + }; + + // Reset wizard state + const resetWizard = useCallback(() => { + setCurrentStep(0); + setLoading(false); + setImportJobId(null); + setFiles({ + users: null, + subscriptions: null, + donations: null, + payments: null, + registration_data: null, + }); + setUploadSummary(null); + setPreviewData({}); + setPreviewTab('users'); + setPreviewPage(1); + setPreviewTotalPages(1); + setOptions({ + skip_notifications: true, + update_existing: false, + import_subscriptions: true, + import_donations: true, + import_payments: true, + import_registration_data: true, + skip_errors: true, + }); + setImportResults(null); + }, []); + + // Handle dialog close + const handleClose = () => { + if (loading) return; + resetWizard(); + onOpenChange(false); + }; + + // Download template + const downloadTemplate = async (templateType) => { + try { + const response = await api.get(`/admin/import/templates/${templateType}/download`, { + responseType: 'blob', + }); + + const blob = new Blob([response.data], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${templateType}_template.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + toast.success(`Downloaded ${templateType} template`); + } catch (error) { + toast.error(`Failed to download ${templateType} template`); + } + }; + + // Handle file selection + const handleFileChange = (fileType, file) => { + setFiles((prev) => ({ + ...prev, + [fileType]: file, + })); + }; + + // Step 2: Upload files + const handleUpload = async () => { + if (!files.users) { + toast.error('Please select a Users CSV file (required)'); + return; + } + + setLoading(true); + try { + const formData = new FormData(); + formData.append('users_file', files.users); + + if (files.subscriptions) { + formData.append('subscriptions_file', files.subscriptions); + } + if (files.donations) { + formData.append('donations_file', files.donations); + } + if (files.payments) { + formData.append('payments_file', files.payments); + } + if (files.registration_data) { + formData.append('registration_data_file', files.registration_data); + } + + const response = await api.post('/admin/import/template/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + setImportJobId(response.data.import_job_id); + setUploadSummary(response.data); + + // Fetch preview for users + await fetchPreview(response.data.import_job_id, 'users', 1); + + toast.success('Files uploaded and validated successfully'); + setCurrentStep(2); + } catch (error) { + const message = error.response?.data?.detail?.message || error.response?.data?.detail || 'Failed to upload files'; + toast.error(message); + } finally { + setLoading(false); + } + }; + + // Fetch preview data + const fetchPreview = async (jobId, fileType, page) => { + try { + const response = await api.get(`/admin/import/template/${jobId}/preview`, { + params: { + file_type: fileType, + page, + page_size: 10, + }, + }); + + setPreviewData((prev) => ({ + ...prev, + [fileType]: response.data, + })); + setPreviewTotalPages(response.data.total_pages); + setPreviewPage(page); + } catch (error) { + toast.error('Failed to fetch preview'); + } + }; + + // Handle preview tab change + const handlePreviewTabChange = async (tab) => { + setPreviewTab(tab); + setPreviewPage(1); + + if (!previewData[tab] && importJobId) { + await fetchPreview(importJobId, tab, 1); + } + }; + + // Execute import + const handleExecute = async () => { + if (!importJobId) return; + + setLoading(true); + try { + const response = await api.post(`/admin/import/template/${importJobId}/execute`, { + options, + }); + + setImportResults(response.data); + setCurrentStep(4); + toast.success('Import completed successfully'); + + if (onSuccess) { + onSuccess(); + } + } catch (error) { + const message = error.response?.data?.detail || 'Failed to execute import'; + toast.error(message); + } finally { + setLoading(false); + } + }; + + // Render step indicator + const renderStepIndicator = () => ( +
+ {STEPS.map((step, index) => { + const Icon = step.icon; + const isActive = index === currentStep; + const isCompleted = index < currentStep; + + return ( + +
+
+ {isCompleted ? ( + + ) : ( + + )} +
+ + {step.title} + +
+ {index < STEPS.length - 1 && ( +
+ )} + + ); + })} +
+ ); + + // Step 1: Download Templates + const renderTemplatesStep = () => ( +
+
+
+ +
+

How Template Import Works

+

+ 1. Download the CSV templates below
+ 2. Fill in your data following the template format
+ 3. Upload your completed files in the next step
+ 4. Review and import your data +

+
+
+
+ +
+ {templatesLoading ? ( +
+ +
+ ) : ( + templates.map((template) => { + const fileType = FILE_TYPES[template.type]; + const Icon = fileType?.icon || FileSpreadsheet; + + return ( + +
+
+
+ +
+
+
+

{template.name}

+ {template.required ? ( + Required + ) : ( + Optional + )} +
+

{template.description}

+

+ {template.field_count} fields | Required: {template.required_fields.join(', ')} +

+
+
+ +
+
+ ); + }) + )} +
+ +
+ +
+
+ ); + + // Step 2: Upload Files + const renderUploadStep = () => ( +
+
+
+ +
+

Important

+

+ The Users CSV is required. Other files are optional and will be linked by email address. +

+
+
+
+ +
+ {Object.entries(FILE_TYPES).map(([key, config]) => { + const Icon = config.icon; + const file = files[key]; + + return ( + +
+
+
+ +
+
+
+

{config.name}

+ {config.required ? ( + Required + ) : ( + Optional + )} +
+ {file ? ( +

{file.name}

+ ) : ( +

No file selected

+ )} +
+
+
+ {file && ( + + )} + +
+
+
+ ); + })} +
+ +
+ + +
+
+ ); + + // Step 3: Preview Data + const renderPreviewStep = () => { + const currentPreview = previewData[previewTab]; + const validation = uploadSummary?.validation?.[previewTab]; + const crossValidation = uploadSummary?.cross_validation; + + return ( +
+ {/* Summary Cards */} + {crossValidation && ( +
+ +
+ {crossValidation.summary.total_users} +
+
Total Users
+
+ +
+ {crossValidation.summary.new_users} +
+
New Users
+
+ +
+ {crossValidation.summary.total_subscriptions} +
+
Subscriptions
+
+ +
+ {crossValidation.summary.total_donations} +
+
Donations
+
+
+ )} + + {/* Warnings */} + {crossValidation?.warnings?.length > 0 && ( +
+

+ + Warnings +

+
    + {crossValidation.warnings.map((warning, idx) => ( +
  • - {warning}
  • + ))} +
+
+ )} + + {/* Preview Tabs */} + + + {Object.entries(FILE_TYPES).map(([key, config]) => { + const Icon = config.icon; + const hasData = uploadSummary?.files_uploaded?.[key]; + + return ( + + + {config.name} + + ); + })} + + + + {/* Validation Status */} + {validation && ( +
+ + + {validation.valid_rows} valid + + {validation.invalid_rows > 0 && ( + + + {validation.invalid_rows} invalid + + )} +
+ )} + + {/* Data Table */} + {currentPreview?.rows?.length > 0 ? ( +
+
+ + + + # + {previewTab === 'users' && ( + <> + Email + Name + Status + Role + + )} + {previewTab === 'subscriptions' && ( + <> + Email + Plan + Status + Amount + + )} + {previewTab === 'donations' && ( + <> + Email/Donor + Amount + Date + Type + + )} + {previewTab === 'payments' && ( + <> + Email + Amount + Date + Type + + )} + {previewTab === 'registration_data' && ( + <> + Email + Field Name + Value + + )} + + + + {currentPreview.rows.map((row, idx) => ( + + + {row._row_number || idx + 1} + + {previewTab === 'users' && ( + <> + {row.email} + {row.first_name} {row.last_name} + + + {row.status || 'active'} + + + {row.role || 'member'} + + )} + {previewTab === 'subscriptions' && ( + <> + {row.email} + {row.plan_name} + + + {row.status || 'active'} + + + ${row.amount} + + )} + {previewTab === 'donations' && ( + <> + + {row.email || row.donor_name || 'Anonymous'} + + ${row.amount} + {row.date?.toString()} + {row.type || 'member'} + + )} + {previewTab === 'payments' && ( + <> + {row.email} + ${row.amount} + {row.date?.toString()} + {row.type || 'subscription'} + + )} + {previewTab === 'registration_data' && ( + <> + {row.email} + {row.field_name} + {row.field_value} + + )} + + ))} + +
+
+ + {/* Pagination */} + {currentPreview.total_pages > 1 && ( +
+ + Page {previewPage} of {currentPreview.total_pages} + +
+ + +
+
+ )} +
+ ) : ( +
+ No data available for this file type +
+ )} + + {/* Errors */} + {validation?.errors_preview?.length > 0 && ( + + + + + {validation.invalid_rows} Validation Errors + + +
+ {validation.errors_preview.map((error, idx) => ( +
+ Row {error.row}:{' '} + {error.errors.join(', ')} +
+ ))} +
+
+
+
+ )} +
+
+ +
+ + +
+
+ ); + }; + + // Step 4: Import Options + const renderOptionsStep = () => ( +
+ +

Notification Settings

+
+
+
+ +

+ Don't send welcome or password emails during import +

+
+ + setOptions((prev) => ({ ...prev, skip_notifications: checked })) + } + /> +
+
+
+ + +

User Import Settings

+
+
+
+ +

+ If email already exists, update the user instead of skipping +

+
+ + setOptions((prev) => ({ ...prev, update_existing: checked })) + } + /> +
+
+
+ +

+ Continue importing even if some rows have errors +

+
+ + setOptions((prev) => ({ ...prev, skip_errors: checked })) + } + /> +
+
+
+ + +

Data Import Settings

+
+ {uploadSummary?.files_uploaded?.subscriptions && ( +
+
+ +

+ Create subscription records from subscriptions.csv +

+
+ + setOptions((prev) => ({ ...prev, import_subscriptions: checked })) + } + /> +
+ )} + {uploadSummary?.files_uploaded?.donations && ( +
+
+ +

+ Create donation records from donations.csv +

+
+ + setOptions((prev) => ({ ...prev, import_donations: checked })) + } + /> +
+ )} + {uploadSummary?.files_uploaded?.payments && ( +
+
+ +

+ Create payment records from payments.csv +

+
+ + setOptions((prev) => ({ ...prev, import_payments: checked })) + } + /> +
+ )} + {uploadSummary?.files_uploaded?.registration_data && ( +
+
+ +

+ Save custom registration data from registration_data.csv +

+
+ + setOptions((prev) => ({ ...prev, import_registration_data: checked })) + } + /> +
+ )} +
+
+ +
+

After Import

+

+ All imported users will have force_password_change enabled. + You can use the Bulk Password Reset feature to send login instructions after import. +

+
+ +
+ + +
+
+ ); + + // Step 5: Results + const renderResultsStep = () => { + if (!importResults) return null; + + const { results, errors, error_count } = importResults; + + return ( +
+
+ +

Import Complete

+
+ +
+ +
+ {results.users.imported} +
+
Users Imported
+
+ +
+ {results.users.updated} +
+
Users Updated
+
+ +
+ {results.subscriptions.imported} +
+
Subscriptions
+
+ +
+ {results.donations.imported} +
+
Donations
+
+
+ + {results.users.failed > 0 && ( +
+

+ + {results.users.failed} users failed to import +

+
+ )} + + {error_count > 0 && ( + + + + + {error_count} Errors + + +
+ {errors.map((error, idx) => ( +
+ {error.type && {error.type}} + Row {error.row}:{' '} + {error.error} +
+ ))} +
+
+
+
+ )} + +
+ +
+
+ ); + }; + + // Render current step content + const renderStepContent = () => { + switch (currentStep) { + case 0: + return renderTemplatesStep(); + case 1: + return renderUploadStep(); + case 2: + return renderPreviewStep(); + case 3: + return renderOptionsStep(); + case 4: + return renderResultsStep(); + default: + return null; + } + }; + + return ( + + + + + + Template Import Wizard + + + Import users and related data using standardized CSV templates + + + + {renderStepIndicator()} + {renderStepContent()} + + + ); +}; + +export default TemplateImportWizard; diff --git a/src/components/admin/AdminPaymentMethodsPanel.js b/src/components/admin/AdminPaymentMethodsPanel.js index 1376a7a..90a2550 100644 --- a/src/components/admin/AdminPaymentMethodsPanel.js +++ b/src/components/admin/AdminPaymentMethodsPanel.js @@ -1,5 +1,4 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { loadStripe } from '@stripe/stripe-js'; import { Elements } from '@stripe/react-stripe-js'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; @@ -26,13 +25,11 @@ import { } from 'lucide-react'; import { toast } from 'sonner'; import api from '../../utils/api'; +import useStripeConfig from '../../hooks/use-stripe-config'; import ConfirmationDialog from '../ConfirmationDialog'; import PasswordConfirmDialog from '../PasswordConfirmDialog'; import AddPaymentMethodDialog from '../AddPaymentMethodDialog'; -// Initialize Stripe with publishable key from environment -const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY); - /** * Get icon for payment method type */ @@ -76,6 +73,9 @@ const AdminPaymentMethodsPanel = ({ userId, userName }) => { const [actionLoading, setActionLoading] = useState(false); const [error, setError] = useState(null); + // Get Stripe configuration from API + const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig(); + // Dialog states const [addCardDialogOpen, setAddCardDialogOpen] = useState(false); const [addManualDialogOpen, setAddManualDialogOpen] = useState(false); diff --git a/src/hooks/use-directory-config.js b/src/hooks/use-directory-config.js new file mode 100644 index 0000000..c7cb88c --- /dev/null +++ b/src/hooks/use-directory-config.js @@ -0,0 +1,92 @@ +import { useState, useEffect, useCallback } from 'react'; +import api from '../utils/api'; + +/** + * Default directory configuration - used as fallback if API fails + */ +const DEFAULT_DIRECTORY_CONFIG = { + fields: { + show_in_directory: { enabled: true, label: 'Show in Directory', required: false }, + directory_email: { enabled: true, label: 'Directory Email', required: false }, + directory_bio: { enabled: true, label: 'Bio', required: false }, + directory_address: { enabled: true, label: 'Address', required: false }, + directory_phone: { enabled: true, label: 'Phone', required: false }, + directory_dob: { enabled: true, label: 'Birthday', required: false }, + directory_partner_name: { enabled: true, label: 'Partner Name', required: false }, + volunteer_interests: { enabled: true, label: 'Volunteer Interests', required: false }, + social_media: { enabled: true, label: 'Social Media Links', required: false }, + } +}; + +/** + * Hook to fetch and manage directory field configuration + * @returns {Object} - { config, loading, error, isFieldEnabled, getFieldLabel, refetch } + */ +const useDirectoryConfig = () => { + const [config, setConfig] = useState(DEFAULT_DIRECTORY_CONFIG); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchConfig = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await api.get('/directory/config'); + setConfig(response.data || DEFAULT_DIRECTORY_CONFIG); + } catch (err) { + console.error('Failed to fetch directory config:', err); + setError(err); + // Use default config on error + setConfig(DEFAULT_DIRECTORY_CONFIG); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + /** + * Check if a field is enabled in the config + * @param {string} fieldId - The field ID to check (e.g., 'directory_email') + * @returns {boolean} - Whether the field is enabled + */ + const isFieldEnabled = useCallback((fieldId) => { + const field = config?.fields?.[fieldId]; + return field?.enabled !== false; // Default to true if not specified + }, [config]); + + /** + * Get the label for a field + * @param {string} fieldId - The field ID + * @param {string} defaultLabel - Default label if not in config + * @returns {string} - The field label + */ + const getFieldLabel = useCallback((fieldId, defaultLabel = '') => { + const field = config?.fields?.[fieldId]; + return field?.label || defaultLabel; + }, [config]); + + /** + * Check if a field is required + * @param {string} fieldId - The field ID + * @returns {boolean} - Whether the field is required + */ + const isFieldRequired = useCallback((fieldId) => { + const field = config?.fields?.[fieldId]; + return field?.required === true; + }, [config]); + + return { + config, + loading, + error, + isFieldEnabled, + getFieldLabel, + isFieldRequired, + refetch: fetchConfig + }; +}; + +export default useDirectoryConfig; diff --git a/src/hooks/use-stripe-config.js b/src/hooks/use-stripe-config.js new file mode 100644 index 0000000..a65daae --- /dev/null +++ b/src/hooks/use-stripe-config.js @@ -0,0 +1,91 @@ +import { useState, useEffect, useCallback } from 'react'; +import { loadStripe } from '@stripe/stripe-js'; +import api from '../utils/api'; + +// Cache the stripe promise to avoid multiple loads +let stripePromiseCache = null; +let cachedPublishableKey = null; + +/** + * Hook to get Stripe configuration from the backend. + * + * Returns the Stripe publishable key and a pre-initialized Stripe promise. + * The publishable key is fetched from the backend API, allowing admins + * to configure it through the admin panel instead of environment variables. + */ +const useStripeConfig = () => { + const [publishableKey, setPublishableKey] = useState(cachedPublishableKey); + const [stripePromise, setStripePromise] = useState(stripePromiseCache); + const [loading, setLoading] = useState(!cachedPublishableKey); + const [error, setError] = useState(null); + const [environment, setEnvironment] = useState(null); + + const fetchConfig = useCallback(async () => { + // If we already have a cached key, use it + if (cachedPublishableKey && stripePromiseCache) { + setPublishableKey(cachedPublishableKey); + setStripePromise(stripePromiseCache); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + + const response = await api.get('/config/stripe'); + const { publishable_key, environment: env } = response.data; + + // Cache the key and stripe promise + cachedPublishableKey = publishable_key; + stripePromiseCache = loadStripe(publishable_key); + + setPublishableKey(publishable_key); + setStripePromise(stripePromiseCache); + setEnvironment(env); + } catch (err) { + console.error('[useStripeConfig] Failed to fetch Stripe config:', err); + setError(err.response?.data?.detail || 'Failed to load Stripe configuration'); + + // Fallback to environment variable if available + const envKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY; + if (envKey) { + console.warn('[useStripeConfig] Falling back to environment variable'); + cachedPublishableKey = envKey; + stripePromiseCache = loadStripe(envKey); + setPublishableKey(envKey); + setStripePromise(stripePromiseCache); + setEnvironment(envKey.startsWith('pk_live_') ? 'live' : 'test'); + setError(null); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + // Function to clear cache (useful after admin updates settings) + const clearCache = useCallback(() => { + cachedPublishableKey = null; + stripePromiseCache = null; + setPublishableKey(null); + setStripePromise(null); + fetchConfig(); + }, [fetchConfig]); + + return { + publishableKey, + stripePromise, + loading, + error, + environment, + refetch: fetchConfig, + clearCache, + isConfigured: !!publishableKey, + }; +}; + +export default useStripeConfig; diff --git a/src/pages/Login.js b/src/pages/Login.js index 5bab4b7..73ac36e 100644 --- a/src/pages/Login.js +++ b/src/pages/Login.js @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { useNavigate, Link } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { useNavigate, Link, useLocation, useSearchParams } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { Button } from '../components/ui/button'; import { Input } from '../components/ui/input'; @@ -13,13 +13,40 @@ import { ArrowRight, ArrowLeft } from 'lucide-react'; const Login = () => { const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); const { login } = useAuth(); + const basename = process.env.REACT_APP_BASENAME || ''; const [loading, setLoading] = useState(false); const [formData, setFormData] = useState({ email: '', password: '' }); + // Show session expiry message on mount + useEffect(() => { + const sessionParam = searchParams.get('session'); + const stateMessage = location.state?.message; + + if (sessionParam === 'expired') { + toast.info('Your session has expired. Please log in again.', { + duration: 5000, + }); + // Clean up URL (respect basename for subpath deployments) + window.history.replaceState({}, '', `${basename}/login`); + } else if (sessionParam === 'idle') { + toast.info('You were logged out due to inactivity. Please log in again.', { + duration: 5000, + }); + // Clean up URL (respect basename for subpath deployments) + window.history.replaceState({}, '', `${basename}/login`); + } else if (stateMessage) { + toast.info(stateMessage, { + duration: 5000, + }); + } + }, [searchParams, location.state, basename]); + const handleInputChange = (e) => { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); diff --git a/src/pages/Profile.js b/src/pages/Profile.js index b60790a..bea4e3d 100644 --- a/src/pages/Profile.js +++ b/src/pages/Profile.js @@ -13,6 +13,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar'; import ChangePasswordDialog from '../components/ChangePasswordDialog'; import PaymentMethodsSection from '../components/PaymentMethodsSection'; import { useNavigate } from 'react-router-dom'; +import useDirectoryConfig from '../hooks/use-directory-config'; const Profile = () => { const { user } = useAuth(); @@ -29,6 +30,7 @@ const Profile = () => { const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [initialFormData, setInitialFormData] = useState(null); const navigate = useNavigate(); + const { isFieldEnabled, loading: directoryConfigLoading } = useDirectoryConfig(); const [formData, setFormData] = useState({ first_name: '', last_name: '', @@ -427,107 +429,121 @@ const Profile = () => {
{/* Member Directory Settings */} -
-

- - Member Directory Settings -

-

- Control your visibility and information in the member directory. -

+ {isFieldEnabled('show_in_directory') && ( +
+

+ + Member Directory Settings +

+

+ Control your visibility and information in the member directory. +

-
- - -
- - {formData.show_in_directory && ( -
-
- - -
- -
- -