From 7fd237c28b8ec8cceacb51f07c94896ab08feabe Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Mon, 10 Nov 2025 16:07:22 +0000 Subject: [PATCH] Email template management --- backend/app/api/v1/__init__.py | 3 +- backend/app/api/v1/auth.py | 6 +- backend/app/api/v1/email_templates.py | 98 ++++ backend/app/api/v1/memberships.py | 3 +- backend/app/api/v1/payments.py | 6 +- backend/app/core/init_db.py | 18 +- backend/app/models/models.py | 15 + backend/app/schemas/__init__.py | 8 + backend/app/schemas/schemas.py | 32 + backend/app/services/email_service.py | 547 +++++++++++------- frontend/src/App.tsx | 4 + .../components/EmailTemplateManagement.tsx | 401 +++++++++++++ frontend/src/components/ProfileMenu.tsx | 45 +- frontend/src/components/SuperAdminMenu.tsx | 19 +- frontend/src/pages/EmailTemplates.tsx | 110 ++++ frontend/src/pages/MembershipTiers.tsx | 358 ++++++++++++ frontend/src/services/membershipService.ts | 7 + 17 files changed, 1421 insertions(+), 259 deletions(-) create mode 100644 backend/app/api/v1/email_templates.py create mode 100644 frontend/src/components/EmailTemplateManagement.tsx create mode 100644 frontend/src/pages/EmailTemplates.tsx create mode 100644 frontend/src/pages/MembershipTiers.tsx diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 37318a6..4f692c5 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -1,5 +1,5 @@ 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() @@ -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(payments.router, prefix="/payments", tags=["payments"]) api_router.include_router(email.router, prefix="/email", tags=["email"]) +api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"]) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 1a016e3..576e9b1 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -52,7 +52,8 @@ async def register( try: await email_service.send_welcome_email( to_email=db_user.email, - first_name=db_user.first_name + first_name=db_user.first_name, + db=db ) except Exception as e: # Log error but don't fail registration @@ -171,7 +172,8 @@ async def forgot_password( await email_service.send_password_reset_email( to_email=user.email, first_name=user.first_name, - reset_token=reset_token + reset_token=reset_token, + db=db ) except Exception as e: # Log error but don't fail the request diff --git a/backend/app/api/v1/email_templates.py b/backend/app/api/v1/email_templates.py new file mode 100644 index 0000000..a47140f --- /dev/null +++ b/backend/app/api/v1/email_templates.py @@ -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"} \ No newline at end of file diff --git a/backend/app/api/v1/memberships.py b/backend/app/api/v1/memberships.py index 3c98100..63bfdfa 100644 --- a/backend/app/api/v1/memberships.py +++ b/backend/app/api/v1/memberships.py @@ -145,7 +145,8 @@ async def update_membership( annual_fee=membership.tier.annual_fee, payment_amount=payment_amount, 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: # Log error but don't fail the membership update diff --git a/backend/app/api/v1/payments.py b/backend/app/api/v1/payments.py index 14e899f..06a5dc5 100644 --- a/backend/app/api/v1/payments.py +++ b/backend/app/api/v1/payments.py @@ -134,7 +134,8 @@ async def update_payment( annual_fee=payment.membership.tier.annual_fee, payment_amount=payment.amount, payment_method=payment.payment_method.value, - renewal_date=payment.membership.end_date.strftime("%d %B %Y") + renewal_date=payment.membership.end_date.strftime("%d %B %Y"), + db=db ) except Exception as e: # Log error but don't fail the payment update @@ -219,7 +220,8 @@ async def record_manual_payment( annual_fee=membership.tier.annual_fee, payment_amount=payment.amount, payment_method=payment.payment_method.value, - renewal_date=membership.end_date.strftime("%d %B %Y") + renewal_date=membership.end_date.strftime("%d %B %Y"), + db=db ) except Exception as e: # Log error but don't fail the payment creation diff --git a/backend/app/core/init_db.py b/backend/app/core/init_db.py index acbe95e..ac42ebc 100644 --- a/backend/app/core/init_db.py +++ b/backend/app/core/init_db.py @@ -1,5 +1,5 @@ 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 datetime import datetime @@ -54,3 +54,19 @@ def init_default_data(db: Session): db.commit() print("✓ Created default admin user (admin@swanseaairport.org / admin123)") 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") diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 80fc24b..1d61920 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -273,3 +273,18 @@ class PasswordResetToken(Base): # Relationships 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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index bcbd7af..c62e1d1 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -23,6 +23,10 @@ from .schemas import ( PaymentUpdate, PaymentResponse, MessageResponse, + EmailTemplateBase, + EmailTemplateCreate, + EmailTemplateUpdate, + EmailTemplateResponse, ) __all__ = [ @@ -50,4 +54,8 @@ __all__ = [ "PaymentUpdate", "PaymentResponse", "MessageResponse", + "EmailTemplateBase", + "EmailTemplateCreate", + "EmailTemplateUpdate", + "EmailTemplateResponse", ] diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 48ec51b..686f186 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -166,3 +166,35 @@ class PaymentResponse(BaseModel): class MessageResponse(BaseModel): message: str 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 diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 32deb4e..056c071 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -1,11 +1,14 @@ import httpx -from typing import List, Optional +from typing import List, Optional, Dict, Any 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 class EmailService: - """Email service using SMTP2GO API""" + """Email service using SMTP2GO API with database-stored templates""" def __init__(self): self.api_key = settings.SMTP2GO_API_KEY @@ -51,96 +54,69 @@ class EmailService: response = await client.post(self.api_url, json=payload, headers=headers) return response.json() - async def send_welcome_email(self, to_email: str, first_name: str) -> dict: - """Send welcome email to new user""" - subject = f"Welcome to {settings.APP_NAME}!" + async def get_template(self, template_key: str, db: Session) -> Optional[EmailTemplate]: + """Get email template from database""" + 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""" - - -

Welcome to {settings.APP_NAME}!

-

Hello {first_name},

-

Thank you for registering with us. Your account has been successfully created.

-

You can now:

- -

If you have any questions, please don't hesitate to contact us.

-

Best regards,
- {settings.APP_NAME}

- - - """ + if not template: + raise ValueError(f"Email template '{template_key}' not found or inactive") - text_body = f""" - Welcome to {settings.APP_NAME}! + # 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 - 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, - {settings.APP_NAME} - """ + 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 + membership_tier: str, + db: Session ) -> dict: """Send payment confirmation email""" - subject = "Payment Confirmation" - - html_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}

- - - """ - - 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) + 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, @@ -150,74 +126,21 @@ class EmailService: annual_fee: float, payment_amount: float, payment_method: str, - renewal_date: str + renewal_date: str, + db: Session ) -> dict: """Send membership activation email with payment details and renewal date""" - subject = f"Your {settings.APP_NAME} Membership is Now Active!" - - html_body = f""" - - -

Welcome to {settings.APP_NAME}!

-

Hello {first_name},

-

Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.

- -
-

Membership Details

-

Membership Tier: {membership_tier}

-

Annual Fee: £{annual_fee:.2f}

-

Next Renewal Date: {renewal_date}

-
- -
-

Payment Information

-

Amount Paid: £{payment_amount:.2f}

-

Payment Method: {payment_method}

-

Payment Date: {datetime.now().strftime('%d %B %Y')}

-
- -

Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.

- -

If you have any questions about your membership or need assistance, please don't hesitate to contact us.

- -

Welcome to the {settings.APP_NAME} community!

- -

Best regards,
- {settings.APP_NAME} Team

- - - """ - - text_body = f""" - Welcome to {settings.APP_NAME}! - - Hello {first_name}, - - Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier. - - MEMBERSHIP DETAILS - ------------------ - Membership Tier: {membership_tier} - Annual Fee: £{annual_fee:.2f} - Next Renewal Date: {renewal_date} - - PAYMENT INFORMATION - ------------------- - Amount Paid: £{payment_amount:.2f} - Payment Method: {payment_method} - Payment Date: {datetime.now().strftime('%d %B %Y')} - - Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard. - - If you have any questions about your membership or need assistance, please don't hesitate to contact us. - - Welcome to the {settings.APP_NAME} community! - - Best regards, - {settings.APP_NAME} Team - """ - - return await self.send_email(to_email, subject, html_body, text_body) + 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, @@ -225,103 +148,277 @@ class EmailService: first_name: str, expiry_date: str, membership_tier: str, - annual_fee: float + annual_fee: float, + db: Session ) -> dict: """Send membership renewal reminder""" - subject = "Membership Renewal Reminder" - - html_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}

- - - """ - - 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) + 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 + reset_token: str, + db: Session ) -> 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}" + 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": """ + + +

Welcome to {app_name}!

+

Hello {first_name},

+

Thank you for registering with us. Your account has been successfully created.

+

You can now:

+ +

If you have any questions, please don't hesitate to contact us.

+

Best regards,
+ {app_name}

+ + +""".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": """ + + +

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(), + "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": """ + + +

Welcome to {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.

- html_body = f""" - - -

Password Reset Request

-

Hello {first_name},

-

You have requested to reset your password for your {settings.APP_NAME} account.

-

Please click the button below to reset your password:

-
- Reset Password -
-

If the button doesn't work, you can copy and paste this link into your browser:

-

{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,
- {settings.APP_NAME}

- - - """ +
+

Membership Details

+

Membership Tier: {membership_tier}

+

Annual Fee: {annual_fee}

+

Next Renewal Date: {renewal_date}

+
- text_body = f""" - Password Reset Request +
+

Payment Information

+

Amount Paid: {payment_amount}

+

Payment Method: {payment_method}

+

Payment Date: {payment_date}

+
- Hello {first_name}, +

Your membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.

- You have requested to reset your password for your {settings.APP_NAME} account. +

If you have any questions about your membership or need assistance, please don't hesitate to contact us.

- Please use this link to reset your password: {reset_url} +

Welcome to the {app_name} community!

- 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, - {settings.APP_NAME} - """ - - return await self.send_email(to_email, subject, html_body, text_body) +

Best regards,
+ {app_name} Team

+ + +""".strip(), + "text_body": """ +Welcome to {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} +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": """ + + +

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(), + "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": """ + + +

Password Reset Request

+

Hello {first_name},

+

You have requested to reset your password for your {app_name} account.

+

Please click the button below to reset your password:

+
+ Reset Password +
+

If the button doesn't work, you can copy and paste this link into your browser:

+

{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(), + "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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0036eac..e78e829 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,8 @@ import Login from './pages/Login'; import ForgotPassword from './pages/ForgotPassword'; import ResetPassword from './pages/ResetPassword'; import Dashboard from './pages/Dashboard'; +import EmailTemplates from './pages/EmailTemplates'; +import MembershipTiers from './pages/MembershipTiers'; import './App.css'; const App: React.FC = () => { @@ -17,6 +19,8 @@ const App: React.FC = () => { } /> } /> } /> + } /> + } /> ); diff --git a/frontend/src/components/EmailTemplateManagement.tsx b/frontend/src/components/EmailTemplateManagement.tsx new file mode 100644 index 0000000..eb77c45 --- /dev/null +++ b/frontend/src/components/EmailTemplateManagement.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [editingTemplate, setEditingTemplate] = useState(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
Loading email templates...
; + } + + return ( +
+
+ +
+ +
+ {templates.map((template) => ( +
+
+

{template.name}

+
+ + {template.is_active ? 'Active' : 'Inactive'} + + +
+
+ +
+ Key: {template.template_key} +
+ +
+ Subject: {template.subject} +
+ +
+ Variables: +
+ {(() => { + try { + const vars = JSON.parse(template.variables); + return Array.isArray(vars) ? vars.join(', ') : template.variables; + } catch { + return template.variables; + } + })()} +
+
+ +
+ HTML Body Preview: +
+
+
+ ))} +
+ + {showEditForm && editingTemplate && ( + + )} +
+ ); +}; + +interface EmailTemplateEditFormProps { + template: EmailTemplate; + onSave: (template: EmailTemplate) => void; + onCancel: () => void; +} + +const EmailTemplateEditForm: React.FC = ({ 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 ( +
+
+

Edit Email Template: {template.name}

+ +
+
+ + + Template key cannot be changed +
+ +
+ + handleChange('name', e.target.value)} + style={{ + width: '100%', + padding: '8px', + border: '1px solid #ddd', + borderRadius: '4px' + }} + required + /> +
+ +
+ + handleChange('subject', e.target.value)} + style={{ + width: '100%', + padding: '8px', + border: '1px solid #ddd', + borderRadius: '4px' + }} + required + /> +
+ +
+ + handleChange('variables', e.target.value.split(',').map(v => v.trim()))} + style={{ + width: '100%', + padding: '8px', + border: '1px solid #ddd', + borderRadius: '4px' + }} + /> +
+ +
+ +