Email template management
This commit is contained in:
@@ -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"])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
98
backend/app/api/v1/email_templates.py
Normal file
98
backend/app/api/v1/email_templates.py
Normal 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"}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,96 +54,69 @@ 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()
|
||||||
|
|
||||||
|
def render_template(self, template_body: str, variables: Dict[str, Any]) -> str:
|
||||||
|
"""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)
|
||||||
|
|
||||||
html_body = f"""
|
if not template:
|
||||||
<html>
|
raise ValueError(f"Email template '{template_key}' not found or inactive")
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
|
||||||
<h2 style="color: #0066cc;">Welcome to {settings.APP_NAME}!</h2>
|
|
||||||
<p>Hello {first_name},</p>
|
|
||||||
<p>Thank you for registering with us. Your account has been successfully created.</p>
|
|
||||||
<p>You can now:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Browse membership tiers and select one that suits you</li>
|
|
||||||
<li>View upcoming events and meetings</li>
|
|
||||||
<li>Access your membership portal</li>
|
|
||||||
</ul>
|
|
||||||
<p>If you have any questions, please don't hesitate to contact us.</p>
|
|
||||||
<p>Best regards,<br>
|
|
||||||
<strong>{settings.APP_NAME}</strong></p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
text_body = f"""
|
# Render subject and bodies with variables
|
||||||
Welcome to {settings.APP_NAME}!
|
subject = self.render_template(template.subject, variables)
|
||||||
|
html_body = self.render_template(template.html_body, variables)
|
||||||
|
text_body = None
|
||||||
|
|
||||||
Hello {first_name},
|
if template.text_body:
|
||||||
|
text_body = self.render_template(template.text_body, variables)
|
||||||
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
|
|
||||||
|
|
||||||
If you have any questions, please don't hesitate to contact us.
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
{settings.APP_NAME}
|
|
||||||
"""
|
|
||||||
|
|
||||||
return await self.send_email(to_email, subject, html_body, text_body)
|
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(
|
async def send_payment_confirmation(
|
||||||
self,
|
self,
|
||||||
to_email: str,
|
to_email: str,
|
||||||
first_name: str,
|
first_name: str,
|
||||||
amount: float,
|
amount: float,
|
||||||
payment_method: str,
|
payment_method: str,
|
||||||
membership_tier: str
|
membership_tier: str,
|
||||||
|
db: Session
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Send payment confirmation email"""
|
"""Send payment confirmation email"""
|
||||||
subject = "Payment Confirmation"
|
variables = {
|
||||||
|
"first_name": first_name,
|
||||||
html_body = f"""
|
"amount": f"£{amount:.2f}",
|
||||||
<html>
|
"payment_method": payment_method,
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
"membership_tier": membership_tier,
|
||||||
<h2 style="color: #0066cc;">Payment Confirmed!</h2>
|
"app_name": settings.APP_NAME
|
||||||
<p>Hello {first_name},</p>
|
}
|
||||||
<p>We have received your payment. Thank you!</p>
|
return await self.send_templated_email("payment_confirmation", to_email, variables, db)
|
||||||
<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>Payment Method:</strong> {payment_method}</p>
|
|
||||||
<p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p>
|
|
||||||
</div>
|
|
||||||
<p>Your membership is now active. You can access all the benefits associated with your tier.</p>
|
|
||||||
<p>Best regards,<br>
|
|
||||||
<strong>{settings.APP_NAME}</strong></p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
text_body = f"""
|
|
||||||
Payment Confirmed!
|
|
||||||
|
|
||||||
Hello {first_name},
|
|
||||||
|
|
||||||
We have received your payment. Thank you!
|
|
||||||
|
|
||||||
Amount: £{amount:.2f}
|
|
||||||
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,
|
|
||||||
{settings.APP_NAME}
|
|
||||||
"""
|
|
||||||
|
|
||||||
return await self.send_email(to_email, subject, html_body, text_body)
|
|
||||||
|
|
||||||
async def send_membership_activation_email(
|
async def send_membership_activation_email(
|
||||||
self,
|
self,
|
||||||
@@ -150,74 +126,21 @@ class EmailService:
|
|||||||
annual_fee: float,
|
annual_fee: float,
|
||||||
payment_amount: float,
|
payment_amount: float,
|
||||||
payment_method: str,
|
payment_method: str,
|
||||||
renewal_date: str
|
renewal_date: str,
|
||||||
|
db: Session
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Send membership activation email with payment details and renewal date"""
|
"""Send membership activation email with payment details and renewal date"""
|
||||||
subject = f"Your {settings.APP_NAME} Membership is Now Active!"
|
variables = {
|
||||||
|
"first_name": first_name,
|
||||||
html_body = f"""
|
"membership_tier": membership_tier,
|
||||||
<html>
|
"annual_fee": f"£{annual_fee:.2f}",
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
"payment_amount": f"£{payment_amount:.2f}",
|
||||||
<h2 style="color: #28a745;">Welcome to {settings.APP_NAME}!</h2>
|
"payment_method": payment_method,
|
||||||
<p>Hello {first_name},</p>
|
"renewal_date": renewal_date,
|
||||||
<p>Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.</p>
|
"payment_date": datetime.now().strftime("%d %B %Y"),
|
||||||
|
"app_name": settings.APP_NAME
|
||||||
<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>
|
return await self.send_templated_email("membership_activation", to_email, variables, db)
|
||||||
<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>Next Renewal Date:</strong> {renewal_date}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="background-color: #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
|
||||||
<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>Payment Method:</strong> {payment_method}</p>
|
|
||||||
<p style="margin: 8px 0;"><strong>Payment Date:</strong> {datetime.now().strftime('%d %B %Y')}</p>
|
|
||||||
</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>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>Best regards,<br>
|
|
||||||
<strong>{settings.APP_NAME} Team</strong></p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
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(
|
async def send_membership_renewal_reminder(
|
||||||
self,
|
self,
|
||||||
@@ -225,103 +148,277 @@ class EmailService:
|
|||||||
first_name: str,
|
first_name: str,
|
||||||
expiry_date: str,
|
expiry_date: str,
|
||||||
membership_tier: str,
|
membership_tier: str,
|
||||||
annual_fee: float
|
annual_fee: float,
|
||||||
|
db: Session
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Send membership renewal reminder"""
|
"""Send membership renewal reminder"""
|
||||||
subject = "Membership Renewal Reminder"
|
variables = {
|
||||||
|
"first_name": first_name,
|
||||||
html_body = f"""
|
"expiry_date": expiry_date,
|
||||||
<html>
|
"membership_tier": membership_tier,
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
"annual_fee": f"£{annual_fee:.2f}",
|
||||||
<h2 style="color: #0066cc;">Membership Renewal Reminder</h2>
|
"app_name": settings.APP_NAME
|
||||||
<p>Hello {first_name},</p>
|
}
|
||||||
<p>This is a friendly reminder that your <strong>{membership_tier}</strong> membership will expire on <strong>{expiry_date}</strong>.</p>
|
return await self.send_templated_email("renewal_reminder", to_email, variables, db)
|
||||||
<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;">
|
|
||||||
<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>Expires:</strong> {expiry_date}</p>
|
|
||||||
</div>
|
|
||||||
<p>Please log in to your account to renew your membership.</p>
|
|
||||||
<p>Best regards,<br>
|
|
||||||
<strong>{settings.APP_NAME}</strong></p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
text_body = f"""
|
|
||||||
Membership Renewal Reminder
|
|
||||||
|
|
||||||
Hello {first_name},
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Membership Tier: {membership_tier}
|
|
||||||
Annual Fee: £{annual_fee:.2f}
|
|
||||||
Expires: {expiry_date}
|
|
||||||
|
|
||||||
Please log in to your account to renew your membership.
|
|
||||||
|
|
||||||
Best regards,
|
|
||||||
{settings.APP_NAME}
|
|
||||||
"""
|
|
||||||
|
|
||||||
return await self.send_email(to_email, subject, html_body, text_body)
|
|
||||||
|
|
||||||
async def send_password_reset_email(
|
async def send_password_reset_email(
|
||||||
self,
|
self,
|
||||||
to_email: str,
|
to_email: str,
|
||||||
first_name: str,
|
first_name: str,
|
||||||
reset_token: str
|
reset_token: str,
|
||||||
|
db: Session
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Send password reset email with reset link"""
|
"""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}"
|
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;">
|
||||||
|
<h2 style="color: #0066cc;">Welcome to {app_name}!</h2>
|
||||||
|
<p>Hello {first_name},</p>
|
||||||
|
<p>Thank you for registering with us. Your account has been successfully created.</p>
|
||||||
|
<p>You can now:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Browse membership tiers and select one that suits you</li>
|
||||||
|
<li>View upcoming events and meetings</li>
|
||||||
|
<li>Access your membership portal</li>
|
||||||
|
</ul>
|
||||||
|
<p>If you have any questions, please don't hesitate to contact us.</p>
|
||||||
|
<p>Best regards,<br>
|
||||||
|
<strong>{app_name}</strong></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".strip(),
|
||||||
|
"text_body": """
|
||||||
|
Welcome to {app_name}!
|
||||||
|
|
||||||
|
Hello {first_name},
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
If you have any questions, please don't hesitate to contact us.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{app_name}
|
||||||
|
""".strip(),
|
||||||
|
"variables": '["first_name", "app_name"]',
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"template_key": "payment_confirmation",
|
||||||
|
"name": "Payment Confirmation",
|
||||||
|
"subject": "Payment Confirmation",
|
||||||
|
"html_body": """
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<h2 style="color: #0066cc;">Payment Confirmed!</h2>
|
||||||
|
<p>Hello {first_name},</p>
|
||||||
|
<p>We have received your payment. Thank you!</p>
|
||||||
|
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
|
||||||
|
<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>Membership Tier:</strong> {membership_tier}</p>
|
||||||
|
</div>
|
||||||
|
<p>Your membership is now active. You can access all the benefits associated with your tier.</p>
|
||||||
|
<p>Best regards,<br>
|
||||||
|
<strong>{app_name}</strong></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".strip(),
|
||||||
|
"text_body": """
|
||||||
|
Payment Confirmed!
|
||||||
|
|
||||||
|
Hello {first_name},
|
||||||
|
|
||||||
|
We have received your payment. Thank you!
|
||||||
|
|
||||||
|
Amount: {amount}
|
||||||
|
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}
|
||||||
|
""".strip(),
|
||||||
|
"variables": '["first_name", "amount", "payment_method", "membership_tier", "app_name"]',
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"template_key": "membership_activation",
|
||||||
|
"name": "Membership Activation",
|
||||||
|
"subject": "Your {app_name} Membership is Now Active!",
|
||||||
|
"html_body": """
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<h2 style="color: #28a745;">Welcome to {app_name}!</h2>
|
||||||
|
<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>
|
||||||
|
|
||||||
html_body = f"""
|
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #28a745;">
|
||||||
<html>
|
<h3 style="margin-top: 0; color: #28a745;">Membership Details</h3>
|
||||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
<p style="margin: 8px 0;"><strong>Membership Tier:</strong> {membership_tier}</p>
|
||||||
<h2 style="color: #0066cc;">Password Reset Request</h2>
|
<p style="margin: 8px 0;"><strong>Annual Fee:</strong> {annual_fee}</p>
|
||||||
<p>Hello {first_name},</p>
|
<p style="margin: 8px 0;"><strong>Next Renewal Date:</strong> {renewal_date}</p>
|
||||||
<p>You have requested to reset your password for your {settings.APP_NAME} account.</p>
|
</div>
|
||||||
<p>Please click the button below to reset your password:</p>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
|
|
||||||
<p style="word-break: break-all; background-color: #f5f5f5; padding: 10px; border-radius: 3px;">{reset_url}</p>
|
|
||||||
<p><strong>This link will expire in 1 hour.</strong></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>Best regards,<br>
|
|
||||||
<strong>{settings.APP_NAME}</strong></p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
text_body = f"""
|
<div style="background-color: #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
Password Reset Request
|
<h3 style="margin-top: 0; color: #495057;">Payment Information</h3>
|
||||||
|
<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 Date:</strong> {payment_date}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
Hello {first_name},
|
<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>
|
||||||
|
|
||||||
You have requested to reset your password for your {settings.APP_NAME} account.
|
<p>If you have any questions about your membership or need assistance, please don't hesitate to contact us.</p>
|
||||||
|
|
||||||
Please use this link to reset your password: {reset_url}
|
<p>Welcome to the {app_name} community!</p>
|
||||||
|
|
||||||
This link will expire in 1 hour.
|
<p>Best regards,<br>
|
||||||
|
<strong>{app_name} Team</strong></p>
|
||||||
If you didn't request this password reset, please ignore this email. Your password will remain unchanged.
|
</body>
|
||||||
|
</html>
|
||||||
For security reasons, please don't share this email with anyone.
|
""".strip(),
|
||||||
|
"text_body": """
|
||||||
Best regards,
|
Welcome to {app_name}!
|
||||||
{settings.APP_NAME}
|
|
||||||
"""
|
Hello {first_name},
|
||||||
|
|
||||||
return await self.send_email(to_email, subject, html_body, text_body)
|
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}
|
||||||
|
|
||||||
|
PAYMENT INFORMATION
|
||||||
|
-------------------
|
||||||
|
Amount Paid: {payment_amount}
|
||||||
|
Payment Method: {payment_method}
|
||||||
|
Payment Date: {payment_date}
|
||||||
|
|
||||||
|
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 {app_name} community!
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{app_name} Team
|
||||||
|
""".strip(),
|
||||||
|
"variables": '["first_name", "membership_tier", "annual_fee", "payment_amount", "payment_method", "renewal_date", "payment_date", "app_name"]',
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"template_key": "renewal_reminder",
|
||||||
|
"name": "Membership Renewal Reminder",
|
||||||
|
"subject": "Membership Renewal Reminder",
|
||||||
|
"html_body": """
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<h2 style="color: #0066cc;">Membership Renewal Reminder</h2>
|
||||||
|
<p>Hello {first_name},</p>
|
||||||
|
<p>This is a friendly reminder that your <strong>{membership_tier}</strong> membership will expire on <strong>{expiry_date}</strong>.</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;">
|
||||||
|
<p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p>
|
||||||
|
<p style="margin: 5px 0;"><strong>Annual Fee:</strong> {annual_fee}</p>
|
||||||
|
<p style="margin: 5px 0;"><strong>Expires:</strong> {expiry_date}</p>
|
||||||
|
</div>
|
||||||
|
<p>Please log in to your account to renew your membership.</p>
|
||||||
|
<p>Best regards,<br>
|
||||||
|
<strong>{app_name}</strong></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".strip(),
|
||||||
|
"text_body": """
|
||||||
|
Membership Renewal Reminder
|
||||||
|
|
||||||
|
Hello {first_name},
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Membership Tier: {membership_tier}
|
||||||
|
Annual Fee: {annual_fee}
|
||||||
|
Expires: {expiry_date}
|
||||||
|
|
||||||
|
Please log in to your account to renew your membership.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{app_name}
|
||||||
|
""".strip(),
|
||||||
|
"variables": '["first_name", "expiry_date", "membership_tier", "annual_fee", "app_name"]',
|
||||||
|
"is_active": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"template_key": "password_reset",
|
||||||
|
"name": "Password Reset",
|
||||||
|
"subject": "Password Reset - {app_name}",
|
||||||
|
"html_body": """
|
||||||
|
<html>
|
||||||
|
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||||
|
<h2 style="color: #0066cc;">Password Reset Request</h2>
|
||||||
|
<p>Hello {first_name},</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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<p>If the button doesn't work, you can copy and paste this link into your browser:</p>
|
||||||
|
<p style="word-break: break-all; background-color: #f5f5f5; padding: 10px; border-radius: 3px;">{reset_url}</p>
|
||||||
|
<p><strong>This link will expire in 1 hour.</strong></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>Best regards,<br>
|
||||||
|
<strong>{app_name}</strong></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".strip(),
|
||||||
|
"text_body": """
|
||||||
|
Password Reset Request
|
||||||
|
|
||||||
|
Hello {first_name},
|
||||||
|
|
||||||
|
You have requested to reset your password for your {app_name} account.
|
||||||
|
|
||||||
|
Please use this link to reset your password: {reset_url}
|
||||||
|
|
||||||
|
This link will expire in 1 hour.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
{app_name}
|
||||||
|
""".strip(),
|
||||||
|
"variables": '["first_name", "reset_url", "app_name"]',
|
||||||
|
"is_active": True
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# Create a singleton instance
|
# Create a singleton instance
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
401
frontend/src/components/EmailTemplateManagement.tsx
Normal file
401
frontend/src/components/EmailTemplateManagement.tsx
Normal 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;
|
||||||
@@ -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
|
<>
|
||||||
style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }}
|
<button
|
||||||
onClick={handleSuperAdmin}
|
style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }}
|
||||||
>
|
onClick={() => {
|
||||||
Super Admin Panel
|
navigate('/membership-tiers');
|
||||||
</button>
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Membership Tiers
|
||||||
|
</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} />
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
110
frontend/src/pages/EmailTemplates.tsx
Normal file
110
frontend/src/pages/EmailTemplates.tsx
Normal 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;
|
||||||
358
frontend/src/pages/MembershipTiers.tsx
Normal file
358
frontend/src/pages/MembershipTiers.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user