diff --git a/backend/app/api/v1/memberships.py b/backend/app/api/v1/memberships.py
index e432694..3c98100 100644
--- a/backend/app/api/v1/memberships.py
+++ b/backend/app/api/v1/memberships.py
@@ -4,11 +4,12 @@ from typing import List
from datetime import date, timedelta
from ...core.database import get_db
-from ...models.models import Membership, MembershipStatus, User, MembershipTier
+from ...models.models import Membership, MembershipStatus, User, MembershipTier, Payment, PaymentStatus
from ...schemas import (
MembershipCreate, MembershipUpdate, MembershipResponse, MessageResponse
)
from ...api.dependencies import get_current_active_user, get_admin_user
+from ...services.email_service import email_service
router = APIRouter()
@@ -112,6 +113,11 @@ async def update_membership(
)
update_data = membership_update.model_dump(exclude_unset=True)
+ was_activated = False
+
+ # Check if status is being changed to ACTIVE
+ if update_data.get("status") == MembershipStatus.ACTIVE and membership.status != MembershipStatus.ACTIVE:
+ was_activated = True
for field, value in update_data.items():
setattr(membership, field, value)
@@ -119,6 +125,32 @@ async def update_membership(
db.commit()
db.refresh(membership)
+ # Send activation email if membership was just activated
+ if was_activated:
+ try:
+ # Get the most recent payment for this membership
+ recent_payment = db.query(Payment).filter(
+ Payment.membership_id == membership.id,
+ Payment.status == PaymentStatus.COMPLETED
+ ).order_by(Payment.payment_date.desc()).first()
+
+ payment_amount = recent_payment.amount if recent_payment else membership.tier.annual_fee
+ payment_method = recent_payment.payment_method.value if recent_payment else "N/A"
+
+ # Send activation email (non-blocking)
+ await email_service.send_membership_activation_email(
+ to_email=membership.user.email,
+ first_name=membership.user.first_name,
+ membership_tier=membership.tier.name,
+ annual_fee=membership.tier.annual_fee,
+ payment_amount=payment_amount,
+ payment_method=payment_method,
+ renewal_date=membership.end_date.strftime("%d %B %Y")
+ )
+ except Exception as e:
+ # Log error but don't fail the membership update
+ print(f"Failed to send membership activation email: {e}")
+
return membership
diff --git a/backend/app/api/v1/payments.py b/backend/app/api/v1/payments.py
index 4f58ace..14e899f 100644
--- a/backend/app/api/v1/payments.py
+++ b/backend/app/api/v1/payments.py
@@ -4,11 +4,12 @@ from typing import List
from datetime import datetime
from ...core.database import get_db
-from ...models.models import Payment, PaymentStatus, User, Membership
+from ...models.models import Payment, PaymentStatus, User, Membership, MembershipStatus
from ...schemas import (
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse
)
from ...api.dependencies import get_current_active_user, get_admin_user
+from ...services.email_service import email_service
router = APIRouter()
@@ -114,6 +115,31 @@ async def update_payment(
db.commit()
db.refresh(payment)
+ # If payment was just marked as completed and has an associated membership,
+ # activate the membership and send activation email
+ if (update_data.get("status") == PaymentStatus.COMPLETED and
+ payment.membership_id and
+ payment.membership.status == MembershipStatus.PENDING):
+
+ # Activate the membership
+ payment.membership.status = MembershipStatus.ACTIVE
+ db.commit()
+
+ # Send activation email (non-blocking)
+ try:
+ await email_service.send_membership_activation_email(
+ to_email=payment.membership.user.email,
+ first_name=payment.membership.user.first_name,
+ membership_tier=payment.membership.tier.name,
+ annual_fee=payment.membership.tier.annual_fee,
+ payment_amount=payment.amount,
+ payment_method=payment.payment_method.value,
+ renewal_date=payment.membership.end_date.strftime("%d %B %Y")
+ )
+ except Exception as e:
+ # Log error but don't fail the payment update
+ print(f"Failed to send membership activation email: {e}")
+
return payment
@@ -152,6 +178,7 @@ async def record_manual_payment(
)
# Verify membership if provided
+ membership = None
if payment_data.membership_id:
membership = db.query(Membership).filter(
Membership.id == payment_data.membership_id,
@@ -178,4 +205,24 @@ async def record_manual_payment(
db.commit()
db.refresh(payment)
+ # If payment has an associated membership that's pending, activate it and send email
+ if membership and membership.status == MembershipStatus.PENDING:
+ membership.status = MembershipStatus.ACTIVE
+ db.commit()
+
+ # Send activation email (non-blocking)
+ try:
+ await email_service.send_membership_activation_email(
+ to_email=membership.user.email,
+ first_name=membership.user.first_name,
+ membership_tier=membership.tier.name,
+ annual_fee=membership.tier.annual_fee,
+ payment_amount=payment.amount,
+ payment_method=payment.payment_method.value,
+ renewal_date=membership.end_date.strftime("%d %B %Y")
+ )
+ except Exception as e:
+ # Log error but don't fail the payment creation
+ print(f"Failed to send membership activation email: {e}")
+
return payment
diff --git a/backend/app/api/v1/tiers.py b/backend/app/api/v1/tiers.py
index eb5241a..24469ea 100644
--- a/backend/app/api/v1/tiers.py
+++ b/backend/app/api/v1/tiers.py
@@ -7,7 +7,7 @@ from ...models.models import MembershipTier
from ...schemas import (
MembershipTierCreate, MembershipTierUpdate, MembershipTierResponse, MessageResponse
)
-from ...api.dependencies import get_current_active_user, get_admin_user
+from ...api.dependencies import get_current_active_user, get_admin_user, get_super_admin_user
router = APIRouter()
@@ -47,10 +47,10 @@ async def get_membership_tier(
@router.post("/", response_model=MembershipTierResponse, status_code=status.HTTP_201_CREATED)
async def create_membership_tier(
tier_data: MembershipTierCreate,
- current_user = Depends(get_admin_user),
+ current_user = Depends(get_super_admin_user),
db: Session = Depends(get_db)
):
- """Create a new membership tier (admin only)"""
+ """Create a new membership tier (super admin only)"""
# Check if tier with same name exists
existing_tier = db.query(MembershipTier).filter(
MembershipTier.name == tier_data.name
@@ -74,10 +74,10 @@ async def create_membership_tier(
async def update_membership_tier(
tier_id: int,
tier_update: MembershipTierUpdate,
- current_user = Depends(get_admin_user),
+ current_user = Depends(get_super_admin_user),
db: Session = Depends(get_db)
):
- """Update membership tier (admin only)"""
+ """Update membership tier (super admin only)"""
tier = db.query(MembershipTier).filter(MembershipTier.id == tier_id).first()
if not tier:
raise HTTPException(
@@ -99,10 +99,10 @@ async def update_membership_tier(
@router.delete("/{tier_id}", response_model=MessageResponse)
async def delete_membership_tier(
tier_id: int,
- current_user = Depends(get_admin_user),
+ current_user = Depends(get_super_admin_user),
db: Session = Depends(get_db)
):
- """Delete membership tier (admin only)"""
+ """Delete membership tier (super admin only)"""
tier = db.query(MembershipTier).filter(MembershipTier.id == tier_id).first()
if not tier:
raise HTTPException(
diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py
index 488aa6e..32deb4e 100644
--- a/backend/app/services/email_service.py
+++ b/backend/app/services/email_service.py
@@ -1,5 +1,6 @@
import httpx
from typing import List, Optional
+from datetime import datetime
from ..core.config import settings
@@ -141,6 +142,83 @@ class EmailService:
return await self.send_email(to_email, subject, html_body, text_body)
+ async def send_membership_activation_email(
+ self,
+ to_email: str,
+ first_name: str,
+ membership_tier: str,
+ annual_fee: float,
+ payment_amount: float,
+ payment_method: str,
+ renewal_date: str
+ ) -> dict:
+ """Send membership activation email with payment details and renewal date"""
+ subject = f"Your {settings.APP_NAME} Membership is Now Active!"
+
+ html_body = f"""
+
+
+ Welcome to {settings.APP_NAME}!
+ Hello {first_name},
+ Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.
+
+
+
Membership Details
+
Membership Tier: {membership_tier}
+
Annual Fee: £{annual_fee:.2f}
+
Next Renewal Date: {renewal_date}
+
+
+
+
Payment Information
+
Amount Paid: £{payment_amount:.2f}
+
Payment Method: {payment_method}
+
Payment Date: {datetime.now().strftime('%d %B %Y')}
+
+
+ Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.
+
+ If you have any questions about your membership or need assistance, please don't hesitate to contact us.
+
+ Welcome to the {settings.APP_NAME} community!
+
+ Best regards,
+ {settings.APP_NAME} Team
+
+
+ """
+
+ text_body = f"""
+ Welcome to {settings.APP_NAME}!
+
+ Hello {first_name},
+
+ Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.
+
+ MEMBERSHIP DETAILS
+ ------------------
+ Membership Tier: {membership_tier}
+ Annual Fee: £{annual_fee:.2f}
+ Next Renewal Date: {renewal_date}
+
+ PAYMENT INFORMATION
+ -------------------
+ Amount Paid: £{payment_amount:.2f}
+ Payment Method: {payment_method}
+ Payment Date: {datetime.now().strftime('%d %B %Y')}
+
+ Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.
+
+ If you have any questions about your membership or need assistance, please don't hesitate to contact us.
+
+ Welcome to the {settings.APP_NAME} community!
+
+ Best regards,
+ {settings.APP_NAME} Team
+ """
+
+ return await self.send_email(to_email, subject, html_body, text_body)
+
async def send_membership_renewal_reminder(
self,
to_email: str,
diff --git a/frontend/src/App.css b/frontend/src/App.css
index e35f5b1..704507d 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -309,3 +309,79 @@ body {
background: #6c757d;
cursor: not-allowed;
}
+
+/* Tab Styles */
+.tab-active {
+ padding: 10px 20px;
+ border: none;
+ background: #007bff;
+ color: white;
+ cursor: pointer;
+ border-bottom: 2px solid #007bff;
+ font-weight: bold;
+}
+
+.tab-inactive {
+ padding: 10px 20px;
+ border: none;
+ background: none;
+ color: #666;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+}
+
+.tab-inactive:hover {
+ color: #007bff;
+ border-bottom-color: #007bff;
+}
+
+/* Super Admin Panel Styles */
+.super-admin-table th {
+ color: #333;
+ font-weight: 600;
+}
+
+.super-admin-table td {
+ color: #555;
+}
+
+.super-admin-loading {
+ color: #666;
+}
+
+.super-admin-placeholder {
+ color: #666;
+}
+
+/* Action buttons in tables */
+.action-btn {
+ padding: 6px 12px;
+ border: 1px solid #007bff;
+ border-radius: 4px;
+ background: #007bff;
+ color: white !important;
+ cursor: pointer;
+ font-size: 12px;
+ font-weight: 500;
+ transition: all 0.2s;
+ display: inline-block;
+ text-decoration: none;
+}
+
+.action-btn:hover {
+ background: #0056b3;
+ border-color: #0056b3;
+ color: white !important;
+}
+
+.action-btn-danger {
+ border-color: #dc3545;
+ background: #dc3545;
+ color: white !important;
+}
+
+.action-btn-danger:hover {
+ background: #c82333;
+ border-color: #c82333;
+ color: white !important;
+}
diff --git a/frontend/src/components/MembershipSetup.tsx b/frontend/src/components/MembershipSetup.tsx
index b49262b..9ea7a84 100644
--- a/frontend/src/components/MembershipSetup.tsx
+++ b/frontend/src/components/MembershipSetup.tsx
@@ -147,7 +147,7 @@ const MembershipSetup: React.FC = ({ onMembershipCreated,
Demo Payment
- This is a fake payment flow for demonstration purposes. In a real application, you would integrate with a payment processor like Stripe or Square.
+ This is a fake payment flow for demo purposes. Square will come soon
diff --git a/frontend/src/components/ProfileMenu.tsx b/frontend/src/components/ProfileMenu.tsx
index 77f0e14..9e189c0 100644
--- a/frontend/src/components/ProfileMenu.tsx
+++ b/frontend/src/components/ProfileMenu.tsx
@@ -1,14 +1,17 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { authService } from '../services/membershipService';
+import SuperAdminMenu from './SuperAdminMenu';
interface ProfileMenuProps {
userName: string;
+ userRole: string;
}
-const ProfileMenu: React.FC = ({ userName }) => {
+const ProfileMenu: React.FC = ({ userName, userRole }) => {
const [isOpen, setIsOpen] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false);
+ const [showSuperAdmin, setShowSuperAdmin] = useState(false);
const menuRef = useRef(null);
const navigate = useNavigate();
@@ -35,8 +38,13 @@ const ProfileMenu: React.FC = ({ userName }) => {
setIsOpen(false);
};
- const handleCloseChangePassword = () => {
- setShowChangePassword(false);
+ const handleSuperAdmin = () => {
+ setShowSuperAdmin(true);
+ setIsOpen(false);
+ };
+
+ const handleCloseSuperAdmin = () => {
+ setShowSuperAdmin(false);
};
const dropdownStyle: React.CSSProperties = {
@@ -85,8 +93,20 @@ const ProfileMenu: React.FC = ({ userName }) => {
{isOpen && (
+ {userRole === 'super_admin' && (
+
+ Super Admin Panel
+
+ )}
Change Password
@@ -104,6 +124,10 @@ const ProfileMenu: React.FC = ({ userName }) => {
{showChangePassword && (
)}
+
+ {showSuperAdmin && (
+
+ )}
>
);
};
diff --git a/frontend/src/components/SuperAdminMenu.tsx b/frontend/src/components/SuperAdminMenu.tsx
new file mode 100644
index 0000000..61c52ac
--- /dev/null
+++ b/frontend/src/components/SuperAdminMenu.tsx
@@ -0,0 +1,376 @@
+import React, { useState, useEffect } from 'react';
+import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
+
+interface SuperAdminMenuProps {
+ onClose: () => void;
+}
+
+const SuperAdminMenu: React.FC = ({ onClose }) => {
+ const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'system'>('tiers');
+ const [tiers, setTiers] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [editingTier, setEditingTier] = useState(null);
+
+ useEffect(() => {
+ if (activeTab === 'tiers') {
+ loadTiers();
+ }
+ }, [activeTab]);
+
+ const loadTiers = async () => {
+ try {
+ setLoading(true);
+ const tierData = await membershipService.getAllTiers(true);
+ setTiers(tierData);
+ } catch (error) {
+ console.error('Failed to load tiers:', error);
+ alert('Failed to load membership tiers');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreateTier = async (data: MembershipTierCreateData) => {
+ try {
+ await membershipService.createTier(data);
+ setShowCreateForm(false);
+ loadTiers();
+ } catch (error: any) {
+ alert(error.response?.data?.detail || 'Failed to create tier');
+ }
+ };
+
+ const handleUpdateTier = async (tierId: number, data: MembershipTierUpdateData) => {
+ try {
+ await membershipService.updateTier(tierId, data);
+ setEditingTier(null);
+ loadTiers();
+ } catch (error: any) {
+ alert(error.response?.data?.detail || 'Failed to update tier');
+ }
+ };
+
+ const handleDeleteTier = async (tierId: number) => {
+ if (!confirm('Are you sure you want to delete this membership tier? This action cannot be undone.')) {
+ return;
+ }
+
+ try {
+ await membershipService.deleteTier(tierId);
+ loadTiers();
+ } catch (error: any) {
+ alert(error.response?.data?.detail || 'Failed to delete tier');
+ }
+ };
+
+ return (
+
+
+
+
Super Admin Panel
+
+ ×
+
+
+
+
+
+ setActiveTab('tiers')}
+ className={activeTab === 'tiers' ? 'tab-active' : 'tab-inactive'}
+ >
+ Membership Tiers
+
+ setActiveTab('users')}
+ className={activeTab === 'users' ? 'tab-active' : 'tab-inactive'}
+ >
+ User Management
+
+ setActiveTab('system')}
+ className={activeTab === 'system' ? 'tab-active' : 'tab-inactive'}
+ >
+ System Settings
+
+
+
+
+ {activeTab === 'tiers' && (
+
setShowCreateForm(true)}
+ onHideCreateForm={() => setShowCreateForm(false)}
+ onEditTier={setEditingTier}
+ onCancelEdit={() => setEditingTier(null)}
+ />
+ )}
+
+ {activeTab === 'users' && (
+
+ User management features coming soon...
+
+ )}
+
+ {activeTab === 'system' && (
+
+ System settings coming soon...
+
+ )}
+
+
+ );
+};
+
+interface TierManagementProps {
+ tiers: MembershipTier[];
+ loading: boolean;
+ showCreateForm: boolean;
+ editingTier: MembershipTier | null;
+ onCreateTier: (data: MembershipTierCreateData) => void;
+ onUpdateTier: (tierId: number, data: MembershipTierUpdateData) => void;
+ onDeleteTier: (tierId: number) => void;
+ onShowCreateForm: () => void;
+ onHideCreateForm: () => void;
+ onEditTier: (tier: MembershipTier) => void;
+ onCancelEdit: () => void;
+}
+
+const TierManagement: React.FC = ({
+ tiers,
+ loading,
+ showCreateForm,
+ editingTier,
+ onCreateTier,
+ onUpdateTier,
+ onDeleteTier,
+ onShowCreateForm,
+ onHideCreateForm,
+ onEditTier,
+ onCancelEdit
+}) => {
+ if (loading) {
+ return Loading tiers...
;
+ }
+
+ return (
+
+
+
Membership Tiers Management
+
+ Create New Tier
+
+
+
+ {showCreateForm && (
+
+ )}
+
+ {editingTier && (
+
onUpdateTier(editingTier.id, data)}
+ onCancel={onCancelEdit}
+ title="Edit Membership Tier"
+ />
+ )}
+
+
+
+
+
+ Name
+ Description
+ Annual Fee
+ Benefits
+ Status
+ Actions
+
+
+
+ {tiers.map(tier => (
+
+ {tier.name}
+
+ {tier.description || 'No description'}
+
+ £{tier.annual_fee.toFixed(2)}
+
+ {tier.benefits || 'No benefits listed'}
+
+
+
+ {tier.is_active ? 'ACTIVE' : 'INACTIVE'}
+
+
+
+ onEditTier(tier)}
+ className="action-btn"
+ style={{ marginRight: '8px', color: 'white', backgroundColor: '#007bff', border: '1px solid #007bff' }}
+ >
+ Edit
+
+ onDeleteTier(tier.id)}
+ className="action-btn action-btn-danger"
+ style={{ color: 'white', backgroundColor: '#dc3545', border: '1px solid #dc3545' }}
+ >
+ Delete
+
+
+
+ ))}
+
+
+
+
+ );
+};
+
+interface TierFormProps {
+ initialData?: MembershipTier;
+ onSubmit: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
+ onCancel: () => void;
+ title: string;
+}
+
+const TierForm: React.FC = ({ initialData, onSubmit, onCancel, title }) => {
+ const [formData, setFormData] = useState({
+ name: initialData?.name || '',
+ description: initialData?.description || '',
+ annual_fee: initialData?.annual_fee || 0,
+ benefits: initialData?.benefits || '',
+ is_active: initialData?.is_active ?? true
+ });
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ onSubmit(formData);
+ };
+
+ const handleChange = (field: string, value: any) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ };
+
+ return (
+
+ );
+};
+
+export default SuperAdminMenu;
\ No newline at end of file
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index b3ebdb1..cce209b 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -132,7 +132,7 @@ const Dashboard: React.FC = () => {
<>
SASA Membership Portal
-
+
{
<>
SASA Membership Portal
-
+
diff --git a/frontend/src/services/membershipService.ts b/frontend/src/services/membershipService.ts
index 3641c4f..3912b09 100644
--- a/frontend/src/services/membershipService.ts
+++ b/frontend/src/services/membershipService.ts
@@ -97,11 +97,19 @@ export interface PaymentUpdateData {
notes?: string;
}
-export interface MembershipUpdateData {
- tier_id?: number;
- status?: string;
- end_date?: string;
- auto_renew?: boolean;
+export interface MembershipTierCreateData {
+ name: string;
+ description?: string;
+ annual_fee: number;
+ benefits?: string;
+}
+
+export interface MembershipTierUpdateData {
+ name?: string;
+ description?: string;
+ annual_fee?: number;
+ benefits?: string;
+ is_active?: boolean;
}
export const authService = {
@@ -186,6 +194,26 @@ export const membershipService = {
async getTiers(): Promise {
const response = await api.get('/tiers/');
return response.data;
+ },
+
+ async createTier(data: MembershipTierCreateData): Promise {
+ const response = await api.post('/tiers/', data);
+ return response.data;
+ },
+
+ async updateTier(tierId: number, data: MembershipTierUpdateData): Promise {
+ const response = await api.put(`/tiers/${tierId}`, data);
+ return response.data;
+ },
+
+ async deleteTier(tierId: number): Promise<{ message: string }> {
+ const response = await api.delete(`/tiers/${tierId}`);
+ return response.data;
+ },
+
+ async getAllTiers(showInactive: boolean = true): Promise {
+ const response = await api.get(`/tiers/?show_inactive=${showInactive}`);
+ return response.data;
}
};