mirror of
https://github.com/myronblair/epic-download
synced 2026-06-30 17:51:00 -05:00
auto-commit for 8a62051e-b038-4363-a62d-7047e3d6e102
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import HTTPException, Security, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
import os
|
||||
|
||||
# JWT Configuration
|
||||
SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "your-secret-key-change-in-production-epic-travel-2025")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 hours
|
||||
|
||||
# Password hashing
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# Security
|
||||
security = HTTPBearer()
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against a hash"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create a JWT access token"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
"""Decode and verify a JWT token"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Could not validate credentials")
|
||||
|
||||
async def get_current_admin(credentials: HTTPAuthorizationCredentials = Security(security)) -> dict:
|
||||
"""Dependency to get current admin from JWT token"""
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
||||
|
||||
return {"email": email}
|
||||
@@ -0,0 +1 @@
|
||||
# Models module
|
||||
@@ -0,0 +1,85 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
class Destination(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str
|
||||
location: str
|
||||
description: str
|
||||
image: str
|
||||
category: str # City, Beach, Adventure
|
||||
rating: float
|
||||
price: float
|
||||
currency: str = "USD"
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class DestinationCreate(BaseModel):
|
||||
name: str
|
||||
location: str
|
||||
description: str
|
||||
image: str
|
||||
category: str
|
||||
rating: float
|
||||
price: float
|
||||
currency: str = "USD"
|
||||
|
||||
class DestinationUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
rating: Optional[float] = None
|
||||
price: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
|
||||
class Special(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
destination_id: str
|
||||
discount: float
|
||||
end_date: str # ISO format date
|
||||
highlights: List[str]
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class SpecialCreate(BaseModel):
|
||||
destination_id: str
|
||||
discount: float
|
||||
end_date: str
|
||||
highlights: List[str]
|
||||
|
||||
class SpecialUpdate(BaseModel):
|
||||
discount: Optional[float] = None
|
||||
end_date: Optional[str] = None
|
||||
highlights: Optional[List[str]] = None
|
||||
|
||||
class AdminUser(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
email: str
|
||||
password_hash: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class AdminLogin(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class Contact(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str
|
||||
email: str
|
||||
message: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
message: str
|
||||
|
||||
class NewsletterSubscriber(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
email: str
|
||||
subscribed_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class NewsletterSubscribe(BaseModel):
|
||||
email: str
|
||||
+121
-25
@@ -1,27 +1,123 @@
|
||||
fastapi==0.110.1
|
||||
uvicorn==0.25.0
|
||||
boto3>=1.34.129
|
||||
requests-oauthlib>=2.0.0
|
||||
cryptography>=42.0.8
|
||||
python-dotenv>=1.0.1
|
||||
pymongo==4.5.0
|
||||
pydantic>=2.6.4
|
||||
email-validator>=2.2.0
|
||||
pyjwt>=2.10.1
|
||||
aiohappyeyeballs==2.6.1
|
||||
aiohttp==3.13.3
|
||||
aiosignal==1.4.0
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.12.1
|
||||
attrs==25.4.0
|
||||
bcrypt==4.1.3
|
||||
passlib>=1.7.4
|
||||
tzdata>=2024.2
|
||||
motor==3.3.1
|
||||
pytest>=8.0.0
|
||||
black>=24.1.1
|
||||
isort>=5.13.2
|
||||
flake8>=7.0.0
|
||||
mypy>=1.8.0
|
||||
python-jose>=3.3.0
|
||||
requests>=2.31.0
|
||||
pandas>=2.2.0
|
||||
numpy>=1.26.0
|
||||
python-multipart>=0.0.9
|
||||
jq>=1.6.0
|
||||
typer>=0.9.0
|
||||
black==26.1.0
|
||||
boto3==1.42.58
|
||||
botocore==1.42.58
|
||||
certifi==2026.2.25
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.4
|
||||
click==8.3.1
|
||||
cryptography==46.0.5
|
||||
distro==1.9.0
|
||||
dnspython==2.8.0
|
||||
ecdsa==0.19.1
|
||||
email-validator==2.3.0
|
||||
emergentintegrations==0.1.0
|
||||
fastapi==0.110.1
|
||||
fastuuid==0.14.0
|
||||
filelock==3.25.0
|
||||
flake8==7.3.0
|
||||
frozenlist==1.8.0
|
||||
fsspec==2026.2.0
|
||||
google-ai-generativelanguage==0.6.15
|
||||
google-api-core==2.30.0
|
||||
google-api-python-client==2.191.0
|
||||
google-auth==2.49.0.dev0
|
||||
google-auth-httplib2==0.3.0
|
||||
google-genai==1.65.0
|
||||
google-generativeai==0.8.6
|
||||
googleapis-common-protos==1.72.0
|
||||
grpcio==1.78.0
|
||||
grpcio-status==1.71.2
|
||||
h11==0.16.0
|
||||
hf-xet==1.3.2
|
||||
httpcore==1.0.9
|
||||
httplib2==0.31.2
|
||||
httpx==0.28.1
|
||||
huggingface_hub==1.5.0
|
||||
idna==3.11
|
||||
importlib_metadata==8.7.1
|
||||
iniconfig==2.3.0
|
||||
isort==8.0.0
|
||||
Jinja2==3.1.6
|
||||
jiter==0.13.0
|
||||
jmespath==1.1.0
|
||||
jq==1.11.0
|
||||
jsonschema==4.26.0
|
||||
jsonschema-specifications==2025.9.1
|
||||
librt==0.8.1
|
||||
litellm==1.80.0
|
||||
markdown-it-py==4.0.0
|
||||
MarkupSafe==3.0.3
|
||||
mccabe==0.7.0
|
||||
mdurl==0.1.2
|
||||
motor==3.3.1
|
||||
multidict==6.7.1
|
||||
mypy==1.19.1
|
||||
mypy_extensions==1.1.0
|
||||
numpy==2.4.2
|
||||
oauthlib==3.3.1
|
||||
openai==1.99.9
|
||||
packaging==26.0
|
||||
pandas==3.0.1
|
||||
passlib==1.7.4
|
||||
pathspec==1.0.4
|
||||
pillow==12.1.1
|
||||
platformdirs==4.9.2
|
||||
pluggy==1.6.0
|
||||
propcache==0.4.1
|
||||
proto-plus==1.27.1
|
||||
protobuf==5.29.6
|
||||
pyasn1==0.6.2
|
||||
pyasn1_modules==0.4.2
|
||||
pycodestyle==2.14.0
|
||||
pycparser==3.0
|
||||
pydantic==2.12.5
|
||||
pydantic_core==2.41.5
|
||||
pyflakes==3.4.0
|
||||
Pygments==2.19.2
|
||||
PyJWT==2.11.0
|
||||
pymongo==4.5.0
|
||||
pyparsing==3.3.2
|
||||
pytest==9.0.2
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.2.1
|
||||
python-jose==3.5.0
|
||||
python-multipart==0.0.22
|
||||
pytokens==0.4.1
|
||||
PyYAML==6.0.3
|
||||
referencing==0.37.0
|
||||
regex==2026.2.28
|
||||
requests==2.32.5
|
||||
requests-oauthlib==2.0.0
|
||||
rich==14.3.3
|
||||
rpds-py==0.30.0
|
||||
rsa==4.9.1
|
||||
s3transfer==0.16.0
|
||||
s5cmd==0.2.0
|
||||
shellingham==1.5.4
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
starlette==0.37.2
|
||||
stripe==14.4.0
|
||||
tenacity==9.1.4
|
||||
tiktoken==0.12.0
|
||||
tokenizers==0.22.2
|
||||
tqdm==4.67.3
|
||||
typer==0.24.1
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2025.3
|
||||
uritemplate==4.2.0
|
||||
urllib3==2.6.3
|
||||
uvicorn==0.25.0
|
||||
watchfiles==1.1.1
|
||||
websockets==16.0
|
||||
yarl==1.23.0
|
||||
zipp==3.23.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Routes module
|
||||
@@ -0,0 +1,61 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from models.schemas import AdminLogin
|
||||
from auth import hash_password, verify_password, create_access_token
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
import os
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
|
||||
# MongoDB connection will be injected
|
||||
db = None
|
||||
|
||||
def set_db(database):
|
||||
global db
|
||||
db = database
|
||||
|
||||
@router.post("/login")
|
||||
async def login(credentials: AdminLogin):
|
||||
"""Admin login endpoint"""
|
||||
# Find admin user
|
||||
admin = await db.admin_users.find_one({"email": credentials.email})
|
||||
|
||||
if not admin:
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
|
||||
# Verify password
|
||||
if not verify_password(credentials.password, admin["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
|
||||
# Create access token
|
||||
access_token = create_access_token(data={"sub": admin["email"]})
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"email": admin["email"]
|
||||
}
|
||||
|
||||
@router.post("/verify")
|
||||
async def verify_token(admin: dict = Depends(lambda: __import__('auth').get_current_admin)):
|
||||
"""Verify JWT token"""
|
||||
return {"valid": True, "email": admin["email"]}
|
||||
|
||||
@router.post("/initialize-admin")
|
||||
async def initialize_admin():
|
||||
"""Initialize default admin user (for development/setup only)"""
|
||||
# Check if admin already exists
|
||||
existing_admin = await db.admin_users.find_one({"email": "admin@epictravel.com"})
|
||||
|
||||
if existing_admin:
|
||||
return {"message": "Admin user already exists"}
|
||||
|
||||
# Create default admin
|
||||
admin_data = {
|
||||
"email": "admin@epictravel.com",
|
||||
"password_hash": hash_password("admin123"),
|
||||
"created_at": __import__('datetime').datetime.utcnow()
|
||||
}
|
||||
|
||||
await db.admin_users.insert_one(admin_data)
|
||||
|
||||
return {"message": "Admin user created successfully", "email": "admin@epictravel.com"}
|
||||
@@ -0,0 +1,113 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List, Optional
|
||||
from models.schemas import Destination, DestinationCreate, DestinationUpdate
|
||||
from auth import get_current_admin
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/api/destinations", tags=["Destinations"])
|
||||
|
||||
# MongoDB connection will be injected
|
||||
db = None
|
||||
|
||||
def set_db(database):
|
||||
global db
|
||||
db = database
|
||||
|
||||
@router.get("", response_model=List[Destination])
|
||||
async def get_destinations(category: Optional[str] = None, search: Optional[str] = None):
|
||||
"""Get all destinations with optional filtering"""
|
||||
query = {}
|
||||
|
||||
if category and category != "All":
|
||||
query["category"] = category
|
||||
|
||||
if search:
|
||||
query["$or"] = [
|
||||
{"name": {"$regex": search, "$options": "i"}},
|
||||
{"location": {"$regex": search, "$options": "i"}}
|
||||
]
|
||||
|
||||
destinations = await db.destinations.find(query).to_list(1000)
|
||||
|
||||
# Convert MongoDB _id to id for response
|
||||
for dest in destinations:
|
||||
if "_id" in dest:
|
||||
del dest["_id"]
|
||||
|
||||
return destinations
|
||||
|
||||
@router.get("/{destination_id}", response_model=Destination)
|
||||
async def get_destination(destination_id: str):
|
||||
"""Get a single destination by ID"""
|
||||
destination = await db.destinations.find_one({"id": destination_id})
|
||||
|
||||
if not destination:
|
||||
raise HTTPException(status_code=404, detail="Destination not found")
|
||||
|
||||
if "_id" in destination:
|
||||
del destination["_id"]
|
||||
|
||||
return destination
|
||||
|
||||
@router.post("", response_model=Destination)
|
||||
async def create_destination(
|
||||
destination: DestinationCreate,
|
||||
admin: dict = Depends(get_current_admin)
|
||||
):
|
||||
"""Create a new destination (admin only)"""
|
||||
destination_data = destination.dict()
|
||||
destination_data["id"] = str(uuid.uuid4())
|
||||
destination_data["created_at"] = datetime.utcnow()
|
||||
|
||||
await db.destinations.insert_one(destination_data)
|
||||
|
||||
if "_id" in destination_data:
|
||||
del destination_data["_id"]
|
||||
|
||||
return destination_data
|
||||
|
||||
@router.put("/{destination_id}", response_model=Destination)
|
||||
async def update_destination(
|
||||
destination_id: str,
|
||||
destination_update: DestinationUpdate,
|
||||
admin: dict = Depends(get_current_admin)
|
||||
):
|
||||
"""Update a destination (admin only)"""
|
||||
# Check if destination exists
|
||||
existing = await db.destinations.find_one({"id": destination_id})
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Destination not found")
|
||||
|
||||
# Update only provided fields
|
||||
update_data = {k: v for k, v in destination_update.dict().items() if v is not None}
|
||||
|
||||
if update_data:
|
||||
await db.destinations.update_one(
|
||||
{"id": destination_id},
|
||||
{"$set": update_data}
|
||||
)
|
||||
|
||||
# Fetch updated destination
|
||||
updated = await db.destinations.find_one({"id": destination_id})
|
||||
|
||||
if "_id" in updated:
|
||||
del updated["_id"]
|
||||
|
||||
return updated
|
||||
|
||||
@router.delete("/{destination_id}")
|
||||
async def delete_destination(
|
||||
destination_id: str,
|
||||
admin: dict = Depends(get_current_admin)
|
||||
):
|
||||
"""Delete a destination (admin only)"""
|
||||
result = await db.destinations.delete_one({"id": destination_id})
|
||||
|
||||
if result.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Destination not found")
|
||||
|
||||
# Also delete any specials for this destination
|
||||
await db.specials.delete_many({"destination_id": destination_id})
|
||||
|
||||
return {"message": "Destination deleted successfully"}
|
||||
@@ -0,0 +1,77 @@
|
||||
from fastapi import APIRouter, HTTPException, File, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from typing import List
|
||||
from models.schemas import ContactCreate, NewsletterSubscribe
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
import os
|
||||
import shutil
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["Other"])
|
||||
|
||||
# MongoDB connection will be injected
|
||||
db = None
|
||||
|
||||
def set_db(database):
|
||||
global db
|
||||
db = database
|
||||
|
||||
@router.post("/contact")
|
||||
async def submit_contact(contact: ContactCreate):
|
||||
"""Submit a contact form"""
|
||||
contact_data = contact.dict()
|
||||
contact_data["id"] = str(uuid.uuid4())
|
||||
contact_data["created_at"] = datetime.utcnow()
|
||||
|
||||
await db.contacts.insert_one(contact_data)
|
||||
|
||||
return {"message": "Contact form submitted successfully"}
|
||||
|
||||
@router.post("/newsletter/subscribe")
|
||||
async def subscribe_newsletter(subscriber: NewsletterSubscribe):
|
||||
"""Subscribe to newsletter"""
|
||||
# Check if already subscribed
|
||||
existing = await db.newsletter_subscribers.find_one({"email": subscriber.email})
|
||||
if existing:
|
||||
return {"message": "Email already subscribed"}
|
||||
|
||||
subscriber_data = subscriber.dict()
|
||||
subscriber_data["id"] = str(uuid.uuid4())
|
||||
subscriber_data["subscribed_at"] = datetime.utcnow()
|
||||
|
||||
await db.newsletter_subscribers.insert_one(subscriber_data)
|
||||
|
||||
return {"message": "Successfully subscribed to newsletter"}
|
||||
|
||||
@router.post("/upload/image")
|
||||
async def upload_image(file: UploadFile = File(...)):
|
||||
"""Upload an image file"""
|
||||
# Validate file type
|
||||
allowed_extensions = [".jpg", ".jpeg", ".png", ".webp"]
|
||||
file_ext = os.path.splitext(file.filename)[1].lower()
|
||||
|
||||
if file_ext not in allowed_extensions:
|
||||
raise HTTPException(status_code=400, detail="Invalid file type. Allowed: jpg, jpeg, png, webp")
|
||||
|
||||
# Generate unique filename
|
||||
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
file_path = f"/app/backend/uploads/{unique_filename}"
|
||||
|
||||
# Save file
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# Return URL
|
||||
file_url = f"/api/uploads/{unique_filename}"
|
||||
|
||||
return {"url": file_url, "filename": unique_filename}
|
||||
|
||||
@router.get("/uploads/{filename}")
|
||||
async def get_uploaded_image(filename: str):
|
||||
"""Serve uploaded images"""
|
||||
file_path = f"/app/backend/uploads/{filename}"
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
|
||||
return FileResponse(file_path)
|
||||
@@ -0,0 +1,109 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import List
|
||||
from models.schemas import Special, SpecialCreate, SpecialUpdate
|
||||
from auth import get_current_admin
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/api/specials", tags=["Specials"])
|
||||
|
||||
# MongoDB connection will be injected
|
||||
db = None
|
||||
|
||||
def set_db(database):
|
||||
global db
|
||||
db = database
|
||||
|
||||
@router.get("", response_model=List[Special])
|
||||
async def get_specials():
|
||||
"""Get all weekly specials"""
|
||||
specials = await db.specials.find().to_list(1000)
|
||||
|
||||
# Convert MongoDB _id to id for response
|
||||
for special in specials:
|
||||
if "_id" in special:
|
||||
del special["_id"]
|
||||
|
||||
return specials
|
||||
|
||||
@router.get("/{special_id}", response_model=Special)
|
||||
async def get_special(special_id: str):
|
||||
"""Get a single special by ID"""
|
||||
special = await db.specials.find_one({"id": special_id})
|
||||
|
||||
if not special:
|
||||
raise HTTPException(status_code=404, detail="Special not found")
|
||||
|
||||
if "_id" in special:
|
||||
del special["_id"]
|
||||
|
||||
return special
|
||||
|
||||
@router.post("", response_model=Special)
|
||||
async def create_special(
|
||||
special: SpecialCreate,
|
||||
admin: dict = Depends(get_current_admin)
|
||||
):
|
||||
"""Add a destination to specials (admin only)"""
|
||||
# Check if destination exists
|
||||
destination = await db.destinations.find_one({"id": special.destination_id})
|
||||
if not destination:
|
||||
raise HTTPException(status_code=404, detail="Destination not found")
|
||||
|
||||
# Check if special already exists for this destination
|
||||
existing = await db.specials.find_one({"destination_id": special.destination_id})
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Special already exists for this destination")
|
||||
|
||||
special_data = special.dict()
|
||||
special_data["id"] = str(uuid.uuid4())
|
||||
special_data["created_at"] = datetime.utcnow()
|
||||
|
||||
await db.specials.insert_one(special_data)
|
||||
|
||||
if "_id" in special_data:
|
||||
del special_data["_id"]
|
||||
|
||||
return special_data
|
||||
|
||||
@router.put("/{special_id}", response_model=Special)
|
||||
async def update_special(
|
||||
special_id: str,
|
||||
special_update: SpecialUpdate,
|
||||
admin: dict = Depends(get_current_admin)
|
||||
):
|
||||
"""Update a special (admin only)"""
|
||||
# Check if special exists
|
||||
existing = await db.specials.find_one({"id": special_id})
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Special not found")
|
||||
|
||||
# Update only provided fields
|
||||
update_data = {k: v for k, v in special_update.dict().items() if v is not None}
|
||||
|
||||
if update_data:
|
||||
await db.specials.update_one(
|
||||
{"id": special_id},
|
||||
{"$set": update_data}
|
||||
)
|
||||
|
||||
# Fetch updated special
|
||||
updated = await db.specials.find_one({"id": special_id})
|
||||
|
||||
if "_id" in updated:
|
||||
del updated["_id"]
|
||||
|
||||
return updated
|
||||
|
||||
@router.delete("/destination/{destination_id}")
|
||||
async def delete_special_by_destination(
|
||||
destination_id: str,
|
||||
admin: dict = Depends(get_current_admin)
|
||||
):
|
||||
"""Remove a destination from specials (admin only)"""
|
||||
result = await db.specials.delete_one({"destination_id": destination_id})
|
||||
|
||||
if result.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Special not found for this destination")
|
||||
|
||||
return {"message": "Special removed successfully"}
|
||||
+224
-52
@@ -1,15 +1,15 @@
|
||||
from fastapi import FastAPI, APIRouter
|
||||
from fastapi import FastAPI
|
||||
from dotenv import load_dotenv
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import List
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from auth import hash_password
|
||||
from datetime import datetime
|
||||
|
||||
# Import route modules
|
||||
from routes import auth_routes, destination_routes, special_routes, other_routes
|
||||
|
||||
ROOT_DIR = Path(__file__).parent
|
||||
load_dotenv(ROOT_DIR / '.env')
|
||||
@@ -19,60 +19,30 @@ mongo_url = os.environ['MONGO_URL']
|
||||
client = AsyncIOMotorClient(mongo_url)
|
||||
db = client[os.environ['DB_NAME']]
|
||||
|
||||
# Create the main app without a prefix
|
||||
app = FastAPI()
|
||||
# Inject database into route modules
|
||||
auth_routes.set_db(db)
|
||||
destination_routes.set_db(db)
|
||||
special_routes.set_db(db)
|
||||
other_routes.set_db(db)
|
||||
|
||||
# Create a router with the /api prefix
|
||||
api_router = APIRouter(prefix="/api")
|
||||
# Create the main app
|
||||
app = FastAPI(title="Epic Travel & Destinations API")
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth_routes.router)
|
||||
app.include_router(destination_routes.router)
|
||||
app.include_router(special_routes.router)
|
||||
app.include_router(other_routes.router)
|
||||
|
||||
# Define Models
|
||||
class StatusCheck(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore") # Ignore MongoDB's _id field
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
client_name: str
|
||||
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
class StatusCheckCreate(BaseModel):
|
||||
client_name: str
|
||||
|
||||
# Add your routes to the router instead of directly to app
|
||||
@api_router.get("/")
|
||||
# Health check endpoint
|
||||
@app.get("/api")
|
||||
async def root():
|
||||
return {"message": "Hello World"}
|
||||
|
||||
@api_router.post("/status", response_model=StatusCheck)
|
||||
async def create_status_check(input: StatusCheckCreate):
|
||||
status_dict = input.model_dump()
|
||||
status_obj = StatusCheck(**status_dict)
|
||||
|
||||
# Convert to dict and serialize datetime to ISO string for MongoDB
|
||||
doc = status_obj.model_dump()
|
||||
doc['timestamp'] = doc['timestamp'].isoformat()
|
||||
|
||||
_ = await db.status_checks.insert_one(doc)
|
||||
return status_obj
|
||||
|
||||
@api_router.get("/status", response_model=List[StatusCheck])
|
||||
async def get_status_checks():
|
||||
# Exclude MongoDB's _id field from the query results
|
||||
status_checks = await db.status_checks.find({}, {"_id": 0}).to_list(1000)
|
||||
|
||||
# Convert ISO string timestamps back to datetime objects
|
||||
for check in status_checks:
|
||||
if isinstance(check['timestamp'], str):
|
||||
check['timestamp'] = datetime.fromisoformat(check['timestamp'])
|
||||
|
||||
return status_checks
|
||||
|
||||
# Include the router in the main app
|
||||
app.include_router(api_router)
|
||||
return {"message": "Epic Travel API is running", "status": "healthy"}
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_credentials=True,
|
||||
allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','),
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
@@ -84,6 +54,208 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_db_client():
|
||||
"""Initialize database with seed data if empty"""
|
||||
try:
|
||||
# Check if admin user exists, if not create one
|
||||
admin_exists = await db.admin_users.find_one({"email": "admin@epictravel.com"})
|
||||
if not admin_exists:
|
||||
admin_data = {
|
||||
"id": "admin-1",
|
||||
"email": "admin@epictravel.com",
|
||||
"password_hash": hash_password("admin123"),
|
||||
"created_at": datetime.utcnow()
|
||||
}
|
||||
await db.admin_users.insert_one(admin_data)
|
||||
logger.info("Default admin user created")
|
||||
|
||||
# Check if destinations exist, if not seed initial data
|
||||
dest_count = await db.destinations.count_documents({})
|
||||
if dest_count == 0:
|
||||
# Seed initial destinations
|
||||
initial_destinations = [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Paris",
|
||||
"location": "France",
|
||||
"description": "Experience the romance and elegance of the City of Light. Visit iconic landmarks like the Eiffel Tower, Louvre Museum, and stroll along the Champs-Élysées.",
|
||||
"image": "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?w=800&q=80",
|
||||
"category": "City",
|
||||
"rating": 4.9,
|
||||
"price": 1299,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Bali",
|
||||
"location": "Indonesia",
|
||||
"description": "Discover tropical paradise with stunning beaches, ancient temples, lush rice terraces, and vibrant culture in this Indonesian gem.",
|
||||
"image": "https://images.unsplash.com/photo-1537996194471-e657df975ab4?w=800&q=80",
|
||||
"category": "Beach",
|
||||
"rating": 4.8,
|
||||
"price": 899,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "Tokyo",
|
||||
"location": "Japan",
|
||||
"description": "Immerse yourself in the perfect blend of ancient tradition and cutting-edge technology in Japan's bustling capital city.",
|
||||
"image": "https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=800&q=80",
|
||||
"category": "City",
|
||||
"rating": 4.9,
|
||||
"price": 1499,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "Santorini",
|
||||
"location": "Greece",
|
||||
"description": "Marvel at breathtaking sunsets, whitewashed buildings, and crystal-clear waters in this stunning Greek island paradise.",
|
||||
"image": "https://images.unsplash.com/photo-1613395877344-13d4a8e0d49e?w=800&q=80",
|
||||
"category": "Beach",
|
||||
"rating": 4.9,
|
||||
"price": 1199,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"name": "Iceland",
|
||||
"location": "Iceland",
|
||||
"description": "Witness the Northern Lights, explore glaciers, geysers, and volcanic landscapes in this land of fire and ice.",
|
||||
"image": "https://images.unsplash.com/photo-1504829857797-ddff29c27927?w=800&q=80",
|
||||
"category": "Adventure",
|
||||
"rating": 4.8,
|
||||
"price": 1699,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"name": "Dubai",
|
||||
"location": "UAE",
|
||||
"description": "Experience luxury and innovation in the desert with world-class shopping, stunning architecture, and endless entertainment.",
|
||||
"image": "https://images.unsplash.com/photo-1512453979798-5ea266f8880c?w=800&q=80",
|
||||
"category": "City",
|
||||
"rating": 4.7,
|
||||
"price": 1399,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"name": "Maldives",
|
||||
"location": "Maldives",
|
||||
"description": "Relax in overwater bungalows, dive in pristine coral reefs, and enjoy the ultimate tropical island getaway.",
|
||||
"image": "https://images.unsplash.com/photo-1514282401047-d79a71a590e8?w=800&q=80",
|
||||
"category": "Beach",
|
||||
"rating": 5.0,
|
||||
"price": 2199,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"name": "New York",
|
||||
"location": "USA",
|
||||
"description": "Explore the city that never sleeps with iconic landmarks, world-class museums, Broadway shows, and diverse neighborhoods.",
|
||||
"image": "https://images.unsplash.com/photo-1496442226666-8d4d0e62e6e9?w=800&q=80",
|
||||
"category": "City",
|
||||
"rating": 4.8,
|
||||
"price": 1099,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"name": "Machu Picchu",
|
||||
"location": "Peru",
|
||||
"description": "Trek to the ancient Incan citadel nestled high in the Andes Mountains, one of the New Seven Wonders of the World.",
|
||||
"image": "https://images.unsplash.com/photo-1587595431973-160d0d94add1?w=800&q=80",
|
||||
"category": "Adventure",
|
||||
"rating": 4.9,
|
||||
"price": 1299,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"name": "Swiss Alps",
|
||||
"location": "Switzerland",
|
||||
"description": "Ski pristine slopes, hike mountain trails, and enjoy charming alpine villages with breathtaking mountain vistas.",
|
||||
"image": "https://images.unsplash.com/photo-1531366936337-7c912a4589a7?w=800&q=80",
|
||||
"category": "Adventure",
|
||||
"rating": 4.9,
|
||||
"price": 1799,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"name": "Venice",
|
||||
"location": "Italy",
|
||||
"description": "Glide through romantic canals, admire Renaissance architecture, and savor authentic Italian cuisine in this unique floating city.",
|
||||
"image": "https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?w=800&q=80",
|
||||
"category": "City",
|
||||
"rating": 4.8,
|
||||
"price": 1149,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"name": "Safari Kenya",
|
||||
"location": "Kenya",
|
||||
"description": "Witness the Great Migration, spot the Big Five, and experience the raw beauty of African wilderness.",
|
||||
"image": "https://images.unsplash.com/photo-1516426122078-c23e76319801?w=800&q=80",
|
||||
"category": "Adventure",
|
||||
"rating": 4.9,
|
||||
"price": 2499,
|
||||
"currency": "USD",
|
||||
"created_at": datetime.utcnow()
|
||||
}
|
||||
]
|
||||
await db.destinations.insert_many(initial_destinations)
|
||||
logger.info(f"Seeded {len(initial_destinations)} initial destinations")
|
||||
|
||||
# Seed initial specials
|
||||
initial_specials = [
|
||||
{
|
||||
"id": "special-1",
|
||||
"destination_id": "2",
|
||||
"discount": 25,
|
||||
"end_date": "2025-02-28",
|
||||
"highlights": ["Free spa treatment", "Complimentary airport transfer", "Sunset dinner cruise"],
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "special-2",
|
||||
"destination_id": "4",
|
||||
"discount": 30,
|
||||
"end_date": "2025-03-15",
|
||||
"highlights": ["Wine tasting tour", "Private yacht excursion", "Luxury accommodation upgrade"],
|
||||
"created_at": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "special-3",
|
||||
"destination_id": "7",
|
||||
"discount": 20,
|
||||
"end_date": "2025-02-20",
|
||||
"highlights": ["Snorkeling adventure", "Couples massage", "Romantic beach dinner"],
|
||||
"created_at": datetime.utcnow()
|
||||
}
|
||||
]
|
||||
await db.specials.insert_many(initial_specials)
|
||||
logger.info(f"Seeded {len(initial_specials)} initial specials")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during startup: {str(e)}")
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_db_client():
|
||||
client.close()
|
||||
client.close()
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
import requests
|
||||
import sys
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
class EpicTravelAPITester:
|
||||
def __init__(self, base_url="https://world-explorer-139.preview.emergentagent.com"):
|
||||
self.base_url = base_url
|
||||
self.api_url = f"{base_url}/api"
|
||||
self.token = None
|
||||
self.tests_run = 0
|
||||
self.tests_passed = 0
|
||||
self.test_results = []
|
||||
|
||||
def log_test(self, name, success, message="", response_data=None):
|
||||
"""Log test results"""
|
||||
self.tests_run += 1
|
||||
if success:
|
||||
self.tests_passed += 1
|
||||
print(f"✅ {name}: PASSED - {message}")
|
||||
else:
|
||||
print(f"❌ {name}: FAILED - {message}")
|
||||
|
||||
self.test_results.append({
|
||||
"test": name,
|
||||
"status": "PASSED" if success else "FAILED",
|
||||
"message": message,
|
||||
"response_data": response_data
|
||||
})
|
||||
return success
|
||||
|
||||
def run_test(self, name, method, endpoint, expected_status, data=None, headers=None):
|
||||
"""Run a single API test"""
|
||||
url = f"{self.api_url}/{endpoint}"
|
||||
test_headers = {'Content-Type': 'application/json'}
|
||||
|
||||
if self.token:
|
||||
test_headers['Authorization'] = f'Bearer {self.token}'
|
||||
|
||||
if headers:
|
||||
test_headers.update(headers)
|
||||
|
||||
try:
|
||||
if method == 'GET':
|
||||
response = requests.get(url, headers=test_headers)
|
||||
elif method == 'POST':
|
||||
response = requests.post(url, json=data, headers=test_headers)
|
||||
elif method == 'PUT':
|
||||
response = requests.put(url, json=data, headers=test_headers)
|
||||
elif method == 'DELETE':
|
||||
response = requests.delete(url, headers=test_headers)
|
||||
|
||||
success = response.status_code == expected_status
|
||||
|
||||
if success:
|
||||
try:
|
||||
response_data = response.json() if response.content else {}
|
||||
return self.log_test(name, True, f"Status: {response.status_code}", response_data), response_data
|
||||
except:
|
||||
return self.log_test(name, True, f"Status: {response.status_code}", {}), {}
|
||||
else:
|
||||
try:
|
||||
error_data = response.json() if response.content else {}
|
||||
return self.log_test(name, False, f"Expected {expected_status}, got {response.status_code} - {error_data}"), {}
|
||||
except:
|
||||
return self.log_test(name, False, f"Expected {expected_status}, got {response.status_code}"), {}
|
||||
|
||||
except Exception as e:
|
||||
return self.log_test(name, False, f"Error: {str(e)}"), {}
|
||||
|
||||
def test_health_check(self):
|
||||
"""Test API health check"""
|
||||
success, data = self.run_test("API Health Check", "GET", "", 200)
|
||||
return success
|
||||
|
||||
def test_admin_login(self):
|
||||
"""Test admin login"""
|
||||
login_data = {
|
||||
"email": "admin@epictravel.com",
|
||||
"password": "admin123"
|
||||
}
|
||||
success, response = self.run_test("Admin Login", "POST", "auth/login", 200, login_data)
|
||||
|
||||
if success and 'access_token' in response:
|
||||
self.token = response['access_token']
|
||||
self.log_test("Token Extraction", True, "JWT token successfully extracted")
|
||||
return True
|
||||
else:
|
||||
self.log_test("Token Extraction", False, "Failed to extract JWT token")
|
||||
return False
|
||||
|
||||
def test_get_destinations(self):
|
||||
"""Test getting all destinations"""
|
||||
success, data = self.run_test("Get All Destinations", "GET", "destinations", 200)
|
||||
if success and isinstance(data, list) and len(data) > 0:
|
||||
self.log_test("Destinations Data Validation", True, f"Found {len(data)} destinations")
|
||||
return data
|
||||
else:
|
||||
self.log_test("Destinations Data Validation", False, "No destinations returned or invalid data")
|
||||
return []
|
||||
|
||||
def test_get_specials(self):
|
||||
"""Test getting all specials"""
|
||||
success, data = self.run_test("Get All Specials", "GET", "specials", 200)
|
||||
if success and isinstance(data, list):
|
||||
self.log_test("Specials Data Validation", True, f"Found {len(data)} specials")
|
||||
return data
|
||||
else:
|
||||
self.log_test("Specials Data Validation", False, "Failed to get specials or invalid data")
|
||||
return []
|
||||
|
||||
def test_search_destinations(self):
|
||||
"""Test destination search functionality"""
|
||||
# Test search by name
|
||||
success, data = self.run_test("Search Destinations by Name", "GET", "destinations?search=Paris", 200)
|
||||
if success:
|
||||
found_paris = any(dest.get('name', '').lower() == 'paris' for dest in data)
|
||||
if found_paris:
|
||||
self.log_test("Search Results Validation", True, "Paris found in search results")
|
||||
else:
|
||||
self.log_test("Search Results Validation", False, "Paris not found in search results")
|
||||
|
||||
# Test filter by category
|
||||
success, data = self.run_test("Filter Destinations by Category", "GET", "destinations?category=City", 200)
|
||||
if success:
|
||||
all_city = all(dest.get('category') == 'City' for dest in data)
|
||||
if all_city:
|
||||
self.log_test("Filter Results Validation", True, "All returned destinations are City type")
|
||||
else:
|
||||
self.log_test("Filter Results Validation", False, "Filter not working correctly")
|
||||
|
||||
def test_contact_form(self):
|
||||
"""Test contact form submission"""
|
||||
contact_data = {
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
"message": "This is a test contact message"
|
||||
}
|
||||
success, data = self.run_test("Contact Form Submission", "POST", "contact", 200, contact_data)
|
||||
return success
|
||||
|
||||
def test_newsletter_subscription(self):
|
||||
"""Test newsletter subscription"""
|
||||
newsletter_data = {
|
||||
"email": "newsletter@test.com"
|
||||
}
|
||||
success, data = self.run_test("Newsletter Subscription", "POST", "newsletter/subscribe", 200, newsletter_data)
|
||||
return success
|
||||
|
||||
def test_admin_crud_operations(self):
|
||||
"""Test full CRUD operations for destinations (requires authentication)"""
|
||||
if not self.token:
|
||||
self.log_test("CRUD Operations", False, "No authentication token available")
|
||||
return False
|
||||
|
||||
# Test creating a new destination
|
||||
new_destination = {
|
||||
"name": "Test Destination",
|
||||
"location": "Test Country",
|
||||
"description": "A test destination for API testing",
|
||||
"image": "https://via.placeholder.com/800x600",
|
||||
"category": "City",
|
||||
"rating": 4.5,
|
||||
"price": 999,
|
||||
"currency": "USD"
|
||||
}
|
||||
|
||||
success, created_dest = self.run_test("Create New Destination", "POST", "destinations", 200, new_destination)
|
||||
|
||||
if not success:
|
||||
return False
|
||||
|
||||
dest_id = created_dest.get('id')
|
||||
if not dest_id:
|
||||
self.log_test("Destination ID Extraction", False, "Failed to get destination ID from creation response")
|
||||
return False
|
||||
|
||||
# Test updating the destination
|
||||
update_data = {
|
||||
"name": "Updated Test Destination",
|
||||
"price": 1299
|
||||
}
|
||||
success, updated_dest = self.run_test("Update Destination", "PUT", f"destinations/{dest_id}", 200, update_data)
|
||||
|
||||
if success and updated_dest.get('name') == "Updated Test Destination":
|
||||
self.log_test("Update Validation", True, "Destination updated successfully")
|
||||
else:
|
||||
self.log_test("Update Validation", False, "Destination update failed or data not persisted")
|
||||
|
||||
# Test getting single destination
|
||||
success, single_dest = self.run_test("Get Single Destination", "GET", f"destinations/{dest_id}", 200)
|
||||
|
||||
# Test adding destination to specials
|
||||
special_data = {
|
||||
"destination_id": dest_id,
|
||||
"discount": 25,
|
||||
"end_date": "2025-12-31",
|
||||
"highlights": ["Test special", "Limited time", "API test"]
|
||||
}
|
||||
success, created_special = self.run_test("Add Destination to Specials", "POST", "specials", 200, special_data)
|
||||
|
||||
special_id = created_special.get('id') if success else None
|
||||
|
||||
if special_id:
|
||||
# Test updating special
|
||||
special_update = {
|
||||
"discount": 30,
|
||||
"end_date": "2025-11-30"
|
||||
}
|
||||
success, updated_special = self.run_test("Update Special", "PUT", f"specials/{special_id}", 200, special_update)
|
||||
|
||||
# Test removing destination from specials
|
||||
success, _ = self.run_test("Remove from Specials", "DELETE", f"specials/destination/{dest_id}", 200)
|
||||
|
||||
# Test deleting the destination (cleanup)
|
||||
success, _ = self.run_test("Delete Test Destination", "DELETE", f"destinations/{dest_id}", 200)
|
||||
|
||||
return True
|
||||
|
||||
def run_all_tests(self):
|
||||
"""Run all tests in sequence"""
|
||||
print("🚀 Starting Epic Travel API Testing...")
|
||||
print("=" * 50)
|
||||
|
||||
# Test 1: Health Check
|
||||
print("\n📋 Testing Basic API Access...")
|
||||
if not self.test_health_check():
|
||||
print("❌ Health check failed, stopping tests")
|
||||
return self.generate_summary()
|
||||
|
||||
# Test 2: Authentication
|
||||
print("\n🔐 Testing Authentication...")
|
||||
if not self.test_admin_login():
|
||||
print("❌ Authentication failed, continuing with public endpoints only")
|
||||
|
||||
# Test 3: Public Endpoints
|
||||
print("\n🌍 Testing Public Endpoints...")
|
||||
destinations = self.test_get_destinations()
|
||||
specials = self.test_get_specials()
|
||||
self.test_search_destinations()
|
||||
|
||||
# Test 4: Forms
|
||||
print("\n📝 Testing Form Submissions...")
|
||||
self.test_contact_form()
|
||||
self.test_newsletter_subscription()
|
||||
|
||||
# Test 5: Admin Operations
|
||||
if self.token:
|
||||
print("\n⚙️ Testing Admin CRUD Operations...")
|
||||
self.test_admin_crud_operations()
|
||||
else:
|
||||
print("\n⚠️ Skipping Admin CRUD tests - No authentication token")
|
||||
|
||||
return self.generate_summary()
|
||||
|
||||
def generate_summary(self):
|
||||
"""Generate test summary"""
|
||||
print("\n" + "=" * 50)
|
||||
print("📊 TEST SUMMARY")
|
||||
print("=" * 50)
|
||||
print(f"Tests Run: {self.tests_run}")
|
||||
print(f"Tests Passed: {self.tests_passed}")
|
||||
print(f"Tests Failed: {self.tests_run - self.tests_passed}")
|
||||
print(f"Success Rate: {(self.tests_passed / self.tests_run * 100):.1f}%" if self.tests_run > 0 else "0%")
|
||||
|
||||
failed_tests = [test for test in self.test_results if test['status'] == 'FAILED']
|
||||
if failed_tests:
|
||||
print("\n❌ FAILED TESTS:")
|
||||
for test in failed_tests:
|
||||
print(f" - {test['test']}: {test['message']}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
|
||||
return {
|
||||
"total_tests": self.tests_run,
|
||||
"passed_tests": self.tests_passed,
|
||||
"failed_tests": self.tests_run - self.tests_passed,
|
||||
"success_rate": (self.tests_passed / self.tests_run * 100) if self.tests_run > 0 else 0,
|
||||
"detailed_results": self.test_results
|
||||
}
|
||||
|
||||
def main():
|
||||
tester = EpicTravelAPITester()
|
||||
results = tester.run_all_tests()
|
||||
|
||||
# Return appropriate exit code
|
||||
return 0 if results['failed_tests'] == 0 else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -13,12 +13,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { destinations as initialDestinations, specials as initialSpecials } from '../mockData';
|
||||
import { destinationsAPI, specialsAPI, uploadAPI } from '../services/api';
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const [destinations, setDestinations] = useState(initialDestinations);
|
||||
const [specials, setSpecials] = useState(initialSpecials);
|
||||
const [destinations, setDestinations] = useState([]);
|
||||
const [specials, setSpecials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editingDestination, setEditingDestination] = useState(null);
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
@@ -37,35 +38,59 @@ const AdminDashboard = () => {
|
||||
const isAuthenticated = localStorage.getItem('isAdminAuthenticated');
|
||||
if (!isAuthenticated) {
|
||||
navigate('/admin');
|
||||
} else {
|
||||
fetchData();
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [destinationsData, specialsData] = await Promise.all([
|
||||
destinationsAPI.getAll(),
|
||||
specialsAPI.getAll()
|
||||
]);
|
||||
setDestinations(destinationsData);
|
||||
setSpecials(specialsData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
toast.error('Failed to load data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('isAdminAuthenticated');
|
||||
localStorage.removeItem('auth_token');
|
||||
toast.success('Logged out successfully');
|
||||
navigate('/admin');
|
||||
};
|
||||
|
||||
const handleAddDestination = () => {
|
||||
const newDest = {
|
||||
...newDestination,
|
||||
id: String(destinations.length + 1),
|
||||
rating: parseFloat(newDestination.rating),
|
||||
price: parseFloat(newDestination.price)
|
||||
};
|
||||
setDestinations([...destinations, newDest]);
|
||||
setIsAddDialogOpen(false);
|
||||
setNewDestination({
|
||||
name: '',
|
||||
location: '',
|
||||
description: '',
|
||||
image: '',
|
||||
category: 'City',
|
||||
rating: 4.5,
|
||||
price: 999,
|
||||
currency: 'USD'
|
||||
});
|
||||
toast.success('Destination added successfully!');
|
||||
const handleAddDestination = async () => {
|
||||
try {
|
||||
const newDest = await destinationsAPI.create({
|
||||
...newDestination,
|
||||
rating: parseFloat(newDestination.rating),
|
||||
price: parseFloat(newDestination.price)
|
||||
});
|
||||
setDestinations([...destinations, newDest]);
|
||||
setIsAddDialogOpen(false);
|
||||
setNewDestination({
|
||||
name: '',
|
||||
location: '',
|
||||
description: '',
|
||||
image: '',
|
||||
category: 'City',
|
||||
rating: 4.5,
|
||||
price: 999,
|
||||
currency: 'USD'
|
||||
});
|
||||
toast.success('Destination added successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error adding destination:', error);
|
||||
toast.error('Failed to add destination');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditDestination = (destination) => {
|
||||
@@ -73,45 +98,71 @@ const AdminDashboard = () => {
|
||||
setIsEditMode(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
setDestinations(destinations.map(dest =>
|
||||
dest.id === editingDestination.id ? editingDestination : dest
|
||||
));
|
||||
setIsEditMode(false);
|
||||
setEditingDestination(null);
|
||||
toast.success('Destination updated successfully!');
|
||||
};
|
||||
|
||||
const handleDeleteDestination = (id) => {
|
||||
setDestinations(destinations.filter(dest => dest.id !== id));
|
||||
// Also remove from specials if exists
|
||||
setSpecials(specials.filter(special => special.destinationId !== id));
|
||||
toast.success('Destination deleted successfully!');
|
||||
};
|
||||
|
||||
const handleToggleSpecial = (destinationId) => {
|
||||
const existingSpecial = specials.find(s => s.destinationId === destinationId);
|
||||
if (existingSpecial) {
|
||||
setSpecials(specials.filter(s => s.destinationId !== destinationId));
|
||||
toast.success('Removed from specials');
|
||||
} else {
|
||||
const newSpecial = {
|
||||
destinationId,
|
||||
discount: 20,
|
||||
endDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
highlights: ['Special offer', 'Limited time', 'Book now']
|
||||
};
|
||||
setSpecials([...specials, newSpecial]);
|
||||
toast.success('Added to specials!');
|
||||
const handleSaveEdit = async () => {
|
||||
try {
|
||||
const updated = await destinationsAPI.update(editingDestination.id, editingDestination);
|
||||
setDestinations(destinations.map(dest =>
|
||||
dest.id === updated.id ? updated : dest
|
||||
));
|
||||
setIsEditMode(false);
|
||||
setEditingDestination(null);
|
||||
toast.success('Destination updated successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error updating destination:', error);
|
||||
toast.error('Failed to update destination');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSpecial = (destinationId, field, value) => {
|
||||
setSpecials(specials.map(special =>
|
||||
special.destinationId === destinationId
|
||||
? { ...special, [field]: field === 'discount' ? parseFloat(value) : value }
|
||||
: special
|
||||
));
|
||||
const handleDeleteDestination = async (id) => {
|
||||
try {
|
||||
await destinationsAPI.delete(id);
|
||||
setDestinations(destinations.filter(dest => dest.id !== id));
|
||||
setSpecials(specials.filter(special => special.destination_id !== id));
|
||||
toast.success('Destination deleted successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error deleting destination:', error);
|
||||
toast.error('Failed to delete destination');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSpecial = async (destinationId) => {
|
||||
const existingSpecial = specials.find(s => s.destination_id === destinationId);
|
||||
try {
|
||||
if (existingSpecial) {
|
||||
await specialsAPI.deleteByDestination(destinationId);
|
||||
setSpecials(specials.filter(s => s.destination_id !== destinationId));
|
||||
toast.success('Removed from specials');
|
||||
} else {
|
||||
const newSpecial = await specialsAPI.create({
|
||||
destination_id: destinationId,
|
||||
discount: 20,
|
||||
end_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
highlights: ['Special offer', 'Limited time', 'Book now']
|
||||
});
|
||||
setSpecials([...specials, newSpecial]);
|
||||
toast.success('Added to specials!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling special:', error);
|
||||
toast.error('Failed to update special');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateSpecial = async (specialId, field, value) => {
|
||||
try {
|
||||
const updatedSpecial = specials.find(s => s.id === specialId);
|
||||
if (!updatedSpecial) return;
|
||||
|
||||
const updateData = { [field]: field === 'discount' ? parseFloat(value) : value };
|
||||
const updated = await specialsAPI.update(specialId, updateData);
|
||||
|
||||
setSpecials(specials.map(special =>
|
||||
special.id === specialId ? updated : special
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Error updating special:', error);
|
||||
toast.error('Failed to update special');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -247,8 +298,13 @@ const AdminDashboard = () => {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{destinations.map((destination) => (
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">Loading destinations...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{destinations.map((destination) => (
|
||||
<Card key={destination.id} className="overflow-hidden">
|
||||
<div className="relative">
|
||||
<img
|
||||
@@ -279,7 +335,7 @@ const AdminDashboard = () => {
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{destination.description}</p>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-xl font-bold text-cyan-600">${destination.price}</span>
|
||||
{specials.some(s => s.destinationId === destination.id) && (
|
||||
{specials.some(s => s.destination_id === destination.id) && (
|
||||
<Badge variant="outline" className="border-red-500 text-red-500">Special</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -307,6 +363,7 @@ const AdminDashboard = () => {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Specials Management */}
|
||||
@@ -318,7 +375,7 @@ const AdminDashboard = () => {
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{destinations.map((destination) => {
|
||||
const special = specials.find(s => s.destinationId === destination.id);
|
||||
const special = specials.find(s => s.destination_id === destination.id);
|
||||
return (
|
||||
<Card key={destination.id}>
|
||||
<CardContent className="pt-6">
|
||||
@@ -342,7 +399,7 @@ const AdminDashboard = () => {
|
||||
<Input
|
||||
type="number"
|
||||
value={special.discount}
|
||||
onChange={(e) => handleUpdateSpecial(destination.id, 'discount', e.target.value)}
|
||||
onChange={(e) => handleUpdateSpecial(special.id, 'discount', e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
@@ -350,8 +407,8 @@ const AdminDashboard = () => {
|
||||
<label className="text-xs text-gray-600">End Date</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={special.endDate}
|
||||
onChange={(e) => handleUpdateSpecial(destination.id, 'endDate', e.target.value)}
|
||||
value={special.end_date}
|
||||
onChange={(e) => handleUpdateSpecial(special.id, 'end_date', e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,22 +5,29 @@ import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import { authAPI } from '../services/api';
|
||||
|
||||
const AdminLogin = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = (e) => {
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
// Mock authentication - will be replaced with real backend
|
||||
if (email === 'admin@epictravel.com' && password === 'admin123') {
|
||||
try {
|
||||
const response = await authAPI.login(email, password);
|
||||
localStorage.setItem('auth_token', response.access_token);
|
||||
localStorage.setItem('isAdminAuthenticated', 'true');
|
||||
toast.success('Login successful!');
|
||||
navigate('/admin/dashboard');
|
||||
} else {
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
toast.error('Invalid credentials. Try: admin@epictravel.com / admin123');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,8 +75,8 @@ const AdminLogin = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full bg-cyan-600 hover:bg-cyan-700" size="lg">
|
||||
Sign In
|
||||
<Button type="submit" className="w-full bg-cyan-600 hover:bg-cyan-700" size="lg" disabled={loading}>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-6 p-4 bg-cyan-50 rounded-lg border border-cyan-200">
|
||||
|
||||
+122
-63
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MapPin, Star, Calendar, Tag, Search, Send, Mail, Phone, MessageSquare } from 'lucide-react';
|
||||
import { destinations, specials, testimonials, categories } from '../mockData';
|
||||
import { testimonials, categories } from '../mockData';
|
||||
import { destinationsAPI, specialsAPI, contactAPI, newsletterAPI } from '../services/api';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Textarea } from '../components/ui/textarea';
|
||||
@@ -13,12 +14,44 @@ const Home = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [contactForm, setContactForm] = useState({ name: '', email: '', message: '' });
|
||||
const [newsletterEmail, setNewsletterEmail] = useState('');
|
||||
const [destinations, setDestinations] = useState([]);
|
||||
const [specials, setSpecials] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch destinations and specials on mount
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [destinationsData, specialsData] = await Promise.all([
|
||||
destinationsAPI.getAll(),
|
||||
specialsAPI.getAll()
|
||||
]);
|
||||
setDestinations(destinationsData);
|
||||
setSpecials(specialsData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
toast.error('Failed to load destinations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get special destinations
|
||||
const specialDestinations = specials.map(special => {
|
||||
const dest = destinations.find(d => d.id === special.destinationId);
|
||||
return { ...dest, ...special };
|
||||
});
|
||||
const dest = destinations.find(d => d.id === special.destination_id);
|
||||
if (!dest) return null;
|
||||
return {
|
||||
...dest,
|
||||
discount: special.discount,
|
||||
endDate: special.end_date,
|
||||
highlights: special.highlights,
|
||||
specialId: special.id
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
// Filter destinations
|
||||
const filteredDestinations = destinations.filter(dest => {
|
||||
@@ -28,16 +61,26 @@ const Home = () => {
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
|
||||
const handleContactSubmit = (e) => {
|
||||
const handleContactSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
toast.success('Message sent! We\'ll get back to you soon.');
|
||||
setContactForm({ name: '', email: '', message: '' });
|
||||
try {
|
||||
await contactAPI.submit(contactForm);
|
||||
toast.success('Message sent! We\'ll get back to you soon.');
|
||||
setContactForm({ name: '', email: '', message: '' });
|
||||
} catch (error) {
|
||||
toast.error('Failed to send message. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewsletterSubmit = (e) => {
|
||||
const handleNewsletterSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
toast.success('Successfully subscribed to our newsletter!');
|
||||
setNewsletterEmail('');
|
||||
try {
|
||||
await newsletterAPI.subscribe(newsletterEmail);
|
||||
toast.success('Successfully subscribed to our newsletter!');
|
||||
setNewsletterEmail('');
|
||||
} catch (error) {
|
||||
toast.error('Failed to subscribe. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -95,59 +138,69 @@ const Home = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{specialDestinations.map((special) => (
|
||||
<Card key={special.id} className="overflow-hidden hover:shadow-2xl transition-shadow duration-300 group">
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
src={special.image}
|
||||
alt={special.name}
|
||||
className="w-full h-64 object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute top-4 right-4 bg-red-500 text-white px-4 py-2 rounded-full font-bold text-lg shadow-lg">
|
||||
{special.discount}% OFF
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">Loading specials...</p>
|
||||
</div>
|
||||
) : specialDestinations.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">No special offers available at the moment.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{specialDestinations.map((special) => (
|
||||
<Card key={special.id} className="overflow-hidden hover:shadow-2xl transition-shadow duration-300 group">
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
src={special.image}
|
||||
alt={special.name}
|
||||
className="w-full h-64 object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute top-4 right-4 bg-red-500 text-white px-4 py-2 rounded-full font-bold text-lg shadow-lg">
|
||||
{special.discount}% OFF
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm px-3 py-1 rounded-full flex items-center space-x-1">
|
||||
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
||||
<span className="font-semibold">{special.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm px-3 py-1 rounded-full flex items-center space-x-1">
|
||||
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
||||
<span className="font-semibold">{special.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">{special.name}</CardTitle>
|
||||
<CardDescription className="flex items-center text-base">
|
||||
<MapPin className="w-4 h-4 mr-1" />
|
||||
{special.location}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-4">{special.description}</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
{special.highlights.map((highlight, idx) => (
|
||||
<div key={idx} className="flex items-start space-x-2 text-sm text-gray-700">
|
||||
<Tag className="w-4 h-4 text-cyan-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{highlight}</span>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">{special.name}</CardTitle>
|
||||
<CardDescription className="flex items-center text-base">
|
||||
<MapPin className="w-4 h-4 mr-1" />
|
||||
{special.location}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600 mb-4">{special.description}</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
{special.highlights.map((highlight, idx) => (
|
||||
<div key={idx} className="flex items-start space-x-2 text-sm text-gray-700">
|
||||
<Tag className="w-4 h-4 text-cyan-600 mt-0.5 flex-shrink-0" />
|
||||
<span>{highlight}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<span className="text-gray-500 line-through text-lg">${special.price}</span>
|
||||
<span className="text-3xl font-bold text-cyan-600 ml-2">
|
||||
${Math.round(special.price * (1 - special.discount / 100))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
Until {new Date(special.endDate).toLocaleDateString()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<span className="text-gray-500 line-through text-lg">${special.price}</span>
|
||||
<span className="text-3xl font-bold text-cyan-600 ml-2">
|
||||
${Math.round(special.price * (1 - special.discount / 100))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
Until {new Date(special.endDate).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full bg-cyan-600 hover:bg-cyan-700">
|
||||
Book Now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Button className="w-full bg-cyan-600 hover:bg-cyan-700">
|
||||
Book Now
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -190,8 +243,13 @@ const Home = () => {
|
||||
</div>
|
||||
|
||||
{/* Destinations Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredDestinations.map((destination) => (
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600">Loading destinations...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredDestinations.map((destination) => (
|
||||
<Card key={destination.id} className="overflow-hidden hover:shadow-xl transition-shadow duration-300 group cursor-pointer">
|
||||
<div className="relative overflow-hidden">
|
||||
<img
|
||||
@@ -229,6 +287,7 @@ const Home = () => {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL;
|
||||
const API = `${BACKEND_URL}/api`;
|
||||
|
||||
// Create axios instance
|
||||
const apiClient = axios.create({
|
||||
baseURL: API,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Add auth token to requests if available
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
login: async (email, password) => {
|
||||
const response = await apiClient.post('/auth/login', { email, password });
|
||||
return response.data;
|
||||
},
|
||||
verify: async () => {
|
||||
const response = await apiClient.post('/auth/verify');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Destinations API
|
||||
export const destinationsAPI = {
|
||||
getAll: async (category, search) => {
|
||||
const params = {};
|
||||
if (category) params.category = category;
|
||||
if (search) params.search = search;
|
||||
const response = await apiClient.get('/destinations', { params });
|
||||
return response.data;
|
||||
},
|
||||
getById: async (id) => {
|
||||
const response = await apiClient.get(`/destinations/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
create: async (destination) => {
|
||||
const response = await apiClient.post('/destinations', destination);
|
||||
return response.data;
|
||||
},
|
||||
update: async (id, destination) => {
|
||||
const response = await apiClient.put(`/destinations/${id}`, destination);
|
||||
return response.data;
|
||||
},
|
||||
delete: async (id) => {
|
||||
const response = await apiClient.delete(`/destinations/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Specials API
|
||||
export const specialsAPI = {
|
||||
getAll: async () => {
|
||||
const response = await apiClient.get('/specials');
|
||||
return response.data;
|
||||
},
|
||||
create: async (special) => {
|
||||
const response = await apiClient.post('/specials', special);
|
||||
return response.data;
|
||||
},
|
||||
update: async (id, special) => {
|
||||
const response = await apiClient.put(`/specials/${id}`, special);
|
||||
return response.data;
|
||||
},
|
||||
deleteByDestination: async (destinationId) => {
|
||||
const response = await apiClient.delete(`/specials/destination/${destinationId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Contact API
|
||||
export const contactAPI = {
|
||||
submit: async (contact) => {
|
||||
const response = await apiClient.post('/contact', contact);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Newsletter API
|
||||
export const newsletterAPI = {
|
||||
subscribe: async (email) => {
|
||||
const response = await apiClient.post('/newsletter/subscribe', { email });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Image Upload API
|
||||
export const uploadAPI = {
|
||||
uploadImage: async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await apiClient.post('/upload/image', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
Reference in New Issue
Block a user