This commit is contained in:
Iliyan Angelov
2025-11-18 18:35:46 +02:00
parent a1bd576540
commit ab832f851b
26 changed files with 8878 additions and 355 deletions

View File

@@ -0,0 +1,62 @@
"""add_page_content_table
Revision ID: 163657e72e93
Revises: 6a126cc5b23c
Create Date: 2025-11-18 18:02:03.480951
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '163657e72e93'
down_revision = '6a126cc5b23c'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Only create the page_contents table, skip other schema changes
op.create_table('page_contents',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('page_type', sa.Enum('home', 'contact', 'about', 'footer', 'seo', name='pagetype'), nullable=False),
sa.Column('title', sa.String(length=500), nullable=True),
sa.Column('subtitle', sa.String(length=1000), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('meta_title', sa.String(length=500), nullable=True),
sa.Column('meta_description', sa.Text(), nullable=True),
sa.Column('meta_keywords', sa.String(length=1000), nullable=True),
sa.Column('og_title', sa.String(length=500), nullable=True),
sa.Column('og_description', sa.Text(), nullable=True),
sa.Column('og_image', sa.String(length=1000), nullable=True),
sa.Column('canonical_url', sa.String(length=1000), nullable=True),
sa.Column('contact_info', sa.Text(), nullable=True),
sa.Column('social_links', sa.Text(), nullable=True),
sa.Column('footer_links', sa.Text(), nullable=True),
sa.Column('hero_title', sa.String(length=500), nullable=True),
sa.Column('hero_subtitle', sa.String(length=1000), nullable=True),
sa.Column('hero_image', sa.String(length=1000), nullable=True),
sa.Column('story_content', sa.Text(), nullable=True),
sa.Column('values', sa.Text(), nullable=True),
sa.Column('features', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_page_contents_id'), 'page_contents', ['id'], unique=False)
op.create_index(op.f('ix_page_contents_page_type'), 'page_contents', ['page_type'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_page_contents_page_type'), table_name='page_contents')
op.drop_index(op.f('ix_page_contents_id'), table_name='page_contents')
op.drop_table('page_contents')
op.execute("DROP TYPE IF EXISTS pagetype")
# ### end Alembic commands ###

View File

@@ -0,0 +1,28 @@
"""add_map_url_to_page_content
Revision ID: cce764ef7a50
Revises: 163657e72e93
Create Date: 2025-11-18 18:11:41.071053
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cce764ef7a50'
down_revision = '163657e72e93'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Only add the map_url column to page_contents table
op.add_column('page_contents', sa.Column('map_url', sa.String(length=1000), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('page_contents', 'map_url')
# ### end Alembic commands ###

View File

@@ -42,15 +42,17 @@ if settings.is_development:
logger.info("Creating database tables (development mode)") logger.info("Creating database tables (development mode)")
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
else: else:
# Ensure new cookie-related tables exist even if full migrations haven't been run yet. # Ensure new tables exist even if full migrations haven't been run yet.
try: try:
from .models.cookie_policy import CookiePolicy from .models.cookie_policy import CookiePolicy
from .models.cookie_integration_config import CookieIntegrationConfig from .models.cookie_integration_config import CookieIntegrationConfig
logger.info("Ensuring cookie-related tables exist") from .models.page_content import PageContent
logger.info("Ensuring required tables exist")
CookiePolicy.__table__.create(bind=engine, checkfirst=True) CookiePolicy.__table__.create(bind=engine, checkfirst=True)
CookieIntegrationConfig.__table__.create(bind=engine, checkfirst=True) CookieIntegrationConfig.__table__.create(bind=engine, checkfirst=True)
PageContent.__table__.create(bind=engine, checkfirst=True)
except Exception as e: except Exception as e:
logger.error(f"Failed to ensure cookie tables exist: {e}") logger.error(f"Failed to ensure required tables exist: {e}")
from .routes import auth_routes from .routes import auth_routes
from .routes import privacy_routes from .routes import privacy_routes
@@ -125,9 +127,11 @@ app.add_exception_handler(Exception, general_exception_handler)
# Enhanced Health check with database connectivity # Enhanced Health check with database connectivity
@app.get("/health", tags=["health"]) @app.get("/health", tags=["health"])
@app.get("/api/health", tags=["health"])
async def health_check(db: Session = Depends(get_db)): async def health_check(db: Session = Depends(get_db)):
""" """
Enhanced health check endpoint with database connectivity test Enhanced health check endpoint with database connectivity test
Available at both /health and /api/health for consistency
""" """
health_status = { health_status = {
"status": "healthy", "status": "healthy",
@@ -196,7 +200,7 @@ from .routes import (
room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, room_routes, booking_routes, payment_routes, invoice_routes, banner_routes,
favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes,
review_routes, user_routes, audit_routes, admin_privacy_routes, review_routes, user_routes, audit_routes, admin_privacy_routes,
system_settings_routes, contact_routes system_settings_routes, contact_routes, page_content_routes
) )
# Legacy routes (maintain backward compatibility) # Legacy routes (maintain backward compatibility)
@@ -234,6 +238,8 @@ app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(contact_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(contact_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(page_content_routes.router, prefix="/api")
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)
logger.info("All routes registered successfully") logger.info("All routes registered successfully")

View File

@@ -19,6 +19,7 @@ from .cookie_policy import CookiePolicy
from .cookie_integration_config import CookieIntegrationConfig from .cookie_integration_config import CookieIntegrationConfig
from .system_settings import SystemSettings from .system_settings import SystemSettings
from .invoice import Invoice, InvoiceItem from .invoice import Invoice, InvoiceItem
from .page_content import PageContent, PageType
__all__ = [ __all__ = [
"Role", "Role",
@@ -48,5 +49,7 @@ __all__ = [
"SystemSettings", "SystemSettings",
"Invoice", "Invoice",
"InvoiceItem", "InvoiceItem",
"PageContent",
"PageType",
] ]

View File

@@ -0,0 +1,60 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Enum as SQLEnum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..config.database import Base
class PageType(str, enum.Enum):
HOME = "home"
CONTACT = "contact"
ABOUT = "about"
FOOTER = "footer"
SEO = "seo"
class PageContent(Base):
__tablename__ = "page_contents"
id = Column(Integer, primary_key=True, index=True)
page_type = Column(SQLEnum(PageType), nullable=False, unique=True, index=True)
# General content fields
title = Column(String(500), nullable=True)
subtitle = Column(String(1000), nullable=True)
description = Column(Text, nullable=True)
content = Column(Text, nullable=True) # Rich text content
# SEO fields
meta_title = Column(String(500), nullable=True)
meta_description = Column(Text, nullable=True)
meta_keywords = Column(String(1000), nullable=True)
og_title = Column(String(500), nullable=True)
og_description = Column(Text, nullable=True)
og_image = Column(String(1000), nullable=True)
canonical_url = Column(String(1000), nullable=True)
# Contact/Footer specific fields (stored as JSON strings)
contact_info = Column(Text, nullable=True) # JSON: phone, email, address
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
# Home page specific
hero_title = Column(String(500), nullable=True)
hero_subtitle = Column(String(1000), nullable=True)
hero_image = Column(String(1000), nullable=True)
# About page specific
story_content = Column(Text, nullable=True)
values = Column(Text, nullable=True) # JSON array of values
features = Column(Text, nullable=True) # JSON array of features
# Status
is_active = Column(Boolean, default=True, nullable=False)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@@ -0,0 +1,413 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
import json
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.page_content import PageContent, PageType
router = APIRouter(prefix="/page-content", tags=["page-content"])
@router.get("/")
async def get_all_page_contents(
db: Session = Depends(get_db)
):
"""Get all page contents"""
try:
contents = db.query(PageContent).all()
result = []
for content in contents:
content_dict = {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"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,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
"story_content": content.story_content,
"values": json.loads(content.values) if content.values else None,
"features": json.loads(content.features) if content.features else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
result.append(content_dict)
return {
"status": "success",
"data": {
"page_contents": result
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching page contents: {str(e)}"
)
@router.get("/{page_type}")
async def get_page_content(
page_type: PageType,
db: Session = Depends(get_db)
):
"""Get content for a specific page"""
try:
content = db.query(PageContent).filter(PageContent.page_type == page_type).first()
if not content:
# Return default structure if not found
return {
"status": "success",
"data": {
"page_content": None
}
}
content_dict = {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"contact_info": json.loads(content.contact_info) if content.contact_info 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,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
"story_content": content.story_content,
"values": json.loads(content.values) if content.values else None,
"features": json.loads(content.features) if content.features else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching page content: {str(e)}"
)
@router.post("/{page_type}")
async def create_or_update_page_content(
page_type: PageType,
title: Optional[str] = None,
subtitle: Optional[str] = None,
description: Optional[str] = None,
content: Optional[str] = None,
meta_title: Optional[str] = None,
meta_description: Optional[str] = None,
meta_keywords: Optional[str] = None,
og_title: Optional[str] = None,
og_description: Optional[str] = None,
og_image: Optional[str] = None,
canonical_url: Optional[str] = None,
contact_info: Optional[str] = None,
map_url: Optional[str] = None,
social_links: Optional[str] = None,
footer_links: Optional[str] = None,
hero_title: Optional[str] = None,
hero_subtitle: Optional[str] = None,
hero_image: Optional[str] = None,
story_content: Optional[str] = None,
values: Optional[str] = None,
features: Optional[str] = None,
is_active: bool = True,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create or update page content (admin only)"""
try:
authorize_roles(current_user, ["admin"])
# Validate JSON fields if provided
if contact_info:
try:
json.loads(contact_info)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in contact_info"
)
if social_links:
try:
json.loads(social_links)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in social_links"
)
if footer_links:
try:
json.loads(footer_links)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in footer_links"
)
if values:
try:
json.loads(values)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in values"
)
if features:
try:
json.loads(features)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid JSON in features"
)
# Check if content exists
existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first()
if existing_content:
# Update existing
if title is not None:
existing_content.title = title
if subtitle is not None:
existing_content.subtitle = subtitle
if description is not None:
existing_content.description = description
if content is not None:
existing_content.content = content
if meta_title is not None:
existing_content.meta_title = meta_title
if meta_description is not None:
existing_content.meta_description = meta_description
if meta_keywords is not None:
existing_content.meta_keywords = meta_keywords
if og_title is not None:
existing_content.og_title = og_title
if og_description is not None:
existing_content.og_description = og_description
if og_image is not None:
existing_content.og_image = og_image
if canonical_url is not None:
existing_content.canonical_url = canonical_url
if contact_info is not None:
existing_content.contact_info = contact_info
if map_url is not None:
existing_content.map_url = map_url
if social_links is not None:
existing_content.social_links = social_links
if footer_links is not None:
existing_content.footer_links = footer_links
if hero_title is not None:
existing_content.hero_title = hero_title
if hero_subtitle is not None:
existing_content.hero_subtitle = hero_subtitle
if hero_image is not None:
existing_content.hero_image = hero_image
if story_content is not None:
existing_content.story_content = story_content
if values is not None:
existing_content.values = values
if features is not None:
existing_content.features = features
if is_active is not None:
existing_content.is_active = is_active
existing_content.updated_at = datetime.utcnow()
db.commit()
db.refresh(existing_content)
return {
"status": "success",
"message": "Page content updated successfully",
"data": {
"page_content": {
"id": existing_content.id,
"page_type": existing_content.page_type.value,
"title": existing_content.title,
"updated_at": existing_content.updated_at.isoformat() if existing_content.updated_at else None,
}
}
}
else:
# Create new
new_content = PageContent(
page_type=page_type,
title=title,
subtitle=subtitle,
description=description,
content=content,
meta_title=meta_title,
meta_description=meta_description,
meta_keywords=meta_keywords,
og_title=og_title,
og_description=og_description,
og_image=og_image,
canonical_url=canonical_url,
contact_info=contact_info,
map_url=map_url,
social_links=social_links,
footer_links=footer_links,
hero_title=hero_title,
hero_subtitle=hero_subtitle,
hero_image=hero_image,
story_content=story_content,
values=values,
features=features,
is_active=is_active,
)
db.add(new_content)
db.commit()
db.refresh(new_content)
return {
"status": "success",
"message": "Page content created successfully",
"data": {
"page_content": {
"id": new_content.id,
"page_type": new_content.page_type.value,
"title": new_content.title,
"created_at": new_content.created_at.isoformat() if new_content.created_at else None,
}
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error saving page content: {str(e)}"
)
@router.put("/{page_type}")
async def update_page_content(
page_type: PageType,
page_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create or update page content using JSON body (admin only)"""
try:
authorize_roles(current_user, ["admin"])
existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first()
if not existing_content:
# Create new content if it doesn't exist
existing_content = PageContent(
page_type=page_type,
is_active=True,
)
db.add(existing_content)
# Update fields from request body
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 isinstance(value, str):
# Already a string, validate it's valid JSON
try:
json.loads(value)
except json.JSONDecodeError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid JSON in {key}"
)
elif isinstance(value, (dict, list)):
# Convert dict/list to JSON string for storage
value = json.dumps(value)
# Skip None values to allow partial updates
if value is not None:
setattr(existing_content, key, value)
existing_content.updated_at = datetime.utcnow()
db.commit()
db.refresh(existing_content)
content_dict = {
"id": existing_content.id,
"page_type": existing_content.page_type.value,
"title": existing_content.title,
"subtitle": existing_content.subtitle,
"description": existing_content.description,
"content": existing_content.content,
"meta_title": existing_content.meta_title,
"meta_description": existing_content.meta_description,
"meta_keywords": existing_content.meta_keywords,
"og_title": existing_content.og_title,
"og_description": existing_content.og_description,
"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,
"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,
"hero_title": existing_content.hero_title,
"hero_subtitle": existing_content.hero_subtitle,
"hero_image": existing_content.hero_image,
"story_content": existing_content.story_content,
"values": json.loads(existing_content.values) if existing_content.values else None,
"features": json.loads(existing_content.features) if existing_content.features else None,
"is_active": existing_content.is_active,
"updated_at": existing_content.updated_at.isoformat() if existing_content.updated_at else None,
}
return {
"status": "success",
"message": "Page content updated successfully",
"data": {
"page_content": content_dict
}
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating page content: {str(e)}"
)

View File

@@ -55,22 +55,12 @@ const ResetPasswordPage = lazy(() => import('./pages/auth/ResetPasswordPage'));
// Lazy load admin pages // Lazy load admin pages
const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage')); const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage'));
const RoomManagementPage = lazy(() => import('./pages/admin/RoomManagementPage'));
const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage')); const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage'));
const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage')); const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard'));
const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage')); const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage'));
const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage')); const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage'));
const ServiceManagementPage = lazy(() => import('./pages/admin/ServiceManagementPage')); const SettingsPage = lazy(() => import('./pages/admin/SettingsPage'));
const ReviewManagementPage = lazy(() => import('./pages/admin/ReviewManagementPage')); const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboardPage'));
const PromotionManagementPage = lazy(() => import('./pages/admin/PromotionManagementPage'));
const BannerManagementPage = lazy(() => import('./pages/admin/BannerManagementPage'));
const ReportsPage = lazy(() => import('./pages/admin/ReportsPage'));
const CookieSettingsPage = lazy(() => import('./pages/admin/CookieSettingsPage'));
const CurrencySettingsPage = lazy(() => import('./pages/admin/CurrencySettingsPage'));
const StripeSettingsPage = lazy(() => import('./pages/admin/StripeSettingsPage'));
const AuditLogsPage = lazy(() => import('./pages/admin/AuditLogsPage'));
const CheckInPage = lazy(() => import('./pages/admin/CheckInPage'));
const CheckOutPage = lazy(() => import('./pages/admin/CheckOutPage'));
// Demo component for pages not yet created // Demo component for pages not yet created
const DemoPage: React.FC<{ title: string }> = ({ title }) => ( const DemoPage: React.FC<{ title: string }> = ({ title }) => (
@@ -300,64 +290,24 @@ function App() {
element={<UserManagementPage />} element={<UserManagementPage />}
/> />
<Route <Route
path="rooms" path="business"
element={<RoomManagementPage />} element={<BusinessDashboardPage />}
/> />
<Route <Route
path="bookings" path="reception"
element={<BookingManagementPage />} element={<ReceptionDashboardPage />}
/> />
<Route <Route
path="payments" path="page-content"
element={<PaymentManagementPage />} element={<PageContentDashboardPage />}
/> />
<Route <Route
path="invoices" path="analytics"
element={<InvoiceManagementPage />} element={<AnalyticsDashboardPage />}
/>
<Route
path="services"
element={<ServiceManagementPage />}
/>
<Route
path="reviews"
element={<ReviewManagementPage />}
/>
<Route
path="promotions"
element={<PromotionManagementPage />}
/>
<Route
path="check-in"
element={<CheckInPage />}
/>
<Route
path="check-out"
element={<CheckOutPage />}
/>
<Route
path="banners"
element={<BannerManagementPage />}
/>
<Route
path="reports"
element={<ReportsPage />}
/>
<Route
path="audit-logs"
element={<AuditLogsPage />}
/> />
<Route <Route
path="settings" path="settings"
element={<CookieSettingsPage />} element={<SettingsPage />}
/>
<Route
path="settings/currency"
element={<CurrencySettingsPage />}
/>
<Route
path="settings/stripe"
element={<StripeSettingsPage />}
/> />
</Route> </Route>

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
Hotel, Hotel,
@@ -15,8 +15,51 @@ import {
Star Star
} from 'lucide-react'; } from 'lucide-react';
import CookiePreferencesLink from '../common/CookiePreferencesLink'; import CookiePreferencesLink from '../common/CookiePreferencesLink';
import { pageContentService } from '../../services/api';
import type { PageContent } from '../../services/api/pageContentService';
const Footer: React.FC = () => { const Footer: React.FC = () => {
const [pageContent, setPageContent] = useState<PageContent | null>(null);
useEffect(() => {
const fetchPageContent = async () => {
try {
const response = await pageContentService.getPageContent('footer');
if (response.status === 'success' && response.data?.page_content) {
setPageContent(response.data.page_content);
}
} catch (err: any) {
console.error('Error fetching footer content:', err);
// Silently fail - use default content
}
};
fetchPageContent();
}, []);
// Default links
const defaultQuickLinks = [
{ label: 'Home', url: '/' },
{ label: 'Rooms & Suites', url: '/rooms' },
{ label: 'My Bookings', url: '/bookings' },
{ label: 'About Us', url: '/about' }
];
const defaultSupportLinks = [
{ label: 'FAQ', url: '/faq' },
{ label: 'Terms of Service', url: '/terms' },
{ label: 'Privacy Policy', url: '/privacy' },
{ label: 'Contact Us', url: '/contact' }
];
const quickLinks = pageContent?.footer_links?.quick_links && pageContent.footer_links.quick_links.length > 0
? pageContent.footer_links.quick_links
: defaultQuickLinks;
const supportLinks = pageContent?.footer_links?.support_links && pageContent.footer_links.support_links.length > 0
? pageContent.footer_links.support_links
: defaultSupportLinks;
return ( return (
<footer className="relative bg-gradient-to-b from-[#1a1a1a] via-[#0f0f0f] to-black text-gray-300 overflow-hidden"> <footer className="relative bg-gradient-to-b from-[#1a1a1a] via-[#0f0f0f] to-black text-gray-300 overflow-hidden">
{/* Elegant top border with gradient */} {/* Elegant top border with gradient */}
@@ -39,7 +82,7 @@ const Footer: React.FC = () => {
</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">
Luxury Hotel {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 Excellence Redefined
@@ -47,8 +90,7 @@ const Footer: React.FC = () => {
</div> </div>
</div> </div>
<p className="text-sm text-gray-400 mb-6 leading-relaxed max-w-md"> <p className="text-sm text-gray-400 mb-6 leading-relaxed max-w-md">
Experience unparalleled luxury and world-class hospitality. {pageContent?.description || 'Experience unparalleled luxury and world-class hospitality. Your journey to exceptional comfort begins here.'}
Your journey to exceptional comfort begins here.
</p> </p>
{/* Premium Certifications */} {/* Premium Certifications */}
@@ -65,46 +107,66 @@ const Footer: React.FC = () => {
{/* Social Media - Premium Style */} {/* Social Media - Premium Style */}
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<a {pageContent?.social_links?.facebook && (
href="#" <a
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10" href={pageContent.social_links.facebook}
aria-label="Facebook" target="_blank"
> rel="noopener noreferrer"
<Facebook className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" /> className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div> aria-label="Facebook"
</a> >
<a <Facebook className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
href="#" <div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10" </a>
aria-label="Twitter" )}
> {pageContent?.social_links?.twitter && (
<Twitter className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" /> <a
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div> href={pageContent.social_links.twitter}
</a> target="_blank"
<a rel="noopener noreferrer"
href="#" className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10" aria-label="Twitter"
aria-label="Instagram" >
> <Twitter className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
<Instagram className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" /> <div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div> </a>
</a> )}
<a {pageContent?.social_links?.instagram && (
href="#" <a
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10" href={pageContent.social_links.instagram}
aria-label="LinkedIn" target="_blank"
> rel="noopener noreferrer"
<Linkedin className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" /> className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div> aria-label="Instagram"
</a> >
<a <Instagram className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
href="#" <div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10" </a>
aria-label="YouTube" )}
> {pageContent?.social_links?.linkedin && (
<Youtube className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" /> <a
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div> href={pageContent.social_links.linkedin}
</a> target="_blank"
rel="noopener noreferrer"
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
aria-label="LinkedIn"
>
<Linkedin className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
</a>
)}
{pageContent?.social_links?.youtube && (
<a
href={pageContent.social_links.youtube}
target="_blank"
rel="noopener noreferrer"
className="group relative w-11 h-11 flex items-center justify-center rounded-full bg-gray-800/50 backdrop-blur-sm border border-gray-700/50 hover:border-[#d4af37]/50 transition-all duration-300 hover:bg-[#d4af37]/10"
aria-label="YouTube"
>
<Youtube className="w-5 h-5 text-gray-400 group-hover:text-[#d4af37] transition-colors" />
<div className="absolute inset-0 rounded-full bg-[#d4af37]/0 group-hover:bg-[#d4af37]/20 blur-lg transition-all duration-300"></div>
</a>
)}
</div> </div>
</div> </div>
@@ -115,42 +177,17 @@ const Footer: React.FC = () => {
<span className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-[#d4af37]/50 to-transparent"></span> <span className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-[#d4af37]/50 to-transparent"></span>
</h3> </h3>
<ul className="space-y-3"> <ul className="space-y-3">
<li> {quickLinks.map((link) => (
<Link <li key={link.url}>
to="/" <Link
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide" to={link.url}
> className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span> >
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Home</span> <span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
</Link> <span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">{link.label}</span>
</li> </Link>
<li> </li>
<Link ))}
to="/rooms"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Rooms & Suites</span>
</Link>
</li>
<li>
<Link
to="/bookings"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">My Bookings</span>
</Link>
</li>
<li>
<Link
to="/about"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">About Us</span>
</Link>
</li>
</ul> </ul>
</div> </div>
@@ -161,42 +198,17 @@ const Footer: React.FC = () => {
<span className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-[#d4af37]/50 to-transparent"></span> <span className="absolute bottom-0 left-0 w-full h-px bg-gradient-to-r from-[#d4af37]/50 to-transparent"></span>
</h3> </h3>
<ul className="space-y-3"> <ul className="space-y-3">
<li> {supportLinks.map((link) => (
<Link <li key={link.url}>
to="/faq" <Link
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide" to={link.url}
> className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span> >
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">FAQ</span> <span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
</Link> <span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">{link.label}</span>
</li> </Link>
<li> </li>
<Link ))}
to="/terms"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Terms of Service</span>
</Link>
</li>
<li>
<Link
to="/privacy"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Privacy Policy</span>
</Link>
</li>
<li>
<Link
to="/contact"
className="group flex items-center text-sm text-gray-400 hover:text-[#d4af37] transition-all duration-300 relative font-light tracking-wide"
>
<span className="absolute left-0 w-0 h-px bg-[#d4af37] group-hover:w-6 transition-all duration-300"></span>
<span className="ml-8 group-hover:translate-x-1 transition-transform duration-300">Contact Us</span>
</Link>
</li>
</ul> </ul>
</div> </div>
@@ -213,8 +225,13 @@ 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">
123 ABC Street, District 1<br /> {((pageContent?.contact_info?.address || '123 ABC Street, District 1\nHo Chi Minh City, Vietnam')
Ho Chi Minh City, Vietnam .split('\n').map((line, i) => (
<React.Fragment key={i}>
{line}
{i < 1 && <br />}
</React.Fragment>
)))}
</span> </span>
</li> </li>
<li className="flex items-center space-x-4 group"> <li className="flex items-center space-x-4 group">
@@ -222,18 +239,22 @@ const Footer: React.FC = () => {
<Phone className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" /> <Phone className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div> <div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div> </div>
<a href="tel:+842812345678" className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide"> {pageContent?.contact_info?.phone && (
(028) 1234 5678 <a href={`tel:${pageContent.contact_info.phone}`} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
</a> {pageContent.contact_info.phone}
</a>
)}
</li> </li>
<li className="flex items-center space-x-4 group"> <li className="flex items-center space-x-4 group">
<div className="relative"> <div className="relative">
<Mail className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" /> <Mail className="w-5 h-5 text-[#d4af37]/80 group-hover:text-[#d4af37] transition-colors flex-shrink-0" />
<div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div> <div className="absolute inset-0 bg-[#d4af37]/20 blur-md opacity-0 group-hover:opacity-100 transition-opacity"></div>
</div> </div>
<a href="mailto:info@luxuryhotel.com" className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide"> {pageContent?.contact_info?.email && (
info@luxuryhotel.com <a href={`mailto:${pageContent.contact_info.email}`} className="text-sm text-gray-400 group-hover:text-[#d4af37] transition-colors font-light tracking-wide">
</a> {pageContent.contact_info.email}
</a>
)}
</li> </li>
</ul> </ul>

View File

@@ -10,14 +10,11 @@ import {
FileText, FileText,
BarChart3, BarChart3,
Tag, Tag,
Image, Globe,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Star, Star,
LogIn, LogIn,
LogOut,
ClipboardList,
DollarSign,
Menu, Menu,
X X
} from 'lucide-react'; } from 'lucide-react';
@@ -85,88 +82,42 @@ const SidebarAdmin: React.FC<SidebarAdminProps> = ({
label: 'Users' label: 'Users'
}, },
{ {
path: '/admin/rooms', path: '/admin/business',
icon: Hotel,
label: 'Rooms'
},
{
path: '/admin/bookings',
icon: Calendar,
label: 'Bookings'
},
{
path: '/admin/payments',
icon: CreditCard,
label: 'Payments'
},
{
path: '/admin/invoices',
icon: FileText, icon: FileText,
label: 'Invoices' label: 'Business'
}, },
{ {
path: '/admin/services', path: '/admin/reception',
icon: Settings,
label: 'Services'
},
{
path: '/admin/promotions',
icon: Tag,
label: 'Promotions'
},
{
path: '/admin/check-in',
icon: LogIn, icon: LogIn,
label: 'Check-in' label: 'Reception'
}, },
{ {
path: '/admin/check-out', path: '/admin/page-content',
icon: LogOut, icon: Globe,
label: 'Check-out' label: 'Page Content'
}, },
{ {
path: '/admin/reviews', path: '/admin/analytics',
icon: Star,
label: 'Reviews'
},
{
path: '/admin/banners',
icon: Image,
label: 'Banners'
},
{
path: '/admin/reports',
icon: BarChart3, icon: BarChart3,
label: 'Reports' label: 'Analytics'
},
{
path: '/admin/audit-logs',
icon: ClipboardList,
label: 'Audit Logs'
}, },
{ {
path: '/admin/settings', path: '/admin/settings',
icon: FileText, icon: Settings,
label: 'Cookie Settings' label: 'Settings'
},
{
path: '/admin/settings/currency',
icon: DollarSign,
label: 'Currency Settings'
},
{
path: '/admin/settings/stripe',
icon: CreditCard,
label: 'Stripe Settings'
}, },
]; ];
const isActive = (path: string) => { const isActive = (path: string) => {
// Exact match // Exact match
if (location.pathname === path) return true; if (location.pathname === path) return true;
// For settings paths, only match if it's an exact match or a direct child // For settings, analytics, business, reception, and page-content paths, only match exact
if (path === '/admin/settings') { if (path === '/admin/settings' || path === '/admin/analytics' || path === '/admin/business' || path === '/admin/reception' || path === '/admin/page-content') {
return location.pathname === '/admin/settings'; return location.pathname === path;
}
// For reception path, also match when inside it (for tabs)
if (path === '/admin/reception') {
return location.pathname === path || location.pathname.startsWith(`${path}/`);
} }
// For other paths, check if it starts with the path followed by / // For other paths, check if it starts with the path followed by /
return location.pathname.startsWith(`${path}/`); return location.pathname.startsWith(`${path}/`);

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Hotel, Hotel,
Award, Award,
@@ -12,8 +12,100 @@ import {
Clock Clock
} from 'lucide-react'; } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { pageContentService } from '../services/api';
import type { PageContent } from '../services/api/pageContentService';
const AboutPage: React.FC = () => { const AboutPage: React.FC = () => {
const [pageContent, setPageContent] = useState<PageContent | null>(null);
useEffect(() => {
const fetchPageContent = async () => {
try {
const response = await pageContentService.getPageContent('about');
if (response.status === 'success' && response.data?.page_content) {
setPageContent(response.data.page_content);
// Update document title and meta tags
if (response.data.page_content.meta_title) {
document.title = response.data.page_content.meta_title;
}
if (response.data.page_content.meta_description) {
let metaDescription = document.querySelector('meta[name="description"]');
if (!metaDescription) {
metaDescription = document.createElement('meta');
metaDescription.setAttribute('name', 'description');
document.head.appendChild(metaDescription);
}
metaDescription.setAttribute('content', response.data.page_content.meta_description);
}
}
} catch (err: any) {
console.error('Error fetching page content:', err);
// Silently fail - use default content
}
};
fetchPageContent();
}, []);
// Default values
const defaultValues = [
{
icon: Heart,
title: 'Passion',
description: 'We are passionate about hospitality and dedicated to creating exceptional experiences for every guest.'
},
{
icon: Award,
title: 'Excellence',
description: 'We strive for excellence in every aspect of our service, from the smallest detail to the grandest gesture.'
},
{
icon: Shield,
title: 'Integrity',
description: 'We conduct our business with honesty, transparency, and respect for our guests and community.'
},
{
icon: Users,
title: 'Service',
description: 'Our guests are at the heart of everything we do. Your comfort and satisfaction are our top priorities.'
}
];
const defaultFeatures = [
{
icon: Star,
title: 'Premium Accommodations',
description: 'Luxuriously appointed rooms and suites designed for ultimate comfort and relaxation.'
},
{
icon: Clock,
title: '24/7 Service',
description: 'Round-the-clock concierge and room service to attend to your needs at any time.'
},
{
icon: Award,
title: 'Award-Winning',
description: 'Recognized for excellence in hospitality and guest satisfaction.'
}
];
const values = pageContent?.values && pageContent.values.length > 0
? pageContent.values.map((v: any) => ({
icon: defaultValues.find(d => d.title === v.title)?.icon || Heart,
title: v.title,
description: v.description
}))
: defaultValues;
const features = pageContent?.features && pageContent.features.length > 0
? pageContent.features.map((f: any) => ({
icon: defaultFeatures.find(d => d.title === f.title)?.icon || Star,
title: f.title,
description: f.description
}))
: defaultFeatures;
return ( return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-white"> <div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
{/* Hero Section */} {/* Hero Section */}
@@ -27,10 +119,10 @@ const AboutPage: React.FC = () => {
</div> </div>
</div> </div>
<h1 className="text-5xl md:text-6xl font-serif font-bold mb-6 tracking-tight"> <h1 className="text-5xl md:text-6xl font-serif font-bold mb-6 tracking-tight">
About Luxury Hotel {pageContent?.title || 'About Luxury Hotel'}
</h1> </h1>
<p className="text-xl text-gray-300 font-light leading-relaxed"> <p className="text-xl text-gray-300 font-light leading-relaxed">
Where Excellence Meets Unforgettable Experiences {pageContent?.subtitle || pageContent?.description || 'Where Excellence Meets Unforgettable Experiences'}
</p> </p>
</div> </div>
</div> </div>
@@ -47,21 +139,27 @@ const AboutPage: React.FC = () => {
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div> <div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
</div> </div>
<div className="prose prose-lg max-w-none text-gray-700 leading-relaxed space-y-6 animate-slide-up"> <div className="prose prose-lg max-w-none text-gray-700 leading-relaxed space-y-6 animate-slide-up">
<p> {pageContent?.story_content ? (
Welcome to Luxury Hotel, where timeless elegance meets modern sophistication. <div dangerouslySetInnerHTML={{ __html: pageContent.story_content.replace(/\n/g, '<br />') }} />
Since our founding, we have been dedicated to providing exceptional hospitality ) : (
and creating unforgettable memories for our guests. <>
</p> <p>
<p> Welcome to Luxury Hotel, where timeless elegance meets modern sophistication.
Nestled in the heart of the city, our hotel combines classic architecture with Since our founding, we have been dedicated to providing exceptional hospitality
contemporary amenities, offering a perfect blend of comfort and luxury. Every and creating unforgettable memories for our guests.
detail has been carefully curated to ensure your stay exceeds expectations. </p>
</p> <p>
<p> Nestled in the heart of the city, our hotel combines classic architecture with
Our commitment to excellence extends beyond our beautiful rooms and facilities. contemporary amenities, offering a perfect blend of comfort and luxury. Every
We believe in creating meaningful connections with our guests, understanding detail has been carefully curated to ensure your stay exceeds expectations.
their needs, and delivering personalized service that makes each visit special. </p>
</p> <p>
Our commitment to excellence extends beyond our beautiful rooms and facilities.
We believe in creating meaningful connections with our guests, understanding
their needs, and delivering personalized service that makes each visit special.
</p>
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -78,28 +176,7 @@ const AboutPage: React.FC = () => {
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div> <div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{[ {values.map((value, index) => (
{
icon: Heart,
title: 'Passion',
description: 'We are passionate about hospitality and dedicated to creating exceptional experiences for every guest.'
},
{
icon: Award,
title: 'Excellence',
description: 'We strive for excellence in every aspect of our service, from the smallest detail to the grandest gesture.'
},
{
icon: Shield,
title: 'Integrity',
description: 'We conduct our business with honesty, transparency, and respect for our guests and community.'
},
{
icon: Users,
title: 'Service',
description: 'Our guests are at the heart of everything we do. Your comfort and satisfaction are our top priorities.'
}
].map((value, index) => (
<div <div
key={value.title} key={value.title}
className="bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" className="bg-white p-6 rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up"
@@ -132,23 +209,7 @@ const AboutPage: React.FC = () => {
<div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div> <div className="w-24 h-1 bg-gradient-to-r from-[#d4af37] to-[#f5d76e] mx-auto"></div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[ {features.map((feature, index) => (
{
icon: Star,
title: 'Premium Accommodations',
description: 'Luxuriously appointed rooms and suites designed for ultimate comfort and relaxation.'
},
{
icon: Clock,
title: '24/7 Service',
description: 'Round-the-clock concierge and room service to attend to your needs at any time.'
},
{
icon: Award,
title: 'Award-Winning',
description: 'Recognized for excellence in hospitality and guest satisfaction.'
}
].map((feature, index) => (
<div <div
key={feature.title} key={feature.title}
className="text-center p-6 animate-slide-up" className="text-center p-6 animate-slide-up"
@@ -192,9 +253,13 @@ const AboutPage: React.FC = () => {
Address Address
</h3> </h3>
<p className="text-gray-600"> <p className="text-gray-600">
123 Luxury Street<br /> {(pageContent?.contact_info?.address || '123 Luxury Street\nCity, State 12345\nCountry')
City, State 12345<br /> .split('\n').map((line, i) => (
Country <React.Fragment key={i}>
{line}
{i < 2 && <br />}
</React.Fragment>
))}
</p> </p>
</div> </div>
<div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.1s' }}> <div className="text-center p-6 bg-white rounded-lg shadow-md hover:shadow-xl transition-shadow duration-300 animate-slide-up" style={{ animationDelay: '0.1s' }}>
@@ -205,8 +270,8 @@ const AboutPage: React.FC = () => {
Phone Phone
</h3> </h3>
<p className="text-gray-600"> <p className="text-gray-600">
<a href="tel:+1234567890" className="hover:text-[#d4af37] transition-colors"> <a href={`tel:${pageContent?.contact_info?.phone || '+1234567890'}`} className="hover:text-[#d4af37] transition-colors">
+1 (234) 567-890 {pageContent?.contact_info?.phone || '+1 (234) 567-890'}
</a> </a>
</p> </p>
</div> </div>
@@ -218,8 +283,8 @@ const AboutPage: React.FC = () => {
Email Email
</h3> </h3>
<p className="text-gray-600"> <p className="text-gray-600">
<a href="mailto:info@luxuryhotel.com" className="hover:text-[#d4af37] transition-colors"> <a href={`mailto:${pageContent?.contact_info?.email || 'info@luxuryhotel.com'}`} className="hover:text-[#d4af37] transition-colors">
info@luxuryhotel.com {pageContent?.contact_info?.email || 'info@luxuryhotel.com'}
</a> </a>
</p> </p>
</div> </div>

View File

@@ -1,9 +1,12 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Mail, Phone, MapPin, Send, User, MessageSquare } from 'lucide-react'; 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 type { PageContent } from '../services/api/pageContentService';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
const ContactPage: React.FC = () => { const ContactPage: React.FC = () => {
const [pageContent, setPageContent] = useState<PageContent | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
email: '', email: '',
@@ -70,6 +73,36 @@ const ContactPage: React.FC = () => {
} }
}; };
useEffect(() => {
const fetchPageContent = async () => {
try {
const response = await pageContentService.getPageContent('contact');
if (response.status === 'success' && response.data?.page_content) {
setPageContent(response.data.page_content);
// Update document title and meta tags
if (response.data.page_content.meta_title) {
document.title = response.data.page_content.meta_title;
}
if (response.data.page_content.meta_description) {
let metaDescription = document.querySelector('meta[name="description"]');
if (!metaDescription) {
metaDescription = document.createElement('meta');
metaDescription.setAttribute('name', 'description');
document.head.appendChild(metaDescription);
}
metaDescription.setAttribute('content', response.data.page_content.meta_description);
}
}
} catch (err: any) {
console.error('Error fetching page content:', err);
// Silently fail - use default content
}
};
fetchPageContent();
}, []);
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 }));
@@ -107,12 +140,12 @@ const ContactPage: React.FC = () => {
</div> </div>
<h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold mb-2 sm:mb-3 tracking-tight leading-tight px-2"> <h1 className="text-2xl xs:text-3xl sm:text-4xl md:text-5xl font-serif font-semibold mb-2 sm:mb-3 tracking-tight leading-tight px-2">
<span className="bg-gradient-to-r from-white via-[#d4af37] to-white bg-clip-text text-transparent"> <span className="bg-gradient-to-r from-white via-[#d4af37] to-white bg-clip-text text-transparent">
Contact Us {pageContent?.title || 'Contact Us'}
</span> </span>
</h1> </h1>
<div className="w-12 sm:w-16 md:w-20 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto mb-2 sm:mb-3"></div> <div className="w-12 sm:w-16 md:w-20 h-0.5 bg-gradient-to-r from-transparent via-[#d4af37] to-transparent mx-auto mb-2 sm:mb-3"></div>
<p className="text-sm sm:text-base md:text-lg text-gray-300 font-light leading-relaxed max-w-xl mx-auto tracking-wide px-2 sm:px-4"> <p className="text-sm sm:text-base md:text-lg text-gray-300 font-light leading-relaxed max-w-xl mx-auto tracking-wide px-2 sm:px-4">
Experience the pinnacle of hospitality. We're here to make your stay extraordinary. {pageContent?.subtitle || pageContent?.description || "Experience the pinnacle of hospitality. We're here to make your stay extraordinary."}
</p> </p>
</div> </div>
</div> </div>
@@ -149,7 +182,7 @@ const ContactPage: React.FC = () => {
<div> <div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Email</h3> <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"> <p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
We'll respond within 24 hours {pageContent?.contact_info?.email || "We'll respond within 24 hours"}
</p> </p>
</div> </div>
</div> </div>
@@ -161,7 +194,7 @@ const ContactPage: React.FC = () => {
<div> <div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Phone</h3> <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"> <p className="text-gray-300 font-light text-xs sm:text-sm leading-relaxed">
Available 24/7 for your convenience {pageContent?.contact_info?.phone || 'Available 24/7 for your convenience'}
</p> </p>
</div> </div>
</div> </div>
@@ -173,31 +206,33 @@ const ContactPage: React.FC = () => {
<div> <div>
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-1 sm:mb-2 tracking-wide">Location</h3> <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">
Visit us at our hotel reception {pageContent?.contact_info?.address || 'Visit us at our hotel reception'}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Google Maps */} {/* Google Maps */}
<div className="mt-6 sm:mt-7 md:mt-8 pt-6 sm:pt-7 md:pt-8 border-t border-[#d4af37]/30"> {pageContent?.map_url && (
<h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-3 sm:mb-4 tracking-wide"> <div className="mt-6 sm:mt-7 md:mt-8 pt-6 sm:pt-7 md:pt-8 border-t border-[#d4af37]/30">
Find Us <h3 className="text-sm sm:text-base font-medium text-[#d4af37] mb-3 sm:mb-4 tracking-wide">
</h3> Find Us
<div className="relative rounded-lg overflow-hidden border-2 border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/10 group hover:border-[#d4af37]/50 transition-all duration-300"> </h3>
<iframe <div className="relative rounded-lg overflow-hidden border-2 border-[#d4af37]/30 shadow-lg shadow-[#d4af37]/10 group hover:border-[#d4af37]/50 transition-all duration-300">
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3022.1841582344433!2d-73.98784668436963!3d40.75889597932664!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x89c25855c6480299%3A0x55194ec5a1ae072e!2sTimes%20Square!5e0!3m2!1sen!2sus!4v1234567890123!5m2!1sen!2sus" <iframe
width="100%" src={pageContent.map_url}
height="200" width="100%"
style={{ border: 0 }} height="200"
allowFullScreen style={{ border: 0 }}
loading="lazy" allowFullScreen
referrerPolicy="no-referrer-when-downgrade" loading="lazy"
className="w-full h-40 sm:h-44 md:h-48 rounded-lg" referrerPolicy="no-referrer-when-downgrade"
title="Hotel Location" className="w-full h-40 sm:h-44 md:h-48 rounded-lg"
/> title="Hotel Location"
/>
</div>
</div> </div>
</div> )}
<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">

View File

@@ -14,19 +14,23 @@ import {
} from '../components/rooms'; } from '../components/rooms';
import { import {
bannerService, bannerService,
roomService roomService,
pageContentService
} from '../services/api'; } from '../services/api';
import type { Banner } from '../services/api/bannerService'; import type { Banner } from '../services/api/bannerService';
import type { Room } from '../services/api/roomService'; import type { Room } from '../services/api/roomService';
import type { PageContent } from '../services/api/pageContentService';
const HomePage: React.FC = () => { const HomePage: React.FC = () => {
const [banners, setBanners] = useState<Banner[]>([]); const [banners, setBanners] = useState<Banner[]>([]);
const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]); const [featuredRooms, setFeaturedRooms] = useState<Room[]>([]);
const [newestRooms, setNewestRooms] = useState<Room[]>([]); const [newestRooms, setNewestRooms] = useState<Room[]>([]);
const [pageContent, setPageContent] = useState<PageContent | null>(null);
const [isLoadingBanners, setIsLoadingBanners] = const [isLoadingBanners, setIsLoadingBanners] =
useState(true); useState(true);
const [isLoadingRooms, setIsLoadingRooms] = useState(true); const [isLoadingRooms, setIsLoadingRooms] = useState(true);
const [isLoadingNewest, setIsLoadingNewest] = useState(true); const [isLoadingNewest, setIsLoadingNewest] = useState(true);
const [isLoadingContent, setIsLoadingContent] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Combine featured and newest rooms, removing duplicates // Combine featured and newest rooms, removing duplicates
@@ -48,6 +52,40 @@ const HomePage: React.FC = () => {
return Array.from(roomMap.values()); return Array.from(roomMap.values());
}, [featuredRooms, newestRooms]); }, [featuredRooms, newestRooms]);
// Fetch page content
useEffect(() => {
const fetchPageContent = async () => {
try {
setIsLoadingContent(true);
const response = await pageContentService.getPageContent('home');
if (response.status === 'success' && response.data?.page_content) {
setPageContent(response.data.page_content);
// Update document title and meta tags
if (response.data.page_content.meta_title) {
document.title = response.data.page_content.meta_title;
}
if (response.data.page_content.meta_description) {
let metaDescription = document.querySelector('meta[name="description"]');
if (!metaDescription) {
metaDescription = document.createElement('meta');
metaDescription.setAttribute('name', 'description');
document.head.appendChild(metaDescription);
}
metaDescription.setAttribute('content', response.data.page_content.meta_description);
}
}
} catch (err: any) {
console.error('Error fetching page content:', err);
// Silently fail - use default content
} finally {
setIsLoadingContent(false);
}
};
fetchPageContent();
}, []);
// Fetch banners // Fetch banners
useEffect(() => { useEffect(() => {
const fetchBanners = async () => { const fetchBanners = async () => {
@@ -184,10 +222,10 @@ const HomePage: React.FC = () => {
{/* Section Header - Centered */} {/* Section Header - Centered */}
<div className="text-center animate-fade-in mb-6 md:mb-8"> <div className="text-center animate-fade-in mb-6 md:mb-8">
<h2 className="luxury-section-title text-center"> <h2 className="luxury-section-title text-center">
Featured & Newest Rooms {pageContent?.hero_title || 'Featured & Newest Rooms'}
</h2> </h2>
<p className="luxury-section-subtitle text-center max-w-2xl mx-auto mt-2"> <p className="luxury-section-subtitle text-center max-w-2xl mx-auto mt-2">
Discover our most popular accommodations and latest additions {pageContent?.hero_subtitle || pageContent?.description || 'Discover our most popular accommodations and latest additions'}
</p> </p>
{/* View All Rooms Button - Golden, Centered */} {/* View All Rooms Button - Golden, Centered */}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,937 @@
import React, { useEffect, useState } from 'react';
import {
Settings,
Shield,
DollarSign,
CreditCard,
Save,
Info,
Globe,
SlidersHorizontal,
Eye,
EyeOff,
Lock,
Key,
Cookie,
Coins,
Sparkles
} from 'lucide-react';
import { toast } from 'react-toastify';
import adminPrivacyService, {
CookieIntegrationSettings,
CookieIntegrationSettingsResponse,
CookiePolicySettings,
CookiePolicySettingsResponse,
} from '../../services/api/adminPrivacyService';
import systemSettingsService, {
PlatformCurrencyResponse,
StripeSettingsResponse,
UpdateStripeSettingsRequest,
} from '../../services/api/systemSettingsService';
import { useCurrency } from '../../contexts/CurrencyContext';
import { Loading } from '../../components/common';
import { getCurrencySymbol } from '../../utils/format';
type SettingsTab = 'general' | 'cookie' | 'currency' | 'payment';
const SettingsPage: React.FC = () => {
const { currency, supportedCurrencies, refreshCurrency } = useCurrency();
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
// Cookie Settings State
const [policy, setPolicy] = useState<CookiePolicySettings>({
analytics_enabled: true,
marketing_enabled: true,
preferences_enabled: true,
});
const [integrations, setIntegrations] = useState<CookieIntegrationSettings>({
ga_measurement_id: '',
fb_pixel_id: '',
});
const [policyMeta, setPolicyMeta] = useState<
Pick<CookiePolicySettingsResponse, 'updated_at' | 'updated_by'> | null
>(null);
const [integrationMeta, setIntegrationMeta] = useState<
Pick<CookieIntegrationSettingsResponse, 'updated_at' | 'updated_by'> | null
>(null);
// Currency Settings State
const [selectedCurrency, setSelectedCurrency] = useState<string>(currency);
const [currencyInfo, setCurrencyInfo] = useState<PlatformCurrencyResponse['data'] | null>(null);
// Stripe Settings State
const [stripeSettings, setStripeSettings] = useState<StripeSettingsResponse['data'] | null>(null);
const [formData, setFormData] = useState<UpdateStripeSettingsRequest>({
stripe_secret_key: '',
stripe_publishable_key: '',
stripe_webhook_secret: '',
});
const [showSecretKey, setShowSecretKey] = useState(false);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const currencyNames: Record<string, string> = {
VND: 'Vietnamese Dong',
USD: 'US Dollar',
EUR: 'Euro',
GBP: 'British Pound',
JPY: 'Japanese Yen',
CNY: 'Chinese Yuan',
KRW: 'South Korean Won',
SGD: 'Singapore Dollar',
THB: 'Thai Baht',
AUD: 'Australian Dollar',
CAD: 'Canadian Dollar',
};
const getCurrencyDisplayName = (code: string): string => {
const name = currencyNames[code] || code;
const symbol = getCurrencySymbol(code);
return `${name} (${symbol})`;
};
// Load all settings
useEffect(() => {
loadAllSettings();
}, []);
useEffect(() => {
setSelectedCurrency(currency);
}, [currency]);
const loadAllSettings = async () => {
try {
setLoading(true);
const [policyRes, integrationRes, currencyRes, stripeRes] = await Promise.all([
adminPrivacyService.getCookiePolicy(),
adminPrivacyService.getIntegrations(),
systemSettingsService.getPlatformCurrency(),
systemSettingsService.getStripeSettings(),
]);
setPolicy(policyRes.data);
setPolicyMeta({
updated_at: policyRes.updated_at,
updated_by: policyRes.updated_by,
});
setIntegrations(integrationRes.data || {});
setIntegrationMeta({
updated_at: integrationRes.updated_at,
updated_by: integrationRes.updated_by,
});
setCurrencyInfo(currencyRes.data);
setSelectedCurrency(currencyRes.data.currency);
setStripeSettings(stripeRes.data);
setFormData({
stripe_secret_key: '',
stripe_publishable_key: stripeRes.data.stripe_publishable_key || '',
stripe_webhook_secret: '',
});
} catch (error: any) {
toast.error(error.message || 'Failed to load settings');
} finally {
setLoading(false);
}
};
// Cookie Settings Handlers
const handleToggle = (key: keyof CookiePolicySettings) => {
setPolicy((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const handleSaveCookie = async () => {
try {
setSaving(true);
const [policyRes, integrationRes] = await Promise.all([
adminPrivacyService.updateCookiePolicy(policy),
adminPrivacyService.updateIntegrations(integrations),
]);
setPolicy(policyRes.data);
setPolicyMeta({
updated_at: policyRes.updated_at,
updated_by: policyRes.updated_by,
});
setIntegrations(integrationRes.data || {});
setIntegrationMeta({
updated_at: integrationRes.updated_at,
updated_by: integrationRes.updated_by,
});
toast.success('Cookie policy and integrations updated successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to update cookie settings');
} finally {
setSaving(false);
}
};
// Currency Settings Handlers
const handleSaveCurrency = async () => {
try {
setSaving(true);
await systemSettingsService.updatePlatformCurrency(selectedCurrency);
await refreshCurrency();
await loadAllSettings();
toast.success('Platform currency updated successfully');
} catch (error: any) {
toast.error(error.message || 'Failed to update platform currency');
} finally {
setSaving(false);
}
};
// Stripe Settings Handlers
const handleSaveStripe = async () => {
try {
setSaving(true);
const updateData: UpdateStripeSettingsRequest = {};
if (formData.stripe_secret_key && formData.stripe_secret_key.trim()) {
updateData.stripe_secret_key = formData.stripe_secret_key.trim();
}
if (formData.stripe_publishable_key && formData.stripe_publishable_key.trim()) {
updateData.stripe_publishable_key = formData.stripe_publishable_key.trim();
}
if (formData.stripe_webhook_secret && formData.stripe_webhook_secret.trim()) {
updateData.stripe_webhook_secret = formData.stripe_webhook_secret.trim();
}
await systemSettingsService.updateStripeSettings(updateData);
await loadAllSettings();
setFormData({
...formData,
stripe_secret_key: '',
stripe_webhook_secret: '',
});
toast.success('Stripe settings updated successfully');
} catch (error: any) {
toast.error(
error.response?.data?.message ||
error.response?.data?.detail ||
'Failed to update Stripe settings'
);
} finally {
setSaving(false);
}
};
if (loading) {
return <Loading fullScreen={false} text="Loading settings..." />;
}
const tabs = [
{ id: 'general' as SettingsTab, label: 'Overview', icon: Settings },
{ id: 'cookie' as SettingsTab, label: 'Privacy & Cookies', icon: Cookie },
{ id: 'currency' as SettingsTab, label: 'Currency', icon: Coins },
{ id: 'payment' as SettingsTab, label: 'Payment', icon: CreditCard },
];
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10 animate-fade-in">
{/* Luxury Header */}
<div className="relative">
{/* Background decorative elements */}
<div className="absolute inset-0 bg-gradient-to-r from-amber-400/5 via-transparent to-amber-600/5 rounded-3xl blur-3xl"></div>
<div className="relative bg-white/80 backdrop-blur-xl rounded-3xl shadow-2xl border border-amber-200/30 p-8 md:p-10">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-8">
<div className="flex items-start gap-5">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-amber-400 to-amber-600 rounded-2xl blur-lg opacity-50"></div>
<div className="relative p-4 rounded-2xl bg-gradient-to-br from-amber-500 via-amber-500 to-amber-600 shadow-xl border border-amber-400/50">
<Settings className="w-8 h-8 text-white" />
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-br from-yellow-300 to-amber-500 rounded-full shadow-lg animate-pulse"></div>
</div>
</div>
<div className="space-y-3 flex-1">
<div className="flex items-center gap-3">
<h1 className="text-4xl md:text-5xl font-extrabold bg-gradient-to-r from-slate-900 via-amber-700 to-slate-900 bg-clip-text text-transparent">
Settings Dashboard
</h1>
<Sparkles className="w-6 h-6 text-amber-500 animate-pulse" />
</div>
<p className="text-gray-600 text-base md:text-lg max-w-2xl leading-relaxed">
Centralized control center for all platform configurations and system settings
</p>
</div>
</div>
</div>
{/* Premium Tab Navigation */}
<div className="mt-10 pt-8 border-t border-gradient-to-r from-transparent via-amber-200/30 to-transparent">
<div className="flex flex-wrap gap-3">
{tabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
group relative flex items-center gap-3 px-6 py-3.5 rounded-xl font-semibold text-sm
transition-all duration-300 overflow-hidden
${
isActive
? 'bg-gradient-to-r from-amber-500 via-amber-500 to-amber-600 text-white shadow-xl shadow-amber-500/40 scale-105'
: 'bg-white/80 text-gray-700 border border-gray-200/60 hover:border-amber-300/60 hover:bg-gradient-to-r hover:from-amber-50/50 hover:to-amber-50/30 hover:shadow-lg hover:scale-102'
}
`}
>
{isActive && (
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"></div>
)}
<Icon className={`w-5 h-5 transition-transform duration-300 ${isActive ? 'text-white' : 'text-gray-600 group-hover:text-amber-600 group-hover:scale-110'}`} />
<span className="relative z-10">{tab.label}</span>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-yellow-300 via-amber-400 to-yellow-300"></div>
)}
</button>
);
})}
</div>
</div>
</div>
</div>
{/* General Overview Tab */}
{activeTab === 'general' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
<div
onClick={() => setActiveTab('cookie')}
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-blue-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-blue-300/60 overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-blue-400/10 to-transparent rounded-bl-full"></div>
<div className="relative space-y-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-blue-400 to-blue-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 shadow-lg border border-blue-400/50 group-hover:scale-110 transition-transform">
<Shield className="w-6 h-6 text-white" />
</div>
</div>
<div>
<h3 className="font-bold text-gray-900 text-xl mb-1">Privacy & Cookies</h3>
<div className="h-1 w-12 bg-gradient-to-r from-blue-500 to-blue-600 rounded-full"></div>
</div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed">
Manage cookie preferences, analytics integrations, and privacy controls
</p>
<div className="pt-5 border-t border-gray-100">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500 font-medium">Last updated</span>
<span className="font-semibold text-gray-800 bg-gray-50 px-3 py-1 rounded-lg">
{policyMeta?.updated_at
? new Date(policyMeta.updated_at).toLocaleDateString()
: 'Never'}
</span>
</div>
</div>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-blue-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
</div>
<div
onClick={() => setActiveTab('currency')}
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-emerald-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-emerald-300/60 overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-emerald-400/10 to-transparent rounded-bl-full"></div>
<div className="relative space-y-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-400 to-emerald-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg border border-emerald-400/50 group-hover:scale-110 transition-transform">
<DollarSign className="w-6 h-6 text-white" />
</div>
</div>
<div>
<h3 className="font-bold text-gray-900 text-xl mb-1">Currency</h3>
<div className="h-1 w-12 bg-gradient-to-r from-emerald-500 to-emerald-600 rounded-full"></div>
</div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed">
Configure platform-wide currency settings and display preferences
</p>
<div className="pt-5 border-t border-gray-100">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500 font-medium">Current currency</span>
<span className="font-bold text-emerald-600 bg-emerald-50 px-3 py-1 rounded-lg text-lg">
{currency}
</span>
</div>
</div>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-emerald-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
</div>
<div
onClick={() => setActiveTab('payment')}
className="group relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-indigo-100/50 p-8 cursor-pointer transition-all duration-300 hover:shadow-2xl hover:scale-105 hover:border-indigo-300/60 overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-indigo-400/10 to-transparent rounded-bl-full"></div>
<div className="relative space-y-5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-indigo-400 to-indigo-600 rounded-xl blur-md opacity-50 group-hover:opacity-75 transition-opacity"></div>
<div className="relative p-3.5 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 shadow-lg border border-indigo-400/50 group-hover:scale-110 transition-transform">
<CreditCard className="w-6 h-6 text-white" />
</div>
</div>
<div>
<h3 className="font-bold text-gray-900 text-xl mb-1">Payment Gateway</h3>
<div className="h-1 w-12 bg-gradient-to-r from-indigo-500 to-indigo-600 rounded-full"></div>
</div>
</div>
</div>
<p className="text-gray-600 text-sm leading-relaxed">
Manage Stripe payment processing credentials and webhook settings
</p>
<div className="pt-5 border-t border-gray-100">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500 font-medium">Status</span>
<span className={`font-bold px-3 py-1 rounded-lg ${
stripeSettings?.has_secret_key && stripeSettings?.has_publishable_key
? 'text-emerald-600 bg-emerald-50'
: 'text-gray-400 bg-gray-50'
}`}>
{stripeSettings?.has_secret_key && stripeSettings?.has_publishable_key ? '✓ Configured' : 'Not configured'}
</span>
</div>
</div>
</div>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-indigo-50/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
</div>
</div>
)}
{/* Cookie & Privacy Tab */}
{activeTab === 'cookie' && (
<div className="space-y-8">
{/* Section Header */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40">
<Shield className="w-6 h-6 text-blue-600" />
</div>
<h2 className="text-3xl font-extrabold text-gray-900">Privacy & Cookie Controls</h2>
</div>
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
Define which cookie categories are allowed in the application. Control user consent preferences and analytics integrations.
</p>
</div>
<button
type="button"
onClick={handleSaveCookie}
disabled={saving}
className="group relative px-8 py-4 bg-gradient-to-r from-amber-500 via-amber-500 to-amber-600 text-white font-semibold rounded-xl shadow-xl shadow-amber-500/30 hover:shadow-2xl hover:shadow-amber-500/40 transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
<div className="relative flex items-center gap-3">
<Save className={`w-5 h-5 ${saving ? 'animate-spin' : ''}`} />
{saving ? 'Saving...' : 'Save Changes'}
</div>
</button>
</div>
</div>
{/* Info Card */}
<div className="relative bg-gradient-to-br from-blue-50/80 via-indigo-50/60 to-blue-50/80 backdrop-blur-xl rounded-2xl shadow-xl border border-blue-200/50 p-8 overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-blue-400/20 to-transparent rounded-bl-full"></div>
<div className="relative flex gap-6">
<div className="flex-shrink-0">
<div className="p-4 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 shadow-lg border border-blue-400/50">
<Info className="w-6 h-6 text-white" />
</div>
</div>
<div className="space-y-3 flex-1">
<p className="font-bold text-gray-900 text-lg">
How these settings affect the guest experience
</p>
<p className="text-gray-700 leading-relaxed">
Disabling a category here prevents it from being offered to guests as part of the cookie consent flow. For example, if marketing cookies are disabled, the website should not load marketing pixels even if a guest previously opted in.
</p>
</div>
</div>
</div>
{/* Cookie Toggles */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ key: 'analytics_enabled' as keyof CookiePolicySettings, label: 'Analytics Cookies', desc: 'Anonymous traffic and performance measurement', color: 'emerald', icon: SlidersHorizontal },
{ key: 'marketing_enabled' as keyof CookiePolicySettings, label: 'Marketing Cookies', desc: 'Personalised offers and remarketing campaigns', color: 'pink', icon: SlidersHorizontal },
{ key: 'preferences_enabled' as keyof CookiePolicySettings, label: 'Preference Cookies', desc: 'Remember guest choices like language and currency', color: 'indigo', icon: SlidersHorizontal },
].map(({ key, label, desc, color, icon: Icon }) => {
const isEnabled = policy[key];
const colorClasses = {
emerald: {
bg: 'from-emerald-500 to-emerald-600',
shadow: 'shadow-emerald-500/30',
iconBg: 'bg-emerald-50 border-emerald-100',
iconColor: 'text-emerald-600',
},
pink: {
bg: 'from-pink-500 to-pink-600',
shadow: 'shadow-pink-500/30',
iconBg: 'bg-pink-50 border-pink-100',
iconColor: 'text-pink-600',
},
indigo: {
bg: 'from-indigo-500 to-indigo-600',
shadow: 'shadow-indigo-500/30',
iconBg: 'bg-indigo-50 border-indigo-100',
iconColor: 'text-indigo-600',
},
}[color];
return (
<div key={key} className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-7 hover:shadow-2xl transition-all duration-300">
<div className="space-y-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3">
<div className={`p-2.5 rounded-lg ${colorClasses.iconBg} border`}>
<Icon className={`w-5 h-5 ${colorClasses.iconColor}`} />
</div>
<p className="font-bold text-gray-900 text-lg">{label}</p>
</div>
<p className="text-sm text-gray-600 leading-relaxed">
{desc}
</p>
</div>
<button
type="button"
onClick={() => handleToggle(key)}
className={`relative inline-flex h-10 w-18 items-center rounded-full transition-all duration-300 shadow-lg ${
isEnabled
? `bg-gradient-to-r ${colorClasses.bg} ${colorClasses.shadow}`
: 'bg-gray-300 shadow-gray-300/20'
}`}
>
<span
className={`inline-block h-8 w-8 transform rounded-full bg-white shadow-xl transition-all duration-300 ${
isEnabled ? 'translate-x-9' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
);
})}
</div>
{/* Integration IDs */}
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="space-y-8">
<div className="flex items-start gap-4 pb-6 border-b border-gray-200/60">
<div className="p-3 rounded-xl bg-gradient-to-br from-blue-500/10 to-indigo-500/10 border border-blue-200/40">
<Globe className="w-6 h-6 text-blue-600" />
</div>
<div className="space-y-2 flex-1">
<p className="font-bold text-gray-900 text-xl">Third-Party Integrations</p>
<p className="text-sm text-gray-600 leading-relaxed">
Configure IDs for supported analytics and marketing platforms. The application will only load these when both the policy and user consent allow it.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<label className="block text-sm font-bold text-gray-900 tracking-wide">
Google Analytics 4 Measurement ID
</label>
<input
type="text"
value={integrations.ga_measurement_id || ''}
onChange={(e) =>
setIntegrations((prev) => ({
...prev,
ga_measurement_id: e.target.value || undefined,
}))
}
placeholder="G-XXXXXXXXXX"
className="w-full px-4 py-3 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
/>
<p className="text-xs text-gray-500 leading-relaxed">
Example: <code className="px-2 py-1 bg-gray-100 rounded text-gray-700 font-mono text-xs">G-ABCDE12345</code>
</p>
</div>
<div className="space-y-3">
<label className="block text-sm font-bold text-gray-900 tracking-wide">
Meta (Facebook) Pixel ID
</label>
<input
type="text"
value={integrations.fb_pixel_id || ''}
onChange={(e) =>
setIntegrations((prev) => ({
...prev,
fb_pixel_id: e.target.value || undefined,
}))
}
placeholder="123456789012345"
className="w-full px-4 py-3 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm"
/>
<p className="text-xs text-gray-500 leading-relaxed">
Numeric ID from your Meta Pixel configuration
</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Currency Tab */}
{activeTab === 'currency' && (
<div className="space-y-8">
{/* Section Header */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-gradient-to-br from-emerald-500/10 to-green-500/10 border border-emerald-200/40">
<DollarSign className="w-6 h-6 text-emerald-600" />
</div>
<h2 className="text-3xl font-extrabold text-gray-900">Platform Currency Settings</h2>
</div>
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
Set the default currency that will be displayed across all dashboards and pages throughout the platform
</p>
</div>
<button
type="button"
onClick={handleSaveCurrency}
disabled={saving || selectedCurrency === currency}
className="group relative px-8 py-4 bg-gradient-to-r from-amber-500 via-amber-500 to-amber-600 text-white font-semibold rounded-xl shadow-xl shadow-amber-500/30 hover:shadow-2xl hover:shadow-amber-500/40 transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
<div className="relative flex items-center gap-3">
<Save className={`w-5 h-5 ${saving ? 'animate-spin' : ''}`} />
{saving ? 'Saving...' : 'Save Changes'}
</div>
</button>
</div>
</div>
{/* Info Card */}
<div className="relative bg-gradient-to-br from-emerald-50/80 via-green-50/60 to-emerald-50/80 backdrop-blur-xl rounded-2xl shadow-xl border border-emerald-200/50 p-8 overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-emerald-400/20 to-transparent rounded-bl-full"></div>
<div className="relative flex gap-6">
<div className="flex-shrink-0">
<div className="p-4 rounded-xl bg-gradient-to-br from-emerald-500 to-green-600 shadow-lg border border-emerald-400/50">
<Info className="w-6 h-6 text-white" />
</div>
</div>
<div className="space-y-3 flex-1">
<p className="font-bold text-gray-900 text-lg">
How platform currency works
</p>
<p className="text-gray-700 leading-relaxed">
The platform currency you select here will be used to display all prices, amounts, and financial information across the entire application. This includes customer-facing pages, admin dashboards, reports, and booking pages. All users will see prices in the selected currency.
</p>
</div>
</div>
</div>
{/* Currency Selection */}
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="space-y-8">
<div className="flex items-start gap-4 pb-6 border-b border-gray-200/60">
<div className="p-3 rounded-xl bg-gradient-to-br from-emerald-500/10 to-green-500/10 border border-emerald-200/40">
<Globe className="w-6 h-6 text-emerald-600" />
</div>
<div className="space-y-2 flex-1">
<p className="font-bold text-gray-900 text-xl">Select Platform Currency</p>
<p className="text-sm text-gray-600 leading-relaxed">
Choose the currency that will be used throughout the platform for displaying all monetary values
</p>
</div>
</div>
<div className="space-y-4 max-w-2xl">
<div className="space-y-3">
<label className="block text-sm font-bold text-gray-900 tracking-wide">
Currency
</label>
<select
value={selectedCurrency}
onChange={(e) => setSelectedCurrency(e.target.value)}
className="w-full px-4 py-3.5 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm font-medium"
>
{supportedCurrencies.map((curr) => (
<option key={curr} value={curr}>
{curr} - {getCurrencyDisplayName(curr)}
</option>
))}
</select>
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">Current platform currency:</span>
<span className="font-bold text-emerald-600 bg-emerald-50 px-3 py-1 rounded-lg">{currency}</span>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* Payment Tab */}
{activeTab === 'payment' && (
<div className="space-y-8">
{/* Section Header */}
<div className="bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-gradient-to-br from-indigo-500/10 to-purple-500/10 border border-indigo-200/40">
<CreditCard className="w-6 h-6 text-indigo-600" />
</div>
<h2 className="text-3xl font-extrabold text-gray-900">Stripe Payment Settings</h2>
</div>
<p className="text-gray-600 text-base max-w-2xl leading-relaxed">
Configure your Stripe account credentials to enable secure card payments. All payments will be processed through your Stripe account.
</p>
</div>
<button
type="button"
onClick={handleSaveStripe}
disabled={saving}
className="group relative px-8 py-4 bg-gradient-to-r from-amber-500 via-amber-500 to-amber-600 text-white font-semibold rounded-xl shadow-xl shadow-amber-500/30 hover:shadow-2xl hover:shadow-amber-500/40 transition-all duration-300 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent translate-x-[-100%] group-hover:translate-x-[100%] transition-transform duration-1000"></div>
<div className="relative flex items-center gap-3">
<Save className={`w-5 h-5 ${saving ? 'animate-spin' : ''}`} />
{saving ? 'Saving...' : 'Save Changes'}
</div>
</button>
</div>
</div>
{/* Info Card */}
<div className="relative bg-gradient-to-br from-indigo-50/80 via-purple-50/60 to-indigo-50/80 backdrop-blur-xl rounded-2xl shadow-xl border border-indigo-200/50 p-8 overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-gradient-to-br from-indigo-400/20 to-transparent rounded-bl-full"></div>
<div className="relative flex gap-6">
<div className="flex-shrink-0">
<div className="p-4 rounded-xl bg-gradient-to-br from-indigo-500 to-purple-600 shadow-lg border border-indigo-400/50">
<Info className="w-6 h-6 text-white" />
</div>
</div>
<div className="space-y-3 flex-1">
<p className="font-bold text-gray-900 text-lg">
How Stripe payments work
</p>
<p className="text-gray-700 leading-relaxed">
Stripe handles all card payments securely. You need to provide your Stripe API keys from your Stripe Dashboard. The secret key is used to process payments on the backend, while the publishable key is used in the frontend payment forms. The webhook secret is required to verify webhook events from Stripe.
</p>
<div className="pt-3 border-t border-indigo-200/50">
<p className="text-sm text-gray-600">
<strong className="text-gray-900">Note:</strong> Leave fields empty to keep existing values. Only enter new values when you want to update them.
</p>
</div>
</div>
</div>
</div>
{/* Stripe Settings Form */}
<div className="relative bg-white/90 backdrop-blur-xl rounded-2xl shadow-xl border border-gray-200/50 p-8">
<div className="space-y-8">
<div className="flex items-start gap-4 pb-6 border-b border-gray-200/60">
<div className="p-3 rounded-xl bg-gradient-to-br from-purple-500/10 to-pink-500/10 border border-purple-200/40">
<Key className="w-6 h-6 text-purple-600" />
</div>
<div className="space-y-2 flex-1">
<p className="font-bold text-gray-900 text-xl">Stripe API Keys</p>
<p className="text-sm text-gray-600 leading-relaxed">
Get these keys from your{' '}
<a
href="https://dashboard.stripe.com/test/apikeys"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-700 underline font-medium"
>
Stripe Dashboard
</a>
</p>
</div>
</div>
<div className="space-y-8">
{/* Secret Key */}
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
<Lock className="w-4 h-4 text-gray-600" />
Stripe Secret Key
<span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showSecretKey ? 'text' : 'password'}
value={formData.stripe_secret_key}
onChange={(e) =>
setFormData({ ...formData, stripe_secret_key: e.target.value })
}
placeholder={
stripeSettings?.has_secret_key
? `Current: ${stripeSettings.stripe_secret_key_masked || '****'}`
: 'sk_test_... or sk_live_...'
}
className="w-full px-4 py-3.5 pr-12 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm font-mono"
/>
<button
type="button"
onClick={() => setShowSecretKey(!showSecretKey)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 transition-colors p-1"
>
{showSecretKey ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<p>Used to process payments on the backend. Must start with</p>
<code className="px-2 py-1 bg-gray-100 rounded text-gray-700 font-mono">sk_</code>
{stripeSettings?.has_secret_key && (
<span className="ml-2 text-emerald-600 font-medium flex items-center gap-1">
<span className="w-2 h-2 bg-emerald-500 rounded-full"></span>
Currently configured
</span>
)}
</div>
</div>
{/* Publishable Key */}
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
<Globe className="w-4 h-4 text-gray-600" />
Stripe Publishable Key
<span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.stripe_publishable_key}
onChange={(e) =>
setFormData({
...formData,
stripe_publishable_key: e.target.value,
})
}
placeholder="pk_test_... or pk_live_..."
className="w-full px-4 py-3.5 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm font-mono"
/>
<div className="flex items-center gap-2 text-xs text-gray-500">
<p>Used in frontend payment forms. Must start with</p>
<code className="px-2 py-1 bg-gray-100 rounded text-gray-700 font-mono">pk_</code>
{stripeSettings?.has_publishable_key && (
<span className="ml-2 text-emerald-600 font-medium flex items-center gap-1">
<span className="w-2 h-2 bg-emerald-500 rounded-full"></span>
Currently configured
</span>
)}
</div>
</div>
{/* Webhook Secret */}
<div className="space-y-4">
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 tracking-wide">
<Lock className="w-4 h-4 text-gray-600" />
Stripe Webhook Secret
</label>
<div className="relative">
<input
type={showWebhookSecret ? 'text' : 'password'}
value={formData.stripe_webhook_secret}
onChange={(e) =>
setFormData({
...formData,
stripe_webhook_secret: e.target.value,
})
}
placeholder={
stripeSettings?.has_webhook_secret
? `Current: ${stripeSettings.stripe_webhook_secret_masked || '****'}`
: 'whsec_...'
}
className="w-full px-4 py-3.5 pr-12 bg-white border border-gray-300 rounded-xl shadow-sm focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-all duration-200 text-sm font-mono"
/>
<button
type="button"
onClick={() => setShowWebhookSecret(!showWebhookSecret)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 transition-colors p-1"
>
{showWebhookSecret ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
<p>Used to verify webhook events from Stripe. Must start with</p>
<code className="px-2 py-1 bg-gray-100 rounded text-gray-700 font-mono">whsec_</code>
{stripeSettings?.has_webhook_secret && (
<span className="ml-2 text-emerald-600 font-medium flex items-center gap-1">
<span className="w-2 h-2 bg-emerald-500 rounded-full"></span>
Currently configured
</span>
)}
</div>
</div>
</div>
{/* Webhook URL Info */}
<div className="relative mt-8 p-6 bg-gradient-to-br from-yellow-50 to-amber-50/50 border border-yellow-200/60 rounded-xl overflow-hidden">
<div className="absolute top-0 right-0 w-32 h-32 bg-gradient-to-br from-yellow-400/20 to-transparent rounded-bl-full"></div>
<div className="relative space-y-3">
<p className="text-sm font-bold text-yellow-900">Webhook Endpoint URL</p>
<p className="text-sm text-yellow-800">
Configure this URL in your{' '}
<a
href="https://dashboard.stripe.com/webhooks"
target="_blank"
rel="noopener noreferrer"
className="underline font-medium hover:text-yellow-900"
>
Stripe Webhooks Dashboard
</a>
:
</p>
<code className="block text-xs bg-yellow-100/80 px-4 py-3 rounded-lg text-yellow-900 break-all font-mono border border-yellow-200/60">
{window.location.origin}/api/payments/stripe/webhook
</code>
<p className="text-xs text-yellow-700 pt-2">
Make sure to subscribe to <code className="px-1.5 py-0.5 bg-yellow-100 rounded font-mono">payment_intent.succeeded</code> and{' '}
<code className="px-1.5 py-0.5 bg-yellow-100 rounded font-mono">payment_intent.payment_failed</code> events.
</p>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default SettingsPage;

View File

@@ -35,6 +35,8 @@ export type * from './promotionService';
export { default as reportService } from './reportService'; export { default as reportService } from './reportService';
export { default as dashboardService } from './dashboardService'; export { default as dashboardService } from './dashboardService';
export { default as auditService } from './auditService'; export { default as auditService } from './auditService';
export { default as pageContentService } from './pageContentService';
export type { CustomerDashboardStats, CustomerDashboardResponse } from './dashboardService'; export type { CustomerDashboardStats, CustomerDashboardResponse } from './dashboardService';
export type * from './reportService'; export type * from './reportService';
export type * from './auditService'; export type * from './auditService';
export type * from './pageContentService';

View File

@@ -0,0 +1,168 @@
import apiClient from './apiClient';
export type PageType = 'home' | 'contact' | 'about' | 'footer' | 'seo';
export interface PageContent {
id?: number;
page_type: PageType;
title?: string;
subtitle?: string;
description?: string;
content?: string;
meta_title?: string;
meta_description?: string;
meta_keywords?: string;
og_title?: string;
og_description?: string;
og_image?: string;
canonical_url?: string;
contact_info?: {
phone?: string;
email?: string;
address?: string;
};
map_url?: string;
social_links?: {
facebook?: string;
twitter?: string;
instagram?: string;
linkedin?: string;
youtube?: string;
};
footer_links?: {
quick_links?: Array<{ label: string; url: string }>;
support_links?: Array<{ label: string; url: string }>;
};
hero_title?: string;
hero_subtitle?: string;
hero_image?: string;
story_content?: string;
values?: Array<{ icon?: string; title: string; description: string }>;
features?: Array<{ icon?: string; title: string; description: string }>;
is_active?: boolean;
created_at?: string;
updated_at?: string;
}
export interface PageContentResponse {
status: string;
data: {
page_content?: PageContent | null;
page_contents?: PageContent[];
};
message?: string;
}
export interface UpdatePageContentData {
title?: string;
subtitle?: string;
description?: string;
content?: string;
meta_title?: string;
meta_description?: string;
meta_keywords?: string;
og_title?: string;
og_description?: string;
og_image?: string;
canonical_url?: string;
contact_info?: {
phone?: string;
email?: string;
address?: string;
};
map_url?: string;
social_links?: {
facebook?: string;
twitter?: string;
instagram?: string;
linkedin?: string;
youtube?: string;
};
footer_links?: {
quick_links?: Array<{ label: string; url: string }>;
support_links?: Array<{ label: string; url: string }>;
};
hero_title?: string;
hero_subtitle?: string;
hero_image?: string;
story_content?: string;
values?: Array<{ icon?: string; title: string; description: string }>;
features?: Array<{ icon?: string; title: string; description: string }>;
is_active?: boolean;
}
const pageContentService = {
/**
* Get all page contents
*/
getAllPageContents: async (): Promise<PageContentResponse> => {
const response = await apiClient.get<PageContentResponse>('/page-content');
return response.data;
},
/**
* Get content for a specific page
*/
getPageContent: async (pageType: PageType): Promise<PageContentResponse> => {
const response = await apiClient.get<PageContentResponse>(`/page-content/${pageType}`);
return response.data;
},
/**
* Update page content
*/
updatePageContent: async (
pageType: PageType,
data: UpdatePageContentData
): Promise<PageContentResponse> => {
// Convert objects to JSON strings for fields that need it
const updateData: any = { ...data };
// Handle contact_info - ensure it has all required fields
if (data.contact_info) {
const contactInfo = {
phone: data.contact_info.phone || '',
email: data.contact_info.email || '',
address: data.contact_info.address || '',
};
updateData.contact_info = contactInfo; // Send as object, backend will convert to JSON
}
// Handle social_links - ensure it has all required fields
if (data.social_links) {
const socialLinks = {
facebook: data.social_links.facebook || '',
twitter: data.social_links.twitter || '',
instagram: data.social_links.instagram || '',
linkedin: data.social_links.linkedin || '',
youtube: data.social_links.youtube || '',
};
updateData.social_links = socialLinks; // Send as object, backend will convert to JSON
}
// Handle footer_links
if (data.footer_links) {
updateData.footer_links = {
quick_links: data.footer_links.quick_links || [],
support_links: data.footer_links.support_links || [],
};
}
// Handle values and features arrays
if (data.values) {
updateData.values = data.values; // Send as array, backend will convert to JSON
}
if (data.features) {
updateData.features = data.features; // Send as array, backend will convert to JSON
}
const response = await apiClient.put<PageContentResponse>(
`/page-content/${pageType}`,
updateData
);
return response.data;
},
};
export default pageContentService;