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
|
aiohappyeyeballs==2.6.1
|
||||||
uvicorn==0.25.0
|
aiohttp==3.13.3
|
||||||
boto3>=1.34.129
|
aiosignal==1.4.0
|
||||||
requests-oauthlib>=2.0.0
|
annotated-doc==0.0.4
|
||||||
cryptography>=42.0.8
|
annotated-types==0.7.0
|
||||||
python-dotenv>=1.0.1
|
anyio==4.12.1
|
||||||
pymongo==4.5.0
|
attrs==25.4.0
|
||||||
pydantic>=2.6.4
|
|
||||||
email-validator>=2.2.0
|
|
||||||
pyjwt>=2.10.1
|
|
||||||
bcrypt==4.1.3
|
bcrypt==4.1.3
|
||||||
passlib>=1.7.4
|
black==26.1.0
|
||||||
tzdata>=2024.2
|
boto3==1.42.58
|
||||||
motor==3.3.1
|
botocore==1.42.58
|
||||||
pytest>=8.0.0
|
certifi==2026.2.25
|
||||||
black>=24.1.1
|
cffi==2.0.0
|
||||||
isort>=5.13.2
|
charset-normalizer==3.4.4
|
||||||
flake8>=7.0.0
|
click==8.3.1
|
||||||
mypy>=1.8.0
|
cryptography==46.0.5
|
||||||
python-jose>=3.3.0
|
distro==1.9.0
|
||||||
requests>=2.31.0
|
dnspython==2.8.0
|
||||||
pandas>=2.2.0
|
ecdsa==0.19.1
|
||||||
numpy>=1.26.0
|
email-validator==2.3.0
|
||||||
python-multipart>=0.0.9
|
|
||||||
jq>=1.6.0
|
|
||||||
typer>=0.9.0
|
|
||||||
emergentintegrations==0.1.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 dotenv import load_dotenv
|
||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from motor.motor_asyncio import AsyncIOMotorClient
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from auth import hash_password
|
||||||
from typing import List
|
from datetime import datetime
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
|
# Import route modules
|
||||||
|
from routes import auth_routes, destination_routes, special_routes, other_routes
|
||||||
|
|
||||||
ROOT_DIR = Path(__file__).parent
|
ROOT_DIR = Path(__file__).parent
|
||||||
load_dotenv(ROOT_DIR / '.env')
|
load_dotenv(ROOT_DIR / '.env')
|
||||||
@@ -19,60 +19,30 @@ mongo_url = os.environ['MONGO_URL']
|
|||||||
client = AsyncIOMotorClient(mongo_url)
|
client = AsyncIOMotorClient(mongo_url)
|
||||||
db = client[os.environ['DB_NAME']]
|
db = client[os.environ['DB_NAME']]
|
||||||
|
|
||||||
# Create the main app without a prefix
|
# Inject database into route modules
|
||||||
app = FastAPI()
|
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
|
# Create the main app
|
||||||
api_router = APIRouter(prefix="/api")
|
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
|
# Health check endpoint
|
||||||
class StatusCheck(BaseModel):
|
@app.get("/api")
|
||||||
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("/")
|
|
||||||
async def root():
|
async def root():
|
||||||
return {"message": "Hello World"}
|
return {"message": "Epic Travel API is running", "status": "healthy"}
|
||||||
|
|
||||||
@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)
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','),
|
allow_origins=["*"],
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
@@ -84,6 +54,208 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
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")
|
@app.on_event("shutdown")
|
||||||
async def shutdown_db_client():
|
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 { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../components/ui/dialog';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { destinations as initialDestinations, specials as initialSpecials } from '../mockData';
|
import { destinationsAPI, specialsAPI, uploadAPI } from '../services/api';
|
||||||
|
|
||||||
const AdminDashboard = () => {
|
const AdminDashboard = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [destinations, setDestinations] = useState(initialDestinations);
|
const [destinations, setDestinations] = useState([]);
|
||||||
const [specials, setSpecials] = useState(initialSpecials);
|
const [specials, setSpecials] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
const [editingDestination, setEditingDestination] = useState(null);
|
const [editingDestination, setEditingDestination] = useState(null);
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
@@ -37,35 +38,59 @@ const AdminDashboard = () => {
|
|||||||
const isAuthenticated = localStorage.getItem('isAdminAuthenticated');
|
const isAuthenticated = localStorage.getItem('isAdminAuthenticated');
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
|
} else {
|
||||||
|
fetchData();
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [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 = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('isAdminAuthenticated');
|
localStorage.removeItem('isAdminAuthenticated');
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
toast.success('Logged out successfully');
|
toast.success('Logged out successfully');
|
||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddDestination = () => {
|
const handleAddDestination = async () => {
|
||||||
const newDest = {
|
try {
|
||||||
...newDestination,
|
const newDest = await destinationsAPI.create({
|
||||||
id: String(destinations.length + 1),
|
...newDestination,
|
||||||
rating: parseFloat(newDestination.rating),
|
rating: parseFloat(newDestination.rating),
|
||||||
price: parseFloat(newDestination.price)
|
price: parseFloat(newDestination.price)
|
||||||
};
|
});
|
||||||
setDestinations([...destinations, newDest]);
|
setDestinations([...destinations, newDest]);
|
||||||
setIsAddDialogOpen(false);
|
setIsAddDialogOpen(false);
|
||||||
setNewDestination({
|
setNewDestination({
|
||||||
name: '',
|
name: '',
|
||||||
location: '',
|
location: '',
|
||||||
description: '',
|
description: '',
|
||||||
image: '',
|
image: '',
|
||||||
category: 'City',
|
category: 'City',
|
||||||
rating: 4.5,
|
rating: 4.5,
|
||||||
price: 999,
|
price: 999,
|
||||||
currency: 'USD'
|
currency: 'USD'
|
||||||
});
|
});
|
||||||
toast.success('Destination added successfully!');
|
toast.success('Destination added successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding destination:', error);
|
||||||
|
toast.error('Failed to add destination');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditDestination = (destination) => {
|
const handleEditDestination = (destination) => {
|
||||||
@@ -73,45 +98,71 @@ const AdminDashboard = () => {
|
|||||||
setIsEditMode(true);
|
setIsEditMode(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveEdit = () => {
|
const handleSaveEdit = async () => {
|
||||||
setDestinations(destinations.map(dest =>
|
try {
|
||||||
dest.id === editingDestination.id ? editingDestination : dest
|
const updated = await destinationsAPI.update(editingDestination.id, editingDestination);
|
||||||
));
|
setDestinations(destinations.map(dest =>
|
||||||
setIsEditMode(false);
|
dest.id === updated.id ? updated : dest
|
||||||
setEditingDestination(null);
|
));
|
||||||
toast.success('Destination updated successfully!');
|
setIsEditMode(false);
|
||||||
};
|
setEditingDestination(null);
|
||||||
|
toast.success('Destination updated successfully!');
|
||||||
const handleDeleteDestination = (id) => {
|
} catch (error) {
|
||||||
setDestinations(destinations.filter(dest => dest.id !== id));
|
console.error('Error updating destination:', error);
|
||||||
// Also remove from specials if exists
|
toast.error('Failed to update destination');
|
||||||
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 handleUpdateSpecial = (destinationId, field, value) => {
|
const handleDeleteDestination = async (id) => {
|
||||||
setSpecials(specials.map(special =>
|
try {
|
||||||
special.destinationId === destinationId
|
await destinationsAPI.delete(id);
|
||||||
? { ...special, [field]: field === 'discount' ? parseFloat(value) : value }
|
setDestinations(destinations.filter(dest => dest.id !== id));
|
||||||
: special
|
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 (
|
return (
|
||||||
@@ -247,8 +298,13 @@ const AdminDashboard = () => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{loading ? (
|
||||||
{destinations.map((destination) => (
|
<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">
|
<Card key={destination.id} className="overflow-hidden">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<img
|
<img
|
||||||
@@ -279,7 +335,7 @@ const AdminDashboard = () => {
|
|||||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{destination.description}</p>
|
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{destination.description}</p>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-xl font-bold text-cyan-600">${destination.price}</span>
|
<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>
|
<Badge variant="outline" className="border-red-500 text-red-500">Special</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -307,6 +363,7 @@ const AdminDashboard = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Specials Management */}
|
{/* Specials Management */}
|
||||||
@@ -318,7 +375,7 @@ const AdminDashboard = () => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{destinations.map((destination) => {
|
{destinations.map((destination) => {
|
||||||
const special = specials.find(s => s.destinationId === destination.id);
|
const special = specials.find(s => s.destination_id === destination.id);
|
||||||
return (
|
return (
|
||||||
<Card key={destination.id}>
|
<Card key={destination.id}>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
@@ -342,7 +399,7 @@ const AdminDashboard = () => {
|
|||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={special.discount}
|
value={special.discount}
|
||||||
onChange={(e) => handleUpdateSpecial(destination.id, 'discount', e.target.value)}
|
onChange={(e) => handleUpdateSpecial(special.id, 'discount', e.target.value)}
|
||||||
className="h-8"
|
className="h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,8 +407,8 @@ const AdminDashboard = () => {
|
|||||||
<label className="text-xs text-gray-600">End Date</label>
|
<label className="text-xs text-gray-600">End Date</label>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={special.endDate}
|
value={special.end_date}
|
||||||
onChange={(e) => handleUpdateSpecial(destination.id, 'endDate', e.target.value)}
|
onChange={(e) => handleUpdateSpecial(special.id, 'end_date', e.target.value)}
|
||||||
className="h-8"
|
className="h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,22 +5,29 @@ import { Button } from '../components/ui/button';
|
|||||||
import { Input } from '../components/ui/input';
|
import { Input } from '../components/ui/input';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { authAPI } from '../services/api';
|
||||||
|
|
||||||
const AdminLogin = () => {
|
const AdminLogin = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleLogin = (e) => {
|
const handleLogin = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
// Mock authentication - will be replaced with real backend
|
try {
|
||||||
if (email === 'admin@epictravel.com' && password === 'admin123') {
|
const response = await authAPI.login(email, password);
|
||||||
|
localStorage.setItem('auth_token', response.access_token);
|
||||||
localStorage.setItem('isAdminAuthenticated', 'true');
|
localStorage.setItem('isAdminAuthenticated', 'true');
|
||||||
toast.success('Login successful!');
|
toast.success('Login successful!');
|
||||||
navigate('/admin/dashboard');
|
navigate('/admin/dashboard');
|
||||||
} else {
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
toast.error('Invalid credentials. Try: admin@epictravel.com / admin123');
|
toast.error('Invalid credentials. Try: admin@epictravel.com / admin123');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,8 +75,8 @@ const AdminLogin = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full bg-cyan-600 hover:bg-cyan-700" size="lg">
|
<Button type="submit" className="w-full bg-cyan-600 hover:bg-cyan-700" size="lg" disabled={loading}>
|
||||||
Sign In
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<div className="mt-6 p-4 bg-cyan-50 rounded-lg border border-cyan-200">
|
<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 { 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 { Button } from '../components/ui/button';
|
||||||
import { Input } from '../components/ui/input';
|
import { Input } from '../components/ui/input';
|
||||||
import { Textarea } from '../components/ui/textarea';
|
import { Textarea } from '../components/ui/textarea';
|
||||||
@@ -13,12 +14,44 @@ const Home = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [contactForm, setContactForm] = useState({ name: '', email: '', message: '' });
|
const [contactForm, setContactForm] = useState({ name: '', email: '', message: '' });
|
||||||
const [newsletterEmail, setNewsletterEmail] = useState('');
|
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
|
// Get special destinations
|
||||||
const specialDestinations = specials.map(special => {
|
const specialDestinations = specials.map(special => {
|
||||||
const dest = destinations.find(d => d.id === special.destinationId);
|
const dest = destinations.find(d => d.id === special.destination_id);
|
||||||
return { ...dest, ...special };
|
if (!dest) return null;
|
||||||
});
|
return {
|
||||||
|
...dest,
|
||||||
|
discount: special.discount,
|
||||||
|
endDate: special.end_date,
|
||||||
|
highlights: special.highlights,
|
||||||
|
specialId: special.id
|
||||||
|
};
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
// Filter destinations
|
// Filter destinations
|
||||||
const filteredDestinations = destinations.filter(dest => {
|
const filteredDestinations = destinations.filter(dest => {
|
||||||
@@ -28,16 +61,26 @@ const Home = () => {
|
|||||||
return matchesCategory && matchesSearch;
|
return matchesCategory && matchesSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleContactSubmit = (e) => {
|
const handleContactSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toast.success('Message sent! We\'ll get back to you soon.');
|
try {
|
||||||
setContactForm({ name: '', email: '', message: '' });
|
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();
|
e.preventDefault();
|
||||||
toast.success('Successfully subscribed to our newsletter!');
|
try {
|
||||||
setNewsletterEmail('');
|
await newsletterAPI.subscribe(newsletterEmail);
|
||||||
|
toast.success('Successfully subscribed to our newsletter!');
|
||||||
|
setNewsletterEmail('');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to subscribe. Please try again.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,59 +138,69 @@ const Home = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
{loading ? (
|
||||||
{specialDestinations.map((special) => (
|
<div className="text-center py-12">
|
||||||
<Card key={special.id} className="overflow-hidden hover:shadow-2xl transition-shadow duration-300 group">
|
<p className="text-gray-600">Loading specials...</p>
|
||||||
<div className="relative overflow-hidden">
|
</div>
|
||||||
<img
|
) : specialDestinations.length === 0 ? (
|
||||||
src={special.image}
|
<div className="text-center py-12">
|
||||||
alt={special.name}
|
<p className="text-gray-600">No special offers available at the moment.</p>
|
||||||
className="w-full h-64 object-cover group-hover:scale-110 transition-transform duration-500"
|
</div>
|
||||||
/>
|
) : (
|
||||||
<div className="absolute top-4 right-4 bg-red-500 text-white px-4 py-2 rounded-full font-bold text-lg shadow-lg">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{special.discount}% OFF
|
{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>
|
||||||
<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">
|
<CardHeader>
|
||||||
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
<CardTitle className="text-2xl">{special.name}</CardTitle>
|
||||||
<span className="font-semibold">{special.rating}</span>
|
<CardDescription className="flex items-center text-base">
|
||||||
</div>
|
<MapPin className="w-4 h-4 mr-1" />
|
||||||
</div>
|
{special.location}
|
||||||
<CardHeader>
|
</CardDescription>
|
||||||
<CardTitle className="text-2xl">{special.name}</CardTitle>
|
</CardHeader>
|
||||||
<CardDescription className="flex items-center text-base">
|
<CardContent>
|
||||||
<MapPin className="w-4 h-4 mr-1" />
|
<p className="text-gray-600 mb-4">{special.description}</p>
|
||||||
{special.location}
|
<div className="space-y-2 mb-4">
|
||||||
</CardDescription>
|
{special.highlights.map((highlight, idx) => (
|
||||||
</CardHeader>
|
<div key={idx} className="flex items-start space-x-2 text-sm text-gray-700">
|
||||||
<CardContent>
|
<Tag className="w-4 h-4 text-cyan-600 mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-gray-600 mb-4">{special.description}</p>
|
<span>{highlight}</span>
|
||||||
<div className="space-y-2 mb-4">
|
</div>
|
||||||
{special.highlights.map((highlight, idx) => (
|
))}
|
||||||
<div key={idx} className="flex items-start space-x-2 text-sm text-gray-700">
|
</div>
|
||||||
<Tag className="w-4 h-4 text-cyan-600 mt-0.5 flex-shrink-0" />
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span>{highlight}</span>
|
<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>
|
|
||||||
<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>
|
||||||
<div className="flex items-center text-sm text-gray-500">
|
<Button className="w-full bg-cyan-600 hover:bg-cyan-700">
|
||||||
<Calendar className="w-4 h-4 mr-1" />
|
Book Now
|
||||||
Until {new Date(special.endDate).toLocaleDateString()}
|
</Button>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
<Button className="w-full bg-cyan-600 hover:bg-cyan-700">
|
))}
|
||||||
Book Now
|
</div>
|
||||||
</Button>
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -190,8 +243,13 @@ const Home = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Destinations Grid */}
|
{/* Destinations Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
{loading ? (
|
||||||
{filteredDestinations.map((destination) => (
|
<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">
|
<Card key={destination.id} className="overflow-hidden hover:shadow-xl transition-shadow duration-300 group cursor-pointer">
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
<img
|
<img
|
||||||
@@ -229,6 +287,7 @@ const Home = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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