updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
26
Backend/alembic/versions/add_badges_to_page_content.py
Normal file
26
Backend/alembic/versions/add_badges_to_page_content.py
Normal 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')
|
||||||
|
|
||||||
Binary file not shown.
@@ -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)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
133
Frontend/src/contexts/CompanySettingsContext.tsx
Normal file
133
Frontend/src/contexts/CompanySettingsContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user