+ }
+ confirmText="Save"
+ variant="info"
+ loading={actionLoading}
+ />
+
+ {/* Delete Confirmation Dialog */}
+
+ >
+ );
+};
+
+export default AdminPaymentMethodsPanel;
diff --git a/src/components/admin/SubscriptionsTable.jsx b/src/components/admin/SubscriptionsTable.jsx
new file mode 100644
index 0000000..e12adbc
--- /dev/null
+++ b/src/components/admin/SubscriptionsTable.jsx
@@ -0,0 +1,347 @@
+import React from 'react';
+import { Button } from '../ui/button';
+import StatusBadge from '../StatusBadge';
+import {
+ ChevronDown,
+ ChevronUp,
+ Edit,
+ XCircle,
+ CreditCard,
+ Info,
+ ExternalLink,
+ Copy
+} from 'lucide-react';
+
+const HEADER_CELLS = [
+ { label: 'Member', align: 'text-left' },
+ { label: 'Plan', align: 'text-left' },
+ { label: 'Status', align: 'text-left' },
+ { label: 'Period', align: 'text-left' },
+ { label: 'Base Fee', align: 'text-right' },
+ { label: 'Donation', align: 'text-right' },
+ { label: 'Total', align: 'text-right' },
+ { label: 'Details', align: 'text-center' },
+ { label: 'Actions', align: 'text-center' }
+];
+
+const HeaderCell = ({ align, children }) => (
+
+);
+
+const TableCell = ({ align = 'text-left', className = '', style, children, ...props }) => (
+
+);
+
+
+const SubscriptionRow = ({
+ sub,
+ isExpanded,
+ onToggle,
+ onEdit,
+ onCancel,
+ hasPermission,
+ formatDate,
+ formatDateTime,
+ formatPrice,
+ copyToClipboard
+}) => (
+ <>
+
+ )}
+ >
+);
+
+const SubscriptionsTable = ({
+ subscriptions,
+ expandedRows,
+ onToggleRowExpansion,
+ onEdit,
+ onCancel,
+ hasPermission,
+ formatDate,
+ formatDateTime,
+ formatPrice,
+ copyToClipboard
+}) => (
+
+);
+
+export default SubscriptionsTable;
diff --git a/src/components/registration/DynamicFormField.js b/src/components/registration/DynamicFormField.js
new file mode 100644
index 0000000..5e097f2
--- /dev/null
+++ b/src/components/registration/DynamicFormField.js
@@ -0,0 +1,427 @@
+import React from 'react';
+import { Label } from '../ui/label';
+import { Input } from '../ui/input';
+import { Textarea } from '../ui/textarea';
+import { Checkbox } from '../ui/checkbox';
+import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '../ui/select';
+
+/**
+ * DynamicFormField - Renders form fields based on schema configuration
+ *
+ * Supports field types:
+ * - text, email, phone, password: Input fields
+ * - date: Date picker input
+ * - textarea: Multi-line text input
+ * - checkbox: Single checkbox
+ * - radio: Radio button group
+ * - dropdown: Select dropdown
+ * - multiselect: Checkbox group for multiple selections
+ * - address_group: Group of address-related fields
+ * - file_upload: File upload input
+ */
+const DynamicFormField = ({
+ field,
+ value,
+ onChange,
+ errors = [],
+ formData = {},
+}) => {
+ const {
+ id,
+ type,
+ label,
+ required,
+ placeholder,
+ options = [],
+ rows = 4,
+ validation = {},
+ } = field;
+
+ const hasError = errors.length > 0;
+ const errorMessage = errors[0];
+
+ const formatPhoneNumber = (rawValue) => {
+ const digits = String(rawValue || '').replace(/\D/g, '').slice(0, 10);
+ if (digits.length <= 3) return digits;
+ if (digits.length <= 6) {
+ return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
+ }
+ return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
+ };
+
+ // Common input className
+ const inputClassName = `h-14 rounded-xl border-2 ${
+ hasError
+ ? 'border-red-500 focus:border-red-500'
+ : 'border-[var(--neutral-800)] focus:border-brand-purple'
+ }`;
+
+ // Handle change for different field types
+ const handleInputChange = (e) => {
+ const { value: newValue, type: inputType, checked } = e.target;
+ if (inputType === 'checkbox') {
+ onChange(id, checked);
+ return;
+ }
+ if (type === 'phone') {
+ onChange(id, formatPhoneNumber(newValue));
+ return;
+ } else {
+ onChange(id, newValue);
+ }
+ };
+
+ const handleSelectChange = (newValue) => {
+ onChange(id, newValue);
+ };
+
+ const handleCheckboxChange = (checked) => {
+ onChange(id, checked);
+ };
+
+ const handleMultiselectChange = (optionValue) => {
+ const currentValues = Array.isArray(value) ? value : [];
+ const newValues = currentValues.includes(optionValue)
+ ? currentValues.filter((v) => v !== optionValue)
+ : [...currentValues, optionValue];
+ onChange(id, newValues);
+ };
+
+ // Render error message
+ const renderError = () => {
+ if (!hasError) return null;
+ return (
+
+ );
+
+ // Render based on field type
+ switch (type) {
+ case 'text':
+ case 'email':
+ case 'phone':
+ return (
+
+ );
+
+ case 'multiselect':
+ const selectedValues = Array.isArray(value) ? value : [];
+ return (
+
+ );
+
+ case 'address_group':
+ // Address group renders multiple related fields
+ return (
+
+ );
+
+ default:
+ console.warn(`Unknown field type: ${type}`);
+ return (
+
+ );
+ }
+};
+
+/**
+ * Get width class based on field width configuration
+ */
+export const getWidthClass = (width) => {
+ switch (width) {
+ case 'half':
+ return 'md:col-span-1';
+ case 'third':
+ return 'md:col-span-1';
+ case 'two-thirds':
+ return 'md:col-span-2';
+ case 'full':
+ default:
+ return 'md:col-span-2';
+ }
+};
+
+/**
+ * Get grid columns class based on field widths in a row
+ */
+export const getGridClass = (fields) => {
+ const hasThird = fields.some((f) => f.width === 'third');
+ if (hasThird) {
+ return 'grid md:grid-cols-3 gap-4';
+ }
+ return 'grid md:grid-cols-2 gap-4';
+};
+
+export default DynamicFormField;
diff --git a/src/components/registration/DynamicRegistrationForm.js b/src/components/registration/DynamicRegistrationForm.js
new file mode 100644
index 0000000..0e6b3c1
--- /dev/null
+++ b/src/components/registration/DynamicRegistrationForm.js
@@ -0,0 +1,482 @@
+import React, { useMemo, useCallback } from 'react';
+import DynamicFormField, { getWidthClass } from './DynamicFormField';
+
+/**
+ * DynamicRegistrationForm - Renders the entire registration form from schema
+ *
+ * Features:
+ * - Renders steps and sections based on schema
+ * - Handles conditional field visibility
+ * - Supports step navigation
+ * - Validates fields per step
+ */
+const DynamicRegistrationForm = ({
+ schema,
+ formData,
+ onFormDataChange,
+ currentStep,
+ errors = {},
+}) => {
+ // Get current step data
+ const stepData = useMemo(() => {
+ const steps = schema?.steps || [];
+ const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
+ return sortedSteps[currentStep - 1] || null;
+ }, [schema, currentStep]);
+
+ // Evaluate conditional rules to determine which fields are visible
+ const hiddenFields = useMemo(() => {
+ const rules = schema?.conditional_rules || [];
+ const hidden = new Set();
+
+ // First pass: collect fields that have "show" rules (hidden by default)
+ for (const rule of rules) {
+ if (rule.action === 'show') {
+ rule.target_fields?.forEach((fieldId) => hidden.add(fieldId));
+ }
+ }
+
+ // Second pass: evaluate rules and show/hide fields
+ for (const rule of rules) {
+ const {
+ trigger_field,
+ trigger_operator = 'equals',
+ trigger_value,
+ action,
+ target_fields = [],
+ } = rule;
+
+ const fieldValue = formData[trigger_field];
+ let conditionMet = false;
+
+ switch (trigger_operator) {
+ case 'equals':
+ conditionMet = fieldValue === trigger_value;
+ break;
+ case 'not_equals':
+ conditionMet = fieldValue !== trigger_value;
+ break;
+ case 'contains':
+ conditionMet = Array.isArray(fieldValue)
+ ? fieldValue.includes(trigger_value)
+ : String(fieldValue || '').includes(trigger_value);
+ break;
+ case 'not_empty':
+ conditionMet = Boolean(fieldValue);
+ break;
+ case 'empty':
+ conditionMet = !Boolean(fieldValue);
+ break;
+ default:
+ conditionMet = false;
+ }
+
+ if (conditionMet) {
+ if (action === 'show') {
+ target_fields.forEach((fieldId) => hidden.delete(fieldId));
+ } else if (action === 'hide') {
+ target_fields.forEach((fieldId) => hidden.add(fieldId));
+ }
+ }
+ }
+
+ return hidden;
+ }, [schema, formData]);
+
+ // Handle field change
+ const handleFieldChange = useCallback(
+ (fieldId, value) => {
+ onFormDataChange((prev) => ({
+ ...prev,
+ [fieldId]: value,
+ }));
+ },
+ [onFormDataChange]
+ );
+
+ // Check if a field is visible
+ const isFieldVisible = useCallback(
+ (fieldId) => {
+ return !hiddenFields.has(fieldId);
+ },
+ [hiddenFields]
+ );
+
+ // Get errors for a specific field
+ const getFieldErrors = useCallback(
+ (fieldId) => {
+ return errors[fieldId] || [];
+ },
+ [errors]
+ );
+
+ // Group fields by their width for rendering
+ const groupFieldsByRow = (fields) => {
+ const rows = [];
+ let currentRow = [];
+ let currentRowWidth = 0;
+
+ const visibleFields = fields.filter((f) => isFieldVisible(f.id));
+
+ for (const field of visibleFields) {
+ const width = field.width || 'full';
+ let widthValue = 1;
+
+ if (width === 'half') widthValue = 0.5;
+ else if (width === 'third') widthValue = 0.33;
+ else if (width === 'two-thirds') widthValue = 0.67;
+
+ if (currentRowWidth + widthValue > 1) {
+ if (currentRow.length > 0) {
+ rows.push(currentRow);
+ }
+ currentRow = [field];
+ currentRowWidth = widthValue;
+ } else {
+ currentRow.push(field);
+ currentRowWidth += widthValue;
+ }
+ }
+
+ if (currentRow.length > 0) {
+ rows.push(currentRow);
+ }
+
+ return rows;
+ };
+
+ if (!stepData) {
+ return (
+
+ );
+};
+
+/**
+ * DynamicStepIndicator - Renders step progress indicator
+ */
+export const DynamicStepIndicator = ({ steps, currentStep }) => {
+ const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
+
+ return (
+
+ );
+};
+
+/**
+ * Validate a single step based on schema
+ */
+export const validateStep = (stepData, formData, hiddenFields) => {
+ const errors = {};
+
+ if (!stepData?.sections) return { isValid: true, errors };
+
+ for (const section of stepData.sections) {
+ // Check section-level validation (e.g., atLeastOne)
+ const sectionValidation = section.validation || {};
+ if (sectionValidation.atLeastOne) {
+ const fieldIds = (section.fields || []).map((f) => f.id);
+ const hasValue = fieldIds.some((id) => {
+ if (hiddenFields.has(id)) return true; // Skip hidden fields
+ const value = formData[id];
+ return Boolean(value);
+ });
+ if (!hasValue) {
+ // Add error to first field in section
+ const firstFieldId = fieldIds[0];
+ if (firstFieldId) {
+ errors[firstFieldId] = [
+ sectionValidation.message ||
+ `At least one field in ${section.title || 'this section'} is required`,
+ ];
+ }
+ }
+ }
+
+ // Check field-level validation
+ for (const field of section.fields || []) {
+ const { id, required, validation = {}, type, label } = field;
+
+ // Skip hidden fields
+ if (hiddenFields.has(id)) continue;
+
+ // Skip client-only fields for server validation
+ if (field.client_only && field.id !== 'confirmPassword') continue;
+
+ const value = formData[id];
+
+ // Required check
+ if (required) {
+ const isEmpty =
+ value === undefined ||
+ value === null ||
+ value === '' ||
+ (Array.isArray(value) && value.length === 0);
+
+ if (isEmpty) {
+ errors[id] = [`${label || id} is required`];
+ continue;
+ }
+ }
+
+ // Skip further validation if value is empty
+ if (!value && value !== false) continue;
+
+ // Type-specific validation
+ const fieldErrors = [];
+
+ if (type === 'email') {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(value)) {
+ fieldErrors.push('Please enter a valid email address');
+ }
+ }
+
+ if (type === 'password') {
+ if (validation.minLength && value.length < validation.minLength) {
+ fieldErrors.push(
+ `Password must be at least ${validation.minLength} characters`
+ );
+ }
+ }
+
+ if (type === 'text' || type === 'textarea') {
+ if (validation.minLength && value.length < validation.minLength) {
+ fieldErrors.push(
+ `${label || id} must be at least ${validation.minLength} characters`
+ );
+ }
+ if (validation.maxLength && value.length > validation.maxLength) {
+ fieldErrors.push(
+ `${label || id} must be at most ${validation.maxLength} characters`
+ );
+ }
+ }
+
+ // Match field validation (for confirmPassword)
+ if (validation.matchField) {
+ if (value !== formData[validation.matchField]) {
+ fieldErrors.push('Passwords do not match');
+ }
+ }
+
+ if (fieldErrors.length > 0) {
+ errors[id] = fieldErrors;
+ }
+ }
+ }
+
+ return {
+ isValid: Object.keys(errors).length === 0,
+ errors,
+ };
+};
+
+/**
+ * Evaluate conditional rules to get hidden fields set
+ */
+export const evaluateConditionalRules = (schema, formData) => {
+ const rules = schema?.conditional_rules || [];
+ const hidden = new Set();
+
+ // First pass: collect fields that have "show" rules (hidden by default)
+ for (const rule of rules) {
+ if (rule.action === 'show') {
+ rule.target_fields?.forEach((fieldId) => hidden.add(fieldId));
+ }
+ }
+
+ // Second pass: evaluate rules and show/hide fields
+ for (const rule of rules) {
+ const {
+ trigger_field,
+ trigger_operator = 'equals',
+ trigger_value,
+ action,
+ target_fields = [],
+ } = rule;
+
+ const fieldValue = formData[trigger_field];
+ let conditionMet = false;
+
+ switch (trigger_operator) {
+ case 'equals':
+ conditionMet = fieldValue === trigger_value;
+ break;
+ case 'not_equals':
+ conditionMet = fieldValue !== trigger_value;
+ break;
+ case 'contains':
+ conditionMet = Array.isArray(fieldValue)
+ ? fieldValue.includes(trigger_value)
+ : String(fieldValue || '').includes(trigger_value);
+ break;
+ case 'not_empty':
+ conditionMet = Boolean(fieldValue);
+ break;
+ case 'empty':
+ conditionMet = !Boolean(fieldValue);
+ break;
+ default:
+ conditionMet = false;
+ }
+
+ if (conditionMet) {
+ if (action === 'show') {
+ target_fields.forEach((fieldId) => hidden.delete(fieldId));
+ } else if (action === 'hide') {
+ target_fields.forEach((fieldId) => hidden.add(fieldId));
+ }
+ }
+ }
+
+ return hidden;
+};
+
+export default DynamicRegistrationForm;
diff --git a/src/components/ui/badge.jsx b/src/components/ui/badge.jsx
index 6907614..d0af7c0 100644
--- a/src/components/ui/badge.jsx
+++ b/src/components/ui/badge.jsx
@@ -9,7 +9,7 @@ const badgeVariants = cva(
variants: {
variant: {
default:
- "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
+ "border-transparent bg-primary text-primary-foreground shadow ",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
diff --git a/src/components/ui/select.jsx b/src/components/ui/select.jsx
index 93913ea..78ae299 100644
--- a/src/components/ui/select.jsx
+++ b/src/components/ui/select.jsx
@@ -83,16 +83,16 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
diff --git a/src/components/ui/table.jsx b/src/components/ui/table.jsx
index 07443a1..07164fb 100644
--- a/src/components/ui/table.jsx
+++ b/src/components/ui/table.jsx
@@ -1,78 +1,91 @@
-import * as React from "react"
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
const Table = React.forwardRef(({ className, ...props }, ref) => (
-))
-Table.displayName = "Table"
+));
+Table.displayName = "Table";
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
-
+));
+TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
-))
-TableBody.displayName = "TableBody"
+ {...props}
+ />
+));
+TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
tr]:last:border-b-0", className)}
- {...props} />
-))
-TableFooter.displayName = "TableFooter"
+ className={cn(
+ "border-t border-[var(--neutral-800)] font-medium [&>tr]:last:border-b-0",
+ className,
+ )}
+ {...props}
+ />
+));
+TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
-))
-TableRow.displayName = "TableRow"
+ {...props}
+ />
+));
+TableRow.displayName = "TableRow";
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
[role=checkbox]]:translate-y-[2px]",
- className
+ "p-4 text-left align-middle font-semibold text-[var(--purple-ink)] [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
+ className,
)}
- {...props} />
-))
-TableHead.displayName = "TableHead"
+ {...props}
+ />
+));
+TableHead.displayName = "TableHead";
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
| [role=checkbox]]:translate-y-[2px]",
- className
+ "p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] ",
+ className,
)}
- {...props} />
-))
-TableCell.displayName = "TableCell"
+ {...props}
+ />
+));
+TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
-))
-TableCaption.displayName = "TableCaption"
+ {...props}
+ />
+));
+TableCaption.displayName = "TableCaption";
export {
Table,
@@ -83,4 +96,4 @@ export {
TableRow,
TableCell,
TableCaption,
-}
+};
diff --git a/src/config/MemberTiers.js b/src/config/MemberTiers.js
new file mode 100644
index 0000000..f1c4aca
--- /dev/null
+++ b/src/config/MemberTiers.js
@@ -0,0 +1,70 @@
+// src/config/memberTiers.js
+
+/**
+ * Default member tier configuration
+ * Used as fallback when API is unavailable
+ * Format matches backend MemberTier interface
+ */
+export const DEFAULT_MEMBER_TIERS = [
+ {
+ id: 'new_member',
+ label: 'New Member',
+ minYears: 0,
+ maxYears: 0.999,
+ iconKey: 'sparkle',
+ badgeClass: 'bg-blue-100 text-blue-800 border-blue-200',
+ },
+ {
+ id: 'member_1_year',
+ label: '1 Year Member',
+ minYears: 1,
+ maxYears: 2.999,
+ iconKey: 'star',
+ badgeClass: 'bg-green-100 text-green-800 border-green-200',
+ },
+ {
+ id: 'member_3_year',
+ label: '3+ Year Member',
+ minYears: 3,
+ maxYears: 4.999,
+ iconKey: 'award',
+ badgeClass: 'bg-purple-100 text-purple-800 border-purple-200',
+ },
+ {
+ id: 'veteran',
+ label: 'Veteran Member',
+ minYears: 5,
+ maxYears: 999,
+ iconKey: 'crown',
+ badgeClass: 'bg-amber-100 text-amber-800 border-amber-200',
+ },
+];
+
+/**
+ * Available icon options for tier configuration
+ */
+export const TIER_ICON_OPTIONS = [
+ { key: 'sparkle', label: 'Sparkle' },
+ { key: 'star', label: 'Star' },
+ { key: 'award', label: 'Award' },
+ { key: 'crown', label: 'Crown' },
+ { key: 'medal', label: 'Medal' },
+ { key: 'trophy', label: 'Trophy' },
+ { key: 'gem', label: 'Gem' },
+ { key: 'heart', label: 'Heart' },
+ { key: 'shield', label: 'Shield' },
+];
+
+/**
+ * Available badge color presets
+ */
+export const BADGE_COLOR_PRESETS = [
+ { label: 'Blue', badgeClass: 'bg-blue-100 text-blue-800 border-blue-200' },
+ { label: 'Green', badgeClass: 'bg-green-100 text-green-800 border-green-200' },
+ { label: 'Purple', badgeClass: 'bg-purple-100 text-purple-800 border-purple-200' },
+ { label: 'Amber', badgeClass: 'bg-amber-100 text-amber-800 border-amber-200' },
+ { label: 'Red', badgeClass: 'bg-red-100 text-red-800 border-red-200' },
+ { label: 'Teal', badgeClass: 'bg-teal-100 text-teal-800 border-teal-200' },
+ { label: 'Pink', badgeClass: 'bg-pink-100 text-pink-800 border-pink-200' },
+ { label: 'Indigo', badgeClass: 'bg-indigo-100 text-indigo-800 border-indigo-200' },
+];
\ No newline at end of file
diff --git a/src/config/memberTierIcons.js b/src/config/memberTierIcons.js
new file mode 100644
index 0000000..957584b
--- /dev/null
+++ b/src/config/memberTierIcons.js
@@ -0,0 +1,29 @@
+// src/config/memberTierIcons.js
+import { User, Star, Crown, Award, Sparkles, Medal, Trophy, Gem, Heart, Shield } from 'lucide-react';
+
+/**
+ * Member tier icon mapping
+ * Maps iconKey strings from backend to Lucide React components
+ */
+export const MEMBER_TIER_ICONS = {
+ // Primary tier icons
+ sparkle: Sparkles,
+ sparkles: Sparkles,
+ star: Star,
+ award: Award,
+ crown: Crown,
+ // Additional options
+ medal: Medal,
+ trophy: Trophy,
+ gem: Gem,
+ heart: Heart,
+ shield: Shield,
+ user: User,
+};
+
+/**
+ * Get icon component by key with fallback
+ */
+export const getTierIcon = (iconKey) => {
+ return MEMBER_TIER_ICONS[iconKey?.toLowerCase()] || MEMBER_TIER_ICONS.sparkle;
+};
diff --git a/src/context/ThemeConfigContext.js b/src/context/ThemeConfigContext.js
new file mode 100644
index 0000000..9432223
--- /dev/null
+++ b/src/context/ThemeConfigContext.js
@@ -0,0 +1,161 @@
+import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
+import axios from 'axios';
+
+const ThemeConfigContext = createContext();
+
+const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
+
+const DEFAULT_THEME = {
+ site_name: 'LOAF - Lesbians Over Age Fifty',
+ site_short_name: 'LOAF',
+ site_description: 'A community organization for lesbians over age fifty in Houston and surrounding areas.',
+ logo_url: null,
+ favicon_url: null,
+ colors: {
+ primary: '280 47% 27%',
+ primary_foreground: '0 0% 100%',
+ accent: '24 86% 55%',
+ brand_purple: '256 35% 47%',
+ brand_orange: '24 86% 55%',
+ brand_lavender: '262 46% 80%'
+ },
+ meta_theme_color: '#664fa3'
+};
+
+export const ThemeConfigProvider = ({ children }) => {
+ const [themeConfig, setThemeConfig] = useState(DEFAULT_THEME);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const applyThemeToDOM = useCallback((config) => {
+ // Apply CSS variables for colors
+ if (config.colors) {
+ const root = document.documentElement;
+ Object.entries(config.colors).forEach(([key, value]) => {
+ // Convert snake_case to kebab-case for CSS variable names
+ const cssVarName = `--${key.replace(/_/g, '-')}`;
+ root.style.setProperty(cssVarName, value);
+ });
+ }
+
+ // Update favicon
+ if (config.favicon_url) {
+ let link = document.querySelector("link[rel*='icon']");
+ if (!link) {
+ link = document.createElement('link');
+ link.rel = 'icon';
+ document.head.appendChild(link);
+ }
+ link.href = config.favicon_url;
+ }
+
+ // Update document title
+ if (config.site_name) {
+ document.title = config.site_name;
+ // Also store for use by pages that want to append their own title
+ window.__SITE_NAME__ = config.site_name;
+ }
+
+ // Update meta description
+ if (config.site_description) {
+ let metaDesc = document.querySelector("meta[name='description']");
+ if (!metaDesc) {
+ metaDesc = document.createElement('meta');
+ metaDesc.name = 'description';
+ document.head.appendChild(metaDesc);
+ }
+ metaDesc.content = config.site_description;
+ }
+
+ // Update meta theme-color for PWA
+ if (config.meta_theme_color) {
+ let meta = document.querySelector("meta[name='theme-color']");
+ if (!meta) {
+ meta = document.createElement('meta');
+ meta.name = 'theme-color';
+ document.head.appendChild(meta);
+ }
+ meta.content = config.meta_theme_color;
+ }
+ }, []);
+
+ const fetchThemeConfig = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const response = await axios.get(`${API_URL}/api/config/theme`);
+ const config = { ...DEFAULT_THEME, ...response.data };
+
+ // Merge colors if provided
+ if (response.data.colors) {
+ config.colors = { ...DEFAULT_THEME.colors, ...response.data.colors };
+ }
+
+ setThemeConfig(config);
+ applyThemeToDOM(config);
+ } catch (err) {
+ console.warn('Failed to fetch theme config, using defaults:', err.message);
+ setError(err.message);
+ // Apply default theme to DOM
+ applyThemeToDOM(DEFAULT_THEME);
+ } finally {
+ setLoading(false);
+ }
+ }, [applyThemeToDOM]);
+
+ // Fetch theme config on mount
+ useEffect(() => {
+ fetchThemeConfig();
+ }, [fetchThemeConfig]);
+
+ // Helper function to get logo URL with fallback
+ const getLogoUrl = useCallback(() => {
+ return themeConfig.logo_url || `${process.env.PUBLIC_URL}/loaf-logo.png`;
+ }, [themeConfig.logo_url]);
+
+ // Helper function to get favicon URL with fallback
+ const getFaviconUrl = useCallback(() => {
+ return themeConfig.favicon_url || `${process.env.PUBLIC_URL}/favicon.ico`;
+ }, [themeConfig.favicon_url]);
+
+ const value = {
+ // Theme configuration
+ themeConfig,
+ loading,
+ error,
+
+ // Convenience accessors
+ siteName: themeConfig.site_name,
+ siteShortName: themeConfig.site_short_name,
+ siteDescription: themeConfig.site_description,
+ colors: themeConfig.colors,
+ metaThemeColor: themeConfig.meta_theme_color,
+
+ // Helper functions
+ getLogoUrl,
+ getFaviconUrl,
+
+ // Actions
+ refreshTheme: fetchThemeConfig,
+
+ // Default theme for reference
+ DEFAULT_THEME
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useThemeConfig = () => {
+ const context = useContext(ThemeConfigContext);
+ if (context === undefined) {
+ throw new Error('useThemeConfig must be used within a ThemeConfigProvider');
+ }
+ return context;
+};
+
+export default ThemeConfigContext;
diff --git a/src/context/UsersContext.js b/src/context/UsersContext.js
new file mode 100644
index 0000000..d61c0d9
--- /dev/null
+++ b/src/context/UsersContext.js
@@ -0,0 +1,93 @@
+import React, { createContext, useState, useContext, useEffect, useCallback, useMemo } from 'react';
+import { toast } from 'sonner';
+import api from '../utils/api';
+
+const UsersContext = createContext();
+
+// Role definitions
+const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
+const MEMBER_ROLES = ['member'];
+
+export const UsersProvider = ({ children }) => {
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchUsers = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await api.get('/admin/users');
+ setUsers(response.data);
+ } catch (err) {
+ setError(err);
+ toast.error('Failed to fetch users');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchUsers();
+ }, [fetchUsers]);
+
+ // Filtered views based on role
+ const staff = useMemo(
+ () => users.filter(user => STAFF_ROLES.includes(user.role)),
+ [users]
+ );
+
+ const members = useMemo(
+ () => users.filter(user => MEMBER_ROLES.includes(user.role)),
+ [users]
+ );
+
+ const allUsers = users;
+
+ // Update a single user in the local state (useful after edits)
+ const updateUser = useCallback((updatedUser) => {
+ setUsers(prev => prev.map(user =>
+ user.id === updatedUser.id ? updatedUser : user
+ ));
+ }, []);
+
+ // Remove a user from local state
+ const removeUser = useCallback((userId) => {
+ setUsers(prev => prev.filter(user => user.id !== userId));
+ }, []);
+
+ // Add a user to local state
+ const addUser = useCallback((newUser) => {
+ setUsers(prev => [...prev, newUser]);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+// Base hook to access the context
+export const useUsers = () => {
+ const context = useContext(UsersContext);
+ if (!context) {
+ throw new Error('useUsers must be used within a UsersProvider');
+ }
+ return context;
+};
+
+export default UsersContext;
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-member-tiers.js b/src/hooks/use-member-tiers.js
new file mode 100644
index 0000000..edc32b7
--- /dev/null
+++ b/src/hooks/use-member-tiers.js
@@ -0,0 +1,106 @@
+// src/hooks/use-member-tiers.js
+import { useState, useEffect, useCallback } from 'react';
+import api from '../utils/api';
+import { DEFAULT_MEMBER_TIERS } from '../config/MemberTiers';
+
+/**
+ * Hook for fetching and managing member tier configuration
+ * @param {Object} options
+ * @param {boolean} options.isAdmin - Whether to use admin endpoint (includes metadata)
+ * @returns {Object} Tier state and methods
+ */
+const useMemberTiers = ({ isAdmin = false } = {}) => {
+ const [tiers, setTiers] = useState(DEFAULT_MEMBER_TIERS);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [saving, setSaving] = useState(false);
+
+ const endpoint = isAdmin
+ ? '/admin/settings/member-tiers'
+ : '/settings/member-tiers';
+
+ const fetchTiers = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const response = await api.get(endpoint);
+ const data = response.data?.tiers || response.data || DEFAULT_MEMBER_TIERS;
+ setTiers(data);
+ } catch (err) {
+ console.error('Failed to fetch member tiers:', err);
+ setError('Failed to load member tiers');
+ // Use defaults on error
+ setTiers(DEFAULT_MEMBER_TIERS);
+ } finally {
+ setLoading(false);
+ }
+ }, [endpoint]);
+
+ useEffect(() => {
+ fetchTiers();
+ }, [fetchTiers]);
+
+ /**
+ * Update tier configuration (admin only)
+ * @param {Array} newTiers - Updated tier array
+ * @returns {Promise} Success status
+ */
+ const updateTiers = useCallback(async (newTiers) => {
+ if (!isAdmin) {
+ console.error('updateTiers requires admin access');
+ return false;
+ }
+
+ try {
+ setSaving(true);
+ setError(null);
+ await api.put('/admin/settings/member-tiers', { tiers: newTiers });
+ setTiers(newTiers);
+ return true;
+ } catch (err) {
+ console.error('Failed to update member tiers:', err);
+ setError('Failed to save member tiers');
+ return false;
+ } finally {
+ setSaving(false);
+ }
+ }, [isAdmin]);
+
+ /**
+ * Reset tiers to defaults (superadmin only)
+ * @returns {Promise} Success status
+ */
+ const resetToDefaults = useCallback(async () => {
+ if (!isAdmin) {
+ console.error('resetToDefaults requires admin access');
+ return false;
+ }
+
+ try {
+ setSaving(true);
+ setError(null);
+ const response = await api.post('/admin/settings/member-tiers/reset');
+ const data = response.data?.tiers || response.data || DEFAULT_MEMBER_TIERS;
+ setTiers(data);
+ return true;
+ } catch (err) {
+ console.error('Failed to reset member tiers:', err);
+ setError('Failed to reset member tiers');
+ return false;
+ } finally {
+ setSaving(false);
+ }
+ }, [isAdmin]);
+
+ return {
+ tiers,
+ loading,
+ error,
+ saving,
+ fetchTiers,
+ updateTiers,
+ resetToDefaults,
+ };
+};
+
+export default useMemberTiers;
diff --git a/src/hooks/use-members.js b/src/hooks/use-members.js
new file mode 100644
index 0000000..3d41844
--- /dev/null
+++ b/src/hooks/use-members.js
@@ -0,0 +1,90 @@
+import { useCallback, useEffect, useState } from 'react';
+import { toast } from 'sonner';
+import api from '../utils/api';
+
+const DEFAULT_SEARCH_FIELDS = ['first_name', 'last_name', 'email'];
+
+/**
+ * Hook for fetching users from a custom endpoint (e.g., member-facing directory).
+ * For admin pages, use hooks from use-users.js instead which share a centralized context.
+ */
+const useMembers = ({
+ endpoint = '/admin/users',
+ initialFilter = 'active',
+ initialSearch = '',
+ filterKey = 'status',
+ allowedRoles = ['member'],
+ searchFields = DEFAULT_SEARCH_FIELDS,
+ fetchErrorMessage = 'Failed to fetch members',
+ searchAccessor,
+ transform,
+ onFetchError,
+} = {}) => {
+ const [users, setUsers] = useState([]);
+ const [filteredUsers, setFilteredUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState(initialSearch);
+ const [filterValue, setFilterValue] = useState(initialFilter);
+
+ const fetchMembers = useCallback(async () => {
+ try {
+ const response = await api.get(endpoint);
+ let filtered = response.data;
+ if (typeof transform === 'function') {
+ filtered = transform(filtered);
+ }
+ if (allowedRoles && allowedRoles.length) {
+ filtered = filtered.filter(user => allowedRoles.includes(user.role));
+ }
+ setUsers(filtered);
+ } catch (error) {
+ if (typeof onFetchError === 'function') {
+ onFetchError(error);
+ } else {
+ toast.error(fetchErrorMessage);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [allowedRoles, endpoint, fetchErrorMessage, onFetchError, transform]);
+
+ useEffect(() => {
+ fetchMembers();
+ }, [fetchMembers]);
+
+ useEffect(() => {
+ let filtered = users;
+
+ if (filterValue && filterValue !== 'all') {
+ filtered = filtered.filter(user => user[filterKey] === filterValue);
+ }
+
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase();
+ filtered = filtered.filter(user => {
+ const values = typeof searchAccessor === 'function'
+ ? searchAccessor(user)
+ : searchFields.map(field => user?.[field]);
+
+ return values
+ .filter(Boolean)
+ .some(value => value.toString().toLowerCase().includes(query));
+ });
+ }
+
+ setFilteredUsers(filtered);
+ }, [users, searchQuery, filterKey, filterValue, searchAccessor, searchFields]);
+
+ return {
+ users,
+ filteredUsers,
+ loading,
+ searchQuery,
+ setSearchQuery,
+ filterValue,
+ setFilterValue,
+ fetchMembers,
+ };
+};
+
+export default useMembers;
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/hooks/use-users.js b/src/hooks/use-users.js
new file mode 100644
index 0000000..22b96b8
--- /dev/null
+++ b/src/hooks/use-users.js
@@ -0,0 +1,171 @@
+import { useState, useMemo } from 'react';
+import { useUsers } from '../context/UsersContext';
+
+const DEFAULT_SEARCH_FIELDS = ['first_name', 'last_name', 'email'];
+
+/**
+ * Base hook that adds search and filter functionality to any user list
+ */
+const useFilteredUsers = ({
+ users,
+ initialFilter = 'all',
+ filterKey = 'status',
+ searchFields = DEFAULT_SEARCH_FIELDS,
+ searchAccessor,
+}) => {
+ const [searchQuery, setSearchQuery] = useState('');
+ const [filterValue, setFilterValue] = useState(initialFilter);
+
+ const filteredUsers = useMemo(() => {
+ let filtered = users;
+
+ // Apply filter
+ if (filterValue && filterValue !== 'all') {
+ filtered = filtered.filter(user => user[filterKey] === filterValue);
+ }
+
+ // Apply search
+ if (searchQuery) {
+ const query = searchQuery.toLowerCase();
+ filtered = filtered.filter(user => {
+ const values = typeof searchAccessor === 'function'
+ ? searchAccessor(user)
+ : searchFields.map(field => user?.[field]);
+
+ return values
+ .filter(Boolean)
+ .some(value => value.toString().toLowerCase().includes(query));
+ });
+ }
+
+ return filtered;
+ }, [users, searchQuery, filterKey, filterValue, searchAccessor, searchFields]);
+
+ return {
+ filteredUsers,
+ searchQuery,
+ setSearchQuery,
+ filterValue,
+ setFilterValue,
+ };
+};
+
+/**
+ * Hook for staff users (admin, superadmin, finance roles)
+ */
+export const useStaff = ({
+ initialFilter = 'all',
+ filterKey = 'role',
+ searchFields = DEFAULT_SEARCH_FIELDS,
+ searchAccessor,
+} = {}) => {
+ const { staff, loading, error, refetch, updateUser, removeUser } = useUsers();
+
+ const {
+ filteredUsers,
+ searchQuery,
+ setSearchQuery,
+ filterValue,
+ setFilterValue,
+ } = useFilteredUsers({
+ users: staff,
+ initialFilter,
+ filterKey,
+ searchFields,
+ searchAccessor,
+ });
+
+ return {
+ users: staff,
+ filteredUsers,
+ loading,
+ error,
+ searchQuery,
+ setSearchQuery,
+ filterValue,
+ setFilterValue,
+ refetch,
+ updateUser,
+ removeUser,
+ };
+};
+
+/**
+ * Hook for member users (non-admin roles)
+ */
+export const useMembers = ({
+ initialFilter = 'active',
+ filterKey = 'status',
+ searchFields = DEFAULT_SEARCH_FIELDS,
+ searchAccessor,
+} = {}) => {
+ const { members, loading, error, refetch, updateUser, removeUser } = useUsers();
+
+ const {
+ filteredUsers,
+ searchQuery,
+ setSearchQuery,
+ filterValue,
+ setFilterValue,
+ } = useFilteredUsers({
+ users: members,
+ initialFilter,
+ filterKey,
+ searchFields,
+ searchAccessor,
+ });
+
+ return {
+ users: members,
+ filteredUsers,
+ loading,
+ error,
+ searchQuery,
+ setSearchQuery,
+ filterValue,
+ setFilterValue,
+ refetch,
+ updateUser,
+ removeUser,
+ };
+};
+
+/**
+ * Hook for all users (both staff and members)
+ */
+export const useAllUsers = ({
+ initialFilter = 'all',
+ filterKey = 'status',
+ searchFields = DEFAULT_SEARCH_FIELDS,
+ searchAccessor,
+} = {}) => {
+ const { users, loading, error, refetch, updateUser, removeUser } = useUsers();
+
+ const {
+ filteredUsers,
+ searchQuery,
+ setSearchQuery,
+ filterValue,
+ setFilterValue,
+ } = useFilteredUsers({
+ users,
+ initialFilter,
+ filterKey,
+ searchFields,
+ searchAccessor,
+ });
+
+ return {
+ users,
+ filteredUsers,
+ loading,
+ error,
+ searchQuery,
+ setSearchQuery,
+ filterValue,
+ setFilterValue,
+ refetch,
+ updateUser,
+ removeUser,
+ };
+};
diff --git a/src/index.js b/src/index.js
index a4a0d50..ee5b950 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider } from 'next-themes';
+import { ThemeConfigProvider } from './context/ThemeConfigContext';
import '@fontsource/fraunces/600.css';
import '@fontsource/dm-sans/400.css';
import '@fontsource/dm-sans/700.css';
@@ -16,7 +17,9 @@ root.render(
enableSystem={false}
storageKey="admin-theme"
>
-
+
+
+
);
diff --git a/src/layouts/AdminLayout.js b/src/layouts/AdminLayout.js
index a57e9d6..aa3580c 100644
--- a/src/layouts/AdminLayout.js
+++ b/src/layouts/AdminLayout.js
@@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import { useTheme } from 'next-themes';
+import { Menu } from 'lucide-react';
import AdminSidebar from '../components/AdminSidebar';
+import { UsersProvider } from '../context/UsersContext';
const AdminLayout = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(true);
@@ -46,29 +48,48 @@ const AdminLayout = ({ children }) => {
};
return (
-
- {/* Sidebar */}
-
-
- {/* Mobile Overlay */}
- {isMobile && sidebarOpen && (
-
+
+ {/* Sidebar */}
+
- )}
- {/* Main Content Area */}
-
-
- {children}
-
-
-
+ {/* Mobile Overlay */}
+ {isMobile && sidebarOpen && (
+
+ )}
+
+ {/* Main Content Area */}
+
+ {isMobile && (
+
+
+
+ Menu
+
+
+ )}
+
+ {children}
+
+
+
+
);
};
diff --git a/src/layouts/SettingsLayout.js b/src/layouts/SettingsLayout.js
new file mode 100644
index 0000000..772e0b8
--- /dev/null
+++ b/src/layouts/SettingsLayout.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import { Outlet } from 'react-router-dom';
+import SettingsTabs from '../components/SettingsSidebar';
+import { Settings } from 'lucide-react';
+
+const SettingsLayout = () => {
+ return (
+
+ {/* Header */}
+
+
+
+ Settings
+
+
+ Manage your platform configuration and preferences
+
+
+
+ {/* Tabs Navigation */}
+
+
+ {/* Content Area */}
+
+
+
+
+ );
+};
+
+export default SettingsLayout;
diff --git a/src/pages/AcceptInvitation.js b/src/pages/AcceptInvitation.js
index 8f3b489..334a0e9 100644
--- a/src/pages/AcceptInvitation.js
+++ b/src/pages/AcceptInvitation.js
@@ -387,7 +387,7 @@ const AcceptInvitation = () => {
value={formData.first_name}
onChange={(e) => handleChange('first_name', e.target.value)}
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
- placeholder="John"
+ placeholder="Jane"
/>
{formErrors.first_name && (
{formErrors.first_name}
diff --git a/src/pages/Dashboard.js b/src/pages/Dashboard.js
index eb9429a..7f740d0 100644
--- a/src/pages/Dashboard.js
+++ b/src/pages/Dashboard.js
@@ -7,8 +7,11 @@ import { Button } from '../components/ui/button';
import { Badge } from '../components/ui/badge';
import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter';
-import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale } from 'lucide-react';
+import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react';
import { toast } from 'sonner';
+import TransactionHistory from '../components/TransactionHistory';
+import MemberBadge from '@/components/MemberBadge';
+import useMemberTiers from '../hooks/use-member-tiers'
const Dashboard = () => {
const { user, resendVerificationEmail, refreshUser } = useAuth();
@@ -17,11 +20,16 @@ const Dashboard = () => {
const [resendLoading, setResendLoading] = useState(false);
const [eventActivity, setEventActivity] = useState(null);
const [activityLoading, setActivityLoading] = useState(true);
+ const [transactionsLoading, setTransactionsLoading] = useState(true);
+ const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
+ const [activeTransactionTab, setActiveTransactionTab] = useState('all');
const joinedDate = user?.member_since || user?.created_at;
+ const { tiers, loading: tiersLoading } = useMemberTiers();
useEffect(() => {
fetchUpcomingEvents();
fetchEventActivity();
+ fetchTransactions();
}, []);
const fetchUpcomingEvents = async () => {
@@ -47,6 +55,19 @@ const Dashboard = () => {
}
};
+ const fetchTransactions = async () => {
+ try {
+ setTransactionsLoading(true);
+ const response = await api.get('/members/transactions');
+ setTransactions(response.data);
+ } catch (error) {
+ console.error('Failed to load transactions:', error);
+ // Don't show error toast - transactions are optional
+ } finally {
+ setTransactionsLoading(false);
+ }
+ };
+
const handleResendVerification = async () => {
setResendLoading(true);
try {
@@ -71,6 +92,7 @@ const Dashboard = () => {
}
};
+
const getStatusBadge = (status) => {
const statusConfig = {
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
@@ -111,6 +133,8 @@ const Dashboard = () => {
return messages[status] || '';
};
+
+
return (
@@ -180,58 +204,81 @@ const Dashboard = () => {
{/* Grid Layout */}
-
+
{/* Quick Stats */}
-
-
- Quick Info
-
-
-
- Email
- {user?.email}
-
-
-
- Member Since
-
- {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
-
-
- {user?.subscription_start_date && user?.subscription_end_date && (
- <>
-
- Membership Period
-
- {new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
-
-
+
+
+
+
+ Quick Info
+
+
+ {/* member date and badge */}
+
- Days Remaining
+ Member Since
- {Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
+ {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
- >
- )}
-
-
+ {!tiersLoading && (
+
+
+
+ )}
+
+ {/* email */}
+
+ Email
+ {user?.email}
+
+ {/* role */}
+
+
+
+
+
+
+
+ Membership Info
+
+
+ {!user.subscription_end_date && !user.subscription_end_date && (
+ No subscriptions yet
+ )}
+ {user?.subscription_start_date && user?.subscription_end_date && (
+ <>
+
+ Membership Period
+
+ {new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
+
+
+
+ Days Remaining
+
+ {Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
+
+
+ >
+ )}
+
+
+
{/* Upcoming Events */}
-
+
- Upcoming Events
+ My Event Activity
-
@@ -313,155 +360,18 @@ const Dashboard = () => {
)}
- {/* Event Activity Section */}
-
-
-
- My Event Activity
-
-
-
- {activityLoading ? (
- Loading event activity...
- ) : eventActivity ? (
-
- {/* Stats Cards */}
-
-
-
-
-
-
-
- Total RSVPs
-
- {eventActivity.total_rsvps}
-
-
-
-
-
-
-
-
-
-
- Events Attended
-
- {eventActivity.total_attended}
-
-
-
-
-
-
- {/* Upcoming RSVP'd Events */}
- {eventActivity.upcoming_events && eventActivity.upcoming_events.length > 0 && (
-
-
- Upcoming Events (RSVP'd)
-
-
- {eventActivity.upcoming_events.map((event) => (
-
-
-
-
- {event.title}
-
- {new Date(event.start_at).toLocaleDateString()} at{' '}
- {new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
-
- {event.location}
-
-
- {event.rsvp_status === 'yes' ? 'Going' :
- event.rsvp_status === 'maybe' ? 'Maybe' : 'Not Going'}
-
-
-
-
- ))}
-
-
- )}
-
- {/* Past Events & Attendance */}
- {eventActivity.past_events && eventActivity.past_events.length > 0 && (
-
-
- Past Events
-
-
- {eventActivity.past_events.slice(0, 5).map((event) => (
-
-
-
- {event.title}
-
- {new Date(event.start_at).toLocaleDateString()} at{' '}
- {new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
-
-
-
-
- {event.attended ? 'Attended' : 'Did not attend'}
-
- {event.attended && event.attended_at && (
-
- Checked in: {new Date(event.attended_at).toLocaleDateString()}
-
- )}
-
-
-
- ))}
-
- {eventActivity.past_events.length > 5 && (
-
- Showing 5 of {eventActivity.past_events.length} past events
-
- )}
-
- )}
-
- {/* No Events Message */}
- {(!eventActivity.upcoming_events || eventActivity.upcoming_events.length === 0) &&
- (!eventActivity.past_events || eventActivity.past_events.length === 0) && (
-
-
-
-
- No Event Activity Yet
-
-
- Browse upcoming events and RSVP to start building your event history!
-
-
-
-
- Browse Events
-
-
-
-
- )}
-
- ) : (
-
-
-
-
- Failed to load event activity. Please try refreshing the page.
-
-
-
- )}
+ {/* Transaction History Section */}
+
+
+
diff --git a/src/pages/Login.js b/src/pages/Login.js
index 5bab4b7..efd100b 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,6 +13,8 @@ import { ArrowRight, ArrowLeft } from 'lucide-react';
const Login = () => {
const navigate = useNavigate();
+ const location = useLocation();
+ const [searchParams] = useSearchParams();
const { login } = useAuth();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
@@ -20,6 +22,30 @@ const Login = () => {
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
+ window.history.replaceState({}, '', '/login');
+ } else if (sessionParam === 'idle') {
+ toast.info('You were logged out due to inactivity. Please log in again.', {
+ duration: 5000,
+ });
+ // Clean up URL
+ window.history.replaceState({}, '', '/login');
+ } else if (stateMessage) {
+ toast.info(stateMessage, {
+ duration: 5000,
+ });
+ }
+ }, [searchParams, location.state]);
+
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
diff --git a/src/pages/MissionValues.js b/src/pages/MissionValues.js
index e118c70..ee9f6e1 100644
--- a/src/pages/MissionValues.js
+++ b/src/pages/MissionValues.js
@@ -2,9 +2,11 @@ import React from 'react';
import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter';
import { Card } from '../components/ui/card';
+import { useThemeConfig } from '../context/ThemeConfigContext';
const MissionValues = () => {
- const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
+ const { getLogoUrl } = useThemeConfig();
+ const loafLogo = getLogoUrl();
return (
diff --git a/src/pages/Profile.js b/src/pages/Profile.js
index d270dfa..bea4e3d 100644
--- a/src/pages/Profile.js
+++ b/src/pages/Profile.js
@@ -8,11 +8,12 @@ import { Label } from '../components/ui/label';
import { Textarea } from '../components/ui/textarea';
import { toast } from 'sonner';
import Navbar from '../components/Navbar';
-import MemberFooter from '../components/MemberFooter';
-import { User, Save, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2 } from 'lucide-react';
+import { User, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2, Eye, CreditCard, Handshake, ArrowLeft } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
import ChangePasswordDialog from '../components/ChangePasswordDialog';
-import TransactionHistory from '../components/TransactionHistory';
+import PaymentMethodsSection from '../components/PaymentMethodsSection';
+import { useNavigate } from 'react-router-dom';
+import useDirectoryConfig from '../hooks/use-directory-config';
const Profile = () => {
const { user } = useAuth();
@@ -23,12 +24,14 @@ const Profile = () => {
const [previewImage, setPreviewImage] = useState(null);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const fileInputRef = useRef(null);
- const [maxFileSizeMB, setMaxFileSizeMB] = useState(50); // Default 50MB
- const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800); // Default 50MB in bytes
- const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
- const [transactionsLoading, setTransactionsLoading] = useState(true);
+ const [maxFileSizeMB, setMaxFileSizeMB] = useState(50);
+ const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800);
+ const [activeTab, setActiveTab] = useState('account');
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+ const [initialFormData, setInitialFormData] = useState(null);
+ const navigate = useNavigate();
+ const { isFieldEnabled, loading: directoryConfigLoading } = useDirectoryConfig();
const [formData, setFormData] = useState({
- // Personal Information
first_name: '',
last_name: '',
phone: '',
@@ -36,19 +39,15 @@ const Profile = () => {
city: '',
state: '',
zipcode: '',
- // Partner Information
partner_first_name: '',
partner_last_name: '',
partner_is_member: false,
partner_plan_to_become_member: false,
- // Newsletter Preferences
newsletter_publish_name: false,
newsletter_publish_photo: false,
newsletter_publish_birthday: false,
newsletter_publish_none: false,
- // Volunteer Interests (array)
volunteer_interests: [],
- // Member Directory Settings
show_in_directory: false,
directory_email: '',
directory_bio: '',
@@ -61,9 +60,16 @@ const Profile = () => {
useEffect(() => {
fetchConfig();
fetchProfile();
- fetchTransactions();
}, []);
+ // Track unsaved changes
+ useEffect(() => {
+ if (initialFormData) {
+ const hasChanges = JSON.stringify(formData) !== JSON.stringify(initialFormData);
+ setHasUnsavedChanges(hasChanges);
+ }
+ }, [formData, initialFormData]);
+
const fetchConfig = async () => {
try {
const response = await api.get('/config');
@@ -71,7 +77,6 @@ const Profile = () => {
setMaxFileSizeBytes(response.data.max_file_size_bytes);
} catch (error) {
console.error('Failed to fetch config, using defaults:', error);
- // Keep default values if fetch fails
}
};
@@ -81,8 +86,7 @@ const Profile = () => {
setProfileData(response.data);
setProfilePhotoUrl(response.data.profile_photo_url);
setPreviewImage(response.data.profile_photo_url);
- setFormData({
- // Personal Information
+ const newFormData = {
first_name: response.data.first_name || '',
last_name: response.data.last_name || '',
phone: response.data.phone || '',
@@ -90,19 +94,15 @@ const Profile = () => {
city: response.data.city || '',
state: response.data.state || '',
zipcode: response.data.zipcode || '',
- // Partner Information
partner_first_name: response.data.partner_first_name || '',
partner_last_name: response.data.partner_last_name || '',
partner_is_member: response.data.partner_is_member || false,
partner_plan_to_become_member: response.data.partner_plan_to_become_member || false,
- // Newsletter Preferences
newsletter_publish_name: response.data.newsletter_publish_name || false,
newsletter_publish_photo: response.data.newsletter_publish_photo || false,
newsletter_publish_birthday: response.data.newsletter_publish_birthday || false,
newsletter_publish_none: response.data.newsletter_publish_none || false,
- // Volunteer Interests
volunteer_interests: response.data.volunteer_interests || [],
- // Member Directory Settings
show_in_directory: response.data.show_in_directory || false,
directory_email: response.data.directory_email || '',
directory_bio: response.data.directory_bio || '',
@@ -110,25 +110,14 @@ const Profile = () => {
directory_phone: response.data.directory_phone || '',
directory_dob: response.data.directory_dob || '',
directory_partner_name: response.data.directory_partner_name || ''
- });
+ };
+ setFormData(newFormData);
+ setInitialFormData(newFormData);
} catch (error) {
toast.error('Failed to load profile');
}
};
- const fetchTransactions = async () => {
- try {
- setTransactionsLoading(true);
- const response = await api.get('/members/transactions');
- setTransactions(response.data);
- } catch (error) {
- console.error('Failed to load transactions:', error);
- // Don't show error toast - transactions are optional
- } finally {
- setTransactionsLoading(false);
- }
- };
-
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
@@ -148,7 +137,6 @@ const Profile = () => {
}));
};
- // Volunteer interest options
const volunteerOptions = [
'Event Planning',
'Social Media',
@@ -166,13 +154,11 @@ const Profile = () => {
const file = e.target.files[0];
if (!file) return;
- // Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Please select an image file');
return;
}
- // Validate file size
if (file.size > maxFileSizeBytes) {
toast.error(`File size must be less than ${maxFileSizeMB}MB`);
return;
@@ -220,6 +206,8 @@ const Profile = () => {
try {
await api.put('/users/profile', formData);
toast.success('Profile updated successfully!');
+ setInitialFormData(formData);
+ setHasUnsavedChanges(false);
fetchProfile();
} catch (error) {
toast.error('Failed to update profile');
@@ -228,512 +216,627 @@ const Profile = () => {
}
};
+ const tabs = [
+ { id: 'account', label: 'Account & Privacy', shortLabel: 'Account', icon: Lock },
+ { id: 'bio', label: 'My Bio & Directory', shortLabel: 'Bio & Directory', icon: User },
+ { id: 'engagement', label: 'Engagement', shortLabel: 'Engagement', icon: Handshake }
+ ];
+
if (!profileData) {
return (
-
+
- Loading profile...
+ Loading profile...
);
}
- return (
-
-
+ // Account & Privacy Tab Content
+ const AccountPrivacyContent = () => (
+
-
-
-
- My Profile
-
-
- Update your personal information below.
+
+
+ Account & Privacy
+
+
+ Login Email
+ {profileData.email}
+
+
+
+
+ setPasswordDialogOpen(true)}
+ variant="outline"
+ className="border-2 border-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg px-4 py-2"
+ >
+ Change
+
+
+
+
+ Membership Status
+
+ {profileData.status?.replace('_', ' ') || 'Active'}
-
- {/* Read-only Information */}
-
-
-
- Account Information
-
-
-
- Email
- {profileData.email}
-
-
- Status
- {profileData.status.replace('_', ' ')}
-
-
- Role
- {profileData.role}
-
-
- Date of Birth
-
- {new Date(profileData.date_of_birth).toLocaleDateString()}
-
-
-
+
-
+ {/* Payment Methods Section */}
+
+
+ );
+
+ // My Bio & Directory Tab Content
+ const BioDirectoryContent = () => (
+
+
+ My Bio & Directory
+
+
+ {/* Profile Photo Section */}
+
+
+
+ Profile Photo
+
+
+
+
+
+ {profileData?.first_name?.charAt(0)}{profileData?.last_name?.charAt(0)}
+
+
+
+
+
+
+ fileInputRef.current?.click()}
+ disabled={uploadingPhoto}
+ className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-4 py-2"
+ >
+
+ {uploadingPhoto ? 'Uploading...' : 'Upload Photo'}
+
+
+ {profilePhotoUrl && (
setPasswordDialogOpen(true)}
+ onClick={handlePhotoDelete}
+ disabled={uploadingPhoto}
variant="outline"
- className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6 py-3"
+ className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-4 py-2"
>
-
- Change Password
+
+ Delete Photo
-
+ )}
+
+
+ Max {maxFileSizeMB}MB
+
+
+
+
+
+ {/* Personal Information */}
+
+
+ Personal Information
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Member Directory Settings */}
+ {isFieldEnabled('show_in_directory') && (
+
+
+
+ Member Directory Settings
+
+
+ Control your visibility and information in the member directory.
+
+
+
+
+
- {/* Profile Photo Section */}
-
-
-
- Profile Photo
-
-
-
-
-
- {profileData?.first_name?.charAt(0)}{profileData?.last_name?.charAt(0)}
-
-
+ {formData.show_in_directory && (
+
+ {isFieldEnabled('directory_email') && (
+
+
+
+
+ )}
-
+ )}
+
+ )}
+
+ );
+
+ // Engagement Tab Content
+ const EngagementContent = () => (
+
+
+ Engagement
+
+
+ {/* Partner Information */}
+
+
+
+ Partner Information
+
+
+
+
+
+ {/* Newsletter Preferences */}
+
+
+
+ Newsletter Preferences
+
+
+ Choose what information you'd like published in our member newsletter.
+
+
+
+
+ {/* Volunteer Interests */}
+ {isFieldEnabled('volunteer_interests') && (
+
+
+
+ Volunteer Interests
+
+
+ Select areas where you'd like to volunteer and help our community.
+
+
+ {volunteerOptions.map(option => (
+
handleVolunteerToggle(option)}
+ className="ui-checkbox"
/>
-
- fileInputRef.current?.click()}
- disabled={uploadingPhoto}
- className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6 py-3"
+
-
- {profilePhotoUrl && (
-
-
- Delete Photo
-
- )}
-
-
- Upload a profile photo (Max {maxFileSizeMB}MB)
-
+ {option}
+
+ ))}
+
+
+ )}
+
+ );
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+ My Profile
+
+ Update your personal information below.
+ {/*
+
+ Public Profile Preview
+ Preview
+ */}
- {/* Editable Form */}
- |