This commit is contained in:
Iliyan Angelov
2025-11-18 23:35:19 +02:00
parent ab832f851b
commit 2043ac897c
27 changed files with 2947 additions and 323 deletions

View File

@@ -0,0 +1,26 @@
"""add_badges_to_page_content
Revision ID: add_badges_to_page_content
Revises: cce764ef7a50
Create Date: 2025-01-14 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_badges_to_page_content'
down_revision = 'cce764ef7a50'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add badges column to page_contents table
op.add_column('page_contents', sa.Column('badges', sa.Text(), nullable=True))
def downgrade() -> None:
# Remove badges column from page_contents table
op.drop_column('page_contents', 'badges')

View File

@@ -40,6 +40,7 @@ class PageContent(Base):
map_url = Column(String(1000), nullable=True) # Google Maps embed URL
social_links = Column(Text, nullable=True) # JSON: facebook, twitter, instagram, etc.
footer_links = Column(Text, nullable=True) # JSON: quick links, support links
badges = Column(Text, nullable=True) # JSON: array of badges with text and icon
# Home page specific
hero_title = Column(String(500), nullable=True)

View File

@@ -39,6 +39,7 @@ async def get_all_page_contents(
"map_url": content.map_url,
"social_links": json.loads(content.social_links) if content.social_links else None,
"footer_links": json.loads(content.footer_links) if content.footer_links else None,
"badges": json.loads(content.badges) if content.badges else None,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
@@ -97,8 +98,10 @@ async def get_page_content(
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"contact_info": json.loads(content.contact_info) if content.contact_info else None,
"map_url": content.map_url,
"social_links": json.loads(content.social_links) if content.social_links else None,
"footer_links": json.loads(content.footer_links) if content.footer_links else None,
"badges": json.loads(content.badges) if content.badges else None,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
@@ -141,6 +144,7 @@ async def create_or_update_page_content(
map_url: Optional[str] = None,
social_links: Optional[str] = None,
footer_links: Optional[str] = None,
badges: Optional[str] = None,
hero_title: Optional[str] = None,
hero_subtitle: Optional[str] = None,
hero_image: Optional[str] = None,
@@ -183,6 +187,15 @@ async def create_or_update_page_content(
detail="Invalid JSON in footer_links"
)
if badges:
try:
json.loads(badges)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in badges"
)
if values:
try:
json.loads(values)
@@ -236,6 +249,8 @@ async def create_or_update_page_content(
existing_content.social_links = social_links
if footer_links is not None:
existing_content.footer_links = footer_links
if badges is not None:
existing_content.badges = badges
if hero_title is not None:
existing_content.hero_title = hero_title
if hero_subtitle is not None:
@@ -286,6 +301,7 @@ async def create_or_update_page_content(
map_url=map_url,
social_links=social_links,
footer_links=footer_links,
badges=badges,
hero_title=hero_title,
hero_subtitle=hero_subtitle,
hero_image=hero_image,
@@ -346,7 +362,7 @@ async def update_page_content(
for key, value in page_data.items():
if hasattr(existing_content, key):
# Handle JSON fields - convert dict/list to JSON string
if key in ["contact_info", "social_links", "footer_links", "values", "features"] and value is not None:
if key in ["contact_info", "social_links", "footer_links", "badges", "values", "features"] and value is not None:
if isinstance(value, str):
# Already a string, validate it's valid JSON
try:
@@ -383,8 +399,10 @@ async def update_page_content(
"og_image": existing_content.og_image,
"canonical_url": existing_content.canonical_url,
"contact_info": json.loads(existing_content.contact_info) if existing_content.contact_info else None,
"map_url": existing_content.map_url,
"social_links": json.loads(existing_content.social_links) if existing_content.social_links else None,
"footer_links": json.loads(existing_content.footer_links) if existing_content.footer_links else None,
"badges": json.loads(existing_content.badges) if existing_content.badges else None,
"hero_title": existing_content.hero_title,
"hero_subtitle": existing_content.hero_subtitle,
"hero_image": existing_content.hero_image,

View File

@@ -1,11 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, EmailStr
from pathlib import Path
import logging
import aiofiles
import uuid
import os
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.system_settings import SystemSettings
from ..utils.mailer import send_email
from ..services.room_service import get_base_url
def normalize_image_url(image_url: str, base_url: str) -> str:
"""Normalize image URL to absolute URL"""
if not image_url:
return image_url
if image_url.startswith('http://') or image_url.startswith('https://'):
return image_url
if image_url.startswith('/'):
return f"{base_url}{image_url}"
return f"{base_url}/{image_url}"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/system-settings", tags=["admin-system-settings"])
@@ -300,3 +322,768 @@ async def update_stripe_settings(
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/smtp")
async def get_smtp_settings(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get SMTP email server settings (Admin only)"""
try:
# Get all SMTP settings
smtp_settings = {}
setting_keys = [
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_password",
"smtp_from_email",
"smtp_from_name",
"smtp_use_tls",
]
for key in setting_keys:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
smtp_settings[key] = setting.value
# Mask password for security (only show last 4 characters if set)
def mask_password(password_value: str) -> str:
if not password_value or len(password_value) < 4:
return ""
return "*" * (len(password_value) - 4) + password_value[-4:]
result = {
"smtp_host": smtp_settings.get("smtp_host", ""),
"smtp_port": smtp_settings.get("smtp_port", ""),
"smtp_user": smtp_settings.get("smtp_user", ""),
"smtp_password": "",
"smtp_password_masked": mask_password(smtp_settings.get("smtp_password", "")),
"smtp_from_email": smtp_settings.get("smtp_from_email", ""),
"smtp_from_name": smtp_settings.get("smtp_from_name", ""),
"smtp_use_tls": smtp_settings.get("smtp_use_tls", "true").lower() == "true",
"has_host": bool(smtp_settings.get("smtp_host")),
"has_user": bool(smtp_settings.get("smtp_user")),
"has_password": bool(smtp_settings.get("smtp_password")),
}
# Get updated_at and updated_by from any setting (prefer password setting if exists)
password_setting = db.query(SystemSettings).filter(
SystemSettings.key == "smtp_password"
).first()
if password_setting:
result["updated_at"] = password_setting.updated_at.isoformat() if password_setting.updated_at else None
result["updated_by"] = password_setting.updated_by.full_name if password_setting.updated_by else None
else:
# Try to get from any other SMTP setting
any_setting = db.query(SystemSettings).filter(
SystemSettings.key.in_(setting_keys)
).first()
if any_setting:
result["updated_at"] = any_setting.updated_at.isoformat() if any_setting.updated_at else None
result["updated_by"] = any_setting.updated_by.full_name if any_setting.updated_by else None
else:
result["updated_at"] = None
result["updated_by"] = None
return {
"status": "success",
"data": result
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/smtp")
async def update_smtp_settings(
smtp_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update SMTP email server settings (Admin only)"""
try:
smtp_host = smtp_data.get("smtp_host", "").strip()
smtp_port = smtp_data.get("smtp_port", "").strip()
smtp_user = smtp_data.get("smtp_user", "").strip()
smtp_password = smtp_data.get("smtp_password", "").strip()
smtp_from_email = smtp_data.get("smtp_from_email", "").strip()
smtp_from_name = smtp_data.get("smtp_from_name", "").strip()
smtp_use_tls = smtp_data.get("smtp_use_tls", True)
# Validate required fields if provided
if smtp_host and not smtp_host:
raise HTTPException(
status_code=400,
detail="SMTP host cannot be empty"
)
if smtp_port:
try:
port_num = int(smtp_port)
if port_num < 1 or port_num > 65535:
raise HTTPException(
status_code=400,
detail="SMTP port must be between 1 and 65535"
)
except ValueError:
raise HTTPException(
status_code=400,
detail="SMTP port must be a valid number"
)
if smtp_from_email:
# Basic email validation
if "@" not in smtp_from_email or "." not in smtp_from_email.split("@")[1]:
raise HTTPException(
status_code=400,
detail="Invalid email address format for 'From Email'"
)
# Helper function to update or create setting
def update_setting(key: str, value: str, description: str):
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
setting.value = value
setting.updated_by_id = current_user.id
else:
setting = SystemSettings(
key=key,
value=value,
description=description,
updated_by_id=current_user.id
)
db.add(setting)
# Update or create settings (only update if value is provided)
if smtp_host:
update_setting(
"smtp_host",
smtp_host,
"SMTP server hostname (e.g., smtp.gmail.com)"
)
if smtp_port:
update_setting(
"smtp_port",
smtp_port,
"SMTP server port (e.g., 587 for STARTTLS, 465 for SSL)"
)
if smtp_user:
update_setting(
"smtp_user",
smtp_user,
"SMTP authentication username/email"
)
if smtp_password:
update_setting(
"smtp_password",
smtp_password,
"SMTP authentication password (stored securely)"
)
if smtp_from_email:
update_setting(
"smtp_from_email",
smtp_from_email,
"Default 'From' email address for outgoing emails"
)
if smtp_from_name:
update_setting(
"smtp_from_name",
smtp_from_name,
"Default 'From' name for outgoing emails"
)
# Update TLS setting (convert boolean to string)
if smtp_use_tls is not None:
update_setting(
"smtp_use_tls",
"true" if smtp_use_tls else "false",
"Use TLS/SSL for SMTP connection (true for port 465, false for port 587 with STARTTLS)"
)
db.commit()
# Return updated settings with masked password
def mask_password(password_value: str) -> str:
if not password_value or len(password_value) < 4:
return ""
return "*" * (len(password_value) - 4) + password_value[-4:]
# Get updated settings
updated_settings = {}
for key in ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from_email", "smtp_from_name", "smtp_use_tls"]:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
updated_settings[key] = setting.value
result = {
"smtp_host": updated_settings.get("smtp_host", ""),
"smtp_port": updated_settings.get("smtp_port", ""),
"smtp_user": updated_settings.get("smtp_user", ""),
"smtp_password": smtp_password if smtp_password else "",
"smtp_password_masked": mask_password(updated_settings.get("smtp_password", "")),
"smtp_from_email": updated_settings.get("smtp_from_email", ""),
"smtp_from_name": updated_settings.get("smtp_from_name", ""),
"smtp_use_tls": updated_settings.get("smtp_use_tls", "true").lower() == "true",
"has_host": bool(updated_settings.get("smtp_host")),
"has_user": bool(updated_settings.get("smtp_user")),
"has_password": bool(updated_settings.get("smtp_password")),
}
# Get updated_by from password setting if it exists
password_setting = db.query(SystemSettings).filter(
SystemSettings.key == "smtp_password"
).first()
if password_setting:
result["updated_at"] = password_setting.updated_at.isoformat() if password_setting.updated_at else None
result["updated_by"] = password_setting.updated_by.full_name if password_setting.updated_by else None
return {
"status": "success",
"message": "SMTP settings updated successfully",
"data": result
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
class TestEmailRequest(BaseModel):
email: EmailStr
@router.post("/smtp/test")
async def test_smtp_email(
request: TestEmailRequest,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Send a test email to verify SMTP settings (Admin only)"""
try:
test_email = str(request.email)
admin_name = str(current_user.full_name or current_user.email or "Admin")
timestamp_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
# Create test email HTML content
test_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background: linear-gradient(135deg, #d4af37 0%, #c9a227 100%);
color: white;
padding: 30px;
text-align: center;
border-radius: 10px 10px 0 0;
}}
.content {{
background: #f9f9f9;
padding: 30px;
border: 1px solid #e0e0e0;
border-radius: 0 0 10px 10px;
}}
.success-icon {{
font-size: 48px;
margin-bottom: 20px;
}}
.info-box {{
background: white;
padding: 20px;
margin: 20px 0;
border-left: 4px solid #d4af37;
border-radius: 5px;
}}
.footer {{
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
text-align: center;
color: #666;
font-size: 12px;
}}
</style>
</head>
<body>
<div class="header">
<h1>✅ SMTP Test Email</h1>
</div>
<div class="content">
<div style="text-align: center;">
<div class="success-icon">🎉</div>
<h2>Email Configuration Test Successful!</h2>
</div>
<p>This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly.</p>
<div class="info-box">
<strong>📧 Test Details:</strong>
<ul>
<li><strong>Recipient:</strong> {test_email}</li>
<li><strong>Sent by:</strong> {admin_name}</li>
<li><strong>Time:</strong> {timestamp_str}</li>
</ul>
</div>
<p>If you received this email, it means your SMTP server settings are working correctly and the system can send emails through your configured email server.</p>
<p><strong>What's next?</strong></p>
<ul>
<li>Welcome emails for new user registrations</li>
<li>Password reset emails</li>
<li>Booking confirmation emails</li>
<li>Payment notifications</li>
<li>And other system notifications</li>
</ul>
<div class="footer">
<p>This is an automated test email from Hotel Booking System</p>
<p>If you did not request this test, please ignore this email.</p>
</div>
</div>
</body>
</html>
"""
# Plain text version
test_text = f"""
SMTP Test Email
This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly.
Test Details:
- Recipient: {test_email}
- Sent by: {admin_name}
- Time: {timestamp_str}
If you received this email, it means your SMTP server settings are working correctly and the system can send emails through your configured email server.
This is an automated test email from Hotel Booking System
If you did not request this test, please ignore this email.
""".strip()
# Send the test email
await send_email(
to=test_email,
subject="SMTP Test Email - Hotel Booking System",
html=test_html,
text=test_text
)
sent_at = datetime.utcnow()
return {
"status": "success",
"message": f"Test email sent successfully to {test_email}",
"data": {
"recipient": test_email,
"sent_at": sent_at.isoformat()
}
}
except HTTPException:
# Re-raise HTTP exceptions (like validation errors from send_email)
raise
except Exception as e:
error_msg = str(e)
logger.error(f"Error sending test email: {type(e).__name__}: {error_msg}", exc_info=True)
# Provide more user-friendly error messages
if "SMTP mailer not configured" in error_msg:
raise HTTPException(
status_code=400,
detail="SMTP settings are not fully configured. Please configure SMTP Host, Username, and Password in the Email Server settings."
)
elif "authentication failed" in error_msg.lower() or "invalid credentials" in error_msg.lower():
raise HTTPException(
status_code=400,
detail="SMTP authentication failed. Please check your SMTP username and password."
)
elif "connection" in error_msg.lower() or "timeout" in error_msg.lower():
raise HTTPException(
status_code=400,
detail=f"Cannot connect to SMTP server. Please check your SMTP host and port settings. Error: {error_msg}"
)
else:
raise HTTPException(
status_code=500,
detail=f"Failed to send test email: {error_msg}"
)
class UpdateCompanySettingsRequest(BaseModel):
company_name: Optional[str] = None
company_tagline: Optional[str] = None
company_phone: Optional[str] = None
company_email: Optional[str] = None
company_address: Optional[str] = None
@router.get("/company")
async def get_company_settings(
db: Session = Depends(get_db)
):
"""Get company settings (public endpoint for frontend)"""
try:
setting_keys = [
"company_name",
"company_tagline",
"company_logo_url",
"company_favicon_url",
"company_phone",
"company_email",
"company_address",
]
settings_dict = {}
for key in setting_keys:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
settings_dict[key] = setting.value
else:
settings_dict[key] = None
# Get updated_at and updated_by from logo setting if exists
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
updated_at = None
updated_by = None
if logo_setting:
updated_at = logo_setting.updated_at.isoformat() if logo_setting.updated_at else None
updated_by = logo_setting.updated_by.full_name if logo_setting.updated_by else None
return {
"status": "success",
"data": {
"company_name": settings_dict.get("company_name", ""),
"company_tagline": settings_dict.get("company_tagline", ""),
"company_logo_url": settings_dict.get("company_logo_url", ""),
"company_favicon_url": settings_dict.get("company_favicon_url", ""),
"company_phone": settings_dict.get("company_phone", ""),
"company_email": settings_dict.get("company_email", ""),
"company_address": settings_dict.get("company_address", ""),
"updated_at": updated_at,
"updated_by": updated_by,
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/company")
async def update_company_settings(
request_data: UpdateCompanySettingsRequest,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update company settings (Admin only)"""
try:
db_settings = {}
if request_data.company_name is not None:
db_settings["company_name"] = request_data.company_name
if request_data.company_tagline is not None:
db_settings["company_tagline"] = request_data.company_tagline
if request_data.company_phone is not None:
db_settings["company_phone"] = request_data.company_phone
if request_data.company_email is not None:
db_settings["company_email"] = request_data.company_email
if request_data.company_address is not None:
db_settings["company_address"] = request_data.company_address
for key, value in db_settings.items():
# Find or create setting
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
# Update existing
setting.value = value if value else None
setting.updated_at = datetime.utcnow()
setting.updated_by_id = current_user.id
else:
# Create new
setting = SystemSettings(
key=key,
value=value if value else None,
updated_by_id=current_user.id
)
db.add(setting)
db.commit()
# Get updated settings
updated_settings = {}
for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address"]:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
updated_settings[key] = setting.value
else:
updated_settings[key] = None
# Get updated_at and updated_by
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
if not logo_setting:
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_name"
).first()
updated_at = None
updated_by = None
if logo_setting:
updated_at = logo_setting.updated_at.isoformat() if logo_setting.updated_at else None
updated_by = logo_setting.updated_by.full_name if logo_setting.updated_by else None
return {
"status": "success",
"message": "Company settings updated successfully",
"data": {
"company_name": updated_settings.get("company_name", ""),
"company_tagline": updated_settings.get("company_tagline", ""),
"company_logo_url": updated_settings.get("company_logo_url", ""),
"company_favicon_url": updated_settings.get("company_favicon_url", ""),
"company_phone": updated_settings.get("company_phone", ""),
"company_email": updated_settings.get("company_email", ""),
"company_address": updated_settings.get("company_address", ""),
"updated_at": updated_at,
"updated_by": updated_by,
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/company/logo")
async def upload_company_logo(
request: Request,
image: UploadFile = File(...),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Upload company logo (Admin only)"""
try:
# Validate file type
if not image.content_type or not image.content_type.startswith('image/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Validate file size (max 2MB)
content = await image.read()
if len(content) > 2 * 1024 * 1024: # 2MB
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Logo file size must be less than 2MB"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
upload_dir.mkdir(parents=True, exist_ok=True)
# Delete old logo if exists
old_logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
if old_logo_setting and old_logo_setting.value:
old_logo_path = Path(__file__).parent.parent.parent / old_logo_setting.value.lstrip('/')
if old_logo_path.exists() and old_logo_path.is_file():
try:
old_logo_path.unlink()
except Exception as e:
logger.warning(f"Could not delete old logo: {e}")
# Generate filename
ext = Path(image.filename).suffix or '.png'
# Always use logo.png to ensure we only have one logo
filename = "logo.png"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
# Store the URL in system_settings
image_url = f"/uploads/company/{filename}"
# Update or create setting
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
if logo_setting:
logo_setting.value = image_url
logo_setting.updated_at = datetime.utcnow()
logo_setting.updated_by_id = current_user.id
else:
logo_setting = SystemSettings(
key="company_logo_url",
value=image_url,
updated_by_id=current_user.id
)
db.add(logo_setting)
db.commit()
# Return the image URL
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
return {
"status": "success",
"message": "Logo uploaded successfully",
"data": {
"logo_url": image_url,
"full_url": full_url
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error uploading logo: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/company/favicon")
async def upload_company_favicon(
request: Request,
image: UploadFile = File(...),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Upload company favicon (Admin only)"""
try:
# Validate file type (favicon can be ico, png, svg)
if not image.content_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File type could not be determined"
)
allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml', 'image/ico']
if image.content_type not in allowed_types:
# Check filename extension as fallback
filename_lower = (image.filename or '').lower()
if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png', '.svg']):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Favicon must be .ico, .png, or .svg file"
)
# Validate file size (max 500KB)
content = await image.read()
if len(content) > 500 * 1024: # 500KB
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Favicon file size must be less than 500KB"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
upload_dir.mkdir(parents=True, exist_ok=True)
# Delete old favicon if exists
old_favicon_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_favicon_url"
).first()
if old_favicon_setting and old_favicon_setting.value:
old_favicon_path = Path(__file__).parent.parent.parent / old_favicon_setting.value.lstrip('/')
if old_favicon_path.exists() and old_favicon_path.is_file():
try:
old_favicon_path.unlink()
except Exception as e:
logger.warning(f"Could not delete old favicon: {e}")
# Generate filename - preserve extension but use standard name
filename_lower = (image.filename or '').lower()
if filename_lower.endswith('.ico'):
filename = "favicon.ico"
elif filename_lower.endswith('.svg'):
filename = "favicon.svg"
else:
filename = "favicon.png"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
# Store the URL in system_settings
image_url = f"/uploads/company/{filename}"
# Update or create setting
favicon_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_favicon_url"
).first()
if favicon_setting:
favicon_setting.value = image_url
favicon_setting.updated_at = datetime.utcnow()
favicon_setting.updated_by_id = current_user.id
else:
favicon_setting = SystemSettings(
key="company_favicon_url",
value=image_url,
updated_by_id=current_user.id
)
db.add(favicon_setting)
db.commit()
# Return the image URL
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
return {
"status": "success",
"message": "Favicon uploaded successfully",
"data": {
"favicon_url": image_url,
"full_url": full_url
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.error(f"Error uploading favicon: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -3,10 +3,123 @@ Email templates for various notifications
"""
from datetime import datetime
from typing import Optional
from ..config.database import SessionLocal
from ..models.system_settings import SystemSettings
def get_base_template(content: str, title: str = "Hotel Booking") -> str:
"""Base HTML email template"""
def _get_company_settings():
"""Get company settings from database"""
try:
db = SessionLocal()
try:
settings = {}
setting_keys = [
"company_name",
"company_tagline",
"company_logo_url",
"company_phone",
"company_email",
"company_address",
]
for key in setting_keys:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting and setting.value:
settings[key] = setting.value
else:
settings[key] = None
return settings
finally:
db.close()
except Exception:
return {
"company_name": None,
"company_tagline": None,
"company_logo_url": None,
"company_phone": None,
"company_email": None,
"company_address": None,
}
def get_base_template(content: str, title: str = "Hotel Booking", client_url: str = "http://localhost:5173") -> str:
"""Luxury HTML email template with premium company branding"""
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
company_tagline = company_settings.get("company_tagline") or "Excellence Redefined"
company_logo_url = company_settings.get("company_logo_url")
company_phone = company_settings.get("company_phone")
company_email = company_settings.get("company_email")
company_address = company_settings.get("company_address")
# Build logo HTML if logo exists
logo_html = ""
if company_logo_url:
# Convert relative URL to absolute if needed
if not company_logo_url.startswith('http'):
# Try to construct full URL
server_url = client_url.replace('://localhost:5173', '').replace('://localhost:3000', '')
if not server_url.startswith('http'):
server_url = f"http://{server_url}" if ':' not in server_url.split('//')[-1] else server_url
full_logo_url = f"{server_url}{company_logo_url}" if company_logo_url.startswith('/') else f"{server_url}/{company_logo_url}"
else:
full_logo_url = company_logo_url
logo_html = f'''
<div style="text-align: center; padding: 15px 0;">
<img src="{full_logo_url}" alt="{company_name}" style="max-height: 80px; max-width: 280px; margin: 0 auto; display: block; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1));" />
{f'<p style="color: #D4AF37; margin: 8px 0 0 0; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; font-weight: 300;">{company_tagline}</p>' if company_tagline else ''}
</div>
'''
else:
logo_html = f'''
<div style="text-align: center; padding: 15px 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 32px; font-weight: 600; letter-spacing: 1px;">{company_name}</h1>
{f'<p style="color: #D4AF37; margin: 8px 0 0 0; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; font-weight: 300;">{company_tagline}</p>' if company_tagline else ''}
</div>
'''
# Build footer contact info
footer_contact = ""
if company_phone or company_email or company_address:
footer_contact = '''
<div style="margin-top: 25px; padding-top: 25px; border-top: 1px solid rgba(212, 175, 55, 0.2);">
<table role="presentation" style="width: 100%; max-width: 500px; margin: 0 auto;">
'''
if company_phone:
footer_contact += f'''
<tr>
<td style="padding: 5px 0; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px;">📞 {company_phone}</p>
</td>
</tr>
'''
if company_email:
footer_contact += f'''
<tr>
<td style="padding: 5px 0; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px;">✉️ <a href="mailto:{company_email}" style="color: #D4AF37; text-decoration: none;">{company_email}</a></p>
</td>
</tr>
'''
if company_address:
# Replace newlines with <br> for address
formatted_address = company_address.replace('\n', '<br>')
footer_contact += f'''
<tr>
<td style="padding: 5px 0; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">📍 {formatted_address}</p>
</td>
</tr>
'''
footer_contact += '''
</table>
</div>
'''
return f"""
<!DOCTYPE html>
<html>
@@ -14,19 +127,22 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap');
</style>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<body style="margin: 0; padding: 0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); background-color: #1a1a1a;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);">
<tr>
<td style="padding: 20px 0; text-align: center; background-color: #4F46E5;">
<h1 style="color: #ffffff; margin: 0;">Hotel Booking</h1>
<td style="padding: 40px 20px; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #1a1a1a 100%); background-color: #1a1a1a; border-bottom: 2px solid rgba(212, 175, 55, 0.3);">
{logo_html}
</td>
</tr>
<tr>
<td style="padding: 40px 20px; background-color: #ffffff;">
<table role="presentation" style="width: 100%; max-width: 600px; margin: 0 auto;">
<td style="padding: 0;">
<table role="presentation" style="width: 100%; max-width: 650px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
<tr>
<td>
<td style="padding: 50px 40px; background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%);">
{content}
</td>
</tr>
@@ -34,9 +150,10 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str:
</td>
</tr>
<tr>
<td style="padding: 20px; text-align: center; background-color: #f4f4f4; color: #666666; font-size: 12px;">
<p style="margin: 0;">This is an automated email. Please do not reply.</p>
<p style="margin: 5px 0 0 0;"{datetime.now().year} Hotel Booking. All rights reserved.</p>
<td style="padding: 40px 20px; text-align: center; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); background-color: #1a1a1a; color: #999999; font-size: 12px;">
<p style="margin: 0 0 15px 0; color: #666666; font-size: 12px;">This is an automated email. Please do not reply.</p>
<p style="margin: 0 0 20px 0; color: #D4AF37; font-size: 13px; font-weight: 500;"{datetime.now().year} {company_name}. All rights reserved.</p>
{footer_contact}
</td>
</tr>
</table>
@@ -47,51 +164,89 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str:
def welcome_email_template(name: str, email: str, client_url: str) -> str:
"""Welcome email template for new registrations"""
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Welcome {name}!</h2>
<p>Thank you for registering an account at <strong>Hotel Booking</strong>.</p>
<p>Your account has been successfully created with email: <strong>{email}</strong></p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>You can:</strong></p>
<ul style="margin-top: 10px;">
<li>Search and book hotel rooms</li>
<li>Manage your bookings</li>
<li>Update your personal information</li>
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">✨</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 15px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center; letter-spacing: -0.5px;">Welcome, {name}!</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 25px;">We are delighted to welcome you to <strong style="color: #1a1a1a;">{company_name}</strong>.</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 30px;">Your account has been successfully created with email: <strong style="color: #D4AF37;">{email}</strong></p>
<div style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-left: 4px solid #D4AF37; padding: 25px; border-radius: 10px; margin: 30px 0; box-shadow: 0 4px 15px rgba(212, 175, 55, 0.1);">
<p style="margin: 0 0 15px 0; color: #1a1a1a; font-weight: 600; font-size: 16px;">🎁 What you can do:</p>
<ul style="margin: 0; padding-left: 20px; color: #555555; line-height: 2;">
<li style="margin-bottom: 8px;">Search and book our exquisite hotel rooms</li>
<li style="margin-bottom: 8px;">Manage your bookings with ease</li>
<li style="margin-bottom: 8px;">Update your personal information anytime</li>
<li>Enjoy exclusive member benefits and offers</li>
</ul>
</div>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/login" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Login Now
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/login" style="background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); color: #1a1a1a; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4); transition: all 0.3s ease;">
Access Your Account
</a>
</p>
</div>
"""
return get_base_template(content, "Welcome to Hotel Booking")
return get_base_template(content, f"Welcome to {company_name}", client_url)
def password_reset_email_template(reset_url: str) -> str:
"""Password reset email template"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Password Reset Request</h2>
<p>You (or someone) has requested to reset your password.</p>
<p>Click the link below to reset your password. This link will expire in 1 hour:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">🔐</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 15px; font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; text-align: center;">Password Reset Request</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 20px;">A password reset request has been received for your account.</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 30px;">Click the button below to reset your password. <strong style="color: #D4AF37;">This link will expire in 1 hour.</strong></p>
<div style="text-align: center; margin: 35px 0;">
<a href="{reset_url}" style="background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); color: #1a1a1a; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4);">
Reset Password
</a>
</p>
<p style="color: #666666; font-size: 14px;">If you did not request this, please ignore this email.</p>
</div>
<div style="background-color: #fff9e6; border: 1px solid #ffe0b2; padding: 15px; border-radius: 8px; margin-top: 30px;">
<p style="margin: 0; color: #856404; font-size: 13px; text-align: center; line-height: 1.6;">⚠️ If you did not request this password reset, please ignore this email and your password will remain unchanged.</p>
</div>
"""
return get_base_template(content, "Password Reset")
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Password Reset - {company_name}", reset_url.split('/reset-password')[0] if '/reset-password' in reset_url else "http://localhost:5173")
def password_changed_email_template(email: str) -> str:
"""Password changed confirmation email template"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Password Changed Successfully</h2>
<p>The password for account <strong>{email}</strong> has been changed successfully.</p>
<p>If you did not make this change, please contact our support team immediately.</p>
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">✓</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 15px; font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; text-align: center;">Password Changed Successfully</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 25px;">The password for account <strong style="color: #1a1a1a;">{email}</strong> has been changed successfully.</p>
<div style="background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border-left: 4px solid #10B981; padding: 20px; border-radius: 10px; margin: 30px 0; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.1);">
<p style="margin: 0; color: #065F46; font-size: 14px; line-height: 1.6; text-align: center;">🔒 If you did not make this change, please contact our support team immediately to secure your account.</p>
</div>
"""
return get_base_template(content, "Password Changed")
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Password Changed - {company_name}", "http://localhost:5173")
def booking_confirmation_email_template(
@@ -111,57 +266,66 @@ def booking_confirmation_email_template(
deposit_info = ""
if requires_deposit and deposit_amount:
deposit_info = f"""
<div style="background-color: #FEF3C7; border-left: 4px solid #F59E0B; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: bold; color: #92400E;">⚠️ Deposit Required</p>
<p style="margin: 5px 0 0 0; color: #78350F;">Please pay a deposit of <strong>€{deposit_amount:.2f}</strong> to confirm your booking.</p>
<p style="margin: 5px 0 0 0; color: #78350F;">Your booking will be confirmed once the deposit is received.</p>
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-left: 4px solid #F59E0B; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(245, 158, 11, 0.2);">
<p style="margin: 0 0 10px 0; font-weight: 700; color: #92400E; font-size: 16px;">⚠️ Deposit Required</p>
<p style="margin: 0 0 8px 0; color: #78350F; font-size: 15px; line-height: 1.6;">Please pay a deposit of <strong style="color: #92400E; font-size: 18px;">€{deposit_amount:.2f}</strong> to confirm your booking.</p>
<p style="margin: 0; color: #78350F; font-size: 14px;">Your booking will be confirmed once the deposit is received.</p>
</div>
"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Booking Confirmation</h2>
<p>Dear {guest_name},</p>
<p>Thank you for your booking! We have received your reservation request.</p>
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">🏨</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 20px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center;">Booking Confirmation</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 10px;">Dear <strong style="color: #1a1a1a;">{guest_name}</strong>,</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 35px;">Thank you for choosing us! We have received your reservation request and are delighted to welcome you.</p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #1F2937;">Booking Details</h3>
<div style="background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%); padding: 30px; border-radius: 12px; margin: 30px 0; border: 1px solid #e5e5e5; box-shadow: 0 4px 20px rgba(0,0,0,0.05);">
<h3 style="margin-top: 0; margin-bottom: 25px; color: #1a1a1a; font-family: 'Playfair Display', serif; font-size: 24px; font-weight: 600; text-align: center; border-bottom: 2px solid #D4AF37; padding-bottom: 15px;">Booking Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
<td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 12px 0; font-weight: 700; color: #1a1a1a; font-size: 15px; letter-spacing: 0.5px;">{booking_number}</td>
</tr>
<tr style="background-color: rgba(212, 175, 55, 0.05);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Room:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{room_type} - Room {room_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Room:</td>
<td style="padding: 8px 0; color: #1F2937;">{room_type} - Room {room_number}</td>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Check-in:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{check_in}</td>
</tr>
<tr style="background-color: rgba(212, 175, 55, 0.05);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Check-out:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{check_out}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Check-in:</td>
<td style="padding: 8px 0; color: #1F2937;">{check_in}</td>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Guests:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{num_guests} guest{'s' if num_guests > 1 else ''}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Check-out:</td>
<td style="padding: 8px 0; color: #1F2937;">{check_out}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Guests:</td>
<td style="padding: 8px 0; color: #1F2937;">{num_guests}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Total Price:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">€{total_price:.2f}</td>
<tr style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-top: 2px solid #D4AF37; border-bottom: 2px solid #D4AF37;">
<td style="padding: 15px 0; color: #1a1a1a; font-size: 16px; font-weight: 600;">Total Price:</td>
<td style="padding: 15px 0; font-weight: 700; color: #D4AF37; font-size: 22px; font-family: 'Playfair Display', serif;">{total_price:.2f}</td>
</tr>
</table>
</div>
{deposit_info}
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/bookings/{booking_number}" style="background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); color: #1a1a1a; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4);">
View Booking Details
</a>
</p>
</div>
"""
return get_base_template(content, "Booking Confirmation")
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Booking Confirmation - {company_name}", client_url)
def payment_confirmation_email_template(
@@ -176,45 +340,54 @@ def payment_confirmation_email_template(
transaction_info = ""
if transaction_id:
transaction_info = f"""
<tr>
<td style="padding: 8px 0; color: #6B7280;">Transaction ID:</td>
<td style="padding: 8px 0; color: #1F2937; font-family: monospace;">{transaction_id}</td>
<tr style="background-color: rgba(16, 185, 129, 0.05);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Transaction ID:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-family: 'Courier New', monospace; font-size: 13px; letter-spacing: 0.5px;">{transaction_id}</td>
</tr>
"""
content = f"""
<h2 style="color: #10B981; margin-top: 0;">Payment Received</h2>
<p>Dear {guest_name},</p>
<p>We have successfully received your payment for booking <strong>{booking_number}</strong>.</p>
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">💳</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 20px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center;">Payment Received</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 10px;">Dear <strong style="color: #1a1a1a;">{guest_name}</strong>,</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 35px;">We have successfully received your payment for booking <strong style="color: #10B981;">{booking_number}</strong>.</p>
<div style="background-color: #ECFDF5; border-left: 4px solid #10B981; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #065F46;">Payment Details</h3>
<div style="background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border-left: 4px solid #10B981; padding: 30px; border-radius: 12px; margin: 30px 0; box-shadow: 0 4px 20px rgba(16, 185, 129, 0.15);">
<h3 style="margin-top: 0; margin-bottom: 25px; color: #065F46; font-family: 'Playfair Display', serif; font-size: 24px; font-weight: 600; text-align: center; border-bottom: 2px solid #10B981; padding-bottom: 15px;">Payment Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
<td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 12px 0; font-weight: 700; color: #1a1a1a; font-size: 15px; letter-spacing: 0.5px;">{booking_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Amount:</td>
<td style="padding: 8px 0; font-weight: bold; color: #065F46; font-size: 18px;">{amount:.2f}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Payment Method:</td>
<td style="padding: 8px 0; color: #1F2937;">{payment_method}</td>
<tr style="background-color: rgba(16, 185, 129, 0.1);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Payment Method:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_method}</td>
</tr>
{transaction_info}
<tr style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); border-top: 2px solid #10B981; border-bottom: 2px solid #10B981;">
<td style="padding: 15px 0; color: #065F46; font-size: 16px; font-weight: 600;">Amount Paid:</td>
<td style="padding: 15px 0; font-weight: 700; color: #059669; font-size: 24px; font-family: 'Playfair Display', serif;">€{amount:.2f}</td>
</tr>
</table>
</div>
<p>Your booking is now confirmed. We look forward to hosting you!</p>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin: 35px 0 25px 0;">✨ Your booking is now confirmed. We look forward to hosting you!</p>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: #10B981; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/bookings/{booking_number}" style="background: linear-gradient(135deg, #10B981 0%, #059669 100%); color: #ffffff; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);">
View Booking
</a>
</p>
</div>
"""
return get_base_template(content, "Payment Confirmation")
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Payment Confirmation - {company_name}", client_url)
def booking_status_changed_email_template(
@@ -225,37 +398,47 @@ def booking_status_changed_email_template(
) -> str:
"""Booking status change email template"""
status_colors = {
"confirmed": ("#10B981", "Confirmed"),
"cancelled": ("#EF4444", "Cancelled"),
"checked_in": ("#3B82F6", "Checked In"),
"checked_out": ("#8B5CF6", "Checked Out"),
"confirmed": ("#10B981", "Confirmed", "", "#ecfdf5", "#d1fae5"),
"cancelled": ("#EF4444", "Cancelled", "", "#fef2f2", "#fee2e2"),
"checked_in": ("#3B82F6", "Checked In", "🔑", "#eff6ff", "#dbeafe"),
"checked_out": ("#8B5CF6", "Checked Out", "🏃", "#f5f3ff", "#e9d5ff"),
}
color, status_text = status_colors.get(status.lower(), ("#6B7280", status.title()))
color, status_text, icon, bg_start, bg_end = status_colors.get(status.lower(), ("#6B7280", status.title(), "📋", "#f3f4f6", "#e5e7eb"))
content = f"""
<h2 style="color: {color}; margin-top: 0;">Booking Status Updated</h2>
<p>Dear {guest_name},</p>
<p>Your booking status has been updated.</p>
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, {color} 0%, {color}dd 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">{icon}</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 20px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center;">Booking Status Updated</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 10px;">Dear <strong style="color: #1a1a1a;">{guest_name}</strong>,</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 35px;">Your booking status has been updated.</p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<div style="background: linear-gradient(135deg, {bg_start} 0%, {bg_end} 100%); border-left: 4px solid {color}; padding: 30px; border-radius: 12px; margin: 30px 0; box-shadow: 0 4px 20px {color}20;">
<h3 style="margin-top: 0; margin-bottom: 25px; color: {color}; font-family: 'Playfair Display', serif; font-size: 22px; font-weight: 600; text-align: center; border-bottom: 2px solid {color}; padding-bottom: 15px;">Status Information</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
<td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 12px 0; font-weight: 700; color: #1a1a1a; font-size: 15px; letter-spacing: 0.5px;">{booking_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">New Status:</td>
<td style="padding: 8px 0; font-weight: bold; color: {color}; font-size: 18px;">{status_text}</td>
<tr style="background: linear-gradient(135deg, {bg_end} 0%, {bg_start} 100%); border-top: 2px solid {color}; border-bottom: 2px solid {color};">
<td style="padding: 15px 0; color: {color}; font-size: 16px; font-weight: 600;">New Status:</td>
<td style="padding: 15px 0; font-weight: 700; color: {color}; font-size: 22px; font-family: 'Playfair Display', serif;">{status_text}</td>
</tr>
</table>
</div>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: {color}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/bookings/{booking_number}" style="background: linear-gradient(135deg, {color} 0%, {color}dd 100%); color: #ffffff; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px {color}40;">
View Booking
</a>
</p>
</div>
"""
return get_base_template(content, f"Booking {status_text}")
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Booking {status_text} - {company_name}", client_url)

View File

@@ -4,22 +4,76 @@ from email.mime.multipart import MIMEMultipart
import os
import logging
from ..config.settings import settings
from ..config.database import SessionLocal
from ..models.system_settings import SystemSettings
logger = logging.getLogger(__name__)
def _get_smtp_settings_from_db():
"""
Get SMTP settings from system_settings table.
Returns dict with settings or None if not available.
"""
try:
db = SessionLocal()
try:
smtp_settings = {}
setting_keys = [
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_password",
"smtp_from_email",
"smtp_from_name",
"smtp_use_tls",
]
for key in setting_keys:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting and setting.value:
smtp_settings[key] = setting.value
# Only return if we have at least host, user, and password
if smtp_settings.get("smtp_host") and smtp_settings.get("smtp_user") and smtp_settings.get("smtp_password"):
return smtp_settings
return None
finally:
db.close()
except Exception as e:
logger.debug(f"Could not fetch SMTP settings from database: {str(e)}")
return None
async def send_email(to: str, subject: str, html: str = None, text: str = None):
"""
Send email using SMTP
Uses settings from config/settings.py with fallback to environment variables
Uses system_settings first, then falls back to config/settings.py and environment variables
"""
try:
# Get SMTP settings from settings.py, fallback to env vars
# Try to get SMTP settings from database first
db_smtp_settings = _get_smtp_settings_from_db()
if db_smtp_settings:
# Use settings from database
mail_host = db_smtp_settings.get("smtp_host")
mail_user = db_smtp_settings.get("smtp_user")
mail_pass = db_smtp_settings.get("smtp_password")
mail_port = int(db_smtp_settings.get("smtp_port", "587"))
mail_use_tls = db_smtp_settings.get("smtp_use_tls", "true").lower() == "true"
from_address = db_smtp_settings.get("smtp_from_email")
from_name = db_smtp_settings.get("smtp_from_name", "Hotel Booking")
logger.info("Using SMTP settings from system_settings database")
else:
# Fallback to config/settings.py and env vars
mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST")
mail_user = settings.SMTP_USER or os.getenv("MAIL_USER")
mail_pass = settings.SMTP_PASSWORD or os.getenv("MAIL_PASS")
mail_port = settings.SMTP_PORT or int(os.getenv("MAIL_PORT", "587"))
mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true"
mail_use_tls = mail_secure # For backward compatibility
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
# Get from address - prefer settings, then env, then generate from client_url
@@ -31,6 +85,8 @@ async def send_email(to: str, subject: str, html: str = None, text: str = None):
# Use from name if available
from_name = settings.SMTP_FROM_NAME or "Hotel Booking"
logger.info("Using SMTP settings from config/environment variables")
from_header = f"{from_name} <{from_address}>"
if not (mail_host and mail_user and mail_pass):
@@ -54,10 +110,10 @@ async def send_email(to: str, subject: str, html: str = None, text: str = None):
message.attach(MIMEText("", "plain"))
# Determine TLS/SSL settings
# For port 587: use STARTTLS (use_tls=False, start_tls=True)
# For port 465: use SSL/TLS (use_tls=True, start_tls=False)
# For port 587: use STARTTLS (use_tls=False, start_tls=True)
# For port 25: plain (usually not used for authenticated sending)
if mail_port == 465 or mail_secure:
if mail_port == 465 or mail_use_tls:
# SSL/TLS connection (port 465)
use_tls = True
start_tls = False

View File

@@ -10,6 +10,7 @@ import 'react-toastify/dist/ReactToastify.css';
import { GlobalLoadingProvider } from './contexts/GlobalLoadingContext';
import { CookieConsentProvider } from './contexts/CookieConsentContext';
import { CurrencyProvider } from './contexts/CurrencyContext';
import { CompanySettingsProvider } from './contexts/CompanySettingsContext';
import OfflineIndicator from './components/common/OfflineIndicator';
import CookieConsentBanner from './components/common/CookieConsentBanner';
import AnalyticsLoader from './components/common/AnalyticsLoader';
@@ -121,6 +122,7 @@ function App() {
<GlobalLoadingProvider>
<CookieConsentProvider>
<CurrencyProvider>
<CompanySettingsProvider>
<BrowserRouter
future={{
v7_startTransition: true,
@@ -337,6 +339,7 @@ function App() {
<AnalyticsLoader />
</Suspense>
</BrowserRouter>
</CompanySettingsProvider>
</CurrencyProvider>
</CookieConsentProvider>
</GlobalLoadingProvider>

View File

@@ -12,13 +12,26 @@ import {
Youtube,
Award,
Shield,
Star
Star,
Trophy,
Medal,
BadgeCheck,
CheckCircle,
Heart,
Crown,
Gem,
Zap,
Target,
TrendingUp,
LucideIcon
} from 'lucide-react';
import CookiePreferencesLink from '../common/CookiePreferencesLink';
import { pageContentService } from '../../services/api';
import type { PageContent } from '../../services/api/pageContentService';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
const Footer: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(null);
useEffect(() => {
@@ -37,6 +50,38 @@ const Footer: React.FC = () => {
fetchPageContent();
}, []);
// Get phone, email, and address from centralized company settings
const displayPhone = settings.company_phone || null;
const displayEmail = settings.company_email || null;
const displayAddress = settings.company_address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam';
// Get logo URL from centralized company settings
const logoUrl = settings.company_logo_url
? (settings.company_logo_url.startsWith('http')
? settings.company_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`)
: null;
// Icon map for badges
const iconMap: Record<string, LucideIcon> = {
Award,
Star,
Trophy,
Medal,
BadgeCheck,
CheckCircle,
Shield,
Heart,
Crown,
Gem,
Zap,
Target,
TrendingUp,
};
// Get badges from page content
const badges = pageContent?.badges || [];
// Default links
const defaultQuickLinks = [
{ label: 'Home', url: '/' },
@@ -76,16 +121,26 @@ const Footer: React.FC = () => {
{/* Company Info - Enhanced */}
<div className="lg:col-span-2">
<div className="flex items-center space-x-3 mb-6">
{logoUrl ? (
<div className="relative">
<img
src={logoUrl}
alt={settings.company_name}
className="h-10 w-auto object-contain drop-shadow-lg"
/>
</div>
) : (
<div className="relative">
<Hotel className="w-10 h-10 text-[#d4af37]" />
<div className="absolute inset-0 bg-[#d4af37]/20 blur-xl"></div>
</div>
)}
<div>
<span className="text-2xl font-serif font-semibold text-white tracking-wide">
{pageContent?.title || 'Luxury Hotel'}
{settings.company_name || pageContent?.title || 'Luxury Hotel'}
</span>
<p className="text-xs text-[#d4af37]/80 font-light tracking-widest uppercase mt-0.5">
Excellence Redefined
{settings.company_tagline || 'Excellence Redefined'}
</p>
</div>
</div>
@@ -94,16 +149,20 @@ const Footer: React.FC = () => {
</p>
{/* Premium Certifications */}
{badges.length > 0 && badges.some(b => b.text) && (
<div className="flex items-center space-x-6 mb-8">
<div className="flex items-center space-x-2 text-[#d4af37]/90">
<Award className="w-5 h-5" />
<span className="text-xs font-medium tracking-wide">5-Star Rated</span>
</div>
<div className="flex items-center space-x-2 text-[#d4af37]/90">
<Shield className="w-5 h-5" />
<span className="text-xs font-medium tracking-wide">Award Winning</span>
{badges.map((badge, index) => {
if (!badge.text) return null;
const BadgeIcon = iconMap[badge.icon] || Award;
return (
<div key={index} className="flex items-center space-x-2 text-[#d4af37]/90">
<BadgeIcon className="w-5 h-5" />
<span className="text-xs font-medium tracking-wide">{badge.text}</span>
</div>
);
})}
</div>
)}
{/* Social Media - Premium Style */}
<div className="flex items-center space-x-3">
@@ -225,37 +284,37 @@ const Footer: React.FC = () => {
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
<span className="text-sm text-gray-400 group-hover:text-gray-300 transition-colors leading-relaxed font-light">
{((pageContent?.contact_info?.address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam')
{(displayAddress
.split('\n').map((line, i) => (
<React.Fragment key={i}>
{line}
{i < 1 && <br />}
{i < displayAddress.split('\n').length - 1 && <br />}
</React.Fragment>
)))}
</span>
</li>
{displayPhone && (
<li className="flex items-center space-x-4 group">
<div className="relative">
<Phone className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
{pageContent?.contact_info?.phone && (
<a href={`tel:${pageContent.contact_info.phone}`} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
{pageContent.contact_info.phone}
<a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
{displayPhone}
</a>
)}
</li>
)}
{displayEmail && (
<li className="flex items-center space-x-4 group">
<div className="relative">
<Mail className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div>
{pageContent?.contact_info?.email && (
<a href={`mailto:${pageContent.contact_info.email}`} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
{pageContent.contact_info.email}
<a href={`mailto:${displayEmail}`} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
{displayEmail}
</a>
)}
</li>
)}
</ul>
{/* Star Rating Display */}

View File

@@ -12,9 +12,9 @@ import {
Phone,
Mail,
Calendar,
MessageSquare,
} from 'lucide-react';
import { useClickOutside } from '../../hooks/useClickOutside';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
interface HeaderProps {
isAuthenticated?: boolean;
@@ -32,6 +32,16 @@ const Header: React.FC<HeaderProps> = ({
userInfo = null,
onLogout
}) => {
const { settings } = useCompanySettings();
// Get phone and email from centralized company settings
const displayPhone = settings.company_phone || '+1 (234) 567-890';
const displayEmail = settings.company_email || 'info@luxuryhotel.com';
const logoUrl = settings.company_logo_url
? (settings.company_logo_url.startsWith('http')
? settings.company_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`)
: null;
const [isMobileMenuOpen, setIsMobileMenuOpen] =
useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
@@ -66,14 +76,18 @@ const Header: React.FC<HeaderProps> = ({
<div className="hidden lg:block bg-[#0a0a0a]/50 border-b border-[#d4af37]/10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
<div className="flex items-center justify-end space-x-6 text-sm">
<a href="tel:+1234567890" className="flex items-center space-x-2 text-[#d4af37] hover:text-[#f5d76e] transition-colors duration-300 font-light">
{displayPhone && (
<a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} className="flex items-center space-x-2 text-[#d4af37] hover:text-[#f5d76e] transition-colors duration-300 font-light">
<Phone className="w-3.5 h-3.5" />
<span className="tracking-wide">+1 (234) 567-890</span>
<span className="tracking-wide">{displayPhone}</span>
</a>
<a href="mailto:info@luxuryhotel.com" className="flex items-center space-x-2 text-[#d4af37] hover:text-[#f5d76e] transition-colors duration-300 font-light">
)}
{displayEmail && (
<a href={`mailto:${displayEmail}`} className="flex items-center space-x-2 text-[#d4af37] hover:text-[#f5d76e] transition-colors duration-300 font-light">
<Mail className="w-3.5 h-3.5" />
<span className="tracking-wide">info@luxuryhotel.com</span>
<span className="tracking-wide">{displayEmail}</span>
</a>
)}
</div>
</div>
</div>
@@ -87,16 +101,26 @@ const Header: React.FC<HeaderProps> = ({
className="flex items-center space-x-3
group transition-all duration-300 hover:opacity-90"
>
{logoUrl ? (
<div className="relative">
<img
src={logoUrl}
alt={settings.company_name}
className="h-10 w-auto object-contain drop-shadow-lg"
/>
</div>
) : (
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-[#d4af37] to-[#f5d76e] rounded-full blur-md opacity-30 group-hover:opacity-50 transition-opacity duration-300"></div>
<Hotel className="relative w-10 h-10 text-[#d4af37] drop-shadow-lg" />
</div>
)}
<div className="flex flex-col">
<span className="text-2xl font-serif font-semibold text-white tracking-tight leading-tight">
Luxury Hotel
{settings.company_name}
</span>
<span className="text-[10px] text-[#d4af37] tracking-[0.2em] uppercase font-light">
Excellence Redefined
{settings.company_tagline || 'Excellence Redefined'}
</span>
</div>
</Link>

View File

@@ -710,7 +710,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
<div className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-4"
>
{safeAmenities.slice(0, 10).map((amenity, index) => (
{safeAmenities.map((amenity, index) => (
<div
key={index}
className="flex items-center gap-3 p-3

View File

@@ -97,7 +97,7 @@ const RoomFilter: React.FC<RoomFilterProps> = ({ onFilterChange }) => {
import('../../services/api/roomService').then((mod) => {
mod.getAmenities().then((res) => {
const list = res.data?.amenities || [];
if (mounted) setAvailableAmenities(list.slice(0, 8));
if (mounted) setAvailableAmenities(list);
}).catch(() => {});
});
return () => {

View File

@@ -0,0 +1,133 @@
import React, {
createContext,
useContext,
useEffect,
useState,
ReactNode,
} from 'react';
import systemSettingsService from '../services/api/systemSettingsService';
type CompanySettings = {
company_name: string;
company_tagline: string;
company_logo_url: string;
company_favicon_url: string;
company_phone: string;
company_email: string;
company_address: string;
};
type CompanySettingsContextValue = {
settings: CompanySettings;
isLoading: boolean;
refreshSettings: () => Promise<void>;
};
const defaultSettings: CompanySettings = {
company_name: 'Luxury Hotel',
company_tagline: 'Excellence Redefined',
company_logo_url: '',
company_favicon_url: '',
company_phone: '',
company_email: '',
company_address: '',
};
const CompanySettingsContext = createContext<CompanySettingsContextValue | undefined>(undefined);
export const useCompanySettings = () => {
const context = useContext(CompanySettingsContext);
if (!context) {
throw new Error('useCompanySettings must be used within CompanySettingsProvider');
}
return context;
};
interface CompanySettingsProviderProps {
children: ReactNode;
}
export const CompanySettingsProvider: React.FC<CompanySettingsProviderProps> = ({ children }) => {
const [settings, setSettings] = useState<CompanySettings>(defaultSettings);
const [isLoading, setIsLoading] = useState<boolean>(true);
// Load company settings from system settings
const loadSettings = async () => {
try {
setIsLoading(true);
const response = await systemSettingsService.getCompanySettings();
if (response.data) {
setSettings({
company_name: response.data.company_name || defaultSettings.company_name,
company_tagline: response.data.company_tagline || defaultSettings.company_tagline,
company_logo_url: response.data.company_logo_url || defaultSettings.company_logo_url,
company_favicon_url: response.data.company_favicon_url || defaultSettings.company_favicon_url,
company_phone: response.data.company_phone || defaultSettings.company_phone,
company_email: response.data.company_email || defaultSettings.company_email,
company_address: response.data.company_address || defaultSettings.company_address,
});
// Update favicon if available
if (response.data.company_favicon_url) {
const faviconUrl = response.data.company_favicon_url.startsWith('http')
? response.data.company_favicon_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${response.data.company_favicon_url}`;
// Remove existing favicon links
const existingLinks = document.querySelectorAll("link[rel~='icon']");
existingLinks.forEach(link => link.remove());
// Add new favicon
const link = document.createElement('link');
link.rel = 'icon';
link.href = faviconUrl;
document.head.appendChild(link);
}
// Update page title if company name is set
if (response.data.company_name) {
document.title = response.data.company_name;
}
}
} catch (error) {
console.error('Error loading company settings:', error);
// Keep default settings
setSettings(defaultSettings);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadSettings();
// Listen for refresh events from settings page
const handleRefresh = () => {
loadSettings();
};
if (typeof window !== 'undefined') {
window.addEventListener('refreshCompanySettings', handleRefresh);
return () => {
window.removeEventListener('refreshCompanySettings', handleRefresh);
};
}
}, []);
const refreshSettings = async () => {
await loadSettings();
};
return (
<CompanySettingsContext.Provider
value={{
settings,
isLoading,
refreshSettings,
}}
>
{children}
</CompanySettingsContext.Provider>
);
};

View File

@@ -14,8 +14,10 @@ import {
import { Link } from 'react-router-dom';
import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
const AboutPage: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(null);
useEffect(() => {
@@ -48,6 +50,11 @@ const AboutPage: React.FC = () => {
fetchPageContent();
}, []);
// Get phone, email, and address from centralized company settings
const displayPhone = settings.company_phone || '+1 (234) 567-890';
const displayEmail = settings.company_email || 'info@luxuryhotel.com';
const displayAddress = settings.company_address || '123 Luxury Street\nCity, State 12345\nCountry';
// Default values
const defaultValues = [
{
@@ -253,11 +260,11 @@ const AboutPage: React.FC = () => {
Address
</h3>
<p className="text-gray-600">
{(pageContent?.contact_info?.address || '123 Luxury Street\nCity, State 12345\nCountry')
{displayAddress
.split('\n').map((line, i) => (
<React.Fragment key={i}>
{line}
{i < 2 && <br />}
{i < displayAddress.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</p>
@@ -270,8 +277,8 @@ const AboutPage: React.FC = () => {
Phone
</h3>
<p className="text-gray-600">
<a href={`tel:${pageContent?.contact_info?.phone || '+1234567890'}`} className="hover:text-[#d4af37] transition-colors">
{pageContent?.contact_info?.phone || '+1 (234) 567-890'}
<a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} className="hover:text-[#d4af37] transition-colors">
{displayPhone}
</a>
</p>
</div>
@@ -283,8 +290,8 @@ const AboutPage: React.FC = () => {
Email
</h3>
<p className="text-gray-600">
<a href={`mailto:${pageContent?.contact_info?.email || 'info@luxuryhotel.com'}`} className="hover:text-[#d4af37] transition-colors">
{pageContent?.contact_info?.email || 'info@luxuryhotel.com'}
<a href={`mailto:${displayEmail}`} className="hover:text-[#d4af37] transition-colors">
{displayEmail}
</a>
</p>
</div>

View File

@@ -3,9 +3,11 @@ import { Mail, Phone, MapPin, Send, User, MessageSquare } from 'lucide-react';
import { submitContactForm } from '../services/api/contactService';
import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import { toast } from 'react-toastify';
const ContactPage: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(null);
const [formData, setFormData] = useState({
name: '',
@@ -103,6 +105,11 @@ const ContactPage: React.FC = () => {
fetchPageContent();
}, []);
// Get phone, email, and address from centralized company settings
const displayPhone = settings.company_phone || 'Available 24/7 for your convenience';
const displayEmail = settings.company_email || "We'll respond within 24 hours";
const displayAddress = settings.company_address || 'Visit us at our hotel reception';
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
@@ -181,9 +188,9 @@ const ContactPage: React.FC = () => {
</div>
<div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Email</h3>
<p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
{pageContent?.contact_info?.email || "We'll respond within 24 hours"}
</p>
<a href={`mailto:${displayEmail}`} className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed hover:text-[#d4af37] transition-colors">
{displayEmail}
</a>
</div>
</div>
@@ -193,9 +200,9 @@ const ContactPage: React.FC = () => {
</div>
<div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Phone</h3>
<p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
{pageContent?.contact_info?.phone || 'Available 24/7 for your convenience'}
</p>
<a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed hover:text-[#d4af37] transition-colors">
{displayPhone}
</a>
</div>
</div>
@@ -205,8 +212,8 @@ const ContactPage: React.FC = () => {
</div>
<div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Location</h3>
<p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
{pageContent?.contact_info?.address || 'Visit us at our hotel reception'}
<p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed whitespace-pre-line">
{displayAddress}
</p>
</div>
</div>
@@ -236,8 +243,7 @@ const ContactPage: React.FC = () => {
<div className="mt-5 sm:mt-6 md:mt-7 pt-5 sm:pt-6 md:pt-7 border-t border-[#d4af37]/30">
<p className="text-gray-400 font-light text-xs sm:text-sm leading-relaxed tracking-wide">
Our team is here to help you with any questions about your stay,
bookings, or special requests. We're committed to exceeding your expectations.
{pageContent?.content || "Our team is here to help you with any questions about your stay, bookings, or special requests. We're committed to exceeding your expectations."}
</p>
</div>
</div>

View File

@@ -23,7 +23,9 @@ import {
Upload,
Loader2,
Check,
XCircle
XCircle,
Award,
Shield
} from 'lucide-react';
import { pageContentService, PageContent, PageType, UpdatePageContentData, bannerService, Banner } from '../../services/api';
import { toast } from 'react-toastify';
@@ -138,7 +140,6 @@ const PageContentDashboard: React.FC = () => {
subtitle: contents.contact.subtitle || '',
description: contents.contact.description || '',
content: contents.contact.content || '',
contact_info: contents.contact.contact_info || { phone: '', email: '', address: '' },
map_url: contents.contact.map_url || '',
meta_title: contents.contact.meta_title || '',
meta_description: contents.contact.meta_description || '',
@@ -165,9 +166,9 @@ const PageContentDashboard: React.FC = () => {
setFooterData({
title: contents.footer.title || '',
description: contents.footer.description || '',
contact_info: contents.footer.contact_info || { phone: '', email: '', address: '' },
social_links: contents.footer.social_links || {},
footer_links: contents.footer.footer_links || { quick_links: [], support_links: [] },
badges: contents.footer.badges || [],
meta_title: contents.footer.meta_title || '',
meta_description: contents.footer.meta_description || '',
});
@@ -190,7 +191,13 @@ const PageContentDashboard: React.FC = () => {
const handleSave = async (pageType: PageType, data: UpdatePageContentData) => {
try {
setSaving(true);
// Remove contact_info for contact and footer pages since it's now managed centrally
const { contact_info, ...dataToSave } = data;
if (pageType === 'contact' || pageType === 'footer') {
await pageContentService.updatePageContent(pageType, dataToSave);
} else {
await pageContentService.updatePageContent(pageType, data);
}
toast.success(`${pageType.charAt(0).toUpperCase() + pageType.slice(1)} content saved successfully`);
await fetchAllPageContents();
} catch (error: any) {
@@ -993,44 +1000,11 @@ const PageContentDashboard: React.FC = () => {
</div>
<div className="border-t border-gray-200 pt-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Contact Information</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Phone</label>
<input
type="tel"
value={contactData.contact_info?.phone || ''}
onChange={(e) => setContactData({
...contactData,
contact_info: { ...contactData.contact_info, phone: e.target.value }
})}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
<input
type="email"
value={contactData.contact_info?.email || ''}
onChange={(e) => setContactData({
...contactData,
contact_info: { ...contactData.contact_info, email: e.target.value }
})}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Address</label>
<input
type="text"
value={contactData.contact_info?.address || ''}
onChange={(e) => setContactData({
...contactData,
contact_info: { ...contactData.contact_info, address: e.target.value }
})}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
/>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<p className="text-sm text-blue-800">
<strong>Note:</strong> Contact information (phone, email, address) is now managed centrally in <strong>Settings Company Info</strong>.
These fields will be displayed across the entire application.
</p>
</div>
</div>
@@ -1065,6 +1039,23 @@ const PageContentDashboard: React.FC = () => {
</div>
</div>
<div className="border-t border-gray-200 pt-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Help Message</h3>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Additional Information Text</label>
<textarea
value={contactData.content || ''}
onChange={(e) => setContactData({ ...contactData, content: e.target.value })}
rows={3}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
placeholder="Our team is here to help you with any questions about your stay, bookings, or special requests. We're committed to exceeding your expectations."
/>
<p className="text-xs text-gray-500 mt-2">
This text will appear below the contact information and map on the contact page.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Meta Title</label>
@@ -1213,44 +1204,11 @@ const PageContentDashboard: React.FC = () => {
</div>
<div className="border-t border-gray-200 pt-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Contact Information</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Phone</label>
<input
type="tel"
value={footerData.contact_info?.phone || ''}
onChange={(e) => setFooterData({
...footerData,
contact_info: { ...footerData.contact_info, phone: e.target.value }
})}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Email</label>
<input
type="email"
value={footerData.contact_info?.email || ''}
onChange={(e) => setFooterData({
...footerData,
contact_info: { ...footerData.contact_info, email: e.target.value }
})}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Address</label>
<input
type="text"
value={footerData.contact_info?.address || ''}
onChange={(e) => setFooterData({
...footerData,
contact_info: { ...footerData.contact_info, address: e.target.value }
})}
className="w-full px-4 py-3 bg-white border-2 border-gray-200 rounded-xl focus:border-purple-400 focus:ring-4 focus:ring-purple-100 transition-all duration-200"
/>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<p className="text-sm text-blue-800">
<strong>Note:</strong> Contact information (phone, email, address) is now managed centrally in <strong>Settings Company Info</strong>.
These fields will be displayed across the entire application, including the footer.
</p>
</div>
</div>
@@ -1325,6 +1283,123 @@ const PageContentDashboard: React.FC = () => {
</div>
</div>
<div className="border-t border-gray-200 pt-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Footer Badges</h3>
<p className="text-sm text-gray-600 mb-4">Customize the badges displayed in the footer (e.g., "5-Star Rated", "Award Winning").</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Badge 1 */}
<div className="space-y-4 p-6 bg-gray-50 rounded-xl border border-gray-200">
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
<Award className="w-4 h-4 text-gray-600" />
Badge 1
</label>
<input
type="text"
value={footerData.badges?.[0]?.text || ''}
onChange={(e) => {
const badges = footerData.badges || [];
const updated = [...badges];
if (updated[0]) {
updated[0] = { ...updated[0], text: e.target.value };
} else {
updated[0] = { text: e.target.value, icon: 'Award' };
}
setFooterData({ ...footerData, badges: updated });
}}
placeholder="5-Star Rated"
className="w-full px-4 py-3 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 transition-all duration-200 text-sm"
/>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-2">Icon</label>
<select
value={footerData.badges?.[0]?.icon || 'Award'}
onChange={(e) => {
const badges = footerData.badges || [];
const updated = [...badges];
if (updated[0]) {
updated[0] = { ...updated[0], icon: e.target.value };
} else {
updated[0] = { text: '', icon: e.target.value };
}
setFooterData({ ...footerData, badges: updated });
}}
className="w-full px-4 py-3 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 transition-all duration-200 text-sm"
>
<option value="Award">Award</option>
<option value="Star">Star</option>
<option value="Trophy">Trophy</option>
<option value="Medal">Medal</option>
<option value="BadgeCheck">Badge Check</option>
<option value="CheckCircle">Check Circle</option>
<option value="Shield">Shield</option>
<option value="Heart">Heart</option>
<option value="Crown">Crown</option>
<option value="Gem">Gem</option>
<option value="Zap">Zap</option>
<option value="Target">Target</option>
<option value="TrendingUp">Trending Up</option>
</select>
</div>
</div>
{/* Badge 2 */}
<div className="space-y-4 p-6 bg-gray-50 rounded-xl border border-gray-200">
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
<Shield className="w-4 h-4 text-gray-600" />
Badge 2
</label>
<input
type="text"
value={footerData.badges?.[1]?.text || ''}
onChange={(e) => {
const badges = footerData.badges || [];
const updated = [...badges];
if (updated[1]) {
updated[1] = { ...updated[1], text: e.target.value };
} else {
updated[1] = { text: e.target.value, icon: 'Shield' };
}
setFooterData({ ...footerData, badges: updated });
}}
placeholder="Award Winning"
className="w-full px-4 py-3 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 transition-all duration-200 text-sm"
/>
<div>
<label className="block text-xs font-semibold text-gray-700 mb-2">Icon</label>
<select
value={footerData.badges?.[1]?.icon || 'Shield'}
onChange={(e) => {
const badges = footerData.badges || [];
const updated = [...badges];
if (updated[1]) {
updated[1] = { ...updated[1], icon: e.target.value };
} else {
updated[1] = { text: '', icon: e.target.value };
}
setFooterData({ ...footerData, badges: updated });
}}
className="w-full px-4 py-3 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500 transition-all duration-200 text-sm"
>
<option value="Award">Award</option>
<option value="Star">Star</option>
<option value="Trophy">Trophy</option>
<option value="Medal">Medal</option>
<option value="BadgeCheck">Badge Check</option>
<option value="CheckCircle">Check Circle</option>
<option value="Shield">Shield</option>
<option value="Heart">Heart</option>
<option value="Crown">Crown</option>
<option value="Gem">Gem</option>
<option value="Zap">Zap</option>
<option value="Target">Target</option>
<option value="TrendingUp">Trending Up</option>
</select>
</div>
</div>
</div>
</div>
<div className="flex justify-end pt-4 border-t border-gray-200">
<button
onClick={() => handleSave('footer', footerData)}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import { Link } from 'react-router-dom';
@@ -15,14 +15,20 @@ import {
forgotPasswordSchema,
ForgotPasswordFormData,
} from '../../utils/validationSchemas';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
const ForgotPasswordPage: React.FC = () => {
const { forgotPassword, isLoading, error, clearError } =
useAuthStore();
const { settings } = useCompanySettings();
const [isSuccess, setIsSuccess] = useState(false);
const [submittedEmail, setSubmittedEmail] = useState('');
// Get email and phone from centralized company settings
const supportEmail = settings.company_email || 'support@hotel.com';
const supportPhone = settings.company_phone || '1900-xxxx';
// React Hook Form setup
const {
register,
@@ -306,18 +312,22 @@ const ForgotPasswordPage: React.FC = () => {
If you're having trouble resetting your password,
please contact our support team via email{' '}
<a
href="mailto:support@hotel.com"
href={`mailto:${supportEmail}`}
className="text-blue-600 hover:underline"
>
support@hotel.com
</a>{' '}
or hotline{' '}
<a
href="tel:1900-xxxx"
className="text-blue-600 hover:underline"
>
1900-xxxx
{supportEmail}
</a>
{supportPhone && (
<>
{' '}or hotline{' '}
<a
href={`tel:${supportPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`}
className="text-blue-600 hover:underline"
>
{supportPhone}
</a>
</>
)}
</p>
</div>
</div>

View File

@@ -8,11 +8,17 @@ import {
Receipt,
Loader2,
} from 'lucide-react';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
const PaymentResultPage: React.FC = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [countdown, setCountdown] = useState(10);
const { settings } = useCompanySettings();
// Get email and phone from centralized company settings
const supportEmail = settings.company_email || 'support@hotel.com';
const supportPhone = settings.company_phone || '1900 xxxx';
const status = searchParams.get('status');
const bookingId = searchParams.get('bookingId');
@@ -229,18 +235,22 @@ const PaymentResultPage: React.FC = () => {
<p>
If you have any issues, please contact{' '}
<a
href="mailto:support@hotel.com"
href={`mailto:${supportEmail}`}
className="text-indigo-600 hover:underline"
>
support@hotel.com
</a>{' '}
or call{' '}
<a
href="tel:1900xxxx"
className="text-indigo-600 hover:underline"
>
1900 xxxx
{supportEmail}
</a>
{supportPhone && (
<>
{' '}or call{' '}
<a
href={`tel:${supportPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`}
className="text-indigo-600 hover:underline"
>
{supportPhone}
</a>
</>
)}
</p>
</div>
</div>

View File

@@ -33,6 +33,7 @@ export interface PageContent {
quick_links?: Array<{ label: string; url: string }>;
support_links?: Array<{ label: string; url: string }>;
};
badges?: Array<{ text: string; icon: string }>;
hero_title?: string;
hero_subtitle?: string;
hero_image?: string;
@@ -82,6 +83,7 @@ export interface UpdatePageContentData {
quick_links?: Array<{ label: string; url: string }>;
support_links?: Array<{ label: string; url: string }>;
};
badges?: Array<{ text: string; icon: string }>;
hero_title?: string;
hero_subtitle?: string;
hero_image?: string;
@@ -148,6 +150,11 @@ const pageContentService = {
};
}
// Handle badges array
if (data.badges) {
updateData.badges = data.badges; // Send as array, backend will convert to JSON
}
// Handle values and features arrays
if (data.values) {
updateData.values = data.values; // Send as array, backend will convert to JSON

View File

@@ -36,6 +36,91 @@ export interface UpdateStripeSettingsRequest {
stripe_webhook_secret?: string;
}
export interface SmtpSettingsResponse {
status: string;
data: {
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_password: string;
smtp_password_masked: string;
smtp_from_email: string;
smtp_from_name: string;
smtp_use_tls: boolean;
has_host: boolean;
has_user: boolean;
has_password: boolean;
updated_at?: string | null;
updated_by?: string | null;
};
message?: string;
}
export interface UpdateSmtpSettingsRequest {
smtp_host?: string;
smtp_port?: string;
smtp_user?: string;
smtp_password?: string;
smtp_from_email?: string;
smtp_from_name?: string;
smtp_use_tls?: boolean;
}
export interface TestSmtpEmailRequest {
email: string;
}
export interface TestSmtpEmailResponse {
status: string;
message: string;
data: {
recipient: string;
sent_at: string;
};
}
export interface CompanySettingsResponse {
status: string;
data: {
company_name: string;
company_tagline: string;
company_logo_url: string;
company_favicon_url: string;
company_phone: string;
company_email: string;
company_address: string;
updated_at?: string | null;
updated_by?: string | null;
};
message?: string;
}
export interface UpdateCompanySettingsRequest {
company_name?: string;
company_tagline?: string;
company_phone?: string;
company_email?: string;
company_address?: string;
}
export interface UploadLogoResponse {
status: string;
message: string;
data: {
logo_url: string;
full_url: string;
};
}
export interface UploadFaviconResponse {
status: string;
message: string;
data: {
favicon_url: string;
full_url: string;
};
}
const systemSettingsService = {
/**
* Get platform currency (public endpoint)
@@ -82,7 +167,123 @@ const systemSettingsService = {
);
return response.data;
},
/**
* Get SMTP settings (Admin only)
*/
getSmtpSettings: async (): Promise<SmtpSettingsResponse> => {
const response = await apiClient.get<SmtpSettingsResponse>(
'/api/admin/system-settings/smtp'
);
return response.data;
},
/**
* Update SMTP settings (Admin only)
*/
updateSmtpSettings: async (
settings: UpdateSmtpSettingsRequest
): Promise<SmtpSettingsResponse> => {
const response = await apiClient.put<SmtpSettingsResponse>(
'/api/admin/system-settings/smtp',
settings
);
return response.data;
},
/**
* Test SMTP email (Admin only)
*/
testSmtpEmail: async (
email: string
): Promise<TestSmtpEmailResponse> => {
const response = await apiClient.post<TestSmtpEmailResponse>(
'/api/admin/system-settings/smtp/test',
{ email }
);
return response.data;
},
/**
* Get company settings (public endpoint)
*/
getCompanySettings: async (): Promise<CompanySettingsResponse> => {
const response = await apiClient.get<CompanySettingsResponse>(
'/api/admin/system-settings/company'
);
return response.data;
},
/**
* Update company settings (Admin only)
*/
updateCompanySettings: async (
settings: UpdateCompanySettingsRequest
): Promise<CompanySettingsResponse> => {
const response = await apiClient.put<CompanySettingsResponse>(
'/api/admin/system-settings/company',
settings
);
return response.data;
},
/**
* Upload company logo (Admin only)
*/
uploadCompanyLogo: async (
file: File
): Promise<UploadLogoResponse> => {
const formData = new FormData();
formData.append('image', file);
const response = await apiClient.post<UploadLogoResponse>(
'/api/admin/system-settings/company/logo',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
return response.data;
},
/**
* Upload company favicon (Admin only)
*/
uploadCompanyFavicon: async (
file: File
): Promise<UploadFaviconResponse> => {
const formData = new FormData();
formData.append('image', file);
const response = await apiClient.post<UploadFaviconResponse>(
'/api/admin/system-settings/company/favicon',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
return response.data;
},
};
export default systemSettingsService;
export type {
PlatformCurrencyResponse,
UpdateCurrencyRequest,
StripeSettingsResponse,
UpdateStripeSettingsRequest,
SmtpSettingsResponse,
UpdateSmtpSettingsRequest,
TestSmtpEmailRequest,
TestSmtpEmailResponse,
CompanySettingsResponse,
UpdateCompanySettingsRequest,
UploadLogoResponse,
UploadFaviconResponse,
};