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 map_url = Column(String(1000), nullable=True) # Google Maps embed URL
social_links = Column(Text, nullable=True) # JSON: facebook, twitter, instagram, etc. social_links = Column(Text, nullable=True) # JSON: facebook, twitter, instagram, etc.
footer_links = Column(Text, nullable=True) # JSON: quick links, support links 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 # Home page specific
hero_title = Column(String(500), nullable=True) hero_title = Column(String(500), nullable=True)

View File

@@ -39,6 +39,7 @@ async def get_all_page_contents(
"map_url": content.map_url, "map_url": content.map_url,
"social_links": json.loads(content.social_links) if content.social_links else None, "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, "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_title": content.hero_title,
"hero_subtitle": content.hero_subtitle, "hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image, "hero_image": content.hero_image,
@@ -97,8 +98,10 @@ async def get_page_content(
"og_image": content.og_image, "og_image": content.og_image,
"canonical_url": content.canonical_url, "canonical_url": content.canonical_url,
"contact_info": json.loads(content.contact_info) if content.contact_info else None, "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, "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, "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_title": content.hero_title,
"hero_subtitle": content.hero_subtitle, "hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image, "hero_image": content.hero_image,
@@ -141,6 +144,7 @@ async def create_or_update_page_content(
map_url: Optional[str] = None, map_url: Optional[str] = None,
social_links: Optional[str] = None, social_links: Optional[str] = None,
footer_links: Optional[str] = None, footer_links: Optional[str] = None,
badges: Optional[str] = None,
hero_title: Optional[str] = None, hero_title: Optional[str] = None,
hero_subtitle: Optional[str] = None, hero_subtitle: Optional[str] = None,
hero_image: 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" 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: if values:
try: try:
json.loads(values) json.loads(values)
@@ -236,6 +249,8 @@ async def create_or_update_page_content(
existing_content.social_links = social_links existing_content.social_links = social_links
if footer_links is not None: if footer_links is not None:
existing_content.footer_links = footer_links existing_content.footer_links = footer_links
if badges is not None:
existing_content.badges = badges
if hero_title is not None: if hero_title is not None:
existing_content.hero_title = hero_title existing_content.hero_title = hero_title
if hero_subtitle is not None: if hero_subtitle is not None:
@@ -286,6 +301,7 @@ async def create_or_update_page_content(
map_url=map_url, map_url=map_url,
social_links=social_links, social_links=social_links,
footer_links=footer_links, footer_links=footer_links,
badges=badges,
hero_title=hero_title, hero_title=hero_title,
hero_subtitle=hero_subtitle, hero_subtitle=hero_subtitle,
hero_image=hero_image, hero_image=hero_image,
@@ -346,7 +362,7 @@ async def update_page_content(
for key, value in page_data.items(): for key, value in page_data.items():
if hasattr(existing_content, key): if hasattr(existing_content, key):
# Handle JSON fields - convert dict/list to JSON string # 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): if isinstance(value, str):
# Already a string, validate it's valid JSON # Already a string, validate it's valid JSON
try: try:
@@ -383,8 +399,10 @@ async def update_page_content(
"og_image": existing_content.og_image, "og_image": existing_content.og_image,
"canonical_url": existing_content.canonical_url, "canonical_url": existing_content.canonical_url,
"contact_info": json.loads(existing_content.contact_info) if existing_content.contact_info else None, "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, "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, "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_title": existing_content.hero_title,
"hero_subtitle": existing_content.hero_subtitle, "hero_subtitle": existing_content.hero_subtitle,
"hero_image": existing_content.hero_image, "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 sqlalchemy.orm import Session
from typing import Optional 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 ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User from ..models.user import User
from ..models.system_settings import SystemSettings 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"]) router = APIRouter(prefix="/admin/system-settings", tags=["admin-system-settings"])
@@ -300,3 +322,768 @@ async def update_stripe_settings(
db.rollback() db.rollback()
raise HTTPException(status_code=500, detail=str(e)) 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 datetime import datetime
from typing import Optional 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: def _get_company_settings():
"""Base HTML email template""" """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""" return f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
@@ -14,19 +127,22 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str:
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title> <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> </head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;"> <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;"> <table role="presentation" style="width: 100%; border-collapse: collapse; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);">
<tr> <tr>
<td style="padding: 20px 0; text-align: center; background-color: #4F46E5;"> <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);">
<h1 style="color: #ffffff; margin: 0;">Hotel Booking</h1> {logo_html}
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding: 40px 20px; background-color: #ffffff;"> <td style="padding: 0;">
<table role="presentation" style="width: 100%; max-width: 600px; margin: 0 auto;"> <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> <tr>
<td> <td style="padding: 50px 40px; background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%);">
{content} {content}
</td> </td>
</tr> </tr>
@@ -34,9 +150,10 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str:
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding: 20px; text-align: center; background-color: #f4f4f4; color: #666666; font-size: 12px;"> <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;">This is an automated email. Please do not reply.</p> <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: 5px 0 0 0;"{datetime.now().year} Hotel Booking. All rights reserved.</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> </td>
</tr> </tr>
</table> </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: def welcome_email_template(name: str, email: str, client_url: str) -> str:
"""Welcome email template for new registrations""" """Welcome email template for new registrations"""
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
content = f""" content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Welcome {name}!</h2> <div style="text-align: center; margin-bottom: 30px;">
<p>Thank you for registering an account at <strong>Hotel Booking</strong>.</p> <div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<p>Your account has been successfully created with email: <strong>{email}</strong></p> <div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;"> <span style="font-size: 32px;">✨</span>
<p style="margin: 0;"><strong>You can:</strong></p> </div>
<ul style="margin-top: 10px;"> </div>
<li>Search and book hotel rooms</li> </div>
<li>Manage your bookings</li> <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>
<li>Update your personal information</li> <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> </ul>
</div> </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;"> <div style="text-align: center; margin-top: 40px;">
Login Now <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> </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: def password_reset_email_template(reset_url: str) -> str:
"""Password reset email template""" """Password reset email template"""
content = f""" content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Password Reset Request</h2> <div style="text-align: center; margin-bottom: 30px;">
<p>You (or someone) has requested to reset your password.</p> <div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<p>Click the link below to reset your password. This link will expire in 1 hour:</p> <div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<p style="text-align: center; margin: 30px 0;"> <span style="font-size: 32px;">🔐</span>
<a href="{reset_url}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;"> </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 Reset Password
</a> </a>
</p> </div>
<p style="color: #666666; font-size: 14px;">If you did not request this, please ignore this email.</p>
<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: def password_changed_email_template(email: str) -> str:
"""Password changed confirmation email template""" """Password changed confirmation email template"""
content = f""" content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Password Changed Successfully</h2> <div style="text-align: center; margin-bottom: 30px;">
<p>The password for account <strong>{email}</strong> has been changed successfully.</p> <div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<p>If you did not make this change, please contact our support team immediately.</p> <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( def booking_confirmation_email_template(
@@ -111,57 +266,66 @@ def booking_confirmation_email_template(
deposit_info = "" deposit_info = ""
if requires_deposit and deposit_amount: if requires_deposit and deposit_amount:
deposit_info = f""" deposit_info = f"""
<div style="background-color: #FEF3C7; border-left: 4px solid #F59E0B; padding: 15px; margin: 20px 0; border-radius: 4px;"> <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; font-weight: bold; color: #92400E;">⚠️ Deposit Required</p> <p style="margin: 0 0 10px 0; font-weight: 700; color: #92400E; font-size: 16px;">⚠️ 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: 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: 5px 0 0 0; color: #78350F;">Your booking will be confirmed once the deposit is received.</p> <p style="margin: 0; color: #78350F; font-size: 14px;">Your booking will be confirmed once the deposit is received.</p>
</div> </div>
""" """
content = f""" content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Booking Confirmation</h2> <div style="text-align: center; margin-bottom: 30px;">
<p>Dear {guest_name},</p> <div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<p>Thank you for your booking! We have received your reservation request.</p> <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;"> <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; color: #1F2937;">Booking Details</h3> <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;"> <table style="width: 100%; border-collapse: collapse;">
<tr> <tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td> <td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{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>
<tr> <tr>
<td style="padding: 8px 0; color: #6B7280;">Room:</td> <td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Check-in:</td>
<td style="padding: 8px 0; color: #1F2937;">{room_type} - Room {room_number}</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>
<tr> <tr>
<td style="padding: 8px 0; color: #6B7280;">Check-in:</td> <td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Guests:</td>
<td style="padding: 8px 0; color: #1F2937;">{check_in}</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>
<tr> <tr style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-top: 2px solid #D4AF37; border-bottom: 2px solid #D4AF37;">
<td style="padding: 8px 0; color: #6B7280;">Check-out:</td> <td style="padding: 15px 0; color: #1a1a1a; font-size: 16px; font-weight: 600;">Total Price:</td>
<td style="padding: 8px 0; color: #1F2937;">{check_out}</td> <td style="padding: 15px 0; font-weight: 700; color: #D4AF37; font-size: 22px; font-family: 'Playfair Display', serif;">{total_price:.2f}</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> </tr>
</table> </table>
</div> </div>
{deposit_info} {deposit_info}
<p style="text-align: center; margin-top: 30px;"> <div style="text-align: center; margin-top: 40px;">
<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;"> <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 View Booking Details
</a> </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( def payment_confirmation_email_template(
@@ -176,45 +340,54 @@ def payment_confirmation_email_template(
transaction_info = "" transaction_info = ""
if transaction_id: if transaction_id:
transaction_info = f""" transaction_info = f"""
<tr> <tr style="background-color: rgba(16, 185, 129, 0.05);">
<td style="padding: 8px 0; color: #6B7280;">Transaction ID:</td> <td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Transaction ID:</td>
<td style="padding: 8px 0; color: #1F2937; font-family: monospace;">{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> </tr>
""" """
content = f""" content = f"""
<h2 style="color: #10B981; margin-top: 0;">Payment Received</h2> <div style="text-align: center; margin-bottom: 30px;">
<p>Dear {guest_name},</p> <div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<p>We have successfully received your payment for booking <strong>{booking_number}</strong>.</p> <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;"> <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; color: #065F46;">Payment Details</h3> <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;"> <table style="width: 100%; border-collapse: collapse;">
<tr> <tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td> <td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{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>
<tr> <tr style="background-color: rgba(16, 185, 129, 0.1);">
<td style="padding: 8px 0; color: #6B7280;">Amount:</td> <td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Payment Method:</td>
<td style="padding: 8px 0; font-weight: bold; color: #065F46; font-size: 18px;">{amount:.2f}</td> <td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_method}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Payment Method:</td>
<td style="padding: 8px 0; color: #1F2937;">{payment_method}</td>
</tr> </tr>
{transaction_info} {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> </table>
</div> </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;"> <div style="text-align: center; margin-top: 40px;">
<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;"> <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 View Booking
</a> </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( def booking_status_changed_email_template(
@@ -225,37 +398,47 @@ def booking_status_changed_email_template(
) -> str: ) -> str:
"""Booking status change email template""" """Booking status change email template"""
status_colors = { status_colors = {
"confirmed": ("#10B981", "Confirmed"), "confirmed": ("#10B981", "Confirmed", "", "#ecfdf5", "#d1fae5"),
"cancelled": ("#EF4444", "Cancelled"), "cancelled": ("#EF4444", "Cancelled", "", "#fef2f2", "#fee2e2"),
"checked_in": ("#3B82F6", "Checked In"), "checked_in": ("#3B82F6", "Checked In", "🔑", "#eff6ff", "#dbeafe"),
"checked_out": ("#8B5CF6", "Checked Out"), "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""" content = f"""
<h2 style="color: {color}; margin-top: 0;">Booking Status Updated</h2> <div style="text-align: center; margin-bottom: 30px;">
<p>Dear {guest_name},</p> <div style="display: inline-block; background: linear-gradient(135deg, {color} 0%, {color}dd 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<p>Your booking status has been updated.</p> <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;"> <table style="width: 100%; border-collapse: collapse;">
<tr> <tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td> <td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{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>
<tr> <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: 8px 0; color: #6B7280;">New Status:</td> <td style="padding: 15px 0; color: {color}; font-size: 16px; font-weight: 600;">New Status:</td>
<td style="padding: 8px 0; font-weight: bold; color: {color}; font-size: 18px;">{status_text}</td> <td style="padding: 15px 0; font-weight: 700; color: {color}; font-size: 22px; font-family: 'Playfair Display', serif;">{status_text}</td>
</tr> </tr>
</table> </table>
</div> </div>
<p style="text-align: center; margin-top: 30px;"> <div style="text-align: center; margin-top: 40px;">
<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;"> <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 View Booking
</a> </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,33 +4,89 @@ from email.mime.multipart import MIMEMultipart
import os import os
import logging import logging
from ..config.settings import settings from ..config.settings import settings
from ..config.database import SessionLocal
from ..models.system_settings import SystemSettings
logger = logging.getLogger(__name__) 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): async def send_email(to: str, subject: str, html: str = None, text: str = None):
""" """
Send email using SMTP 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: try:
# Get SMTP settings from settings.py, fallback to env vars # Try to get SMTP settings from database first
mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST") db_smtp_settings = _get_smtp_settings_from_db()
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"
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 if db_smtp_settings:
from_address = settings.SMTP_FROM_EMAIL or os.getenv("MAIL_FROM") # Use settings from database
if not from_address: mail_host = db_smtp_settings.get("smtp_host")
# Generate from client_url if not set mail_user = db_smtp_settings.get("smtp_user")
domain = client_url.replace('https://', '').replace('http://', '').split('/')[0] mail_pass = db_smtp_settings.get("smtp_password")
from_address = f"no-reply@{domain}" 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
from_address = settings.SMTP_FROM_EMAIL or os.getenv("MAIL_FROM")
if not from_address:
# Generate from client_url if not set
domain = client_url.replace('https://', '').replace('http://', '').split('/')[0]
from_address = f"no-reply@{domain}"
# Use from name if available
from_name = settings.SMTP_FROM_NAME or "Hotel Booking"
logger.info("Using SMTP settings from config/environment variables")
# Use from name if available
from_name = settings.SMTP_FROM_NAME or "Hotel Booking"
from_header = f"{from_name} <{from_address}>" from_header = f"{from_name} <{from_address}>"
if not (mail_host and mail_user and mail_pass): 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")) message.attach(MIMEText("", "plain"))
# Determine TLS/SSL settings # 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 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) # 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) # SSL/TLS connection (port 465)
use_tls = True use_tls = True
start_tls = False start_tls = False

View File

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

View File

@@ -12,13 +12,26 @@ import {
Youtube, Youtube,
Award, Award,
Shield, Shield,
Star Star,
Trophy,
Medal,
BadgeCheck,
CheckCircle,
Heart,
Crown,
Gem,
Zap,
Target,
TrendingUp,
LucideIcon
} from 'lucide-react'; } from 'lucide-react';
import CookiePreferencesLink from '../common/CookiePreferencesLink'; import CookiePreferencesLink from '../common/CookiePreferencesLink';
import { pageContentService } from '../../services/api'; import { pageContentService } from '../../services/api';
import type { PageContent } from '../../services/api/pageContentService'; import type { PageContent } from '../../services/api/pageContentService';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
const Footer: React.FC = () => { const Footer: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(null); const [pageContent, setPageContent] = useState<PageContent | null>(null);
useEffect(() => { useEffect(() => {
@@ -37,6 +50,38 @@ const Footer: React.FC = () => {
fetchPageContent(); 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 // Default links
const defaultQuickLinks = [ const defaultQuickLinks = [
{ label: 'Home', url: '/' }, { label: 'Home', url: '/' },
@@ -76,16 +121,26 @@ const Footer: React.FC = () => {
{/* Company Info - Enhanced */} {/* Company Info - Enhanced */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<div className="flex items-center space-x-3 mb-6"> <div className="flex items-center space-x-3 mb-6">
<div className="relative"> {logoUrl ? (
<Hotel className="w-10 h-10 text-[#d4af37]" /> <div className="relative">
<div className="absolute inset-0 bg-[#d4af37]/20 blur-xl"></div> <img
</div> 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> <div>
<span className="text-2xl font-serif font-semibold text-white tracking-wide"> <span className="text-2xl font-serif font-semibold text-white tracking-wide">
{pageContent?.title || 'Luxury Hotel'} {settings.company_name || pageContent?.title || 'Luxury Hotel'}
</span> </span>
<p className="text-xs text-[#d4af37]/80 font-light tracking-widest uppercase mt-0.5"> <p className="text-xs text-[#d4af37]/80 font-light tracking-widest uppercase mt-0.5">
Excellence Redefined {settings.company_tagline || 'Excellence Redefined'}
</p> </p>
</div> </div>
</div> </div>
@@ -94,16 +149,20 @@ const Footer: React.FC = () => {
</p> </p>
{/* Premium Certifications */} {/* Premium Certifications */}
<div className="flex items-center space-x-6 mb-8"> {badges.length > 0 && badges.some(b => b.text) && (
<div className="flex items-center space-x-2 text-[#d4af37]/90"> <div className="flex items-center space-x-6 mb-8">
<Award className="w-5 h-5" /> {badges.map((badge, index) => {
<span className="text-xs font-medium tracking-wide">5-Star Rated</span> 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> </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>
</div>
</div>
{/* Social Media - Premium Style */} {/* Social Media - Premium Style */}
<div className="flex items-center space-x-3"> <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 className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div> </div>
<span className="text-sm text-gray-400 group-hover:text-gray-300 transition-colors leading-relaxed font-light"> <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) => ( .split('\n').map((line, i) => (
<React.Fragment key={i}> <React.Fragment key={i}>
{line} {line}
{i < 1 && <br />} {i < displayAddress.split('\n').length - 1 && <br />}
</React.Fragment> </React.Fragment>
)))} )))}
</span> </span>
</li> </li>
<li className="flex items-center space-x-4 group"> {displayPhone && (
<div className="relative"> <li className="flex items-center space-x-4 group">
<Phone className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" /> <div className="relative">
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div> <Phone className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
</div> <div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
{pageContent?.contact_info?.phone && ( </div>
<a href={`tel:${pageContent.contact_info.phone}`} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide"> <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">
{pageContent.contact_info.phone} {displayPhone}
</a> </a>
)} </li>
</li> )}
<li className="flex items-center space-x-4 group"> {displayEmail && (
<div className="relative"> <li className="flex items-center space-x-4 group">
<Mail className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" /> <div className="relative">
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div> <Mail className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
</div> <div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
{pageContent?.contact_info?.email && ( </div>
<a href={`mailto:${pageContent.contact_info.email}`} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide"> <a href={`mailto:${displayEmail}`} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
{pageContent.contact_info.email} {displayEmail}
</a> </a>
)} </li>
</li> )}
</ul> </ul>
{/* Star Rating Display */} {/* Star Rating Display */}

View File

@@ -12,9 +12,9 @@ import {
Phone, Phone,
Mail, Mail,
Calendar, Calendar,
MessageSquare,
} from 'lucide-react'; } from 'lucide-react';
import { useClickOutside } from '../../hooks/useClickOutside'; import { useClickOutside } from '../../hooks/useClickOutside';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
interface HeaderProps { interface HeaderProps {
isAuthenticated?: boolean; isAuthenticated?: boolean;
@@ -32,6 +32,16 @@ const Header: React.FC<HeaderProps> = ({
userInfo = null, userInfo = null,
onLogout 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] = const [isMobileMenuOpen, setIsMobileMenuOpen] =
useState(false); useState(false);
const [isUserMenuOpen, setIsUserMenuOpen] = 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="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="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"> <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 && (
<Phone className="w-3.5 h-3.5" /> <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">
<span className="tracking-wide">+1 (234) 567-890</span> <Phone className="w-3.5 h-3.5" />
</a> <span className="tracking-wide">{displayPhone}</span>
<a href="mailto:info@luxuryhotel.com" className="flex items-center space-x-2 text-[#d4af37] hover:text-[#f5d76e] transition-colors duration-300 font-light"> </a>
<Mail className="w-3.5 h-3.5" /> )}
<span className="tracking-wide">info@luxuryhotel.com</span> {displayEmail && (
</a> <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">{displayEmail}</span>
</a>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -87,16 +101,26 @@ const Header: React.FC<HeaderProps> = ({
className="flex items-center space-x-3 className="flex items-center space-x-3
group transition-all duration-300 hover:opacity-90" group transition-all duration-300 hover:opacity-90"
> >
<div className="relative"> {logoUrl ? (
<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> <div className="relative">
<Hotel className="relative w-10 h-10 text-[#d4af37] drop-shadow-lg" /> <img
</div> 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"> <div className="flex flex-col">
<span className="text-2xl font-serif font-semibold text-white tracking-tight leading-tight"> <span className="text-2xl font-serif font-semibold text-white tracking-tight leading-tight">
Luxury Hotel {settings.company_name}
</span> </span>
<span className="text-[10px] text-[#d4af37] tracking-[0.2em] uppercase font-light"> <span className="text-[10px] text-[#d4af37] tracking-[0.2em] uppercase font-light">
Excellence Redefined {settings.company_tagline || 'Excellence Redefined'}
</span> </span>
</div> </div>
</Link> </Link>

View File

@@ -710,7 +710,7 @@ const RoomAmenities: React.FC<RoomAmenitiesProps> = ({
<div className="grid grid-cols-1 md:grid-cols-2 <div className="grid grid-cols-1 md:grid-cols-2
lg:grid-cols-3 gap-4" lg:grid-cols-3 gap-4"
> >
{safeAmenities.slice(0, 10).map((amenity, index) => ( {safeAmenities.map((amenity, index) => (
<div <div
key={index} key={index}
className="flex items-center gap-3 p-3 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) => { import('../../services/api/roomService').then((mod) => {
mod.getAmenities().then((res) => { mod.getAmenities().then((res) => {
const list = res.data?.amenities || []; const list = res.data?.amenities || [];
if (mounted) setAvailableAmenities(list.slice(0, 8)); if (mounted) setAvailableAmenities(list);
}).catch(() => {}); }).catch(() => {});
}); });
return () => { 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 { Link } from 'react-router-dom';
import { pageContentService } from '../services/api'; import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService'; import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
const AboutPage: React.FC = () => { const AboutPage: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(null); const [pageContent, setPageContent] = useState<PageContent | null>(null);
useEffect(() => { useEffect(() => {
@@ -48,6 +50,11 @@ const AboutPage: React.FC = () => {
fetchPageContent(); 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 // Default values
const defaultValues = [ const defaultValues = [
{ {
@@ -253,11 +260,11 @@ const AboutPage: React.FC = () => {
Address Address
</h3> </h3>
<p className="text-gray-600"> <p className="text-gray-600">
{(pageContent?.contact_info?.address || '123 Luxury Street\nCity, State 12345\nCountry') {displayAddress
.split('\n').map((line, i) => ( .split('\n').map((line, i) => (
<React.Fragment key={i}> <React.Fragment key={i}>
{line} {line}
{i < 2 && <br />} {i < displayAddress.split('\n').length - 1 && <br />}
</React.Fragment> </React.Fragment>
))} ))}
</p> </p>
@@ -270,8 +277,8 @@ const AboutPage: React.FC = () => {
Phone Phone
</h3> </h3>
<p className="text-gray-600"> <p className="text-gray-600">
<a href={`tel:${pageContent?.contact_info?.phone || '+1234567890'}`} className="hover:text-[#d4af37] transition-colors"> <a href={`tel:${displayPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`} className="hover:text-[#d4af37] transition-colors">
{pageContent?.contact_info?.phone || '+1 (234) 567-890'} {displayPhone}
</a> </a>
</p> </p>
</div> </div>
@@ -283,8 +290,8 @@ const AboutPage: React.FC = () => {
Email Email
</h3> </h3>
<p className="text-gray-600"> <p className="text-gray-600">
<a href={`mailto:${pageContent?.contact_info?.email || 'info@luxuryhotel.com'}`} className="hover:text-[#d4af37] transition-colors"> <a href={`mailto:${displayEmail}`} className="hover:text-[#d4af37] transition-colors">
{pageContent?.contact_info?.email || 'info@luxuryhotel.com'} {displayEmail}
</a> </a>
</p> </p>
</div> </div>

View File

@@ -3,9 +3,11 @@ import { Mail, Phone, MapPin, Send, User, MessageSquare } from 'lucide-react';
import { submitContactForm } from '../services/api/contactService'; import { submitContactForm } from '../services/api/contactService';
import { pageContentService } from '../services/api'; import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService'; import type { PageContent } from '../services/api/pageContentService';
import { useCompanySettings } from '../contexts/CompanySettingsContext';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
const ContactPage: React.FC = () => { const ContactPage: React.FC = () => {
const { settings } = useCompanySettings();
const [pageContent, setPageContent] = useState<PageContent | null>(null); const [pageContent, setPageContent] = useState<PageContent | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
@@ -103,6 +105,11 @@ const ContactPage: React.FC = () => {
fetchPageContent(); 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 handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value })); setFormData((prev) => ({ ...prev, [name]: value }));
@@ -181,9 +188,9 @@ const ContactPage: React.FC = () => {
</div> </div>
<div> <div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Email</h3> <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"> <a href={`mailto:${displayEmail}`} className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed hover:text-[#d4af37] transition-colors">
{pageContent?.contact_info?.email || "We'll respond within 24 hours"} {displayEmail}
</p> </a>
</div> </div>
</div> </div>
@@ -193,9 +200,9 @@ const ContactPage: React.FC = () => {
</div> </div>
<div> <div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Phone</h3> <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"> <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">
{pageContent?.contact_info?.phone || 'Available 24/7 for your convenience'} {displayPhone}
</p> </a>
</div> </div>
</div> </div>
@@ -205,8 +212,8 @@ const ContactPage: React.FC = () => {
</div> </div>
<div> <div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Location</h3> <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"> <p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed whitespace-pre-line">
{pageContent?.contact_info?.address || 'Visit us at our hotel reception'} {displayAddress}
</p> </p>
</div> </div>
</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"> <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"> <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, {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."}
bookings, or special requests. We're committed to exceeding your expectations.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -23,7 +23,9 @@ import {
Upload, Upload,
Loader2, Loader2,
Check, Check,
XCircle XCircle,
Award,
Shield
} from 'lucide-react'; } from 'lucide-react';
import { pageContentService, PageContent, PageType, UpdatePageContentData, bannerService, Banner } from '../../services/api'; import { pageContentService, PageContent, PageType, UpdatePageContentData, bannerService, Banner } from '../../services/api';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -138,7 +140,6 @@ const PageContentDashboard: React.FC = () => {
subtitle: contents.contact.subtitle || '', subtitle: contents.contact.subtitle || '',
description: contents.contact.description || '', description: contents.contact.description || '',
content: contents.contact.content || '', content: contents.contact.content || '',
contact_info: contents.contact.contact_info || { phone: '', email: '', address: '' },
map_url: contents.contact.map_url || '', map_url: contents.contact.map_url || '',
meta_title: contents.contact.meta_title || '', meta_title: contents.contact.meta_title || '',
meta_description: contents.contact.meta_description || '', meta_description: contents.contact.meta_description || '',
@@ -165,9 +166,9 @@ const PageContentDashboard: React.FC = () => {
setFooterData({ setFooterData({
title: contents.footer.title || '', title: contents.footer.title || '',
description: contents.footer.description || '', description: contents.footer.description || '',
contact_info: contents.footer.contact_info || { phone: '', email: '', address: '' },
social_links: contents.footer.social_links || {}, social_links: contents.footer.social_links || {},
footer_links: contents.footer.footer_links || { quick_links: [], support_links: [] }, footer_links: contents.footer.footer_links || { quick_links: [], support_links: [] },
badges: contents.footer.badges || [],
meta_title: contents.footer.meta_title || '', meta_title: contents.footer.meta_title || '',
meta_description: contents.footer.meta_description || '', meta_description: contents.footer.meta_description || '',
}); });
@@ -190,7 +191,13 @@ const PageContentDashboard: React.FC = () => {
const handleSave = async (pageType: PageType, data: UpdatePageContentData) => { const handleSave = async (pageType: PageType, data: UpdatePageContentData) => {
try { try {
setSaving(true); setSaving(true);
await pageContentService.updatePageContent(pageType, data); // 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`); toast.success(`${pageType.charAt(0).toUpperCase() + pageType.slice(1)} content saved successfully`);
await fetchAllPageContents(); await fetchAllPageContents();
} catch (error: any) { } catch (error: any) {
@@ -993,44 +1000,11 @@ const PageContentDashboard: React.FC = () => {
</div> </div>
<div className="border-t border-gray-200 pt-6"> <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="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <p className="text-sm text-blue-800">
<div> <strong>Note:</strong> Contact information (phone, email, address) is now managed centrally in <strong>Settings Company Info</strong>.
<label className="block text-sm font-semibold text-gray-700 mb-2">Phone</label> These fields will be displayed across the entire application.
<input </p>
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> </div>
</div> </div>
@@ -1065,6 +1039,23 @@ const PageContentDashboard: React.FC = () => {
</div> </div>
</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 className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label className="block text-sm font-semibold text-gray-700 mb-2">Meta Title</label> <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>
<div className="border-t border-gray-200 pt-6"> <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="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <p className="text-sm text-blue-800">
<div> <strong>Note:</strong> Contact information (phone, email, address) is now managed centrally in <strong>Settings Company Info</strong>.
<label className="block text-sm font-semibold text-gray-700 mb-2">Phone</label> These fields will be displayed across the entire application, including the footer.
<input </p>
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> </div>
</div> </div>
@@ -1325,6 +1283,123 @@ const PageContentDashboard: React.FC = () => {
</div> </div>
</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"> <div className="flex justify-end pt-4 border-t border-gray-200">
<button <button
onClick={() => handleSave('footer', footerData)} 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 { useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -15,14 +15,20 @@ import {
forgotPasswordSchema, forgotPasswordSchema,
ForgotPasswordFormData, ForgotPasswordFormData,
} from '../../utils/validationSchemas'; } from '../../utils/validationSchemas';
import { useCompanySettings } from '../../contexts/CompanySettingsContext';
const ForgotPasswordPage: React.FC = () => { const ForgotPasswordPage: React.FC = () => {
const { forgotPassword, isLoading, error, clearError } = const { forgotPassword, isLoading, error, clearError } =
useAuthStore(); useAuthStore();
const { settings } = useCompanySettings();
const [isSuccess, setIsSuccess] = useState(false); const [isSuccess, setIsSuccess] = useState(false);
const [submittedEmail, setSubmittedEmail] = useState(''); 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 // React Hook Form setup
const { const {
register, register,
@@ -306,18 +312,22 @@ const ForgotPasswordPage: React.FC = () => {
If you're having trouble resetting your password, If you're having trouble resetting your password,
please contact our support team via email{' '} please contact our support team via email{' '}
<a <a
href="mailto:support@hotel.com" href={`mailto:${supportEmail}`}
className="text-blue-600 hover:underline" className="text-blue-600 hover:underline"
> >
support@hotel.com {supportEmail}
</a>{' '}
or hotline{' '}
<a
href="tel:1900-xxxx"
className="text-blue-600 hover:underline"
>
1900-xxxx
</a> </a>
{supportPhone && (
<>
{' '}or hotline{' '}
<a
href={`tel:${supportPhone.replace(/\s+/g, '').replace(/[()]/g, '')}`}
className="text-blue-600 hover:underline"
>
{supportPhone}
</a>
</>
)}
</p> </p>
</div> </div>
</div> </div>

View File

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

View File

@@ -33,6 +33,7 @@ export interface PageContent {
quick_links?: Array<{ label: string; url: string }>; quick_links?: Array<{ label: string; url: string }>;
support_links?: Array<{ label: string; url: string }>; support_links?: Array<{ label: string; url: string }>;
}; };
badges?: Array<{ text: string; icon: string }>;
hero_title?: string; hero_title?: string;
hero_subtitle?: string; hero_subtitle?: string;
hero_image?: string; hero_image?: string;
@@ -82,6 +83,7 @@ export interface UpdatePageContentData {
quick_links?: Array<{ label: string; url: string }>; quick_links?: Array<{ label: string; url: string }>;
support_links?: Array<{ label: string; url: string }>; support_links?: Array<{ label: string; url: string }>;
}; };
badges?: Array<{ text: string; icon: string }>;
hero_title?: string; hero_title?: string;
hero_subtitle?: string; hero_subtitle?: string;
hero_image?: 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 // Handle values and features arrays
if (data.values) { if (data.values) {
updateData.values = data.values; // Send as array, backend will convert to JSON 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; 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 = { const systemSettingsService = {
/** /**
* Get platform currency (public endpoint) * Get platform currency (public endpoint)
@@ -82,7 +167,123 @@ const systemSettingsService = {
); );
return response.data; 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 default systemSettingsService;
export type {
PlatformCurrencyResponse,
UpdateCurrencyRequest,
StripeSettingsResponse,
UpdateStripeSettingsRequest,
SmtpSettingsResponse,
UpdateSmtpSettingsRequest,
TestSmtpEmailRequest,
TestSmtpEmailResponse,
CompanySettingsResponse,
UpdateCompanySettingsRequest,
UploadLogoResponse,
UploadFaviconResponse,
};