fix: critical bug fixes - routes UUID, image paths, favorites loading, bottom nav debounce
This commit is contained in:
@ -6,6 +6,8 @@ from app.models.business import Business
|
|||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
|
|
||||||
|
from app.services.image_handler import save_image, delete_image
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/businesses", tags=["businesses"])
|
router = APIRouter(prefix="/api/businesses", tags=["businesses"])
|
||||||
|
|
||||||
@router.get("", response_model=List[Business])
|
@router.get("", response_model=List[Business])
|
||||||
@ -41,17 +43,7 @@ async def create_business(
|
|||||||
|
|
||||||
image_url = None
|
image_url = None
|
||||||
if image:
|
if image:
|
||||||
import os
|
image_url = save_image(image, "businesses")
|
||||||
import shutil
|
|
||||||
from uuid import uuid4
|
|
||||||
UPLOAD_DIR = "uploads/businesses"
|
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
||||||
ext = os.path.splitext(image.filename)[1]
|
|
||||||
filename = f"{uuid4()}{ext}"
|
|
||||||
path = os.path.join(UPLOAD_DIR, filename)
|
|
||||||
with open(path, "wb") as buffer:
|
|
||||||
shutil.copyfileobj(image.file, buffer)
|
|
||||||
image_url = f"/uploads/businesses/{filename}"
|
|
||||||
|
|
||||||
db_business = Business(
|
db_business = Business(
|
||||||
name=name,
|
name=name,
|
||||||
@ -110,17 +102,9 @@ async def update_business(
|
|||||||
db_business.longitude = longitude
|
db_business.longitude = longitude
|
||||||
|
|
||||||
if image:
|
if image:
|
||||||
import os
|
if db_business.image_url:
|
||||||
import shutil
|
delete_image(db_business.image_url)
|
||||||
from uuid import uuid4
|
db_business.image_url = save_image(image, "businesses")
|
||||||
UPLOAD_DIR = "uploads/businesses"
|
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
||||||
ext = os.path.splitext(image.filename)[1]
|
|
||||||
filename = f"{uuid4()}{ext}"
|
|
||||||
path = os.path.join(UPLOAD_DIR, filename)
|
|
||||||
with open(path, "wb") as buffer:
|
|
||||||
shutil.copyfileobj(image.file, buffer)
|
|
||||||
db_business.image_url = f"/uploads/businesses/{filename}"
|
|
||||||
|
|
||||||
session.add(db_business)
|
session.add(db_business)
|
||||||
session.commit()
|
session.commit()
|
||||||
@ -153,6 +137,9 @@ async def delete_business(
|
|||||||
if not db_business:
|
if not db_business:
|
||||||
raise HTTPException(status_code=404, detail="Business not found")
|
raise HTTPException(status_code=404, detail="Business not found")
|
||||||
|
|
||||||
|
if db_business.image_url:
|
||||||
|
delete_image(db_business.image_url)
|
||||||
|
|
||||||
session.delete(db_business)
|
session.delete(db_business)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"status": "success", "message": "Business deleted"}
|
return {"status": "success", "message": "Business deleted"}
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Form, File, UploadFile
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from datetime import datetime
|
||||||
from app.core.database import get_session
|
from app.core.database import get_session
|
||||||
from app.models.coupon import Coupon, CouponCreate, CouponUpdate
|
from app.models.coupon import Coupon
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
|
from app.services.image_handler import save_image, delete_image
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/coupons", tags=["coupons"])
|
router = APIRouter(prefix="/api/coupons", tags=["coupons"])
|
||||||
|
|
||||||
@ -27,7 +30,19 @@ async def list_coupons(
|
|||||||
async def create_coupon(
|
async def create_coupon(
|
||||||
*,
|
*,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
coupon_in: CouponCreate,
|
title: str = Form(...),
|
||||||
|
business_id: Optional[UUID] = Form(None),
|
||||||
|
description: Optional[str] = Form(None),
|
||||||
|
business_name: Optional[str] = Form(None),
|
||||||
|
business_address: Optional[str] = Form(None),
|
||||||
|
business_phone: Optional[str] = Form(None),
|
||||||
|
discount_percentage: Optional[int] = Form(None),
|
||||||
|
discount_amount: Optional[float] = Form(None),
|
||||||
|
category: Optional[str] = Form(None),
|
||||||
|
valid_from: Optional[datetime] = Form(None),
|
||||||
|
valid_until: Optional[datetime] = Form(None),
|
||||||
|
is_active: bool = Form(True),
|
||||||
|
image: Optional[UploadFile] = File(None),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Create a new coupon (Promoters and Admins only)."""
|
"""Create a new coupon (Promoters and Admins only)."""
|
||||||
@ -37,7 +52,25 @@ async def create_coupon(
|
|||||||
detail="Only promoters and admins can create coupons"
|
detail="Only promoters and admins can create coupons"
|
||||||
)
|
)
|
||||||
|
|
||||||
db_coupon = Coupon.from_orm(coupon_in)
|
image_url = None
|
||||||
|
if image:
|
||||||
|
image_url = save_image(image, "coupons")
|
||||||
|
|
||||||
|
db_coupon = Coupon(
|
||||||
|
title=title,
|
||||||
|
business_id=business_id,
|
||||||
|
description=description,
|
||||||
|
business_name=business_name,
|
||||||
|
business_address=business_address,
|
||||||
|
business_phone=business_phone,
|
||||||
|
discount_percentage=discount_percentage,
|
||||||
|
discount_amount=discount_amount,
|
||||||
|
category=category,
|
||||||
|
valid_from=valid_from,
|
||||||
|
valid_until=valid_until,
|
||||||
|
is_active=is_active,
|
||||||
|
image_url=image_url
|
||||||
|
)
|
||||||
session.add(db_coupon)
|
session.add(db_coupon)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_coupon)
|
session.refresh(db_coupon)
|
||||||
@ -47,8 +80,13 @@ async def create_coupon(
|
|||||||
async def update_coupon(
|
async def update_coupon(
|
||||||
*,
|
*,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
coupon_id: str,
|
coupon_id: UUID,
|
||||||
coupon_in: CouponUpdate,
|
title: Optional[str] = Form(None),
|
||||||
|
description: Optional[str] = Form(None),
|
||||||
|
discount_percentage: Optional[int] = Form(None),
|
||||||
|
valid_until: Optional[datetime] = Form(None),
|
||||||
|
is_active: Optional[bool] = Form(None),
|
||||||
|
image: Optional[UploadFile] = File(None),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Update a coupon (Promoters and Admins only)."""
|
"""Update a coupon (Promoters and Admins only)."""
|
||||||
@ -62,9 +100,21 @@ async def update_coupon(
|
|||||||
if not db_coupon:
|
if not db_coupon:
|
||||||
raise HTTPException(status_code=404, detail="Coupon not found")
|
raise HTTPException(status_code=404, detail="Coupon not found")
|
||||||
|
|
||||||
coupon_data = coupon_in.dict(exclude_unset=True)
|
if title is not None:
|
||||||
for key, value in coupon_data.items():
|
db_coupon.title = title
|
||||||
setattr(db_coupon, key, value)
|
if description is not None:
|
||||||
|
db_coupon.description = description
|
||||||
|
if discount_percentage is not None:
|
||||||
|
db_coupon.discount_percentage = discount_percentage
|
||||||
|
if valid_until is not None:
|
||||||
|
db_coupon.valid_until = valid_until
|
||||||
|
if is_active is not None:
|
||||||
|
db_coupon.is_active = is_active
|
||||||
|
|
||||||
|
if image:
|
||||||
|
if db_coupon.image_url:
|
||||||
|
delete_image(db_coupon.image_url)
|
||||||
|
db_coupon.image_url = save_image(image, "coupons")
|
||||||
|
|
||||||
session.add(db_coupon)
|
session.add(db_coupon)
|
||||||
session.commit()
|
session.commit()
|
||||||
@ -75,7 +125,7 @@ async def update_coupon(
|
|||||||
async def delete_coupon(
|
async def delete_coupon(
|
||||||
*,
|
*,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
coupon_id: str,
|
coupon_id: UUID,
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Delete a coupon (Promoters and Admins only)."""
|
"""Delete a coupon (Promoters and Admins only)."""
|
||||||
@ -89,6 +139,9 @@ async def delete_coupon(
|
|||||||
if not db_coupon:
|
if not db_coupon:
|
||||||
raise HTTPException(status_code=404, detail="Coupon not found")
|
raise HTTPException(status_code=404, detail="Coupon not found")
|
||||||
|
|
||||||
|
if db_coupon.image_url:
|
||||||
|
delete_image(db_coupon.image_url)
|
||||||
|
|
||||||
session.delete(db_coupon)
|
session.delete(db_coupon)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"status": "success", "message": "Coupon deleted"}
|
return {"status": "success", "message": "Coupon deleted"}
|
||||||
|
|||||||
@ -46,16 +46,24 @@ async def get_route_stops(route_id: str, session: Session = Depends(get_session)
|
|||||||
"""Get all stops for a route."""
|
"""Get all stops for a route."""
|
||||||
from app.models.route_stop import RouteStop
|
from app.models.route_stop import RouteStop
|
||||||
from app.models.bus_stop import BusStop
|
from app.models.bus_stop import BusStop
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
try:
|
||||||
|
route_id_uuid = UUID(route_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid route ID format")
|
||||||
|
|
||||||
statement = select(RouteStop, BusStop).join(
|
statement = select(RouteStop, BusStop).join(
|
||||||
BusStop, RouteStop.stop_id == BusStop.id
|
BusStop, RouteStop.stop_id == BusStop.id
|
||||||
).where(RouteStop.route_id == route_id).order_by(RouteStop.stop_order)
|
).where(RouteStop.route_id == route_id_uuid).order_by(RouteStop.stop_order)
|
||||||
|
|
||||||
results = session.exec(statement).all()
|
results = session.exec(statement).all()
|
||||||
# Merge RouteStop data into BusStop response
|
# Merge RouteStop data into BusStop response
|
||||||
stops = []
|
stops = []
|
||||||
for route_stop, bus_stop in results:
|
for route_stop, bus_stop in results:
|
||||||
stop_data = bus_stop.model_dump()
|
stop_data = bus_stop.model_dump()
|
||||||
|
# Convert UUIDs to strings for JSON compatibility
|
||||||
|
stop_data['id'] = str(stop_data['id'])
|
||||||
stop_data['stop_order'] = route_stop.stop_order
|
stop_data['stop_order'] = route_stop.stop_order
|
||||||
stop_data['travel_time_minutes'] = route_stop.travel_time_minutes
|
stop_data['travel_time_minutes'] = route_stop.travel_time_minutes
|
||||||
stop_data['stop_delay_minutes'] = route_stop.stop_delay_minutes
|
stop_data['stop_delay_minutes'] = route_stop.stop_delay_minutes
|
||||||
@ -124,21 +132,28 @@ async def add_stop_to_route(
|
|||||||
_: bool = Depends(get_current_admin)
|
_: bool = Depends(get_current_admin)
|
||||||
):
|
):
|
||||||
"""Add a stop to a route with cascading order adjustment."""
|
"""Add a stop to a route with cascading order adjustment."""
|
||||||
|
from uuid import UUID
|
||||||
|
try:
|
||||||
|
route_id_uuid = UUID(route_id)
|
||||||
|
stop_id_uuid = UUID(stop_data.stop_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid UUID format")
|
||||||
|
|
||||||
# 1. Check if route exists
|
# 1. Check if route exists
|
||||||
route = session.get(Route, route_id)
|
route = session.get(Route, route_id_uuid)
|
||||||
if not route:
|
if not route:
|
||||||
raise HTTPException(status_code=404, detail="Route not found")
|
raise HTTPException(status_code=404, detail="Route not found")
|
||||||
|
|
||||||
# 2. Determine stop order
|
# 2. Determine stop order
|
||||||
if stop_data.stop_order is None:
|
if stop_data.stop_order is None:
|
||||||
# Append to end
|
# Append to end
|
||||||
max_order = session.exec(select(func.max(RouteStop.stop_order)).where(RouteStop.route_id == route_id)).one()
|
max_order = session.exec(select(func.max(RouteStop.stop_order)).where(RouteStop.route_id == route_id_uuid)).one()
|
||||||
# handle case where max_order is None (no stops)
|
# handle case where max_order is None (no stops)
|
||||||
stop_data.stop_order = (max_order or 0) + 1
|
stop_data.stop_order = (max_order or 0) + 1
|
||||||
else:
|
else:
|
||||||
# Shift existing stops equal to or greater than new order
|
# Shift existing stops equal to or greater than new order
|
||||||
existing_stops = session.exec(
|
existing_stops = session.exec(
|
||||||
select(RouteStop).where(RouteStop.route_id == route_id, RouteStop.stop_order >= stop_data.stop_order)
|
select(RouteStop).where(RouteStop.route_id == route_id_uuid, RouteStop.stop_order >= stop_data.stop_order)
|
||||||
).all()
|
).all()
|
||||||
for stop in existing_stops:
|
for stop in existing_stops:
|
||||||
stop.stop_order += 1
|
stop.stop_order += 1
|
||||||
@ -146,8 +161,8 @@ async def add_stop_to_route(
|
|||||||
|
|
||||||
# 3. Create new RouteStop
|
# 3. Create new RouteStop
|
||||||
new_stop = RouteStop(
|
new_stop = RouteStop(
|
||||||
route_id=route_id,
|
route_id=route_id_uuid,
|
||||||
stop_id=stop_data.stop_id,
|
stop_id=stop_id_uuid,
|
||||||
stop_order=stop_data.stop_order,
|
stop_order=stop_data.stop_order,
|
||||||
travel_time_minutes=stop_data.travel_time_minutes,
|
travel_time_minutes=stop_data.travel_time_minutes,
|
||||||
stop_delay_minutes=stop_data.stop_delay_minutes or 0,
|
stop_delay_minutes=stop_data.stop_delay_minutes or 0,
|
||||||
@ -168,12 +183,14 @@ async def update_route_stop_order(
|
|||||||
_: bool = Depends(get_current_admin)
|
_: bool = Depends(get_current_admin)
|
||||||
):
|
):
|
||||||
"""Update a route stop, potentially reordering others."""
|
"""Update a route stop, potentially reordering others."""
|
||||||
# This assumes we find the connection by route_id and stop_id.
|
from uuid import UUID
|
||||||
# NOTE: If a stop is on a route multiple times, this logic needs ID, but for now assuming unique stop per route.
|
try:
|
||||||
# Actually RouteStop has its own ID but we are using stop_id in path. Let's find the RouteStop entry.
|
route_id_uuid = UUID(route_id)
|
||||||
|
stop_id_uuid = UUID(stop_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid UUID format")
|
||||||
route_stop = session.exec(
|
route_stop = session.exec(
|
||||||
select(RouteStop).where(RouteStop.route_id == route_id, RouteStop.stop_id == stop_id)
|
select(RouteStop).where(RouteStop.route_id == route_id_uuid, RouteStop.stop_id == stop_id_uuid)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not route_stop:
|
if not route_stop:
|
||||||
@ -199,7 +216,7 @@ async def update_route_stop_order(
|
|||||||
# Moving down: shift stops between old+1 and new DOWN (-1)
|
# Moving down: shift stops between old+1 and new DOWN (-1)
|
||||||
stops_to_shift = session.exec(
|
stops_to_shift = session.exec(
|
||||||
select(RouteStop).where(
|
select(RouteStop).where(
|
||||||
RouteStop.route_id == route_id,
|
RouteStop.route_id == route_id_uuid,
|
||||||
RouteStop.stop_order > old_order,
|
RouteStop.stop_order > old_order,
|
||||||
RouteStop.stop_order <= new_order
|
RouteStop.stop_order <= new_order
|
||||||
)
|
)
|
||||||
@ -211,7 +228,7 @@ async def update_route_stop_order(
|
|||||||
# Moving up: shift stops between new and old-1 UP (+1)
|
# Moving up: shift stops between new and old-1 UP (+1)
|
||||||
stops_to_shift = session.exec(
|
stops_to_shift = session.exec(
|
||||||
select(RouteStop).where(
|
select(RouteStop).where(
|
||||||
RouteStop.route_id == route_id,
|
RouteStop.route_id == route_id_uuid,
|
||||||
RouteStop.stop_order >= new_order,
|
RouteStop.stop_order >= new_order,
|
||||||
RouteStop.stop_order < old_order
|
RouteStop.stop_order < old_order
|
||||||
)
|
)
|
||||||
@ -227,3 +244,45 @@ async def update_route_stop_order(
|
|||||||
session.refresh(route_stop)
|
session.refresh(route_stop)
|
||||||
return route_stop
|
return route_stop
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{route_id}/stops/{stop_id}")
|
||||||
|
async def remove_stop_from_route(
|
||||||
|
route_id: str,
|
||||||
|
stop_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_: bool = Depends(get_current_admin)
|
||||||
|
):
|
||||||
|
"""Remove a stop from a route."""
|
||||||
|
from uuid import UUID
|
||||||
|
try:
|
||||||
|
route_id_uuid = UUID(route_id)
|
||||||
|
stop_id_uuid = UUID(stop_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid UUID format")
|
||||||
|
|
||||||
|
route_stop = session.exec(
|
||||||
|
select(RouteStop).where(
|
||||||
|
RouteStop.route_id == route_id_uuid,
|
||||||
|
RouteStop.stop_id == stop_id_uuid
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not route_stop:
|
||||||
|
raise HTTPException(status_code=404, detail="Stop not found on this route")
|
||||||
|
|
||||||
|
# Re-order remaining stops
|
||||||
|
removed_order = route_stop.stop_order
|
||||||
|
remaining_stops = session.exec(
|
||||||
|
select(RouteStop).where(
|
||||||
|
RouteStop.route_id == route_id_uuid,
|
||||||
|
RouteStop.stop_order > removed_order
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for s in remaining_stops:
|
||||||
|
s.stop_order -= 1
|
||||||
|
session.add(s)
|
||||||
|
|
||||||
|
session.delete(route_stop)
|
||||||
|
session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|||||||
@ -8,9 +8,9 @@ from app.core.database import get_session
|
|||||||
from app.models.shuttle import Shuttle
|
from app.models.shuttle import Shuttle
|
||||||
from app.api.deps import get_current_admin
|
from app.api.deps import get_current_admin
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/shuttles", tags=["shuttles"])
|
from app.services.image_handler import save_image, delete_image
|
||||||
|
|
||||||
UPLOAD_DIR = "uploads"
|
router = APIRouter(prefix="/api/shuttles", tags=["shuttles"])
|
||||||
|
|
||||||
@router.get("", response_model=List[Shuttle])
|
@router.get("", response_model=List[Shuttle])
|
||||||
async def get_shuttles(
|
async def get_shuttles(
|
||||||
@ -63,12 +63,7 @@ async def create_shuttle(
|
|||||||
"""Create a new shuttle trip (Admin only)."""
|
"""Create a new shuttle trip (Admin only)."""
|
||||||
image_url = None
|
image_url = None
|
||||||
if image:
|
if image:
|
||||||
ext = os.path.splitext(image.filename)[1]
|
image_url = save_image(image, "vehicles")
|
||||||
filename = f"{uuid4()}{ext}"
|
|
||||||
path = os.path.join(UPLOAD_DIR, "vehicles", filename)
|
|
||||||
with open(path, "wb") as buffer:
|
|
||||||
shutil.copyfileobj(image.file, buffer)
|
|
||||||
image_url = f"/uploads/vehicles/{filename}"
|
|
||||||
|
|
||||||
shuttle = Shuttle(
|
shuttle = Shuttle(
|
||||||
route_name=route_name,
|
route_name=route_name,
|
||||||
@ -137,12 +132,10 @@ async def update_shuttle(
|
|||||||
db_shuttle.is_active = is_active
|
db_shuttle.is_active = is_active
|
||||||
|
|
||||||
if image:
|
if image:
|
||||||
ext = os.path.splitext(image.filename)[1]
|
# Delete old image if exists
|
||||||
filename = f"{uuid4()}{ext}"
|
if db_shuttle.image_url:
|
||||||
path = os.path.join(UPLOAD_DIR, "vehicles", filename)
|
delete_image(db_shuttle.image_url)
|
||||||
with open(path, "wb") as buffer:
|
db_shuttle.image_url = save_image(image, "vehicles")
|
||||||
shutil.copyfileobj(image.file, buffer)
|
|
||||||
db_shuttle.image_url = f"/uploads/vehicles/{filename}"
|
|
||||||
|
|
||||||
session.add(db_shuttle)
|
session.add(db_shuttle)
|
||||||
session.commit()
|
session.commit()
|
||||||
@ -159,6 +152,11 @@ async def delete_shuttle(
|
|||||||
db_shuttle = session.get(Shuttle, shuttle_id)
|
db_shuttle = session.get(Shuttle, shuttle_id)
|
||||||
if not db_shuttle:
|
if not db_shuttle:
|
||||||
raise HTTPException(status_code=404, detail="Shuttle not found")
|
raise HTTPException(status_code=404, detail="Shuttle not found")
|
||||||
|
|
||||||
|
# Delete image from storage
|
||||||
|
if db_shuttle.image_url:
|
||||||
|
delete_image(db_shuttle.image_url)
|
||||||
|
|
||||||
session.delete(db_shuttle)
|
session.delete(db_shuttle)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@ -8,11 +8,10 @@ from app.core.database import get_session
|
|||||||
from app.models.taxi import Taxi
|
from app.models.taxi import Taxi
|
||||||
from app.api.deps import get_current_admin
|
from app.api.deps import get_current_admin
|
||||||
|
|
||||||
|
from app.services.image_handler import save_image, delete_image
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/taxis", tags=["taxis"])
|
router = APIRouter(prefix="/api/taxis", tags=["taxis"])
|
||||||
|
|
||||||
UPLOAD_DIR = "uploads"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[Taxi])
|
@router.get("", response_model=List[Taxi])
|
||||||
async def get_taxis(
|
async def get_taxis(
|
||||||
corregimiento: Optional[str] = Query(None),
|
corregimiento: Optional[str] = Query(None),
|
||||||
@ -62,12 +61,7 @@ async def create_taxi(
|
|||||||
"""Create a new taxi entry (Admin only)."""
|
"""Create a new taxi entry (Admin only)."""
|
||||||
image_url = None
|
image_url = None
|
||||||
if image:
|
if image:
|
||||||
ext = os.path.splitext(image.filename)[1]
|
image_url = save_image(image, "profiles")
|
||||||
filename = f"{uuid4()}{ext}"
|
|
||||||
path = os.path.join(UPLOAD_DIR, "profiles", filename)
|
|
||||||
with open(path, "wb") as buffer:
|
|
||||||
shutil.copyfileobj(image.file, buffer)
|
|
||||||
image_url = f"/uploads/profiles/{filename}"
|
|
||||||
|
|
||||||
taxi = Taxi(
|
taxi = Taxi(
|
||||||
owner_name=owner_name,
|
owner_name=owner_name,
|
||||||
@ -121,12 +115,9 @@ async def update_taxi(
|
|||||||
|
|
||||||
# Handle image upload
|
# Handle image upload
|
||||||
if image:
|
if image:
|
||||||
ext = os.path.splitext(image.filename)[1]
|
if db_taxi.image_url:
|
||||||
filename = f"{uuid4()}{ext}"
|
delete_image(db_taxi.image_url)
|
||||||
path = os.path.join(UPLOAD_DIR, "profiles", filename)
|
db_taxi.image_url = save_image(image, "profiles")
|
||||||
with open(path, "wb") as buffer:
|
|
||||||
shutil.copyfileobj(image.file, buffer)
|
|
||||||
db_taxi.image_url = f"/uploads/profiles/{filename}"
|
|
||||||
|
|
||||||
session.add(db_taxi)
|
session.add(db_taxi)
|
||||||
session.commit()
|
session.commit()
|
||||||
@ -144,6 +135,10 @@ async def delete_taxi(
|
|||||||
db_taxi = session.get(Taxi, taxi_id)
|
db_taxi = session.get(Taxi, taxi_id)
|
||||||
if not db_taxi:
|
if not db_taxi:
|
||||||
raise HTTPException(status_code=404, detail="Taxi not found")
|
raise HTTPException(status_code=404, detail="Taxi not found")
|
||||||
|
|
||||||
|
if db_taxi.image_url:
|
||||||
|
delete_image(db_taxi.image_url)
|
||||||
|
|
||||||
session.delete(db_taxi)
|
session.delete(db_taxi)
|
||||||
session.commit()
|
session.commit()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@ -4,6 +4,10 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
# Absolute path for uploads directory (relative to this file: app/main.py -> backend/uploads)
|
||||||
|
_BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
UPLOAD_BASE = os.path.join(_BACKEND_DIR, "uploads")
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import init_db, engine
|
from app.core.database import init_db, engine
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
@ -31,6 +35,8 @@ from alembic import command
|
|||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from app.services.image_handler import cleanup_expired_coupons
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Run migrations
|
# Run migrations
|
||||||
@ -46,6 +52,13 @@ async def lifespan(app: FastAPI):
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Run cleanup of expired coupons
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
cleanup_expired_coupons(session)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WARNING: Initial cleanup failed: {e}")
|
||||||
|
|
||||||
# Seed sample data if empty
|
# Seed sample data if empty
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
# 1. Taxis
|
# 1. Taxis
|
||||||
@ -96,7 +109,7 @@ async def lifespan(app: FastAPI):
|
|||||||
biz = Business(name=spot["name"], address=f"Sector {spot['area']}", phone="6000-0000", category="Area Turistica", area=spot["area"], latitude=spot["lat"], longitude=spot["lng"])
|
biz = Business(name=spot["name"], address=f"Sector {spot['area']}", phone="6000-0000", category="Area Turistica", area=spot["area"], latitude=spot["lat"], longitude=spot["lng"])
|
||||||
session.add(biz)
|
session.add(biz)
|
||||||
session.flush()
|
session.flush()
|
||||||
coupon = Coupon(title=f"Oferta en {biz.name}", description=f"Descuento especial en {biz.name}", business_id=biz.id, business_name=biz.name, business_address=biz.address, business_phone=biz.phone, category=biz.category, discount_percentage=15, valid_from=datetime.now().isoformat(), valid_until=(datetime.now() + timedelta(days=30)).isoformat(), is_active=True)
|
coupon = Coupon(title=f"Oferta en {biz.name}", description=f"Descuento especial en {biz.name}", business_id=biz.id, business_name=biz.name, business_address=biz.address, business_phone=biz.phone, category=biz.category, discount_percentage=15, valid_from=datetime.now(), valid_until=(datetime.now() + timedelta(days=30)), is_active=True)
|
||||||
session.add(coupon)
|
session.add(coupon)
|
||||||
session.commit()
|
session.commit()
|
||||||
yield
|
yield
|
||||||
@ -128,11 +141,11 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Ensure upload directories exist
|
# Ensure upload directories exist
|
||||||
for sub in ["profiles", "vehicles", "businesses"]:
|
for sub in ["profiles", "vehicles", "businesses", "coupons"]:
|
||||||
os.makedirs(os.path.join("uploads", sub), exist_ok=True)
|
os.makedirs(os.path.join(UPLOAD_BASE, sub), exist_ok=True)
|
||||||
|
|
||||||
# Mount static files
|
# Mount static files
|
||||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
app.mount("/uploads", StaticFiles(directory=UPLOAD_BASE), name="uploads")
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(routes_router)
|
app.include_router(routes_router)
|
||||||
|
|||||||
79
backend/app/services/image_handler.py
Normal file
79
backend/app/services/image_handler.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from uuid import uuid4
|
||||||
|
from fastapi import UploadFile
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from app.models.coupon import Coupon
|
||||||
|
|
||||||
|
# Use absolute path relative to THIS file's location (app/services/image_handler.py)
|
||||||
|
# Going up 3 levels: services -> app -> backend -> uploads
|
||||||
|
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
UPLOAD_BASE = os.path.join(_BASE_DIR, "uploads")
|
||||||
|
|
||||||
|
|
||||||
|
def save_image(image: UploadFile, subfolder: str) -> str:
|
||||||
|
"""Saves an image to the local filesystem and returns its relative URL."""
|
||||||
|
upload_dir = os.path.join(UPLOAD_BASE, subfolder)
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
ext = os.path.splitext(image.filename or "")[1].lower()
|
||||||
|
if not ext or ext not in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif']:
|
||||||
|
ext = ".jpg" # Default extension if missing or invalid
|
||||||
|
|
||||||
|
filename = f"{uuid4()}{ext}"
|
||||||
|
path = os.path.join(upload_dir, filename)
|
||||||
|
|
||||||
|
# Reset file pointer to start before reading
|
||||||
|
image.file.seek(0)
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
with open(path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(image.file, buffer)
|
||||||
|
|
||||||
|
print(f"DEBUG: Saved image to {path}")
|
||||||
|
return f"/uploads/{subfolder}/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def delete_image(image_url: Optional[str]):
|
||||||
|
"""Deletes an image from the filesystem if it's stored locally."""
|
||||||
|
if not image_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
# We only delete if it points to our local uploads folder
|
||||||
|
if image_url.startswith("/uploads/"):
|
||||||
|
# Build absolute path
|
||||||
|
relative = image_url.lstrip("/") # e.g. "uploads/businesses/uuid.jpg"
|
||||||
|
file_path = os.path.join(_BASE_DIR, relative)
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
print(f"DEBUG: Deleted file {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Failed to delete {file_path}: {e}")
|
||||||
|
else:
|
||||||
|
print(f"DEBUG: File not found for deletion: {file_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired_coupons(session: Session):
|
||||||
|
"""Finds and deletes expired coupons and their associated images."""
|
||||||
|
try:
|
||||||
|
now = datetime.now()
|
||||||
|
# Coupons where valid_until is passed
|
||||||
|
statement = select(Coupon).where(Coupon.valid_until < now)
|
||||||
|
expired = session.exec(statement).all()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for coupon in expired:
|
||||||
|
if coupon.image_url:
|
||||||
|
delete_image(coupon.image_url)
|
||||||
|
session.delete(coupon)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
session.commit()
|
||||||
|
print(f"DEBUG: Cleaned up {count} expired coupons and their images.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: Cleanup failed: {e}")
|
||||||
@ -4,12 +4,16 @@ import { RouterView, useRoute } from "vue-router";
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import MainLayout from "./components/layouts/MainLayout.vue";
|
import MainLayout from "./components/layouts/MainLayout.vue";
|
||||||
import { useThemeStore } from './stores/theme'
|
import { useThemeStore } from './stores/theme'
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { useFavoritesStore } from './stores/favorites'
|
||||||
import { analyticsService } from '@/services/analyticsService'
|
import { analyticsService } from '@/services/analyticsService'
|
||||||
|
|
||||||
// Initialize theme store
|
// Initialize theme store
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const favoritesStore = useFavoritesStore()
|
||||||
|
|
||||||
const isSplashScreen = computed(() => route.name === 'splash')
|
const isSplashScreen = computed(() => route.name === 'splash')
|
||||||
const isAuthScreen = computed(() => route.name === 'auth' || route.path === '/login')
|
const isAuthScreen = computed(() => route.name === 'auth' || route.path === '/login')
|
||||||
@ -20,6 +24,10 @@ onMounted(() => {
|
|||||||
event_name: 'app_open',
|
event_name: 'app_open',
|
||||||
properties: { language: locale.value }
|
properties: { language: locale.value }
|
||||||
})
|
})
|
||||||
|
// Load favorites if the user is already logged in
|
||||||
|
if (authStore.isAuthenticated) {
|
||||||
|
favoritesStore.loadFavorites()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -14,12 +14,20 @@ const navItems = [
|
|||||||
{ name: 'taxi', path: '/taxi', icon: 'directions_bus' }
|
{ name: 'taxi', path: '/taxi', icon: 'directions_bus' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
let isNavigating = false
|
||||||
|
|
||||||
const navigateTo = (path: string) => {
|
const navigateTo = (path: string) => {
|
||||||
router.push(path)
|
// Prevent rapid multiple navigations (debounce guard)
|
||||||
|
if (isNavigating) return
|
||||||
|
if (route.path === path) return
|
||||||
|
isNavigating = true
|
||||||
|
router.push(path).finally(() => {
|
||||||
|
setTimeout(() => { isNavigating = false }, 300)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
return route.path === path
|
return route.path === path || route.path.startsWith(path + '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll detection logic
|
// Scroll detection logic
|
||||||
|
|||||||
38
frontend/src/utils/imageUrl.ts
Normal file
38
frontend/src/utils/imageUrl.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { API_URL } from '@/services/apiClient'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a full URL for an image path.
|
||||||
|
* Handles null/undefined paths, absolute URLs, and relative backend paths.
|
||||||
|
*/
|
||||||
|
export function getImageUrl(path?: string | null, type: 'taxi' | 'shuttle' | 'business' | 'coupon' = 'business') {
|
||||||
|
if (!path) {
|
||||||
|
const defaultNames: Record<string, string> = {
|
||||||
|
taxi: 'Taxi',
|
||||||
|
shuttle: 'Transporte',
|
||||||
|
business: 'Negocio',
|
||||||
|
coupon: 'Oferta'
|
||||||
|
}
|
||||||
|
const name = defaultNames[type] || 'SIBU'
|
||||||
|
return `https://ui-avatars.com/api/?name=${name}&background=fee715&color=101820&size=256&bold=true`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith('http')) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure path starts with / for joining with API_URL
|
||||||
|
const cleanPath = path.startsWith('/') ? path : `/${path}`
|
||||||
|
|
||||||
|
// Ensure API_URL includes protocol if missing (safety check)
|
||||||
|
let cleanBaseUrl = API_URL.trim()
|
||||||
|
if (!cleanBaseUrl.startsWith('http')) {
|
||||||
|
cleanBaseUrl = `https://${cleanBaseUrl}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash from base URL
|
||||||
|
if (cleanBaseUrl.endsWith('/')) {
|
||||||
|
cleanBaseUrl = cleanBaseUrl.slice(0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${cleanBaseUrl}${cleanPath}`
|
||||||
|
}
|
||||||
@ -211,7 +211,8 @@ async function saveShuttle() {
|
|||||||
|
|
||||||
<div class="preview-container">
|
<div class="preview-container">
|
||||||
<!-- PREVIEW CARD -->
|
<!-- PREVIEW CARD -->
|
||||||
<div class="shuttle-card-preview" :class="{ expanded: true }" :style="{ backgroundImage: `url(${previewImageUrl})` }">
|
<div class="shuttle-card-preview" :class="{ expanded: true }">
|
||||||
|
<img :src="previewImageUrl" class="shuttle-card-bg" @error="(e) => (e.target as HTMLImageElement).src = 'https://images.unsplash.com/photo-1449034446853-66c86144b0ad?q=80&w=2070&auto=format&fit=crop'" />
|
||||||
<div class="shuttle-main-info">
|
<div class="shuttle-main-info">
|
||||||
<div class="shuttle-header-mini">
|
<div class="shuttle-header-mini">
|
||||||
<div class="company-badge">
|
<div class="company-badge">
|
||||||
@ -516,12 +517,11 @@ async function saveShuttle() {
|
|||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 30px 60px rgba(0,0,0,0.5);
|
box-shadow: 0 30px 60px rgba(0,0,0,0.5);
|
||||||
|
background: #101820;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shuttle-card-preview::before {
|
.shuttle-card-preview::before {
|
||||||
@ -534,7 +534,7 @@ async function saveShuttle() {
|
|||||||
rgba(0, 0, 0, 0.65) 55%,
|
rgba(0, 0, 0, 0.65) 55%,
|
||||||
rgba(0, 0, 0, 0.30) 100%
|
rgba(0, 0, 0, 0.30) 100%
|
||||||
);
|
);
|
||||||
z-index: 0;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shuttle-card-preview.expanded {
|
.shuttle-card-preview.expanded {
|
||||||
@ -553,7 +553,7 @@ async function saveShuttle() {
|
|||||||
.shuttle-main-info,
|
.shuttle-main-info,
|
||||||
.shuttle-details {
|
.shuttle-details {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { businessService } from '@/services/businessService'
|
import { businessService } from '@/services/businessService'
|
||||||
import { API_URL } from '@/services/apiClient'
|
|
||||||
import type { Business } from '@/types'
|
import type { Business } from '@/types'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import FavoriteButton from '@/components/FavoriteButton.vue'
|
import FavoriteButton from '@/components/FavoriteButton.vue'
|
||||||
import { analyticsService } from '@/services/analyticsService'
|
import { analyticsService } from '@/services/analyticsService'
|
||||||
|
import { getImageUrl } from '@/utils/imageUrl'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const businesses = ref<Business[]>([])
|
const businesses = ref<Business[]>([])
|
||||||
@ -98,11 +98,6 @@ function handleExplore(biz: Business) {
|
|||||||
router.push('/business/' + biz.id)
|
router.push('/business/' + biz.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageUrl(path?: string | null) {
|
|
||||||
if (!path) return `https://ui-avatars.com/api/?name=Negocio&background=fee715&color=101820&size=200&bold=true`
|
|
||||||
if (path.startsWith('http')) return path
|
|
||||||
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
selectedCategory.value = 'Todas'
|
selectedCategory.value = 'Todas'
|
||||||
@ -209,7 +204,12 @@ function resetFilters() {
|
|||||||
@click="handleExplore(biz)"
|
@click="handleExplore(biz)"
|
||||||
>
|
>
|
||||||
<div class="biz-img-wrap">
|
<div class="biz-img-wrap">
|
||||||
<img :src="getImageUrl(biz.image_url)" :alt="biz.name" class="biz-img" />
|
<img
|
||||||
|
:src="getImageUrl(biz.image_url, 'business')"
|
||||||
|
:alt="biz.name"
|
||||||
|
class="biz-img"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'business')"
|
||||||
|
/>
|
||||||
<div class="biz-fav">
|
<div class="biz-fav">
|
||||||
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
|
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
|
||||||
</div>
|
</div>
|
||||||
@ -274,7 +274,12 @@ function resetFilters() {
|
|||||||
class="featured-card"
|
class="featured-card"
|
||||||
@click="handleExplore(biz)"
|
@click="handleExplore(biz)"
|
||||||
>
|
>
|
||||||
<img :src="getImageUrl(biz.image_url)" :alt="biz.name" class="featured-img" />
|
<img
|
||||||
|
:src="getImageUrl(biz.image_url, 'business')"
|
||||||
|
:alt="biz.name"
|
||||||
|
class="featured-img"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'business')"
|
||||||
|
/>
|
||||||
<div class="featured-gradient"></div>
|
<div class="featured-gradient"></div>
|
||||||
<div class="featured-fav">
|
<div class="featured-fav">
|
||||||
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
|
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
|
||||||
@ -305,7 +310,12 @@ function resetFilters() {
|
|||||||
@click="handleExplore(biz)"
|
@click="handleExplore(biz)"
|
||||||
>
|
>
|
||||||
<div class="biz-img-wrap">
|
<div class="biz-img-wrap">
|
||||||
<img :src="getImageUrl(biz.image_url)" :alt="biz.name" class="biz-img" />
|
<img
|
||||||
|
:src="getImageUrl(biz.image_url, 'business')"
|
||||||
|
:alt="biz.name"
|
||||||
|
class="biz-img"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'business')"
|
||||||
|
/>
|
||||||
<div class="biz-fav">
|
<div class="biz-fav">
|
||||||
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
|
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import { useMapStore } from "@/stores/map";
|
|||||||
import { useBusStopStore } from "@/stores/busStop";
|
import { useBusStopStore } from "@/stores/busStop";
|
||||||
import { useCouponStore } from "@/stores/coupon";
|
import { useCouponStore } from "@/stores/coupon";
|
||||||
import { useGoogleMaps } from "@/composables/useGoogleMaps";
|
import { useGoogleMaps } from "@/composables/useGoogleMaps";
|
||||||
import { API_URL } from "@/services/apiClient";
|
|
||||||
import { telemetryService } from "@/services/telemetryService";
|
import { telemetryService } from "@/services/telemetryService";
|
||||||
import { analyticsService } from "@/services/analyticsService";
|
import { analyticsService } from "@/services/analyticsService";
|
||||||
|
import { getImageUrl } from "@/utils/imageUrl";
|
||||||
|
|
||||||
import BusStopInfoModal from "@/components/BusStopInfoModal.vue";
|
import BusStopInfoModal from "@/components/BusStopInfoModal.vue";
|
||||||
import type { BusStop } from '@/types'
|
import type { BusStop } from '@/types'
|
||||||
@ -194,11 +194,6 @@ function closePromoModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function getImageUrl(path: string | null | undefined) {
|
|
||||||
if (!path) return '/default-coupon.png'
|
|
||||||
if (path.startsWith('http')) return path
|
|
||||||
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function claimPromo() {
|
async function claimPromo() {
|
||||||
if (!selectedPromo.value) return
|
if (!selectedPromo.value) return
|
||||||
@ -1150,7 +1145,12 @@ function clearNavigation() {
|
|||||||
>
|
>
|
||||||
<!-- Image -->
|
<!-- Image -->
|
||||||
<div class="sheet-img-wrap">
|
<div class="sheet-img-wrap">
|
||||||
<img :src="getImageUrl(currentPromo.image_url)" class="sheet-img" :alt="currentPromo.title" />
|
<img
|
||||||
|
:src="getImageUrl(currentPromo.image_url, 'coupon')"
|
||||||
|
class="sheet-img"
|
||||||
|
:alt="currentPromo.title"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'coupon')"
|
||||||
|
/>
|
||||||
<span v-if="currentPromo.discount_percentage" class="sheet-discount">
|
<span v-if="currentPromo.discount_percentage" class="sheet-discount">
|
||||||
-{{ currentPromo.discount_percentage }}%
|
-{{ currentPromo.discount_percentage }}%
|
||||||
</span>
|
</span>
|
||||||
@ -1203,7 +1203,11 @@ function clearNavigation() {
|
|||||||
<div v-if="showPromoModal && selectedPromo" class="promo-modal-overlay" @click="closePromoModal">
|
<div v-if="showPromoModal && selectedPromo" class="promo-modal-overlay" @click="closePromoModal">
|
||||||
<div class="promo-modal-content" @click.stop>
|
<div class="promo-modal-content" @click.stop>
|
||||||
<div class="promo-header-modal">
|
<div class="promo-header-modal">
|
||||||
<img :src="getImageUrl(selectedPromo.image_url)" class="promo-img-modal" />
|
<img
|
||||||
|
:src="getImageUrl(selectedPromo.image_url, 'coupon')"
|
||||||
|
class="promo-img-modal"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'coupon')"
|
||||||
|
/>
|
||||||
<div class="promo-badge-modal">PROMO</div>
|
<div class="promo-badge-modal">PROMO</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="promo-body-modal">
|
<div class="promo-body-modal">
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useTaxiStore } from '@/stores/taxi'
|
import { useTaxiStore } from '@/stores/taxi'
|
||||||
import { useShuttleStore } from '@/stores/shuttle'
|
import { useShuttleStore } from '@/stores/shuttle'
|
||||||
import { analyticsService } from '@/services/analyticsService'
|
import { analyticsService } from '@/services/analyticsService'
|
||||||
import { API_URL } from '@/services/apiClient'
|
|
||||||
import type { Taxi, Shuttle } from '@/types'
|
import type { Taxi, Shuttle } from '@/types'
|
||||||
import FavoriteButton from '@/components/FavoriteButton.vue'
|
import FavoriteButton from '@/components/FavoriteButton.vue'
|
||||||
|
import { getImageUrl } from '@/utils/imageUrl'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const taxiStore = useTaxiStore()
|
const taxiStore = useTaxiStore()
|
||||||
@ -74,11 +74,6 @@ const filteredTaxis = computed(() => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function getImageUrl(path?: string) {
|
|
||||||
if (!path) return `https://ui-avatars.com/api/?name=Taxi&background=fee715&color=101820`
|
|
||||||
if (path.startsWith('http')) return path
|
|
||||||
return `${API_URL}${path.startsWith('/') ? '' : '/'}${path}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCall = (taxi: Taxi) => {
|
const handleCall = (taxi: Taxi) => {
|
||||||
analyticsService.logEvent({
|
analyticsService.logEvent({
|
||||||
@ -195,7 +190,11 @@ function getShiftLabel(shift: string) {
|
|||||||
<div v-for="taxi in filteredTaxis" :key="taxi.id" class="taxi-card-new">
|
<div v-for="taxi in filteredTaxis" :key="taxi.id" class="taxi-card-new">
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
<div class="driver-avatar">
|
<div class="driver-avatar">
|
||||||
<img :src="getImageUrl(taxi.image_url)" alt="Driver">
|
<img
|
||||||
|
:src="getImageUrl(taxi.image_url, 'taxi')"
|
||||||
|
alt="Driver"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'taxi')"
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="driver-info">
|
<div class="driver-info">
|
||||||
<h3>{{ taxi.owner_name }}</h3>
|
<h3>{{ taxi.owner_name }}</h3>
|
||||||
@ -288,7 +287,6 @@ function getShiftLabel(shift: string) {
|
|||||||
:ref="el => setShuttleRef(el, shuttle.id)"
|
:ref="el => setShuttleRef(el, shuttle.id)"
|
||||||
class="shuttle-card"
|
class="shuttle-card"
|
||||||
:class="{ expanded: expandedShuttleId === shuttle.id }"
|
:class="{ expanded: expandedShuttleId === shuttle.id }"
|
||||||
:style="{ backgroundImage: `url(${getImageUrl(shuttle.image_url)})` }"
|
|
||||||
@click="() => {
|
@click="() => {
|
||||||
expandedShuttleId = expandedShuttleId === shuttle.id ? null : shuttle.id;
|
expandedShuttleId = expandedShuttleId === shuttle.id ? null : shuttle.id;
|
||||||
if (expandedShuttleId === shuttle.id) {
|
if (expandedShuttleId === shuttle.id) {
|
||||||
@ -296,6 +294,11 @@ function getShiftLabel(shift: string) {
|
|||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
:src="getImageUrl(shuttle.image_url, 'shuttle')"
|
||||||
|
class="shuttle-card-bg"
|
||||||
|
@error="(e) => (e.target as HTMLImageElement).src = getImageUrl(null, 'shuttle')"
|
||||||
|
/>
|
||||||
<!-- Collapsed info (always visible) -->
|
<!-- Collapsed info (always visible) -->
|
||||||
<div class="shuttle-main-info">
|
<div class="shuttle-main-info">
|
||||||
<div class="shuttle-header-mini">
|
<div class="shuttle-header-mini">
|
||||||
@ -502,16 +505,24 @@ function getShiftLabel(shift: string) {
|
|||||||
/* ---- La tarjeta base ---- */
|
/* ---- La tarjeta base ---- */
|
||||||
.shuttle-card {
|
.shuttle-card {
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(255,255,255,0.12);
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 170px;
|
min-height: 170px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #0d1b2a 0%, #1a2a40 50%, #101820 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shuttle-card-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Overlay oscuro base (compacto) */
|
/* Overlay oscuro base (compacto) */
|
||||||
@ -525,7 +536,7 @@ function getShiftLabel(shift: string) {
|
|||||||
rgba(0, 0, 0, 0.65) 55%,
|
rgba(0, 0, 0, 0.65) 55%,
|
||||||
rgba(0, 0, 0, 0.30) 100%
|
rgba(0, 0, 0, 0.30) 100%
|
||||||
);
|
);
|
||||||
z-index: 0;
|
z-index: 1;
|
||||||
transition: background 0.4s ease;
|
transition: background 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -550,7 +561,7 @@ function getShiftLabel(shift: string) {
|
|||||||
.shuttle-main-info,
|
.shuttle-main-info,
|
||||||
.shuttle-details {
|
.shuttle-details {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
padding: 18px 20px;
|
padding: 18px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user