Email template management

This commit is contained in:
James Pattinson
2025-11-10 16:07:22 +00:00
parent 43b13ef52d
commit 7fd237c28b
17 changed files with 1421 additions and 259 deletions

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter from fastapi import APIRouter
from . import auth, users, tiers, memberships, payments, email from . import auth, users, tiers, memberships, payments, email, email_templates
api_router = APIRouter() api_router = APIRouter()
@@ -9,3 +9,4 @@ api_router.include_router(tiers.router, prefix="/tiers", tags=["membership-tiers
api_router.include_router(memberships.router, prefix="/memberships", tags=["memberships"]) api_router.include_router(memberships.router, prefix="/memberships", tags=["memberships"])
api_router.include_router(payments.router, prefix="/payments", tags=["payments"]) api_router.include_router(payments.router, prefix="/payments", tags=["payments"])
api_router.include_router(email.router, prefix="/email", tags=["email"]) api_router.include_router(email.router, prefix="/email", tags=["email"])
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])

View File

@@ -52,7 +52,8 @@ async def register(
try: try:
await email_service.send_welcome_email( await email_service.send_welcome_email(
to_email=db_user.email, to_email=db_user.email,
first_name=db_user.first_name first_name=db_user.first_name,
db=db
) )
except Exception as e: except Exception as e:
# Log error but don't fail registration # Log error but don't fail registration
@@ -171,7 +172,8 @@ async def forgot_password(
await email_service.send_password_reset_email( await email_service.send_password_reset_email(
to_email=user.email, to_email=user.email,
first_name=user.first_name, first_name=user.first_name,
reset_token=reset_token reset_token=reset_token,
db=db
) )
except Exception as e: except Exception as e:
# Log error but don't fail the request # Log error but don't fail the request

View File

@@ -0,0 +1,98 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from ...core.database import get_db
from ...models.models import EmailTemplate
from ...schemas import (
EmailTemplateCreate, EmailTemplateUpdate, EmailTemplateResponse, MessageResponse
)
from ...api.dependencies import get_super_admin_user
router = APIRouter()
@router.get("/", response_model=List[EmailTemplateResponse])
async def list_email_templates(
skip: int = 0,
limit: int = 100,
current_user = Depends(get_super_admin_user),
db: Session = Depends(get_db)
):
"""List all email templates (super admin only)"""
templates = db.query(EmailTemplate).offset(skip).limit(limit).all()
return templates
@router.get("/{template_key}", response_model=EmailTemplateResponse)
async def get_email_template(
template_key: str,
current_user = Depends(get_super_admin_user),
db: Session = Depends(get_db)
):
"""Get email template by key (super admin only)"""
template = db.query(EmailTemplate).filter(EmailTemplate.template_key == template_key).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Email template not found"
)
return template
@router.put("/{template_key}", response_model=EmailTemplateResponse)
async def update_email_template(
template_key: str,
template_update: EmailTemplateUpdate,
current_user = Depends(get_super_admin_user),
db: Session = Depends(get_db)
):
"""Update email template (super admin only)"""
template = db.query(EmailTemplate).filter(EmailTemplate.template_key == template_key).first()
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Email template not found"
)
update_data = template_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(template, field, value)
db.commit()
db.refresh(template)
return template
@router.post("/seed-defaults", response_model=MessageResponse)
async def seed_default_templates(
current_user = Depends(get_super_admin_user),
db: Session = Depends(get_db)
):
"""Seed database with default email templates (super admin only)"""
from ...services.email_service import get_default_templates
default_templates = get_default_templates()
created_count = 0
for template_data in default_templates:
# Check if template already exists
existing = db.query(EmailTemplate).filter(
EmailTemplate.template_key == template_data["template_key"]
).first()
if not existing:
template = EmailTemplate(**template_data)
db.add(template)
created_count += 1
if created_count > 0:
db.commit()
return {"message": f"Created {created_count} default email templates"}
else:
return {"message": "All default templates already exist"}

View File

@@ -145,7 +145,8 @@ async def update_membership(
annual_fee=membership.tier.annual_fee, annual_fee=membership.tier.annual_fee,
payment_amount=payment_amount, payment_amount=payment_amount,
payment_method=payment_method, payment_method=payment_method,
renewal_date=membership.end_date.strftime("%d %B %Y") renewal_date=membership.end_date.strftime("%d %B %Y"),
db=db
) )
except Exception as e: except Exception as e:
# Log error but don't fail the membership update # Log error but don't fail the membership update

View File

@@ -134,7 +134,8 @@ async def update_payment(
annual_fee=payment.membership.tier.annual_fee, annual_fee=payment.membership.tier.annual_fee,
payment_amount=payment.amount, payment_amount=payment.amount,
payment_method=payment.payment_method.value, payment_method=payment.payment_method.value,
renewal_date=payment.membership.end_date.strftime("%d %B %Y") renewal_date=payment.membership.end_date.strftime("%d %B %Y"),
db=db
) )
except Exception as e: except Exception as e:
# Log error but don't fail the payment update # Log error but don't fail the payment update
@@ -219,7 +220,8 @@ async def record_manual_payment(
annual_fee=membership.tier.annual_fee, annual_fee=membership.tier.annual_fee,
payment_amount=payment.amount, payment_amount=payment.amount,
payment_method=payment.payment_method.value, payment_method=payment.payment_method.value,
renewal_date=membership.end_date.strftime("%d %B %Y") renewal_date=membership.end_date.strftime("%d %B %Y"),
db=db
) )
except Exception as e: except Exception as e:
# Log error but don't fail the payment creation # Log error but don't fail the payment creation

View File

@@ -1,5 +1,5 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models.models import MembershipTier, User, UserRole from ..models.models import MembershipTier, User, UserRole, EmailTemplate
from .security import get_password_hash from .security import get_password_hash
from datetime import datetime from datetime import datetime
@@ -54,3 +54,19 @@ def init_default_data(db: Session):
db.commit() db.commit()
print("✓ Created default admin user (admin@swanseaairport.org / admin123)") print("✓ Created default admin user (admin@swanseaairport.org / admin123)")
print(" ⚠️ Remember to change the admin password!") print(" ⚠️ Remember to change the admin password!")
# Check if email templates exist
existing_templates = db.query(EmailTemplate).count()
if existing_templates == 0:
print("Creating default email templates...")
from ..services.email_service import get_default_templates
default_templates_data = get_default_templates()
default_templates = []
for template_data in default_templates_data:
template = EmailTemplate(**template_data)
default_templates.append(template)
db.add_all(default_templates)
db.commit()
print(f"✓ Created {len(default_templates)} default email templates")

View File

@@ -273,3 +273,18 @@ class PasswordResetToken(Base):
# Relationships # Relationships
user = relationship("User", backref="password_reset_tokens") user = relationship("User", backref="password_reset_tokens")
class EmailTemplate(Base):
__tablename__ = "email_templates"
id = Column(Integer, primary_key=True, index=True)
template_key = Column(String(100), unique=True, nullable=False, index=True)
name = Column(String(255), nullable=False)
subject = Column(String(255), nullable=False)
html_body = Column(Text, nullable=False)
text_body = Column(Text, nullable=True)
variables = Column(Text, nullable=True) # JSON string of available variables
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@@ -23,6 +23,10 @@ from .schemas import (
PaymentUpdate, PaymentUpdate,
PaymentResponse, PaymentResponse,
MessageResponse, MessageResponse,
EmailTemplateBase,
EmailTemplateCreate,
EmailTemplateUpdate,
EmailTemplateResponse,
) )
__all__ = [ __all__ = [
@@ -50,4 +54,8 @@ __all__ = [
"PaymentUpdate", "PaymentUpdate",
"PaymentResponse", "PaymentResponse",
"MessageResponse", "MessageResponse",
"EmailTemplateBase",
"EmailTemplateCreate",
"EmailTemplateUpdate",
"EmailTemplateResponse",
] ]

View File

@@ -166,3 +166,35 @@ class PaymentResponse(BaseModel):
class MessageResponse(BaseModel): class MessageResponse(BaseModel):
message: str message: str
detail: Optional[str] = None detail: Optional[str] = None
# Email Template Schemas
class EmailTemplateBase(BaseModel):
template_key: str
name: str
subject: str
html_body: str
text_body: Optional[str] = None
variables: Optional[str] = None
class EmailTemplateCreate(EmailTemplateBase):
pass
class EmailTemplateUpdate(BaseModel):
name: Optional[str] = None
subject: Optional[str] = None
html_body: Optional[str] = None
text_body: Optional[str] = None
variables: Optional[str] = None
is_active: Optional[bool] = None
class EmailTemplateResponse(EmailTemplateBase):
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
created_at: datetime
updated_at: datetime

View File

@@ -1,11 +1,14 @@
import httpx import httpx
from typing import List, Optional from typing import List, Optional, Dict, Any
from datetime import datetime from datetime import datetime
from ..core.database import get_db
from ..models.models import EmailTemplate
from sqlalchemy.orm import Session
from ..core.config import settings from ..core.config import settings
class EmailService: class EmailService:
"""Email service using SMTP2GO API""" """Email service using SMTP2GO API with database-stored templates"""
def __init__(self): def __init__(self):
self.api_key = settings.SMTP2GO_API_KEY self.api_key = settings.SMTP2GO_API_KEY
@@ -51,14 +54,141 @@ class EmailService:
response = await client.post(self.api_url, json=payload, headers=headers) response = await client.post(self.api_url, json=payload, headers=headers)
return response.json() return response.json()
async def send_welcome_email(self, to_email: str, first_name: str) -> dict: async def get_template(self, template_key: str, db: Session) -> Optional[EmailTemplate]:
"""Send welcome email to new user""" """Get email template from database"""
subject = f"Welcome to {settings.APP_NAME}!" return db.query(EmailTemplate).filter(
EmailTemplate.template_key == template_key,
EmailTemplate.is_active == True
).first()
html_body = f""" def render_template(self, template_body: str, variables: Dict[str, Any]) -> str:
<html> """Render template with variables"""
result = template_body
for key, value in variables.items():
result = result.replace(f"{{{key}}}", str(value))
return result
async def send_templated_email(
self,
template_key: str,
to_email: str,
variables: Dict[str, Any],
db: Session
) -> dict:
"""Send email using database template"""
template = await self.get_template(template_key, db)
if not template:
raise ValueError(f"Email template '{template_key}' not found or inactive")
# Render subject and bodies with variables
subject = self.render_template(template.subject, variables)
html_body = self.render_template(template.html_body, variables)
text_body = None
if template.text_body:
text_body = self.render_template(template.text_body, variables)
return await self.send_email(to_email, subject, html_body, text_body)
async def send_welcome_email(self, to_email: str, first_name: str, db: Session) -> dict:
"""Send welcome email to new user"""
variables = {
"first_name": first_name,
"app_name": settings.APP_NAME
}
return await self.send_templated_email("welcome", to_email, variables, db)
async def send_payment_confirmation(
self,
to_email: str,
first_name: str,
amount: float,
payment_method: str,
membership_tier: str,
db: Session
) -> dict:
"""Send payment confirmation email"""
variables = {
"first_name": first_name,
"amount": f"£{amount:.2f}",
"payment_method": payment_method,
"membership_tier": membership_tier,
"app_name": settings.APP_NAME
}
return await self.send_templated_email("payment_confirmation", to_email, variables, db)
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,
db: Session
) -> dict:
"""Send membership activation email with payment details and renewal date"""
variables = {
"first_name": first_name,
"membership_tier": membership_tier,
"annual_fee": f"£{annual_fee:.2f}",
"payment_amount": f"£{payment_amount:.2f}",
"payment_method": payment_method,
"renewal_date": renewal_date,
"payment_date": datetime.now().strftime("%d %B %Y"),
"app_name": settings.APP_NAME
}
return await self.send_templated_email("membership_activation", to_email, variables, db)
async def send_membership_renewal_reminder(
self,
to_email: str,
first_name: str,
expiry_date: str,
membership_tier: str,
annual_fee: float,
db: Session
) -> dict:
"""Send membership renewal reminder"""
variables = {
"first_name": first_name,
"expiry_date": expiry_date,
"membership_tier": membership_tier,
"annual_fee": f"£{annual_fee:.2f}",
"app_name": settings.APP_NAME
}
return await self.send_templated_email("renewal_reminder", to_email, variables, db)
async def send_password_reset_email(
self,
to_email: str,
first_name: str,
reset_token: str,
db: Session
) -> dict:
"""Send password reset email with reset link"""
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
variables = {
"first_name": first_name,
"reset_url": reset_url,
"app_name": settings.APP_NAME
}
return await self.send_templated_email("password_reset", to_email, variables, db)
def get_default_templates() -> List[Dict[str, Any]]:
"""Get default email templates for seeding the database"""
return [
{
"template_key": "welcome",
"name": "Welcome Email",
"subject": "Welcome to {app_name}!",
"html_body": """
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #0066cc;">Welcome to {settings.APP_NAME}!</h2> <h2 style="color: #0066cc;">Welcome to {app_name}!</h2>
<p>Hello {first_name},</p> <p>Hello {first_name},</p>
<p>Thank you for registering with us. Your account has been successfully created.</p> <p>Thank you for registering with us. Your account has been successfully created.</p>
<p>You can now:</p> <p>You can now:</p>
@@ -69,169 +199,143 @@ class EmailService:
</ul> </ul>
<p>If you have any questions, please don't hesitate to contact us.</p> <p>If you have any questions, please don't hesitate to contact us.</p>
<p>Best regards,<br> <p>Best regards,<br>
<strong>{settings.APP_NAME}</strong></p> <strong>{app_name}</strong></p>
</body> </body>
</html> </html>
""" """.strip(),
"text_body": """
Welcome to {app_name}!
text_body = f""" Hello {first_name},
Welcome to {settings.APP_NAME}!
Hello {first_name}, Thank you for registering with us. Your account has been successfully created.
Thank you for registering with us. Your account has been successfully created. You can now:
- Browse membership tiers and select one that suits you
- View upcoming events and meetings
- Access your membership portal
You can now: If you have any questions, please don't hesitate to contact us.
- Browse membership tiers and select one that suits you
- View upcoming events and meetings
- Access your membership portal
If you have any questions, please don't hesitate to contact us. Best regards,
{app_name}
Best regards, """.strip(),
{settings.APP_NAME} "variables": '["first_name", "app_name"]',
""" "is_active": True
},
return await self.send_email(to_email, subject, html_body, text_body) {
"template_key": "payment_confirmation",
async def send_payment_confirmation( "name": "Payment Confirmation",
self, "subject": "Payment Confirmation",
to_email: str, "html_body": """
first_name: str, <html>
amount: float,
payment_method: str,
membership_tier: str
) -> dict:
"""Send payment confirmation email"""
subject = "Payment Confirmation"
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #0066cc;">Payment Confirmed!</h2> <h2 style="color: #0066cc;">Payment Confirmed!</h2>
<p>Hello {first_name},</p> <p>Hello {first_name},</p>
<p>We have received your payment. Thank you!</p> <p>We have received your payment. Thank you!</p>
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;"> <div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p style="margin: 5px 0;"><strong>Amount:</strong> £{amount:.2f}</p> <p style="margin: 5px 0;"><strong>Amount:</strong> {amount}</p>
<p style="margin: 5px 0;"><strong>Payment Method:</strong> {payment_method}</p> <p style="margin: 5px 0;"><strong>Payment Method:</strong> {payment_method}</p>
<p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p> <p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p>
</div> </div>
<p>Your membership is now active. You can access all the benefits associated with your tier.</p> <p>Your membership is now active. You can access all the benefits associated with your tier.</p>
<p>Best regards,<br> <p>Best regards,<br>
<strong>{settings.APP_NAME}</strong></p> <strong>{app_name}</strong></p>
</body> </body>
</html> </html>
""" """.strip(),
"text_body": """
Payment Confirmed!
text_body = f""" Hello {first_name},
Payment Confirmed!
Hello {first_name}, We have received your payment. Thank you!
We have received your payment. Thank you! Amount: {amount}
Payment Method: {payment_method}
Membership Tier: {membership_tier}
Amount: £{amount:.2f} Your membership is now active. You can access all the benefits associated with your tier.
Payment Method: {payment_method}
Membership Tier: {membership_tier}
Your membership is now active. You can access all the benefits associated with your tier. Best regards,
{app_name}
Best regards, """.strip(),
{settings.APP_NAME} "variables": '["first_name", "amount", "payment_method", "membership_tier", "app_name"]',
""" "is_active": True
},
return await self.send_email(to_email, subject, html_body, text_body) {
"template_key": "membership_activation",
async def send_membership_activation_email( "name": "Membership Activation",
self, "subject": "Your {app_name} Membership is Now Active!",
to_email: str, "html_body": """
first_name: str, <html>
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"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #28a745;">Welcome to {settings.APP_NAME}!</h2> <h2 style="color: #28a745;">Welcome to {app_name}!</h2>
<p>Hello {first_name},</p> <p>Hello {first_name},</p>
<p>Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.</p> <p>Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.</p>
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #28a745;"> <div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #28a745;">
<h3 style="margin-top: 0; color: #28a745;">Membership Details</h3> <h3 style="margin-top: 0; color: #28a745;">Membership Details</h3>
<p style="margin: 8px 0;"><strong>Membership Tier:</strong> {membership_tier}</p> <p style="margin: 8px 0;"><strong>Membership Tier:</strong> {membership_tier}</p>
<p style="margin: 8px 0;"><strong>Annual Fee:</strong> £{annual_fee:.2f}</p> <p style="margin: 8px 0;"><strong>Annual Fee:</strong> {annual_fee}</p>
<p style="margin: 8px 0;"><strong>Next Renewal Date:</strong> {renewal_date}</p> <p style="margin: 8px 0;"><strong>Next Renewal Date:</strong> {renewal_date}</p>
</div> </div>
<div style="background-color: #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0;"> <div style="background-color: #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #495057;">Payment Information</h3> <h3 style="margin-top: 0; color: #495057;">Payment Information</h3>
<p style="margin: 8px 0;"><strong>Amount Paid:</strong> £{payment_amount:.2f}</p> <p style="margin: 8px 0;"><strong>Amount Paid:</strong> {payment_amount}</p>
<p style="margin: 8px 0;"><strong>Payment Method:</strong> {payment_method}</p> <p style="margin: 8px 0;"><strong>Payment Method:</strong> {payment_method}</p>
<p style="margin: 8px 0;"><strong>Payment Date:</strong> {datetime.now().strftime('%d %B %Y')}</p> <p style="margin: 8px 0;"><strong>Payment Date:</strong> {payment_date}</p>
</div> </div>
<p>Your membership will automatically renew on <strong>{renewal_date}</strong> unless you choose to cancel it. You can manage your membership settings in your account dashboard.</p> <p>Your membership will automatically renew on <strong>{renewal_date}</strong> unless you choose to cancel it. You can manage your membership settings in your account dashboard.</p>
<p>If you have any questions about your membership or need assistance, please don't hesitate to contact us.</p> <p>If you have any questions about your membership or need assistance, please don't hesitate to contact us.</p>
<p>Welcome to the {settings.APP_NAME} community!</p> <p>Welcome to the {app_name} community!</p>
<p>Best regards,<br> <p>Best regards,<br>
<strong>{settings.APP_NAME} Team</strong></p> <strong>{app_name} Team</strong></p>
</body> </body>
</html> </html>
""" """.strip(),
"text_body": """
Welcome to {app_name}!
text_body = f""" Hello {first_name},
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.
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}
Next Renewal Date: {renewal_date}
MEMBERSHIP DETAILS PAYMENT INFORMATION
------------------ -------------------
Membership Tier: {membership_tier} Amount Paid: {payment_amount}
Annual Fee: £{annual_fee:.2f} Payment Method: {payment_method}
Next Renewal Date: {renewal_date} Payment Date: {payment_date}
PAYMENT INFORMATION Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.
-------------------
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.
If you have any questions about your membership or need assistance, please don't hesitate to contact us. Welcome to the {app_name} community!
Welcome to the {settings.APP_NAME} community! Best regards,
{app_name} Team
Best regards, """.strip(),
{settings.APP_NAME} Team "variables": '["first_name", "membership_tier", "annual_fee", "payment_amount", "payment_method", "renewal_date", "payment_date", "app_name"]',
""" "is_active": True
},
return await self.send_email(to_email, subject, html_body, text_body) {
"template_key": "renewal_reminder",
async def send_membership_renewal_reminder( "name": "Membership Renewal Reminder",
self, "subject": "Membership Renewal Reminder",
to_email: str, "html_body": """
first_name: str, <html>
expiry_date: str,
membership_tier: str,
annual_fee: float
) -> dict:
"""Send membership renewal reminder"""
subject = "Membership Renewal Reminder"
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #0066cc;">Membership Renewal Reminder</h2> <h2 style="color: #0066cc;">Membership Renewal Reminder</h2>
<p>Hello {first_name},</p> <p>Hello {first_name},</p>
@@ -239,54 +343,46 @@ class EmailService:
<p>To continue enjoying your membership benefits, please renew your membership.</p> <p>To continue enjoying your membership benefits, please renew your membership.</p>
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;"> <div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p> <p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p>
<p style="margin: 5px 0;"><strong>Annual Fee:</strong> £{annual_fee:.2f}</p> <p style="margin: 5px 0;"><strong>Annual Fee:</strong> {annual_fee}</p>
<p style="margin: 5px 0;"><strong>Expires:</strong> {expiry_date}</p> <p style="margin: 5px 0;"><strong>Expires:</strong> {expiry_date}</p>
</div> </div>
<p>Please log in to your account to renew your membership.</p> <p>Please log in to your account to renew your membership.</p>
<p>Best regards,<br> <p>Best regards,<br>
<strong>{settings.APP_NAME}</strong></p> <strong>{app_name}</strong></p>
</body> </body>
</html> </html>
""" """.strip(),
"text_body": """
Membership Renewal Reminder
text_body = f""" Hello {first_name},
Membership Renewal Reminder
Hello {first_name}, This is a friendly reminder that your {membership_tier} membership will expire on {expiry_date}.
This is a friendly reminder that your {membership_tier} membership will expire on {expiry_date}. To continue enjoying your membership benefits, please renew your membership.
To continue enjoying your membership benefits, please renew your membership. Membership Tier: {membership_tier}
Annual Fee: {annual_fee}
Expires: {expiry_date}
Membership Tier: {membership_tier} Please log in to your account to renew your membership.
Annual Fee: £{annual_fee:.2f}
Expires: {expiry_date}
Please log in to your account to renew your membership. Best regards,
{app_name}
Best regards, """.strip(),
{settings.APP_NAME} "variables": '["first_name", "expiry_date", "membership_tier", "annual_fee", "app_name"]',
""" "is_active": True
},
return await self.send_email(to_email, subject, html_body, text_body) {
"template_key": "password_reset",
async def send_password_reset_email( "name": "Password Reset",
self, "subject": "Password Reset - {app_name}",
to_email: str, "html_body": """
first_name: str, <html>
reset_token: str
) -> dict:
"""Send password reset email with reset link"""
subject = f"Password Reset - {settings.APP_NAME}"
reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #0066cc;">Password Reset Request</h2> <h2 style="color: #0066cc;">Password Reset Request</h2>
<p>Hello {first_name},</p> <p>Hello {first_name},</p>
<p>You have requested to reset your password for your {settings.APP_NAME} account.</p> <p>You have requested to reset your password for your {app_name} account.</p>
<p>Please click the button below to reset your password:</p> <p>Please click the button below to reset your password:</p>
<div style="text-align: center; margin: 30px 0;"> <div style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">Reset Password</a> <a href="{reset_url}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">Reset Password</a>
@@ -297,31 +393,32 @@ class EmailService:
<p>If you didn't request this password reset, please ignore this email. Your password will remain unchanged.</p> <p>If you didn't request this password reset, please ignore this email. Your password will remain unchanged.</p>
<p>For security reasons, please don't share this email with anyone.</p> <p>For security reasons, please don't share this email with anyone.</p>
<p>Best regards,<br> <p>Best regards,<br>
<strong>{settings.APP_NAME}</strong></p> <strong>{app_name}</strong></p>
</body> </body>
</html> </html>
""" """.strip(),
"text_body": """
Password Reset Request
text_body = f""" Hello {first_name},
Password Reset Request
Hello {first_name}, You have requested to reset your password for your {app_name} account.
You have requested to reset your password for your {settings.APP_NAME} account. Please use this link to reset your password: {reset_url}
Please use this link to reset your password: {reset_url} This link will expire in 1 hour.
This link will expire in 1 hour. If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
If you didn't request this password reset, please ignore this email. Your password will remain unchanged. For security reasons, please don't share this email with anyone.
For security reasons, please don't share this email with anyone. Best regards,
{app_name}
Best regards, """.strip(),
{settings.APP_NAME} "variables": '["first_name", "reset_url", "app_name"]',
""" "is_active": True
}
return await self.send_email(to_email, subject, html_body, text_body) ]
# Create a singleton instance # Create a singleton instance

View File

@@ -5,6 +5,8 @@ import Login from './pages/Login';
import ForgotPassword from './pages/ForgotPassword'; import ForgotPassword from './pages/ForgotPassword';
import ResetPassword from './pages/ResetPassword'; import ResetPassword from './pages/ResetPassword';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import EmailTemplates from './pages/EmailTemplates';
import MembershipTiers from './pages/MembershipTiers';
import './App.css'; import './App.css';
const App: React.FC = () => { const App: React.FC = () => {
@@ -17,6 +19,8 @@ const App: React.FC = () => {
<Route path="/forgot-password" element={<ForgotPassword />} /> <Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} /> <Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/email-templates" element={<EmailTemplates />} />
<Route path="/membership-tiers" element={<MembershipTiers />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
); );

View File

@@ -0,0 +1,401 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
interface EmailTemplate {
template_key: string;
name: string;
subject: string;
html_body: string;
text_body: string;
variables: string; // This comes as JSON string from backend
is_active: boolean;
}
const EmailTemplateManagement: React.FC = () => {
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [showEditForm, setShowEditForm] = useState(false);
useEffect(() => {
fetchTemplates();
}, []);
const fetchTemplates = async () => {
try {
const token = localStorage.getItem('token');
const response = await axios.get('/api/v1/email-templates/', {
headers: { Authorization: `Bearer ${token}` }
});
setTemplates(response.data);
} catch (error) {
console.error('Error fetching email templates:', error);
} finally {
setLoading(false);
}
};
const handleEditTemplate = (template: EmailTemplate) => {
setEditingTemplate(template);
setShowEditForm(true);
};
const handleSaveTemplate = async (updatedTemplate: EmailTemplate) => {
try {
const token = localStorage.getItem('token');
await axios.put(`/api/v1/email-templates/${updatedTemplate.template_key}`, updatedTemplate, {
headers: { Authorization: `Bearer ${token}` }
});
setShowEditForm(false);
setEditingTemplate(null);
fetchTemplates(); // Refresh the list
} catch (error) {
console.error('Error updating email template:', error);
}
};
const handleCancelEdit = () => {
setShowEditForm(false);
setEditingTemplate(null);
};
if (loading) {
return <div style={{ padding: '20px', textAlign: 'center' }}>Loading email templates...</div>;
}
return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<button
onClick={fetchTemplates}
style={{
padding: '8px 16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Refresh Templates
</button>
</div>
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))' }}>
{templates.map((template) => (
<div
key={template.template_key}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '20px',
backgroundColor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{template.name}</h3>
<div>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
fontWeight: 'bold',
backgroundColor: template.is_active ? '#d4edda' : '#f8d7da',
color: template.is_active ? '#155724' : '#721c24'
}}>
{template.is_active ? 'Active' : 'Inactive'}
</span>
<button
onClick={() => handleEditTemplate(template)}
style={{
marginLeft: '10px',
padding: '6px 12px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Edit
</button>
</div>
</div>
<div style={{ marginBottom: '10px' }}>
<strong style={{ color: '#666' }}>Key:</strong> <code style={{ backgroundColor: '#f8f9fa', padding: '2px 4px', borderRadius: '3px' }}>{template.template_key}</code>
</div>
<div style={{ marginBottom: '10px' }}>
<strong style={{ color: '#666' }}>Subject:</strong> <span style={{ color: '#333' }}>{template.subject}</span>
</div>
<div style={{ marginBottom: '15px' }}>
<strong style={{ color: '#666' }}>Variables:</strong>
<div style={{ marginTop: '5px', fontFamily: 'monospace', fontSize: '14px', color: '#333' }}>
{(() => {
try {
const vars = JSON.parse(template.variables);
return Array.isArray(vars) ? vars.join(', ') : template.variables;
} catch {
return template.variables;
}
})()}
</div>
</div>
<div>
<strong style={{ color: '#666' }}>HTML Body Preview:</strong>
<div
style={{
marginTop: '8px',
padding: '12px',
backgroundColor: '#f8f9fa',
border: '1px solid #e9ecef',
borderRadius: '4px',
maxHeight: '200px',
overflow: 'auto',
fontSize: '13px',
lineHeight: '1.4',
color: '#333'
}}
dangerouslySetInnerHTML={{ __html: template.html_body.substring(0, 300) + '...' }}
/>
</div>
</div>
))}
</div>
{showEditForm && editingTemplate && (
<EmailTemplateEditForm
template={editingTemplate}
onSave={handleSaveTemplate}
onCancel={handleCancelEdit}
/>
)}
</div>
);
};
interface EmailTemplateEditFormProps {
template: EmailTemplate;
onSave: (template: EmailTemplate) => void;
onCancel: () => void;
}
const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template, onSave, onCancel }) => {
const [formData, setFormData] = useState({
name: template.name,
subject: template.subject,
html_body: template.html_body,
text_body: template.text_body,
variables: (() => {
try {
const vars = JSON.parse(template.variables);
return Array.isArray(vars) ? vars : [];
} catch {
return [];
}
})(),
is_active: template.is_active
});
const handleChange = (field: keyof EmailTemplate, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const dataToSave = {
template_key: template.template_key,
...formData,
variables: JSON.stringify(formData.variables)
};
onSave(dataToSave);
}; return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
padding: '20px',
borderRadius: '8px',
width: '90%',
maxWidth: '800px',
maxHeight: '90vh',
overflow: 'auto'
}}>
<h3 style={{ marginTop: 0, marginBottom: '20px' }}>Edit Email Template: {template.name}</h3>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Template Key:
</label>
<input
type="text"
value={template.template_key}
disabled
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
backgroundColor: '#f5f5f5'
}}
/>
<small style={{ color: '#666' }}>Template key cannot be changed</small>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Name:
</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
required
/>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Subject:
</label>
<input
type="text"
value={formData.subject}
onChange={(e) => handleChange('subject', e.target.value)}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
required
/>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Variables (comma-separated):
</label>
<input
type="text"
value={formData.variables.join(', ')}
onChange={(e) => handleChange('variables', e.target.value.split(',').map(v => v.trim()))}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
/>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
HTML Body:
</label>
<textarea
value={formData.html_body}
onChange={(e) => handleChange('html_body', e.target.value)}
rows={15}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '14px'
}}
required
/>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Text Body:
</label>
<textarea
value={formData.text_body}
onChange={(e) => handleChange('text_body', e.target.value)}
rows={10}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '14px'
}}
required
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'flex', alignItems: 'center' }}>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
style={{ marginRight: '8px' }}
/>
Active
</label>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '8px 16px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
type="submit"
style={{
padding: '8px 16px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Save Changes
</button>
</div>
</form>
</div>
</div>
);
};
export default EmailTemplateManagement;

View File

@@ -1,7 +1,6 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { authService } from '../services/membershipService'; import { authService } from '../services/membershipService';
import SuperAdminMenu from './SuperAdminMenu';
interface ProfileMenuProps { interface ProfileMenuProps {
userName: string; userName: string;
@@ -11,7 +10,6 @@ interface ProfileMenuProps {
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => { const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false); const [showChangePassword, setShowChangePassword] = useState(false);
const [showSuperAdmin, setShowSuperAdmin] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -38,15 +36,6 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
setIsOpen(false); setIsOpen(false);
}; };
const handleSuperAdmin = () => {
setShowSuperAdmin(true);
setIsOpen(false);
};
const handleCloseSuperAdmin = () => {
setShowSuperAdmin(false);
};
const dropdownStyle: React.CSSProperties = { const dropdownStyle: React.CSSProperties = {
position: 'absolute', position: 'absolute',
top: '100%', top: '100%',
@@ -94,17 +83,31 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
{isOpen && ( {isOpen && (
<div style={dropdownStyle}> <div style={dropdownStyle}>
{userRole === 'super_admin' && ( {userRole === 'super_admin' && (
<>
<button <button
style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }} style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }}
onClick={handleSuperAdmin} onClick={() => {
navigate('/membership-tiers');
setIsOpen(false);
}}
> >
Super Admin Panel Membership Tiers
</button> </button>
<button
style={{ ...menuItemStyle, borderTop: '1px solid #eee', borderRadius: '0' }}
onClick={() => {
navigate('/email-templates');
setIsOpen(false);
}}
>
Email Templates
</button>
</>
)} )}
<button <button
style={{ style={{
...menuItemStyle, ...menuItemStyle,
borderRadius: userRole === 'super_admin' ? '0' : '4px 4px 0 0', borderRadius: userRole === 'super_admin' ? '0 0 4px 4px' : '4px 4px 0 0',
borderTop: userRole === 'super_admin' ? '1px solid #eee' : 'none' borderTop: userRole === 'super_admin' ? '1px solid #eee' : 'none'
}} }}
onClick={handleChangePassword} onClick={handleChangePassword}
@@ -122,11 +125,7 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
</div> </div>
{showChangePassword && ( {showChangePassword && (
<ChangePasswordModal onClose={handleCloseChangePassword} /> <ChangePasswordModal onClose={() => setShowChangePassword(false)} />
)}
{showSuperAdmin && (
<SuperAdminMenu onClose={handleCloseSuperAdmin} />
)} )}
</> </>
); );

View File

@@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService'; import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
import EmailTemplateManagement from './EmailTemplateManagement';
interface SuperAdminMenuProps { interface SuperAdminMenuProps {
onClose: () => void; onClose: () => void;
} }
const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => { const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'system'>('tiers'); const [activeTab, setActiveTab] = useState<'tiers' | 'users' | 'email' | 'system'>('tiers');
const [tiers, setTiers] = useState<MembershipTier[]>([]); const [tiers, setTiers] = useState<MembershipTier[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
@@ -31,9 +32,9 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
} }
}; };
const handleCreateTier = async (data: MembershipTierCreateData) => { const handleCreateTier = async (data: MembershipTierCreateData | MembershipTierUpdateData) => {
try { try {
await membershipService.createTier(data); await membershipService.createTier(data as MembershipTierCreateData);
setShowCreateForm(false); setShowCreateForm(false);
loadTiers(); loadTiers();
} catch (error: any) { } catch (error: any) {
@@ -97,6 +98,12 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
> >
User Management User Management
</button> </button>
<button
onClick={() => setActiveTab('email')}
className={activeTab === 'email' ? 'tab-active' : 'tab-inactive'}
>
Email Templates
</button>
<button <button
onClick={() => setActiveTab('system')} onClick={() => setActiveTab('system')}
className={activeTab === 'system' ? 'tab-active' : 'tab-inactive'} className={activeTab === 'system' ? 'tab-active' : 'tab-inactive'}
@@ -128,6 +135,10 @@ const SuperAdminMenu: React.FC<SuperAdminMenuProps> = ({ onClose }) => {
</div> </div>
)} )}
{activeTab === 'email' && (
<EmailTemplateManagement />
)}
{activeTab === 'system' && ( {activeTab === 'system' && (
<div style={{ padding: '20px', textAlign: 'center' }} className="super-admin-placeholder"> <div style={{ padding: '20px', textAlign: 'center' }} className="super-admin-placeholder">
System settings coming soon... System settings coming soon...
@@ -143,7 +154,7 @@ interface TierManagementProps {
loading: boolean; loading: boolean;
showCreateForm: boolean; showCreateForm: boolean;
editingTier: MembershipTier | null; editingTier: MembershipTier | null;
onCreateTier: (data: MembershipTierCreateData) => void; onCreateTier: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
onUpdateTier: (tierId: number, data: MembershipTierUpdateData) => void; onUpdateTier: (tierId: number, data: MembershipTierUpdateData) => void;
onDeleteTier: (tierId: number) => void; onDeleteTier: (tierId: number) => void;
onShowCreateForm: () => void; onShowCreateForm: () => void;

View File

@@ -0,0 +1,110 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import EmailTemplateManagement from '../components/EmailTemplateManagement';
const EmailTemplates: React.FC = () => {
const navigate = useNavigate();
const [isSuperAdmin, setIsSuperAdmin] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkSuperAdminAccess();
}, []);
const checkSuperAdminAccess = async () => {
try {
const token = localStorage.getItem('token');
if (!token) {
navigate('/login');
return;
}
const response = await axios.get('/api/v1/users/me', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.data.role !== 'super_admin') {
navigate('/dashboard');
return;
}
setIsSuperAdmin(true);
} catch (error) {
console.error('Error checking user role:', error);
navigate('/login');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
backgroundColor: '#f8f9fa'
}}>
<div>Loading...</div>
</div>
);
}
if (!isSuperAdmin) {
return null; // Will redirect
}
return (
<div style={{
minHeight: '100vh',
backgroundColor: '#f8f9fa',
padding: '20px'
}}>
<div style={{
maxWidth: '1200px',
margin: '0 auto',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
overflow: 'hidden'
}}>
<div style={{
backgroundColor: '#007bff',
color: 'white',
padding: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<h1 style={{ margin: 0, fontSize: '24px' }}>Email Template Management</h1>
<p style={{ margin: '5px 0 0 0', opacity: 0.9 }}>
Manage email templates for the membership system
</p>
</div>
<button
onClick={() => navigate('/dashboard')}
style={{
padding: '8px 16px',
backgroundColor: 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Back to Dashboard
</button>
</div>
<div style={{ padding: '20px' }}>
<EmailTemplateManagement />
</div>
</div>
</div>
);
};
export default EmailTemplates;

View File

@@ -0,0 +1,358 @@
import React, { useState, useEffect } from 'react';
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
const MembershipTiers: React.FC = () => {
const [tiers, setTiers] = useState<MembershipTier[]>([]);
const [loading, setLoading] = useState(true);
const [showCreateForm, setShowCreateForm] = useState(false);
const [editingTier, setEditingTier] = useState<MembershipTier | null>(null);
useEffect(() => {
loadTiers();
}, []);
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 | MembershipTierUpdateData) => {
try {
await membershipService.createTier(data as MembershipTierCreateData);
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');
}
};
const handleEditTier = (tier: MembershipTier) => {
setEditingTier(tier);
};
const handleCancelEdit = () => {
setEditingTier(null);
setShowCreateForm(false);
};
if (loading) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '200px',
fontSize: '16px',
color: '#666'
}}>
Loading membership tiers...
</div>
);
}
return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '30px'
}}>
<h1 style={{ margin: 0, color: '#333' }}>Membership Tiers Management</h1>
<button
onClick={() => setShowCreateForm(true)}
style={{
padding: '10px 20px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Create New Tier
</button>
</div>
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}>
{tiers.map((tier) => (
<div
key={tier.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '20px',
backgroundColor: 'white',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '15px' }}>
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{tier.name}</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => handleEditTier(tier)}
style={{
padding: '6px 12px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Edit
</button>
<button
onClick={() => handleDeleteTier(tier.id)}
style={{
padding: '6px 12px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Delete
</button>
</div>
</div>
<div style={{ marginBottom: '10px' }}>
<strong style={{ color: '#666' }}>Annual Fee:</strong>
<span style={{ color: '#28a745', fontWeight: 'bold', marginLeft: '8px' }}>
£{tier.annual_fee.toFixed(2)}
</span>
</div>
<div style={{ marginBottom: '10px' }}>
<strong style={{ color: '#666' }}>Status:</strong>
<span style={{
marginLeft: '8px',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 'bold',
backgroundColor: tier.is_active ? '#d4edda' : '#f8d7da',
color: tier.is_active ? '#155724' : '#721c24'
}}>
{tier.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<div>
<strong style={{ color: '#666' }}>Benefits:</strong>
<p style={{
marginTop: '8px',
color: '#555',
fontSize: '14px',
lineHeight: '1.4'
}}>
{tier.benefits}
</p>
</div>
</div>
))}
</div>
{(showCreateForm || editingTier) && (
<MembershipTierForm
tier={editingTier}
onSave={editingTier ? (data) => handleUpdateTier(editingTier.id, data) : handleCreateTier}
onCancel={handleCancelEdit}
/>
)}
</div>
);
};
interface MembershipTierFormProps {
tier: MembershipTier | null;
onSave: (data: MembershipTierCreateData | MembershipTierUpdateData) => void;
onCancel: () => void;
}
const MembershipTierForm: React.FC<MembershipTierFormProps> = ({ tier, onSave, onCancel }) => {
const [formData, setFormData] = useState({
name: tier?.name || '',
annual_fee: tier?.annual_fee || 0,
benefits: tier?.benefits || '',
is_active: tier?.is_active ?? true
});
const handleChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
padding: '20px',
borderRadius: '8px',
width: '90%',
maxWidth: '500px',
maxHeight: '90vh',
overflow: 'auto'
}}>
<h3 style={{ marginTop: 0, marginBottom: '20px' }}>
{tier ? 'Edit Membership Tier' : 'Create New Membership Tier'}
</h3>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Name:
</label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
required
/>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Annual Fee (£):
</label>
<input
type="number"
step="0.01"
min="0"
value={formData.annual_fee}
onChange={(e) => handleChange('annual_fee', parseFloat(e.target.value) || 0)}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
required
/>
</div>
<div style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>
Benefits:
</label>
<textarea
value={formData.benefits}
onChange={(e) => handleChange('benefits', e.target.value)}
rows={4}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ddd',
borderRadius: '4px',
fontFamily: 'inherit',
resize: 'vertical'
}}
required
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'flex', alignItems: 'center' }}>
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleChange('is_active', e.target.checked)}
style={{ marginRight: '8px' }}
/>
Active
</label>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button
type="button"
onClick={onCancel}
style={{
padding: '8px 16px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
type="submit"
style={{
padding: '8px 16px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
{tier ? 'Update Tier' : 'Create Tier'}
</button>
</div>
</form>
</div>
</div>
);
};
export default MembershipTiers;

View File

@@ -83,6 +83,13 @@ export interface MembershipCreateData {
auto_renew: boolean; auto_renew: boolean;
} }
export interface MembershipUpdateData {
status?: string;
start_date?: string;
end_date?: string;
auto_renew?: boolean;
}
export interface PaymentCreateData { export interface PaymentCreateData {
amount: number; amount: number;
payment_method: string; payment_method: string;