fix: critical bug fixes - routes UUID, image paths, favorites loading, bottom nav debounce

This commit is contained in:
2026-02-25 16:29:13 -05:00
parent c449083171
commit fd95df461b
14 changed files with 379 additions and 116 deletions

View File

@ -6,6 +6,8 @@ from app.models.business import Business
from app.models.user import User, UserRole
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.get("", response_model=List[Business])
@ -41,17 +43,7 @@ async def create_business(
image_url = None
if image:
import os
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}"
image_url = save_image(image, "businesses")
db_business = Business(
name=name,
@ -110,17 +102,9 @@ async def update_business(
db_business.longitude = longitude
if image:
import os
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)
db_business.image_url = f"/uploads/businesses/{filename}"
if db_business.image_url:
delete_image(db_business.image_url)
db_business.image_url = save_image(image, "businesses")
session.add(db_business)
session.commit()
@ -153,6 +137,9 @@ async def delete_business(
if not db_business:
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.commit()
return {"status": "success", "message": "Business deleted"}

View File

@ -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 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.models.coupon import Coupon, CouponCreate, CouponUpdate
from app.models.coupon import Coupon
from app.models.user import User, UserRole
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"])
@ -27,7 +30,19 @@ async def list_coupons(
async def create_coupon(
*,
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)
):
"""Create a new coupon (Promoters and Admins only)."""
@ -37,7 +52,25 @@ async def create_coupon(
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.commit()
session.refresh(db_coupon)
@ -47,8 +80,13 @@ async def create_coupon(
async def update_coupon(
*,
session: Session = Depends(get_session),
coupon_id: str,
coupon_in: CouponUpdate,
coupon_id: UUID,
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)
):
"""Update a coupon (Promoters and Admins only)."""
@ -62,9 +100,21 @@ async def update_coupon(
if not db_coupon:
raise HTTPException(status_code=404, detail="Coupon not found")
coupon_data = coupon_in.dict(exclude_unset=True)
for key, value in coupon_data.items():
setattr(db_coupon, key, value)
if title is not None:
db_coupon.title = title
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.commit()
@ -75,7 +125,7 @@ async def update_coupon(
async def delete_coupon(
*,
session: Session = Depends(get_session),
coupon_id: str,
coupon_id: UUID,
current_user: User = Depends(get_current_user)
):
"""Delete a coupon (Promoters and Admins only)."""
@ -89,6 +139,9 @@ async def delete_coupon(
if not db_coupon:
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.commit()
return {"status": "success", "message": "Coupon deleted"}

View File

@ -46,16 +46,24 @@ async def get_route_stops(route_id: str, session: Session = Depends(get_session)
"""Get all stops for a route."""
from app.models.route_stop import RouteStop
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(
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()
# Merge RouteStop data into BusStop response
stops = []
for route_stop, bus_stop in results:
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['travel_time_minutes'] = route_stop.travel_time_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)
):
"""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
route = session.get(Route, route_id)
route = session.get(Route, route_id_uuid)
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# 2. Determine stop order
if stop_data.stop_order is None:
# 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)
stop_data.stop_order = (max_order or 0) + 1
else:
# Shift existing stops equal to or greater than new order
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()
for stop in existing_stops:
stop.stop_order += 1
@ -146,8 +161,8 @@ async def add_stop_to_route(
# 3. Create new RouteStop
new_stop = RouteStop(
route_id=route_id,
stop_id=stop_data.stop_id,
route_id=route_id_uuid,
stop_id=stop_id_uuid,
stop_order=stop_data.stop_order,
travel_time_minutes=stop_data.travel_time_minutes,
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)
):
"""Update a route stop, potentially reordering others."""
# This assumes we find the connection by route_id and stop_id.
# NOTE: If a stop is on a route multiple times, this logic needs ID, but for now assuming unique stop per route.
# Actually RouteStop has its own ID but we are using stop_id in path. Let's find the RouteStop entry.
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, RouteStop.stop_id == stop_id)
select(RouteStop).where(RouteStop.route_id == route_id_uuid, RouteStop.stop_id == stop_id_uuid)
).first()
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)
stops_to_shift = session.exec(
select(RouteStop).where(
RouteStop.route_id == route_id,
RouteStop.route_id == route_id_uuid,
RouteStop.stop_order > old_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)
stops_to_shift = session.exec(
select(RouteStop).where(
RouteStop.route_id == route_id,
RouteStop.route_id == route_id_uuid,
RouteStop.stop_order >= new_order,
RouteStop.stop_order < old_order
)
@ -227,3 +244,45 @@ async def update_route_stop_order(
session.refresh(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}

View File

@ -8,9 +8,9 @@ from app.core.database import get_session
from app.models.shuttle import Shuttle
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])
async def get_shuttles(
@ -63,12 +63,7 @@ async def create_shuttle(
"""Create a new shuttle trip (Admin only)."""
image_url = None
if image:
ext = os.path.splitext(image.filename)[1]
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}"
image_url = save_image(image, "vehicles")
shuttle = Shuttle(
route_name=route_name,
@ -137,12 +132,10 @@ async def update_shuttle(
db_shuttle.is_active = is_active
if image:
ext = os.path.splitext(image.filename)[1]
filename = f"{uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, "vehicles", filename)
with open(path, "wb") as buffer:
shutil.copyfileobj(image.file, buffer)
db_shuttle.image_url = f"/uploads/vehicles/{filename}"
# Delete old image if exists
if db_shuttle.image_url:
delete_image(db_shuttle.image_url)
db_shuttle.image_url = save_image(image, "vehicles")
session.add(db_shuttle)
session.commit()
@ -159,6 +152,11 @@ async def delete_shuttle(
db_shuttle = session.get(Shuttle, shuttle_id)
if not db_shuttle:
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.commit()
return {"ok": True}

View File

@ -8,11 +8,10 @@ from app.core.database import get_session
from app.models.taxi import Taxi
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"])
UPLOAD_DIR = "uploads"
@router.get("", response_model=List[Taxi])
async def get_taxis(
corregimiento: Optional[str] = Query(None),
@ -62,12 +61,7 @@ async def create_taxi(
"""Create a new taxi entry (Admin only)."""
image_url = None
if image:
ext = os.path.splitext(image.filename)[1]
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}"
image_url = save_image(image, "profiles")
taxi = Taxi(
owner_name=owner_name,
@ -121,12 +115,9 @@ async def update_taxi(
# Handle image upload
if image:
ext = os.path.splitext(image.filename)[1]
filename = f"{uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, "profiles", filename)
with open(path, "wb") as buffer:
shutil.copyfileobj(image.file, buffer)
db_taxi.image_url = f"/uploads/profiles/{filename}"
if db_taxi.image_url:
delete_image(db_taxi.image_url)
db_taxi.image_url = save_image(image, "profiles")
session.add(db_taxi)
session.commit()
@ -144,6 +135,10 @@ async def delete_taxi(
db_taxi = session.get(Taxi, taxi_id)
if not db_taxi:
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.commit()
return {"ok": True}

View File

@ -4,6 +4,10 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
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.database import init_db, engine
from sqlmodel import Session, select
@ -31,6 +35,8 @@ from alembic import command
import random
from datetime import datetime, timedelta
from app.services.image_handler import cleanup_expired_coupons
@asynccontextmanager
async def lifespan(app: FastAPI):
# Run migrations
@ -46,6 +52,13 @@ async def lifespan(app: FastAPI):
except:
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
with Session(engine) as session:
# 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"])
session.add(biz)
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.commit()
yield
@ -128,11 +141,11 @@ app.add_middleware(
)
# Ensure upload directories exist
for sub in ["profiles", "vehicles", "businesses"]:
os.makedirs(os.path.join("uploads", sub), exist_ok=True)
for sub in ["profiles", "vehicles", "businesses", "coupons"]:
os.makedirs(os.path.join(UPLOAD_BASE, sub), exist_ok=True)
# Mount static files
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
app.mount("/uploads", StaticFiles(directory=UPLOAD_BASE), name="uploads")
# Include routers
app.include_router(routes_router)

View 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}")

View File

@ -4,12 +4,16 @@ import { RouterView, useRoute } from "vue-router";
import { useI18n } from 'vue-i18n'
import MainLayout from "./components/layouts/MainLayout.vue";
import { useThemeStore } from './stores/theme'
import { useAuthStore } from './stores/auth'
import { useFavoritesStore } from './stores/favorites'
import { analyticsService } from '@/services/analyticsService'
// Initialize theme store
const route = useRoute()
const { locale } = useI18n()
const themeStore = useThemeStore()
const authStore = useAuthStore()
const favoritesStore = useFavoritesStore()
const isSplashScreen = computed(() => route.name === 'splash')
const isAuthScreen = computed(() => route.name === 'auth' || route.path === '/login')
@ -20,6 +24,10 @@ onMounted(() => {
event_name: 'app_open',
properties: { language: locale.value }
})
// Load favorites if the user is already logged in
if (authStore.isAuthenticated) {
favoritesStore.loadFavorites()
}
})
</script>

View File

@ -14,12 +14,20 @@ const navItems = [
{ name: 'taxi', path: '/taxi', icon: 'directions_bus' }
]
let isNavigating = false
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) => {
return route.path === path
return route.path === path || route.path.startsWith(path + '/')
}
// Scroll detection logic

View 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}`
}

View File

@ -211,7 +211,8 @@ async function saveShuttle() {
<div class="preview-container">
<!-- 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-header-mini">
<div class="company-badge">
@ -516,12 +517,11 @@ async function saveShuttle() {
border-radius: 20px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.12);
background-size: cover;
background-position: center;
position: relative;
display: flex;
flex-direction: column;
box-shadow: 0 30px 60px rgba(0,0,0,0.5);
background: #101820;
}
.shuttle-card-preview::before {
@ -534,7 +534,7 @@ async function saveShuttle() {
rgba(0, 0, 0, 0.65) 55%,
rgba(0, 0, 0, 0.30) 100%
);
z-index: 0;
z-index: 1;
}
.shuttle-card-preview.expanded {
@ -553,7 +553,7 @@ async function saveShuttle() {
.shuttle-main-info,
.shuttle-details {
position: relative;
z-index: 1;
z-index: 2;
padding: 18px 20px;
}

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { businessService } from '@/services/businessService'
import { API_URL } from '@/services/apiClient'
import type { Business } from '@/types'
import { useRouter } from 'vue-router'
import FavoriteButton from '@/components/FavoriteButton.vue'
import { analyticsService } from '@/services/analyticsService'
import { getImageUrl } from '@/utils/imageUrl'
const router = useRouter()
const businesses = ref<Business[]>([])
@ -98,11 +98,6 @@ function handleExplore(biz: Business) {
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() {
selectedCategory.value = 'Todas'
@ -209,7 +204,12 @@ function resetFilters() {
@click="handleExplore(biz)"
>
<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">
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
</div>
@ -274,7 +274,12 @@ function resetFilters() {
class="featured-card"
@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-fav">
<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)"
>
<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">
<FavoriteButton item-type="business" :item-id="biz.id" :item-name="biz.name" :item-image="biz.image_url || undefined" />
</div>

View File

@ -7,9 +7,9 @@ import { useMapStore } from "@/stores/map";
import { useBusStopStore } from "@/stores/busStop";
import { useCouponStore } from "@/stores/coupon";
import { useGoogleMaps } from "@/composables/useGoogleMaps";
import { API_URL } from "@/services/apiClient";
import { telemetryService } from "@/services/telemetryService";
import { analyticsService } from "@/services/analyticsService";
import { getImageUrl } from "@/utils/imageUrl";
import BusStopInfoModal from "@/components/BusStopInfoModal.vue";
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() {
if (!selectedPromo.value) return
@ -1150,7 +1145,12 @@ function clearNavigation() {
>
<!-- Image -->
<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">
-{{ currentPromo.discount_percentage }}%
</span>
@ -1203,7 +1203,11 @@ function clearNavigation() {
<div v-if="showPromoModal && selectedPromo" class="promo-modal-overlay" @click="closePromoModal">
<div class="promo-modal-content" @click.stop>
<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>
<div class="promo-body-modal">

View File

@ -4,9 +4,9 @@ import { useI18n } from 'vue-i18n'
import { useTaxiStore } from '@/stores/taxi'
import { useShuttleStore } from '@/stores/shuttle'
import { analyticsService } from '@/services/analyticsService'
import { API_URL } from '@/services/apiClient'
import type { Taxi, Shuttle } from '@/types'
import FavoriteButton from '@/components/FavoriteButton.vue'
import { getImageUrl } from '@/utils/imageUrl'
const { t } = useI18n()
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) => {
analyticsService.logEvent({
@ -195,7 +190,11 @@ function getShiftLabel(shift: string) {
<div v-for="taxi in filteredTaxis" :key="taxi.id" class="taxi-card-new">
<div class="card-top">
<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 class="driver-info">
<h3>{{ taxi.owner_name }}</h3>
@ -288,7 +287,6 @@ function getShiftLabel(shift: string) {
:ref="el => setShuttleRef(el, shuttle.id)"
class="shuttle-card"
:class="{ expanded: expandedShuttleId === shuttle.id }"
:style="{ backgroundImage: `url(${getImageUrl(shuttle.image_url)})` }"
@click="() => {
expandedShuttleId = expandedShuttleId === shuttle.id ? null : 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) -->
<div class="shuttle-main-info">
<div class="shuttle-header-mini">
@ -502,16 +505,24 @@ function getShiftLabel(shift: string) {
/* ---- La tarjeta base ---- */
.shuttle-card {
border-radius: 20px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.12);
background-size: cover;
background-position: center;
position: relative;
min-height: 170px;
display: flex;
flex-direction: column;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
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) */
@ -525,7 +536,7 @@ function getShiftLabel(shift: string) {
rgba(0, 0, 0, 0.65) 55%,
rgba(0, 0, 0, 0.30) 100%
);
z-index: 0;
z-index: 1;
transition: background 0.4s ease;
}
@ -550,7 +561,7 @@ function getShiftLabel(shift: string) {
.shuttle-main-info,
.shuttle-details {
position: relative;
z-index: 1;
z-index: 2;
padding: 18px 20px;
}