diff --git a/Backend/alembic/versions/__pycache__/add_badges_to_page_content.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_badges_to_page_content.cpython-312.pyc new file mode 100644 index 00000000..1c38df1e Binary files /dev/null and b/Backend/alembic/versions/__pycache__/add_badges_to_page_content.cpython-312.pyc differ diff --git a/Backend/alembic/versions/__pycache__/cce764ef7a50_add_map_url_to_page_content.cpython-312.pyc b/Backend/alembic/versions/__pycache__/cce764ef7a50_add_map_url_to_page_content.cpython-312.pyc index dca8999b..0f094e2d 100644 Binary files a/Backend/alembic/versions/__pycache__/cce764ef7a50_add_map_url_to_page_content.cpython-312.pyc and b/Backend/alembic/versions/__pycache__/cce764ef7a50_add_map_url_to_page_content.cpython-312.pyc differ diff --git a/Backend/alembic/versions/add_badges_to_page_content.py b/Backend/alembic/versions/add_badges_to_page_content.py new file mode 100644 index 00000000..54f7cb0c --- /dev/null +++ b/Backend/alembic/versions/add_badges_to_page_content.py @@ -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') + diff --git a/Backend/src/models/__pycache__/page_content.cpython-312.pyc b/Backend/src/models/__pycache__/page_content.cpython-312.pyc index 6ba79360..ce674008 100644 Binary files a/Backend/src/models/__pycache__/page_content.cpython-312.pyc and b/Backend/src/models/__pycache__/page_content.cpython-312.pyc differ diff --git a/Backend/src/models/page_content.py b/Backend/src/models/page_content.py index 6e382195..31b8304f 100644 --- a/Backend/src/models/page_content.py +++ b/Backend/src/models/page_content.py @@ -40,6 +40,7 @@ class PageContent(Base): map_url = Column(String(1000), nullable=True) # Google Maps embed URL social_links = Column(Text, nullable=True) # JSON: facebook, twitter, instagram, etc. footer_links = Column(Text, nullable=True) # JSON: quick links, support links + badges = Column(Text, nullable=True) # JSON: array of badges with text and icon # Home page specific hero_title = Column(String(500), nullable=True) diff --git a/Backend/src/routes/__pycache__/page_content_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/page_content_routes.cpython-312.pyc index d97a9ae9..2e11cc3c 100644 Binary files a/Backend/src/routes/__pycache__/page_content_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/page_content_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/system_settings_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/system_settings_routes.cpython-312.pyc index 197db1e9..694e174c 100644 Binary files a/Backend/src/routes/__pycache__/system_settings_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/system_settings_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/page_content_routes.py b/Backend/src/routes/page_content_routes.py index 0374f7d5..05e1291f 100644 --- a/Backend/src/routes/page_content_routes.py +++ b/Backend/src/routes/page_content_routes.py @@ -39,6 +39,7 @@ async def get_all_page_contents( "map_url": content.map_url, "social_links": json.loads(content.social_links) if content.social_links else None, "footer_links": json.loads(content.footer_links) if content.footer_links else None, + "badges": json.loads(content.badges) if content.badges else None, "hero_title": content.hero_title, "hero_subtitle": content.hero_subtitle, "hero_image": content.hero_image, @@ -97,8 +98,10 @@ async def get_page_content( "og_image": content.og_image, "canonical_url": content.canonical_url, "contact_info": json.loads(content.contact_info) if content.contact_info else None, + "map_url": content.map_url, "social_links": json.loads(content.social_links) if content.social_links else None, "footer_links": json.loads(content.footer_links) if content.footer_links else None, + "badges": json.loads(content.badges) if content.badges else None, "hero_title": content.hero_title, "hero_subtitle": content.hero_subtitle, "hero_image": content.hero_image, @@ -141,6 +144,7 @@ async def create_or_update_page_content( map_url: Optional[str] = None, social_links: Optional[str] = None, footer_links: Optional[str] = None, + badges: Optional[str] = None, hero_title: Optional[str] = None, hero_subtitle: Optional[str] = None, hero_image: Optional[str] = None, @@ -183,6 +187,15 @@ async def create_or_update_page_content( detail="Invalid JSON in footer_links" ) + if badges: + try: + json.loads(badges) + except json.JSONDecodeError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid JSON in badges" + ) + if values: try: json.loads(values) @@ -236,6 +249,8 @@ async def create_or_update_page_content( existing_content.social_links = social_links if footer_links is not None: existing_content.footer_links = footer_links + if badges is not None: + existing_content.badges = badges if hero_title is not None: existing_content.hero_title = hero_title if hero_subtitle is not None: @@ -286,6 +301,7 @@ async def create_or_update_page_content( map_url=map_url, social_links=social_links, footer_links=footer_links, + badges=badges, hero_title=hero_title, hero_subtitle=hero_subtitle, hero_image=hero_image, @@ -346,7 +362,7 @@ async def update_page_content( for key, value in page_data.items(): if hasattr(existing_content, key): # Handle JSON fields - convert dict/list to JSON string - if key in ["contact_info", "social_links", "footer_links", "values", "features"] and value is not None: + if key in ["contact_info", "social_links", "footer_links", "badges", "values", "features"] and value is not None: if isinstance(value, str): # Already a string, validate it's valid JSON try: @@ -383,8 +399,10 @@ async def update_page_content( "og_image": existing_content.og_image, "canonical_url": existing_content.canonical_url, "contact_info": json.loads(existing_content.contact_info) if existing_content.contact_info else None, + "map_url": existing_content.map_url, "social_links": json.loads(existing_content.social_links) if existing_content.social_links else None, "footer_links": json.loads(existing_content.footer_links) if existing_content.footer_links else None, + "badges": json.loads(existing_content.badges) if existing_content.badges else None, "hero_title": existing_content.hero_title, "hero_subtitle": existing_content.hero_subtitle, "hero_image": existing_content.hero_image, diff --git a/Backend/src/routes/system_settings_routes.py b/Backend/src/routes/system_settings_routes.py index e197a835..ea83a785 100644 --- a/Backend/src/routes/system_settings_routes.py +++ b/Backend/src/routes/system_settings_routes.py @@ -1,11 +1,33 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File from sqlalchemy.orm import Session from typing import Optional +from datetime import datetime +from pydantic import BaseModel, EmailStr +from pathlib import Path +import logging +import aiofiles +import uuid +import os from ..config.database import get_db from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.system_settings import SystemSettings +from ..utils.mailer import send_email +from ..services.room_service import get_base_url + + +def normalize_image_url(image_url: str, base_url: str) -> str: + """Normalize image URL to absolute URL""" + if not image_url: + return image_url + if image_url.startswith('http://') or image_url.startswith('https://'): + return image_url + if image_url.startswith('/'): + return f"{base_url}{image_url}" + return f"{base_url}/{image_url}" + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/admin/system-settings", tags=["admin-system-settings"]) @@ -300,3 +322,768 @@ async def update_stripe_settings( db.rollback() raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/smtp") +async def get_smtp_settings( + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Get SMTP email server settings (Admin only)""" + try: + # Get all SMTP settings + smtp_settings = {} + setting_keys = [ + "smtp_host", + "smtp_port", + "smtp_user", + "smtp_password", + "smtp_from_email", + "smtp_from_name", + "smtp_use_tls", + ] + + for key in setting_keys: + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + if setting: + smtp_settings[key] = setting.value + + # Mask password for security (only show last 4 characters if set) + def mask_password(password_value: str) -> str: + if not password_value or len(password_value) < 4: + return "" + return "*" * (len(password_value) - 4) + password_value[-4:] + + result = { + "smtp_host": smtp_settings.get("smtp_host", ""), + "smtp_port": smtp_settings.get("smtp_port", ""), + "smtp_user": smtp_settings.get("smtp_user", ""), + "smtp_password": "", + "smtp_password_masked": mask_password(smtp_settings.get("smtp_password", "")), + "smtp_from_email": smtp_settings.get("smtp_from_email", ""), + "smtp_from_name": smtp_settings.get("smtp_from_name", ""), + "smtp_use_tls": smtp_settings.get("smtp_use_tls", "true").lower() == "true", + "has_host": bool(smtp_settings.get("smtp_host")), + "has_user": bool(smtp_settings.get("smtp_user")), + "has_password": bool(smtp_settings.get("smtp_password")), + } + + # Get updated_at and updated_by from any setting (prefer password setting if exists) + password_setting = db.query(SystemSettings).filter( + SystemSettings.key == "smtp_password" + ).first() + + if password_setting: + result["updated_at"] = password_setting.updated_at.isoformat() if password_setting.updated_at else None + result["updated_by"] = password_setting.updated_by.full_name if password_setting.updated_by else None + else: + # Try to get from any other SMTP setting + any_setting = db.query(SystemSettings).filter( + SystemSettings.key.in_(setting_keys) + ).first() + if any_setting: + result["updated_at"] = any_setting.updated_at.isoformat() if any_setting.updated_at else None + result["updated_by"] = any_setting.updated_by.full_name if any_setting.updated_by else None + else: + result["updated_at"] = None + result["updated_by"] = None + + return { + "status": "success", + "data": result + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/smtp") +async def update_smtp_settings( + smtp_data: dict, + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Update SMTP email server settings (Admin only)""" + try: + smtp_host = smtp_data.get("smtp_host", "").strip() + smtp_port = smtp_data.get("smtp_port", "").strip() + smtp_user = smtp_data.get("smtp_user", "").strip() + smtp_password = smtp_data.get("smtp_password", "").strip() + smtp_from_email = smtp_data.get("smtp_from_email", "").strip() + smtp_from_name = smtp_data.get("smtp_from_name", "").strip() + smtp_use_tls = smtp_data.get("smtp_use_tls", True) + + # Validate required fields if provided + if smtp_host and not smtp_host: + raise HTTPException( + status_code=400, + detail="SMTP host cannot be empty" + ) + + if smtp_port: + try: + port_num = int(smtp_port) + if port_num < 1 or port_num > 65535: + raise HTTPException( + status_code=400, + detail="SMTP port must be between 1 and 65535" + ) + except ValueError: + raise HTTPException( + status_code=400, + detail="SMTP port must be a valid number" + ) + + if smtp_from_email: + # Basic email validation + if "@" not in smtp_from_email or "." not in smtp_from_email.split("@")[1]: + raise HTTPException( + status_code=400, + detail="Invalid email address format for 'From Email'" + ) + + # Helper function to update or create setting + def update_setting(key: str, value: str, description: str): + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + + if setting: + setting.value = value + setting.updated_by_id = current_user.id + else: + setting = SystemSettings( + key=key, + value=value, + description=description, + updated_by_id=current_user.id + ) + db.add(setting) + + # Update or create settings (only update if value is provided) + if smtp_host: + update_setting( + "smtp_host", + smtp_host, + "SMTP server hostname (e.g., smtp.gmail.com)" + ) + + if smtp_port: + update_setting( + "smtp_port", + smtp_port, + "SMTP server port (e.g., 587 for STARTTLS, 465 for SSL)" + ) + + if smtp_user: + update_setting( + "smtp_user", + smtp_user, + "SMTP authentication username/email" + ) + + if smtp_password: + update_setting( + "smtp_password", + smtp_password, + "SMTP authentication password (stored securely)" + ) + + if smtp_from_email: + update_setting( + "smtp_from_email", + smtp_from_email, + "Default 'From' email address for outgoing emails" + ) + + if smtp_from_name: + update_setting( + "smtp_from_name", + smtp_from_name, + "Default 'From' name for outgoing emails" + ) + + # Update TLS setting (convert boolean to string) + if smtp_use_tls is not None: + update_setting( + "smtp_use_tls", + "true" if smtp_use_tls else "false", + "Use TLS/SSL for SMTP connection (true for port 465, false for port 587 with STARTTLS)" + ) + + db.commit() + + # Return updated settings with masked password + def mask_password(password_value: str) -> str: + if not password_value or len(password_value) < 4: + return "" + return "*" * (len(password_value) - 4) + password_value[-4:] + + # Get updated settings + updated_settings = {} + for key in ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from_email", "smtp_from_name", "smtp_use_tls"]: + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + if setting: + updated_settings[key] = setting.value + + result = { + "smtp_host": updated_settings.get("smtp_host", ""), + "smtp_port": updated_settings.get("smtp_port", ""), + "smtp_user": updated_settings.get("smtp_user", ""), + "smtp_password": smtp_password if smtp_password else "", + "smtp_password_masked": mask_password(updated_settings.get("smtp_password", "")), + "smtp_from_email": updated_settings.get("smtp_from_email", ""), + "smtp_from_name": updated_settings.get("smtp_from_name", ""), + "smtp_use_tls": updated_settings.get("smtp_use_tls", "true").lower() == "true", + "has_host": bool(updated_settings.get("smtp_host")), + "has_user": bool(updated_settings.get("smtp_user")), + "has_password": bool(updated_settings.get("smtp_password")), + } + + # Get updated_by from password setting if it exists + password_setting = db.query(SystemSettings).filter( + SystemSettings.key == "smtp_password" + ).first() + if password_setting: + result["updated_at"] = password_setting.updated_at.isoformat() if password_setting.updated_at else None + result["updated_by"] = password_setting.updated_by.full_name if password_setting.updated_by else None + + return { + "status": "success", + "message": "SMTP settings updated successfully", + "data": result + } + except HTTPException: + raise + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +class TestEmailRequest(BaseModel): + email: EmailStr + + +@router.post("/smtp/test") +async def test_smtp_email( + request: TestEmailRequest, + current_user: User = Depends(authorize_roles("admin")), + db: Session = Depends(get_db) +): + """Send a test email to verify SMTP settings (Admin only)""" + try: + test_email = str(request.email) + admin_name = str(current_user.full_name or current_user.email or "Admin") + timestamp_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC") + + # Create test email HTML content + test_html = f""" + + + + + + + +
+

✅ SMTP Test Email

+
+
+
+
🎉
+

Email Configuration Test Successful!

+
+ +

This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly.

+ +
+ 📧 Test Details: + +
+ +

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.

+ +

What's next?

+ + + +
+ + + """ + + # 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)) + diff --git a/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc b/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc index f446400c..6414cf35 100644 Binary files a/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc and b/Backend/src/utils/__pycache__/email_templates.cpython-312.pyc differ diff --git a/Backend/src/utils/__pycache__/mailer.cpython-312.pyc b/Backend/src/utils/__pycache__/mailer.cpython-312.pyc index 40b320bd..99b84e65 100644 Binary files a/Backend/src/utils/__pycache__/mailer.cpython-312.pyc and b/Backend/src/utils/__pycache__/mailer.cpython-312.pyc differ diff --git a/Backend/src/utils/email_templates.py b/Backend/src/utils/email_templates.py index b70ef0a9..42a4b614 100644 --- a/Backend/src/utils/email_templates.py +++ b/Backend/src/utils/email_templates.py @@ -3,10 +3,123 @@ Email templates for various notifications """ from datetime import datetime from typing import Optional +from ..config.database import SessionLocal +from ..models.system_settings import SystemSettings -def get_base_template(content: str, title: str = "Hotel Booking") -> str: - """Base HTML email template""" +def _get_company_settings(): + """Get company settings from database""" + try: + db = SessionLocal() + try: + settings = {} + setting_keys = [ + "company_name", + "company_tagline", + "company_logo_url", + "company_phone", + "company_email", + "company_address", + ] + + for key in setting_keys: + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + if setting and setting.value: + settings[key] = setting.value + else: + settings[key] = None + + return settings + finally: + db.close() + except Exception: + return { + "company_name": None, + "company_tagline": None, + "company_logo_url": None, + "company_phone": None, + "company_email": None, + "company_address": None, + } + + +def get_base_template(content: str, title: str = "Hotel Booking", client_url: str = "http://localhost:5173") -> str: + """Luxury HTML email template with premium company branding""" + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + company_tagline = company_settings.get("company_tagline") or "Excellence Redefined" + company_logo_url = company_settings.get("company_logo_url") + company_phone = company_settings.get("company_phone") + company_email = company_settings.get("company_email") + company_address = company_settings.get("company_address") + + # Build logo HTML if logo exists + logo_html = "" + if company_logo_url: + # Convert relative URL to absolute if needed + if not company_logo_url.startswith('http'): + # Try to construct full URL + server_url = client_url.replace('://localhost:5173', '').replace('://localhost:3000', '') + if not server_url.startswith('http'): + server_url = f"http://{server_url}" if ':' not in server_url.split('//')[-1] else server_url + full_logo_url = f"{server_url}{company_logo_url}" if company_logo_url.startswith('/') else f"{server_url}/{company_logo_url}" + else: + full_logo_url = company_logo_url + + logo_html = f''' +
+ {company_name} + {f'

{company_tagline}

' if company_tagline else ''} +
+ ''' + else: + logo_html = f''' +
+

{company_name}

+ {f'

{company_tagline}

' if company_tagline else ''} +
+ ''' + + # Build footer contact info + footer_contact = "" + if company_phone or company_email or company_address: + footer_contact = ''' +
+ + ''' + if company_phone: + footer_contact += f''' + + + + ''' + if company_email: + footer_contact += f''' + + + + ''' + if company_address: + # Replace newlines with
for address + formatted_address = company_address.replace('\n', '
') + footer_contact += f''' + + + + ''' + footer_contact += ''' +
+

📞 {company_phone}

+
+

✉️ {company_email}

+
+

📍 {formatted_address}

+
+
+ ''' + return f""" @@ -14,19 +127,22 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str: {title} + - - + +
- -
-

Hotel Booking

+
+ {logo_html}
- + - - - + + + + """ content = f""" -

Payment Received

-

Dear {guest_name},

-

We have successfully received your payment for booking {booking_number}.

+
+
+
+ 💳 +
+
+
+

Payment Received

+

Dear {guest_name},

+

We have successfully received your payment for booking {booking_number}.

-
-

Payment Details

+
+

Payment Details

+ - @@ -34,9 +150,10 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str: -
+ {content}
-

This is an automated email. Please do not reply.

-

© {datetime.now().year} Hotel Booking. All rights reserved.

+
+

This is an automated email. Please do not reply.

+

© {datetime.now().year} {company_name}. All rights reserved.

+ {footer_contact}
@@ -47,51 +164,89 @@ def get_base_template(content: str, title: str = "Hotel Booking") -> str: def welcome_email_template(name: str, email: str, client_url: str) -> str: """Welcome email template for new registrations""" + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + content = f""" -

Welcome {name}!

-

Thank you for registering an account at Hotel Booking.

-

Your account has been successfully created with email: {email}

-
-

You can:

-
    -
  • Search and book hotel rooms
  • -
  • Manage your bookings
  • -
  • Update your personal information
  • +
    +
    +
    + +
    +
    +
    +

    Welcome, {name}!

    +

    We are delighted to welcome you to {company_name}.

    +

    Your account has been successfully created with email: {email}

    + +
    +

    🎁 What you can do:

    +
      +
    • Search and book our exquisite hotel rooms
    • +
    • Manage your bookings with ease
    • +
    • Update your personal information anytime
    • +
    • Enjoy exclusive member benefits and offers
    -

    - - Login Now + +

    """ - return get_base_template(content, "Welcome to Hotel Booking") + return get_base_template(content, f"Welcome to {company_name}", client_url) def password_reset_email_template(reset_url: str) -> str: """Password reset email template""" content = f""" -

    Password Reset Request

    -

    You (or someone) has requested to reset your password.

    -

    Click the link below to reset your password. This link will expire in 1 hour:

    -

    - +

    +

    Password Reset Request

    +

    A password reset request has been received for your account.

    +

    Click the button below to reset your password. This link will expire in 1 hour.

    + +
    + Reset Password -

    -

    If you did not request this, please ignore this email.

    +
    + +
    +

    ⚠️ If you did not request this password reset, please ignore this email and your password will remain unchanged.

    +
    """ - return get_base_template(content, "Password Reset") + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + return get_base_template(content, f"Password Reset - {company_name}", reset_url.split('/reset-password')[0] if '/reset-password' in reset_url else "http://localhost:5173") def password_changed_email_template(email: str) -> str: """Password changed confirmation email template""" content = f""" -

    Password Changed Successfully

    -

    The password for account {email} has been changed successfully.

    -

    If you did not make this change, please contact our support team immediately.

    +
    +
    +
    + +
    +
    +
    +

    Password Changed Successfully

    +

    The password for account {email} has been changed successfully.

    + +
    +

    🔒 If you did not make this change, please contact our support team immediately to secure your account.

    +
    """ - return get_base_template(content, "Password Changed") + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + return get_base_template(content, f"Password Changed - {company_name}", "http://localhost:5173") def booking_confirmation_email_template( @@ -111,57 +266,66 @@ def booking_confirmation_email_template( deposit_info = "" if requires_deposit and deposit_amount: deposit_info = f""" -
    -

    ⚠️ Deposit Required

    -

    Please pay a deposit of €{deposit_amount:.2f} to confirm your booking.

    -

    Your booking will be confirmed once the deposit is received.

    +
    +

    ⚠️ Deposit Required

    +

    Please pay a deposit of €{deposit_amount:.2f} to confirm your booking.

    +

    Your booking will be confirmed once the deposit is received.

    """ content = f""" -

    Booking Confirmation

    -

    Dear {guest_name},

    -

    Thank you for your booking! We have received your reservation request.

    +
    +
    +
    + 🏨 +
    +
    +
    +

    Booking Confirmation

    +

    Dear {guest_name},

    +

    Thank you for choosing us! We have received your reservation request and are delighted to welcome you.

    -
    -

    Booking Details

    +
    +

    Booking Details

    - - + + + + + + - - + + + + + + - - + + - - - - - - - - - - - + + +
    Booking Number:{booking_number}Booking Number:{booking_number}
    Room:{room_type} - Room {room_number}
    Room:{room_type} - Room {room_number}Check-in:{check_in}
    Check-out:{check_out}
    Check-in:{check_in}Guests:{num_guests} guest{'s' if num_guests > 1 else ''}
    Check-out:{check_out}
    Guests:{num_guests}
    Total Price:€{total_price:.2f}
    Total Price:€{total_price:.2f}
    {deposit_info} -

    - +

    """ - return get_base_template(content, "Booking Confirmation") + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + return get_base_template(content, f"Booking Confirmation - {company_name}", client_url) def payment_confirmation_email_template( @@ -176,45 +340,54 @@ def payment_confirmation_email_template( transaction_info = "" if transaction_id: transaction_info = f""" -
Transaction ID:{transaction_id}
Transaction ID:{transaction_id}
- - + + - - - - - - - + + + {transaction_info} + + + +
Booking Number:{booking_number}Booking Number:{booking_number}
Amount:€{amount:.2f}
Payment Method:{payment_method}
Payment Method:{payment_method}
Amount Paid:€{amount:.2f}
-

Your booking is now confirmed. We look forward to hosting you!

+

✨ Your booking is now confirmed. We look forward to hosting you!

-

- +

+ View Booking -

+
""" - return get_base_template(content, "Payment Confirmation") + company_settings = _get_company_settings() + company_name = company_settings.get("company_name") or "Hotel Booking" + return get_base_template(content, f"Payment Confirmation - {company_name}", client_url) def booking_status_changed_email_template( @@ -225,37 +398,47 @@ def booking_status_changed_email_template( ) -> str: """Booking status change email template""" status_colors = { - "confirmed": ("#10B981", "Confirmed"), - "cancelled": ("#EF4444", "Cancelled"), - "checked_in": ("#3B82F6", "Checked In"), - "checked_out": ("#8B5CF6", "Checked Out"), + "confirmed": ("#10B981", "Confirmed", "✅", "#ecfdf5", "#d1fae5"), + "cancelled": ("#EF4444", "Cancelled", "❌", "#fef2f2", "#fee2e2"), + "checked_in": ("#3B82F6", "Checked In", "🔑", "#eff6ff", "#dbeafe"), + "checked_out": ("#8B5CF6", "Checked Out", "🏃", "#f5f3ff", "#e9d5ff"), } - color, status_text = status_colors.get(status.lower(), ("#6B7280", status.title())) + color, status_text, icon, bg_start, bg_end = status_colors.get(status.lower(), ("#6B7280", status.title(), "📋", "#f3f4f6", "#e5e7eb")) content = f""" -

Booking Status Updated

-

Dear {guest_name},

-

Your booking status has been updated.

+
+
+
+ {icon} +
+
+
+

Booking Status Updated

+

Dear {guest_name},

+

Your booking status has been updated.

-
+
+

Status Information

- - + + - - - + + +
Booking Number:{booking_number}Booking Number:{booking_number}
New Status:{status_text}
New Status:{status_text}
-

- +

""" - 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) diff --git a/Backend/src/utils/mailer.py b/Backend/src/utils/mailer.py index d059a589..dfdcb627 100644 --- a/Backend/src/utils/mailer.py +++ b/Backend/src/utils/mailer.py @@ -4,33 +4,89 @@ from email.mime.multipart import MIMEMultipart import os import logging from ..config.settings import settings +from ..config.database import SessionLocal +from ..models.system_settings import SystemSettings logger = logging.getLogger(__name__) +def _get_smtp_settings_from_db(): + """ + Get SMTP settings from system_settings table. + Returns dict with settings or None if not available. + """ + try: + db = SessionLocal() + try: + smtp_settings = {} + setting_keys = [ + "smtp_host", + "smtp_port", + "smtp_user", + "smtp_password", + "smtp_from_email", + "smtp_from_name", + "smtp_use_tls", + ] + + for key in setting_keys: + setting = db.query(SystemSettings).filter( + SystemSettings.key == key + ).first() + if setting and setting.value: + smtp_settings[key] = setting.value + + # Only return if we have at least host, user, and password + if smtp_settings.get("smtp_host") and smtp_settings.get("smtp_user") and smtp_settings.get("smtp_password"): + return smtp_settings + return None + finally: + db.close() + except Exception as e: + logger.debug(f"Could not fetch SMTP settings from database: {str(e)}") + return None + + async def send_email(to: str, subject: str, html: str = None, text: str = None): """ Send email using SMTP - Uses settings from config/settings.py with fallback to environment variables + Uses system_settings first, then falls back to config/settings.py and environment variables """ try: - # Get SMTP settings from settings.py, fallback to env vars - 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" - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + # Try to get SMTP settings from database first + db_smtp_settings = _get_smtp_settings_from_db() - # 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}" + if db_smtp_settings: + # Use settings from database + mail_host = db_smtp_settings.get("smtp_host") + mail_user = db_smtp_settings.get("smtp_user") + mail_pass = db_smtp_settings.get("smtp_password") + mail_port = int(db_smtp_settings.get("smtp_port", "587")) + mail_use_tls = db_smtp_settings.get("smtp_use_tls", "true").lower() == "true" + from_address = db_smtp_settings.get("smtp_from_email") + from_name = db_smtp_settings.get("smtp_from_name", "Hotel Booking") + logger.info("Using SMTP settings from system_settings database") + else: + # Fallback to config/settings.py and env vars + mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST") + mail_user = settings.SMTP_USER or os.getenv("MAIL_USER") + mail_pass = settings.SMTP_PASSWORD or os.getenv("MAIL_PASS") + mail_port = settings.SMTP_PORT or int(os.getenv("MAIL_PORT", "587")) + mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true" + mail_use_tls = mail_secure # For backward compatibility + client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") + + # Get from address - prefer settings, then env, then generate from client_url + 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}>" if not (mail_host and mail_user and mail_pass): @@ -54,10 +110,10 @@ async def send_email(to: str, subject: str, html: str = None, text: str = None): message.attach(MIMEText("", "plain")) # Determine TLS/SSL settings - # For port 587: use STARTTLS (use_tls=False, start_tls=True) # For port 465: use SSL/TLS (use_tls=True, start_tls=False) + # For port 587: use STARTTLS (use_tls=False, start_tls=True) # For port 25: plain (usually not used for authenticated sending) - if mail_port == 465 or mail_secure: + if mail_port == 465 or mail_use_tls: # SSL/TLS connection (port 465) use_tls = True start_tls = False diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index 94e626c7..96973d43 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -10,6 +10,7 @@ import 'react-toastify/dist/ReactToastify.css'; import { GlobalLoadingProvider } from './contexts/GlobalLoadingContext'; import { CookieConsentProvider } from './contexts/CookieConsentContext'; import { CurrencyProvider } from './contexts/CurrencyContext'; +import { CompanySettingsProvider } from './contexts/CompanySettingsContext'; import OfflineIndicator from './components/common/OfflineIndicator'; import CookieConsentBanner from './components/common/CookieConsentBanner'; import AnalyticsLoader from './components/common/AnalyticsLoader'; @@ -121,6 +122,7 @@ function App() { + + diff --git a/Frontend/src/components/layout/Footer.tsx b/Frontend/src/components/layout/Footer.tsx index 166117fb..5f098593 100644 --- a/Frontend/src/components/layout/Footer.tsx +++ b/Frontend/src/components/layout/Footer.tsx @@ -12,13 +12,26 @@ import { Youtube, Award, Shield, - Star + Star, + Trophy, + Medal, + BadgeCheck, + CheckCircle, + Heart, + Crown, + Gem, + Zap, + Target, + TrendingUp, + LucideIcon } from 'lucide-react'; import CookiePreferencesLink from '../common/CookiePreferencesLink'; import { pageContentService } from '../../services/api'; import type { PageContent } from '../../services/api/pageContentService'; +import { useCompanySettings } from '../../contexts/CompanySettingsContext'; const Footer: React.FC = () => { + const { settings } = useCompanySettings(); const [pageContent, setPageContent] = useState(null); useEffect(() => { @@ -37,6 +50,38 @@ const Footer: React.FC = () => { fetchPageContent(); }, []); + // Get phone, email, and address from centralized company settings + const displayPhone = settings.company_phone || null; + const displayEmail = settings.company_email || null; + const displayAddress = settings.company_address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam'; + + // Get logo URL from centralized company settings + const logoUrl = settings.company_logo_url + ? (settings.company_logo_url.startsWith('http') + ? settings.company_logo_url + : `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`) + : null; + + // Icon map for badges + const iconMap: Record = { + Award, + Star, + Trophy, + Medal, + BadgeCheck, + CheckCircle, + Shield, + Heart, + Crown, + Gem, + Zap, + Target, + TrendingUp, + }; + + // Get badges from page content + const badges = pageContent?.badges || []; + // Default links const defaultQuickLinks = [ { label: 'Home', url: '/' }, @@ -76,16 +121,26 @@ const Footer: React.FC = () => { {/* Company Info - Enhanced */}
-
- -
-
+ {logoUrl ? ( +
+ {settings.company_name} +
+ ) : ( +
+ +
+
+ )}
- {pageContent?.title || 'Luxury Hotel'} + {settings.company_name || pageContent?.title || 'Luxury Hotel'}

- Excellence Redefined + {settings.company_tagline || 'Excellence Redefined'}

@@ -94,16 +149,20 @@ const Footer: React.FC = () => {

{/* Premium Certifications */} -
-
- - 5-Star Rated + {badges.length > 0 && badges.some(b => b.text) && ( +
+ {badges.map((badge, index) => { + if (!badge.text) return null; + const BadgeIcon = iconMap[badge.icon] || Award; + return ( +
+ + {badge.text} +
+ ); + })}
-
- - Award Winning -
-
+ )} {/* Social Media - Premium Style */}
@@ -225,37 +284,37 @@ const Footer: React.FC = () => {
- {((pageContent?.contact_info?.address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam') + {(displayAddress .split('\n').map((line, i) => ( {line} - {i < 1 &&
} + {i < displayAddress.split('\n').length - 1 &&
}
)))}
-
  • -
    - -
    -
    - {pageContent?.contact_info?.phone && ( - - {pageContent.contact_info.phone} + {displayPhone && ( +
  • +
    + +
    +
    +
    + {displayPhone} - )} -
  • -
  • -
    - -
    -
    - {pageContent?.contact_info?.email && ( - - {pageContent.contact_info.email} +
  • + )} + {displayEmail && ( +
  • +
    + +
    +
    +
    + {displayEmail} - )} -
  • + + )} {/* Star Rating Display */} diff --git a/Frontend/src/components/layout/Header.tsx b/Frontend/src/components/layout/Header.tsx index feac7ee0..ea9dad06 100644 --- a/Frontend/src/components/layout/Header.tsx +++ b/Frontend/src/components/layout/Header.tsx @@ -12,9 +12,9 @@ import { Phone, Mail, Calendar, - MessageSquare, } from 'lucide-react'; import { useClickOutside } from '../../hooks/useClickOutside'; +import { useCompanySettings } from '../../contexts/CompanySettingsContext'; interface HeaderProps { isAuthenticated?: boolean; @@ -32,6 +32,16 @@ const Header: React.FC = ({ userInfo = null, onLogout }) => { + const { settings } = useCompanySettings(); + + // Get phone and email from centralized company settings + const displayPhone = settings.company_phone || '+1 (234) 567-890'; + const displayEmail = settings.company_email || 'info@luxuryhotel.com'; + const logoUrl = settings.company_logo_url + ? (settings.company_logo_url.startsWith('http') + ? settings.company_logo_url + : `${import.meta.env.VITE_API_URL || 'http://localhost:8000'}${settings.company_logo_url}`) + : null; const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); @@ -66,14 +76,18 @@ const Header: React.FC = ({ @@ -87,16 +101,26 @@ const Header: React.FC = ({ className="flex items-center space-x-3 group transition-all duration-300 hover:opacity-90" > -
    -
    - -
    + {logoUrl ? ( +
    + {settings.company_name} +
    + ) : ( +
    +
    + +
    + )}
    - Luxury Hotel + {settings.company_name} - Excellence Redefined + {settings.company_tagline || 'Excellence Redefined'}
    diff --git a/Frontend/src/components/rooms/RoomAmenities.tsx b/Frontend/src/components/rooms/RoomAmenities.tsx index ae8a7b34..87936311 100644 --- a/Frontend/src/components/rooms/RoomAmenities.tsx +++ b/Frontend/src/components/rooms/RoomAmenities.tsx @@ -710,7 +710,7 @@ const RoomAmenities: React.FC = ({
    - {safeAmenities.slice(0, 10).map((amenity, index) => ( + {safeAmenities.map((amenity, index) => (
    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 ( + + {children} + + ); +}; + diff --git a/Frontend/src/pages/AboutPage.tsx b/Frontend/src/pages/AboutPage.tsx index ddf7c3d1..661ebae6 100644 --- a/Frontend/src/pages/AboutPage.tsx +++ b/Frontend/src/pages/AboutPage.tsx @@ -14,8 +14,10 @@ import { import { Link } from 'react-router-dom'; import { pageContentService } from '../services/api'; import type { PageContent } from '../services/api/pageContentService'; +import { useCompanySettings } from '../contexts/CompanySettingsContext'; const AboutPage: React.FC = () => { + const { settings } = useCompanySettings(); const [pageContent, setPageContent] = useState(null); useEffect(() => { @@ -48,6 +50,11 @@ const AboutPage: React.FC = () => { fetchPageContent(); }, []); + // Get phone, email, and address from centralized company settings + const displayPhone = settings.company_phone || '+1 (234) 567-890'; + const displayEmail = settings.company_email || 'info@luxuryhotel.com'; + const displayAddress = settings.company_address || '123 Luxury Street\nCity, State 12345\nCountry'; + // Default values const defaultValues = [ { @@ -253,11 +260,11 @@ const AboutPage: React.FC = () => { Address

    - {(pageContent?.contact_info?.address || '123 Luxury Street\nCity, State 12345\nCountry') + {displayAddress .split('\n').map((line, i) => ( {line} - {i < 2 &&
    } + {i < displayAddress.split('\n').length - 1 &&
    }
    ))}

    @@ -270,8 +277,8 @@ const AboutPage: React.FC = () => { Phone

    - - {pageContent?.contact_info?.phone || '+1 (234) 567-890'} + + {displayPhone}

    @@ -283,8 +290,8 @@ const AboutPage: React.FC = () => { Email

    - - {pageContent?.contact_info?.email || 'info@luxuryhotel.com'} + + {displayEmail}

    diff --git a/Frontend/src/pages/ContactPage.tsx b/Frontend/src/pages/ContactPage.tsx index 0ddf6a00..bb80a4cc 100644 --- a/Frontend/src/pages/ContactPage.tsx +++ b/Frontend/src/pages/ContactPage.tsx @@ -3,9 +3,11 @@ import { Mail, Phone, MapPin, Send, User, MessageSquare } from 'lucide-react'; import { submitContactForm } from '../services/api/contactService'; import { pageContentService } from '../services/api'; import type { PageContent } from '../services/api/pageContentService'; +import { useCompanySettings } from '../contexts/CompanySettingsContext'; import { toast } from 'react-toastify'; const ContactPage: React.FC = () => { + const { settings } = useCompanySettings(); const [pageContent, setPageContent] = useState(null); const [formData, setFormData] = useState({ name: '', @@ -103,6 +105,11 @@ const ContactPage: React.FC = () => { fetchPageContent(); }, []); + // Get phone, email, and address from centralized company settings + const displayPhone = settings.company_phone || 'Available 24/7 for your convenience'; + const displayEmail = settings.company_email || "We'll respond within 24 hours"; + const displayAddress = settings.company_address || 'Visit us at our hotel reception'; + const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); @@ -181,9 +188,9 @@ const ContactPage: React.FC = () => {

    Email

    -

    - {pageContent?.contact_info?.email || "We'll respond within 24 hours"} -

    + + {displayEmail} +
    @@ -193,9 +200,9 @@ const ContactPage: React.FC = () => {

    Phone

    -

    - {pageContent?.contact_info?.phone || 'Available 24/7 for your convenience'} -

    + + {displayPhone} +
    @@ -205,8 +212,8 @@ const ContactPage: React.FC = () => {

    Location

    -

    - {pageContent?.contact_info?.address || 'Visit us at our hotel reception'} +

    + {displayAddress}

    @@ -236,8 +243,7 @@ const ContactPage: React.FC = () => {

    - Our team is here to help you with any questions about your stay, - bookings, or special requests. We're committed to exceeding your expectations. + {pageContent?.content || "Our team is here to help you with any questions about your stay, bookings, or special requests. We're committed to exceeding your expectations."}

    diff --git a/Frontend/src/pages/admin/PageContentDashboard.tsx b/Frontend/src/pages/admin/PageContentDashboard.tsx index 1ca92592..d52089e8 100644 --- a/Frontend/src/pages/admin/PageContentDashboard.tsx +++ b/Frontend/src/pages/admin/PageContentDashboard.tsx @@ -23,7 +23,9 @@ import { Upload, Loader2, Check, - XCircle + XCircle, + Award, + Shield } from 'lucide-react'; import { pageContentService, PageContent, PageType, UpdatePageContentData, bannerService, Banner } from '../../services/api'; import { toast } from 'react-toastify'; @@ -138,7 +140,6 @@ const PageContentDashboard: React.FC = () => { subtitle: contents.contact.subtitle || '', description: contents.contact.description || '', content: contents.contact.content || '', - contact_info: contents.contact.contact_info || { phone: '', email: '', address: '' }, map_url: contents.contact.map_url || '', meta_title: contents.contact.meta_title || '', meta_description: contents.contact.meta_description || '', @@ -165,9 +166,9 @@ const PageContentDashboard: React.FC = () => { setFooterData({ title: contents.footer.title || '', description: contents.footer.description || '', - contact_info: contents.footer.contact_info || { phone: '', email: '', address: '' }, social_links: contents.footer.social_links || {}, footer_links: contents.footer.footer_links || { quick_links: [], support_links: [] }, + badges: contents.footer.badges || [], meta_title: contents.footer.meta_title || '', meta_description: contents.footer.meta_description || '', }); @@ -190,7 +191,13 @@ const PageContentDashboard: React.FC = () => { const handleSave = async (pageType: PageType, data: UpdatePageContentData) => { try { setSaving(true); - 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`); await fetchAllPageContents(); } catch (error: any) { @@ -993,44 +1000,11 @@ const PageContentDashboard: React.FC = () => {
    -

    Contact Information

    -
    -
    - - 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" - /> -
    -
    - - 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" - /> -
    -
    - - 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" - /> -
    +
    +

    + Note: Contact information (phone, email, address) is now managed centrally in Settings → Company Info. + These fields will be displayed across the entire application. +

    @@ -1065,6 +1039,23 @@ const PageContentDashboard: React.FC = () => {
    +
    +

    Help Message

    +
    + +