- Parte 3 : Viaggi

- Chat
This commit is contained in:
Surya Paolo
2025-12-24 00:26:29 +01:00
parent 11e946bfc6
commit 11c17bdd8e
126 changed files with 3580 additions and 2259 deletions

View File

@@ -2141,13 +2141,33 @@
<li>Trasporti</li>
</ul>
</div>
<div class="comparison-card">
<div class="card-title">
<q-icon name="luggage" size="24px" color="primary" />
<span>Viaggi</span>
</div>
<div class="card-content">
<div class="feature-item">
<q-icon name="people" size="18px" color="positive" />
<span>Condivisione passaggi</span>
</div>
<div class="feature-item">
<q-icon name="inventory_2" size="18px" color="info" />
<span>Trasporto merci e pacchi</span>
</div>
<div class="feature-item">
<q-icon name="pets" size="18px" color="warning" />
<span>Trasporto animali domestici</span>
</div>
</div>
</div>
<p><strong>Come decidere il prezzo?</strong> Usa il riferimento euro (20€ → 20 RIS). Il mercato
ti
darà feedback: se nessuno compra, abbassa; se vendi tutto subito, alza leggermente.</p>
</div>
<p><strong>Come decidere il prezzo?</strong> Usa il riferimento euro (20€ → 20 RIS). Il mercato ti
darà feedback: se nessuno compra, abbassa; se vendi tutto subito, alza leggermente.</p>
</div>
</div>
</details>

View File

@@ -715,7 +715,7 @@
}
&.q-btn--unelevated {
background: linear-gradient(135deg, currentColor, color-darken($primary-color, 5%));
background: linear-gradient(135deg, currentCoalor, color.adjust($primary-color, $lightness: -5%));
}
}

View File

@@ -110,7 +110,7 @@
}
.q-item:hover & {
background: linear-gradient(135deg, color-darken($primary-color, 5%), $primary-color);
background: linear-gradient(135deg, color-adjust($primary-color, lightness, 5%), $primary-color);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -163,7 +163,7 @@
strong {
font-weight: 600;
color: color-darken($grey-text, 10%);
color: color.adjust($grey-text, $lightness: -10%);
}
em {

View File

@@ -100,10 +100,9 @@ export default defineComponent({
$router.push('/hosps')
};
const goToTransport = () => {
const goToViaggi = () => {
showAnnunciDialog.value = false;
// TODO: navigare a /transport (da creare?)
// $router.push('/transport')
$router.push('/viaggi')
};
// Hero Cards
@@ -275,7 +274,7 @@ export default defineComponent({
goToGoods,
goToServices,
goToHospitality,
goToTransport,
goToViaggi,
goToWallet,
goToEvents,
goToProfile,

View File

@@ -221,14 +221,14 @@
<span class="option-subtitle">Ospitare · Viaggi · Accoglienza</span>
</div>
<div class="annuncio-option gradient-teal">
<div class="annuncio-option gradient-teal"
@click="goToViaggi">
<q-icon
name="directions_car"
size="2.5rem"
/>
<span class="option-title">Trasporti</span>
<span class="option-subtitle">Condivisione viaggi</span>
<span class="option-subtitle"> (IN ARRIVO...)</span>
<span class="option-title">Viaggi</span>
<span class="option-subtitle">Condivisione passaggi e trasporti</span>
</div>
</div>
</q-card-section>

View File

@@ -86,28 +86,6 @@ export default defineComponent({
showAnnunciDialog.value = true;
};
// Navigazione Annunci
const goToGoods = () => {
showAnnunciDialog.value = false;
$router.push('/goods')
};
const goToServices = () => {
showAnnunciDialog.value = false;
$router.push('/services')
};
const goToHospitality = () => {
showAnnunciDialog.value = false;
$router.push('/hosps')
};
const goToTransport = () => {
showAnnunciDialog.value = false;
// TODO: navigare a /transport (da creare?)
// $router.push('/transport')
};
// Hero Cards
const goToWallet = () => {
// TODO: navigare al portafoglio dettagliato
@@ -570,10 +548,6 @@ export default defineComponent({
// Methods
openAnnunciDialog,
goToGoods,
goToServices,
goToHospitality,
goToTransport,
goToWallet,
goToEvents,
goToProfile,

View File

@@ -256,75 +256,6 @@
/>
</section>
<!-- Dialog Annunci -->
<q-dialog
v-model="showAnnunciDialog"
transition-show="slide-up"
transition-hide="slide-down"
>
<q-card class="annunci-dialog">
<q-card-section class="dialog-header">
<h3 class="dialog-title">Scegli Categoria</h3>
<q-btn
flat
round
dense
icon="close"
v-close-popup
/>
</q-card-section>
<q-card-section class="dialog-content">
<div class="annunci-options-mobile">
<div
class="annuncio-option gradient-indigo"
@click="goToGoods"
>
<q-icon
name="fas fa-tshirt"
size="2.5rem"
/>
<span class="option-title">Beni</span>
<span class="option-subtitle">Autoproduzioni, cibo</span>
</div>
<div
class="annuncio-option gradient-red"
@click="goToServices"
>
<q-icon
name="fas fa-house-user"
size="2.5rem"
/>
<span class="option-title">Servizi</span>
<span class="option-subtitle">Competenze, aiuti</span>
</div>
<div
class="annuncio-option gradient-lime"
@click="goToHospitality"
>
<q-icon
name="fas fa-bed"
size="2.5rem"
/>
<span class="option-title">Ospitalità</span>
<span class="option-subtitle">Ospitare viaggiatori</span>
</div>
<div class="annuncio-option gradient-teal">
<q-icon
name="directions_car"
size="2.5rem"
/>
<span class="option-title">Trasporti</span>
<span class="option-subtitle">Condivisione viaggi</span>
<span class="option-subtitle"> (IN ARRIVO...)</span>
</div>
</div>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>

View File

@@ -65,7 +65,6 @@ h1 {
font-weight: $heading-primary-weight;
letter-spacing: $heading-primary-letter-spacing;
line-height: $heading-primary-line-height;
color: var(--q-primary, #1976d2);
margin-bottom: 1rem;
transition: color 0.3s ease;
}

View File

@@ -1,376 +0,0 @@
<!-- CityAutocomplete.vue -->
<template>
<div class="city-autocomplete">
<q-input
ref="inputRef"
v-model="inputValue"
:label="label"
:placeholder="placeholder"
:prepend-icon="prependIcon"
:rules="rules"
outlined
clearable
@update:model-value="onInputChange"
@focus="onFocus"
@blur="onBlur"
@clear="onClear"
>
<template #prepend v-if="prependIcon">
<q-icon :name="prependIcon" :color="iconColor" />
</template>
<template #append>
<q-icon
v-if="loading"
name="hourglass_empty"
class="rotating"
/>
<q-icon
v-else-if="inputValue && !loading"
name="search"
/>
</template>
</q-input>
<!-- Suggestions Menu -->
<q-menu
v-model="showSuggestions"
:target="inputRef?.$el"
no-parent-event
fit
no-focus
max-height="400px"
class="city-autocomplete__menu"
>
<q-list v-if="hasAnySuggestions">
<!-- Recent Searches Section -->
<template v-if="recentSearches.length > 0">
<q-item-label header class="text-grey-7">
<q-icon name="history" size="18px" class="q-mr-xs" />
Ricerche recenti
</q-item-label>
<q-item
v-for="(recent, index) in recentSearches"
:key="`search-${index}`"
clickable
v-close-popup
@click="selectSuggestion(recent)"
class="city-autocomplete__recent-item"
>
<q-item-section avatar>
<q-icon name="schedule" color="grey-6" />
</q-item-section>
<q-item-section>
<q-item-label>{{ recent.city }}</q-item-label>
<q-item-label caption v-if="recent.region">
{{ recent.region }}<span v-if="recent.country">, {{ recent.country }}</span>
</q-item-label>
</q-item-section>
</q-item>
<q-separator class="q-my-sm" />
</template>
<!-- Recent Trips Section -->
<template v-if="recentTrips.length > 0">
<q-item-label header class="text-grey-7">
<q-icon name="route" size="18px" class="q-mr-xs" />
Viaggi recenti
</q-item-label>
<q-item
v-for="(recent, index) in recentTrips"
:key="`trip-${index}`"
clickable
v-close-popup
@click="selectSuggestion(recent)"
class="city-autocomplete__recent-item"
>
<q-item-section avatar>
<q-icon name="near_me" color="primary" />
</q-item-section>
<q-item-section>
<q-item-label>{{ recent.city }}</q-item-label>
<q-item-label caption v-if="recent.region">
{{ recent.region }}<span v-if="recent.country">, {{ recent.country }}</span>
</q-item-label>
</q-item-section>
</q-item>
<q-separator class="q-my-sm" />
</template>
<!-- Geocoding Results Section -->
<template v-if="geocodingSuggestions.length > 0">
<q-item-label header class="text-grey-7" v-if="hasRecentItems">
<q-icon name="search" size="18px" class="q-mr-xs" />
Suggerimenti
</q-item-label>
<q-item
v-for="(suggestion, index) in geocodingSuggestions"
:key="`geo-${index}`"
clickable
v-close-popup
@click="selectSuggestion(suggestion)"
>
<q-item-section avatar>
<q-icon name="place" color="primary" />
</q-item-section>
<q-item-section>
<q-item-label>{{ suggestion.city }}</q-item-label>
<q-item-label caption v-if="suggestion.region">
{{ suggestion.region }}, {{ suggestion.country }}
</q-item-label>
</q-item-section>
</q-item>
</template>
</q-list>
<q-item v-else-if="inputValue && !loading">
<q-item-section>
<q-item-label class="text-grey-6 text-center">
Nessun risultato trovato
</q-item-label>
</q-item-section>
</q-item>
<q-item v-else-if="!inputValue && !hasRecentItems">
<q-item-section>
<q-item-label class="text-grey-6 text-center">
Inizia a digitare per cercare
</q-item-label>
</q-item-section>
</q-item>
</q-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch, onMounted, PropType } from 'vue';
import { useGeocoding } from '../../composables/useGeocoding';
import { useRecentCities } from '../../composables/useRecentCities';
import { Api } from '@api';
import type { Location } from '../../types';
export default defineComponent({
name: 'CityAutocomplete',
props: {
modelValue: {
type: Object as PropType<Location | undefined>,
default: undefined
},
label: {
type: String,
default: 'Città'
},
placeholder: {
type: String,
default: 'Cerca una città...'
},
prependIcon: {
type: String,
default: ''
},
iconColor: {
type: String,
default: 'primary'
},
rules: {
type: Array,
default: () => []
}
},
emits: ['update:modelValue', 'select'],
setup(props, { emit }) {
const { searchCities } = useGeocoding();
const {
getRecentSearches,
getRecentTrips,
addRecentSearch
} = useRecentCities();
// Refs
const inputRef = ref<any>(null);
const inputValue = ref('');
const showSuggestions = ref(false);
const loading = ref(false);
const geocodingSuggestions = ref<Location[]>([]);
const searchTimeout = ref<any>(null);
const serverRecentTrips = ref<any[]>([]);
// Computed
const recentSearches = computed(() => getRecentSearches.value.slice(0, 2));
const recentTrips = computed(() => {
// Combine localStorage trips with server trips
const localTrips = getRecentTrips.value.slice(0, 2);
const combined = [...localTrips, ...serverRecentTrips.value];
// Remove duplicates
const unique = new Map();
combined.forEach(trip => {
const key = `${trip.city}-${trip.region}`;
if (!unique.has(key)) {
unique.set(key, trip);
}
});
return Array.from(unique.values()).slice(0, 2);
});
const hasRecentItems = computed(() => {
return recentSearches.value.length > 0 || recentTrips.value.length > 0;
});
const hasAnySuggestions = computed(() => {
return hasRecentItems.value || geocodingSuggestions.value.length > 0;
});
// Watch modelValue changes from parent
watch(() => props.modelValue, (newVal) => {
if (newVal?.city) {
inputValue.value = newVal.city;
} else if (!newVal) {
inputValue.value = '';
}
}, { immediate: true });
// Methods
const loadRecentTripsFromServer = async () => {
try {
const response = await Api.SendReqWithData('/api/trasporti/cities/recent', 'GET');
if (response.success && response.data?.cities) {
serverRecentTrips.value = response.data.cities;
}
} catch (error) {
console.error('Error loading recent trips:', error);
}
};
const onInputChange = async (val: string) => {
inputValue.value = val;
// Clear previous timeout
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
}
// If input is empty, show recent items
if (!val || val.length < 2) {
geocodingSuggestions.value = [];
showSuggestions.value = hasRecentItems.value;
if (!val) {
emit('update:modelValue', undefined);
}
return;
}
// Debounce search
searchTimeout.value = setTimeout(async () => {
loading.value = true;
try {
const results = await searchCities(val);
geocodingSuggestions.value = results || [];
showSuggestions.value = true;
} catch (error) {
console.error('Error searching cities:', error);
geocodingSuggestions.value = [];
} finally {
loading.value = false;
}
}, 300);
};
const selectSuggestion = (suggestion: Location) => {
inputValue.value = suggestion.city;
geocodingSuggestions.value = [];
showSuggestions.value = false;
// Save to recent searches (only if it's from geocoding, not from recent)
addRecentSearch(suggestion);
emit('update:modelValue', suggestion);
emit('select', suggestion);
};
const onFocus = () => {
if (!inputValue.value) {
// Load recent trips when focusing
loadRecentTripsFromServer();
showSuggestions.value = hasRecentItems.value;
} else if (inputValue.value && (geocodingSuggestions.value.length > 0 || hasRecentItems.value)) {
showSuggestions.value = true;
}
};
const onBlur = () => {
// Delay to allow click on suggestion
setTimeout(() => {
showSuggestions.value = false;
}, 200);
};
const onClear = () => {
inputValue.value = '';
geocodingSuggestions.value = [];
showSuggestions.value = false;
emit('update:modelValue', undefined);
};
// Initialize
onMounted(() => {
loadRecentTripsFromServer();
});
return {
inputRef,
inputValue,
showSuggestions,
loading,
recentSearches,
recentTrips,
geocodingSuggestions,
hasRecentItems,
hasAnySuggestions,
onInputChange,
selectSuggestion,
onFocus,
onBlur,
onClear
};
}
});
</script>
<style lang="scss" scoped>
.city-autocomplete {
position: relative;
width: 100%;
&__menu {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&__recent-item {
background: rgba(102, 126, 234, 0.05);
&:hover {
background: rgba(102, 126, 234, 0.1);
}
}
.rotating {
animation: rotate 2s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
</style>

View File

@@ -1,440 +0,0 @@
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
defineComponent,
PropType,
} from 'vue';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useGeocoding } from '../../composables/useGeocoding';
import type { Location, Waypoint, Coordinates, RouteResult } from '../../types';
// Fix per icone Leaflet con Vite
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
// @ts-ignore
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl,
iconRetinaUrl,
shadowUrl,
});
export default defineComponent({
name: 'RideMap',
props: {
departure: {
type: Object as PropType<Location | null>,
default: null,
},
destination: {
type: Object as PropType<Location | null>,
default: null,
},
waypoints: {
type: Array as PropType<Waypoint[]>,
default: () => [],
},
mapHeight: {
type: String,
default: '400px',
},
showRoute: {
type: Boolean,
default: true,
},
showRouteInfo: {
type: Boolean,
default: true,
},
showLegend: {
type: Boolean,
default: true,
},
showFullscreenButton: {
type: Boolean,
default: true,
},
interactive: {
type: Boolean,
default: true,
},
autoFit: {
type: Boolean,
default: true,
},
},
emits: ['route-calculated', 'marker-click'],
setup(props, { emit }) {
const { calculateRoute } = useGeocoding();
// State
const mapContainer = ref<HTMLElement | null>(null);
const loading = ref(false);
const isFullscreen = ref(false);
const routeInfo = ref<RouteResult | null>(null);
// Map instance
let map: L.Map | null = null;
let routeLayer: L.Polyline | null = null;
let markersLayer: L.LayerGroup | null = null;
// Custom icons
const startIcon = L.divIcon({
className: 'ride-map-marker ride-map-marker--start',
html: '<div class="marker-inner">🟢</div>',
iconSize: [32, 32],
iconAnchor: [16, 32],
});
const endIcon = L.divIcon({
className: 'ride-map-marker ride-map-marker--end',
html: '<div class="marker-inner">🔴</div>',
iconSize: [32, 32],
iconAnchor: [16, 32],
});
const waypointIcon = L.divIcon({
className: 'ride-map-marker ride-map-marker--waypoint',
html: '<div class="marker-inner">📍</div>',
iconSize: [28, 28],
iconAnchor: [14, 28],
});
// Computed
const formattedDuration = computed(() => {
if (!routeInfo.value) return '';
const mins = routeInfo.value.duration;
const hours = Math.floor(mins / 60);
const minutes = mins % 60;
if (hours === 0) return `${minutes} min`;
if (minutes === 0) return `${hours} h`;
return `${hours} h ${minutes} min`;
});
// Initialize map
const initMap = () => {
if (!mapContainer.value || map) return;
map = L.map(mapContainer.value, {
center: [41.9028, 12.4964], // Centro Italia
zoom: 6,
zoomControl: true,
attributionControl: true,
dragging: props.interactive,
touchZoom: props.interactive,
scrollWheelZoom: props.interactive,
doubleClickZoom: props.interactive,
});
// Tile layer OpenStreetMap
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
// Layer per markers
markersLayer = L.layerGroup().addTo(map);
// Update markers iniziale
updateMarkers();
};
// Update markers on map
const updateMarkers = () => {
if (!map || !markersLayer) return;
markersLayer.clearLayers();
// Marker partenza
if (props.departure?.coordinates) {
const marker = L.marker(
[props.departure.coordinates.lat, props.departure.coordinates.lng],
{ icon: startIcon }
);
marker.bindPopup(`<strong>Partenza</strong><br>${props.departure.city}`);
marker.on('click', () => emit('marker-click', 'departure', props.departure));
markersLayer.addLayer(marker);
}
// Marker waypoints
props.waypoints.forEach((wp, index) => {
if (wp.location?.coordinates) {
const marker = L.marker(
[wp.location.coordinates.lat, wp.location.coordinates.lng],
{ icon: waypointIcon }
);
marker.bindPopup(`<strong>Tappa ${index + 1}</strong><br>${wp.location.city}`);
marker.on('click', () => emit('marker-click', 'waypoint', wp, index));
markersLayer.addLayer(marker);
}
});
// Marker destinazione
if (props.destination?.coordinates) {
const marker = L.marker(
[props.destination.coordinates.lat, props.destination.coordinates.lng],
{ icon: endIcon }
);
marker.bindPopup(`<strong>Arrivo</strong><br>${props.destination.city}`);
marker.on('click', () => emit('marker-click', 'destination', props.destination));
markersLayer.addLayer(marker);
}
// Calcola e mostra percorso
if (props.showRoute) {
calculateAndShowRoute();
} else if (props.autoFit) {
fitBounds();
}
};
// Calculate and show route
// Calculate and show route
const calculateAndShowRoute = async () => {
if (!map || !props.departure?.coordinates || !props.destination?.coordinates) {
return;
}
loading.value = true;
try {
const waypointCoords: Coordinates[] = props.waypoints
.filter((wp) => wp.location?.coordinates)
.sort((a, b) => a.order - b.order)
.map((wp) => wp.location!.coordinates);
const result = await calculateRoute(
props.departure.coordinates,
props.destination.coordinates,
waypointCoords
);
console.log('Route result:', result); // Debug
if (result) {
// ✅ Convert to proper format for routeInfo
routeInfo.value = {
distance: Math.round((result.distance / 1000) * 10) / 10, // meters to km
duration: Math.round(result.duration / 60), // seconds to minutes
polyline: null,
};
emit('route-calculated', routeInfo.value);
// Remove previous route
if (routeLayer) {
map.removeLayer(routeLayer);
}
// ✅ Handle GeoJSON geometry (what OSRM returns with geometries=geojson)
if (result.geometry) {
let coordinates: [number, number][];
if (result.geometry.type === 'LineString') {
// GeoJSON format: [lng, lat] -> need to swap to [lat, lng] for Leaflet
coordinates = result.geometry.coordinates.map(
(coord: [number, number]) => [coord[1], coord[0]] as [number, number]
);
} else if (Array.isArray(result.geometry.coordinates)) {
// Already an array of coordinates
coordinates = result.geometry.coordinates.map(
(coord: [number, number]) => [coord[1], coord[0]] as [number, number]
);
} else {
console.warn('Unknown geometry format:', result.geometry);
coordinates = [];
}
if (coordinates.length > 0) {
routeLayer = L.polyline(coordinates, {
color: '#1976D2',
weight: 5,
opacity: 0.8,
smoothFactor: 1,
}).addTo(map);
}
}
// ✅ Fallback: Handle encoded polyline (if format changes)
else if (result.polyline) {
const decodedPath = decodePolyline(result.polyline);
routeLayer = L.polyline(decodedPath, {
color: '#1976D2',
weight: 5,
opacity: 0.8,
smoothFactor: 1,
}).addTo(map);
}
// ✅ Last fallback: straight line
else {
console.warn('No route geometry, drawing straight line');
const points: [number, number][] = [
[props.departure.coordinates.lat, props.departure.coordinates.lng],
...waypointCoords.map((c) => [c.lat, c.lng] as [number, number]),
[props.destination.coordinates.lat, props.destination.coordinates.lng],
];
routeLayer = L.polyline(points, {
color: '#1976D2',
weight: 5,
opacity: 0.6,
dashArray: '10, 10',
}).addTo(map);
}
if (props.autoFit) {
fitBounds();
}
}
} catch (error) {
console.error('Errore calcolo percorso:', error);
} finally {
loading.value = false;
}
};
// Decode Google Polyline format
const decodePolyline = (encoded: string): [number, number][] => {
const points: [number, number][] = [];
let index = 0;
let lat = 0;
let lng = 0;
while (index < encoded.length) {
let shift = 0;
let result = 0;
let byte: number;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
const deltaLat = result & 1 ? ~(result >> 1) : result >> 1;
lat += deltaLat;
shift = 0;
result = 0;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
const deltaLng = result & 1 ? ~(result >> 1) : result >> 1;
lng += deltaLng;
points.push([lat / 1e5, lng / 1e5]);
}
return points;
};
// Fit map to show all markers
const fitBounds = () => {
if (!map) return;
const bounds: L.LatLngBoundsExpression = [];
if (props.departure?.coordinates) {
bounds.push([props.departure.coordinates.lat, props.departure.coordinates.lng]);
}
props.waypoints.forEach((wp) => {
if (wp.location?.coordinates) {
bounds.push([wp.location.coordinates.lat, wp.location.coordinates.lng]);
}
});
if (props.destination?.coordinates) {
bounds.push([
props.destination.coordinates.lat,
props.destination.coordinates.lng,
]);
}
if (bounds.length > 0) {
map.fitBounds(bounds as L.LatLngBoundsExpression, {
padding: [50, 50],
maxZoom: 14,
});
}
};
// Center on user location
const centerOnUser = () => {
if (!map) return;
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
map!.setView([position.coords.latitude, position.coords.longitude], 13);
},
(error) => {
console.error('Errore geolocalizzazione:', error);
}
);
}
};
// Toggle fullscreen
const toggleFullscreen = () => {
if (!mapContainer.value) return;
if (!document.fullscreenElement) {
mapContainer.value.parentElement?.requestFullscreen();
isFullscreen.value = true;
} else {
document.exitFullscreen();
isFullscreen.value = false;
}
// Resize map after fullscreen change
setTimeout(() => {
map?.invalidateSize();
}, 100);
};
// Watch for changes
watch(
[() => props.departure, () => props.destination, () => props.waypoints],
() => {
updateMarkers();
},
{ deep: true }
);
// Lifecycle
onMounted(() => {
initMap();
});
onBeforeUnmount(() => {
if (map) {
map.remove();
map = null;
}
});
return {
mapContainer,
loading,
isFullscreen,
routeInfo,
formattedDuration,
fitBounds,
centerOnUser,
toggleFullscreen,
};
},
});

View File

@@ -1,132 +0,0 @@
.ride-type-toggle {
width: 100%;
&--vertical {
.ride-type-toggle__buttons {
flex-direction: column;
.q-btn {
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
}
}
}
&__buttons {
width: 100%;
border-radius: 16px;
background: rgba(0, 0, 0, 0.05);
padding: 4px;
.q-btn {
flex: 1;
padding: 12px 16px;
border-radius: 12px !important;
transition: all 0.3s ease;
&.q-btn--active {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
&.selected-offer .q-btn--active {
background: linear-gradient(135deg, #4caf50, #66bb6a) !important;
color: white !important;
}
&.selected-request .q-btn--active {
background: linear-gradient(135deg, #f44336, #ef5350) !important;
color: white !important;
}
}
}
.ride-type-option {
display: flex;
align-items: center;
gap: 12px;
&__icon {
font-size: 24px;
}
&__content {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
}
&__label {
font-weight: 600;
font-size: 14px;
}
&__desc {
font-size: 11px;
opacity: 0.8;
margin-top: 2px;
}
}
// Card Mode
.ride-type-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.ride-type-card {
cursor: pointer;
transition: all 0.3s ease;
border-radius: 16px !important;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&--selected {
border-color: var(--q-primary) !important;
border-width: 2px;
background: rgba(var(--q-primary-rgb), 0.05);
.ride-type-card__icon {
transform: scale(1.2);
}
}
&__icon {
font-size: 48px;
margin-bottom: 8px;
transition: transform 0.3s ease;
}
&__label {
font-weight: 600;
font-size: 16px;
color: var(--q-dark);
}
&__desc {
font-size: 12px;
color: var(--q-grey);
margin-top: 4px;
}
}
// Dark mode
.body--dark {
.ride-type-toggle__buttons {
background: rgba(255, 255, 255, 0.1);
}
.ride-type-card {
&__label {
color: #fff;
}
}
}

View File

@@ -1,59 +0,0 @@
import { computed, defineComponent, PropType } from 'vue';
import type { RideType } from '../../types';
export default defineComponent({
name: 'RideTypeToggle',
props: {
modelValue: {
type: String as PropType<RideType>,
default: 'offer'
},
vertical: {
type: Boolean,
default: false
},
showDescription: {
type: Boolean,
default: true
},
cardMode: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue', 'change'],
setup(props, { emit }) {
const selectedType = computed({
get: () => props.modelValue,
set: (value: RideType) => {
emit('update:modelValue', value);
emit('change', value);
}
});
const typeOptions = [
{
label: 'Offro Passaggio',
value: 'offer' as RideType,
slot: 'offer'
},
{
label: 'Cerco Passaggio',
value: 'request' as RideType,
slot: 'request'
}
];
return {
selectedType,
typeOptions
};
}
});

View File

@@ -1,66 +0,0 @@
<template>
<div :class="['ride-type-toggle', { 'ride-type-toggle--vertical': vertical }]">
<q-btn-toggle
v-model="selectedType"
:options="typeOptions"
spread
no-caps
rounded
unelevated
toggle-color="primary"
:class="['ride-type-toggle__buttons', `selected-${selectedType}`]"
>
<template v-slot:offer>
<div class="ride-type-option">
<span class="ride-type-option__icon">🟢</span>
<div class="ride-type-option__content">
<span class="ride-type-option__label">Offro Passaggio</span>
<span v-if="showDescription" class="ride-type-option__desc">
Ho posti disponibili
</span>
</div>
</div>
</template>
<template v-slot:request>
<div class="ride-type-option">
<span class="ride-type-option__icon">🔴</span>
<div class="ride-type-option__content">
<span class="ride-type-option__label">Cerco Passaggio</span>
<span v-if="showDescription" class="ride-type-option__desc">
Sto cercando un passaggio
</span>
</div>
</div>
</template>
</q-btn-toggle>
<!-- Versione Cards alternative -->
<div v-if="cardMode" class="ride-type-cards">
<q-card
v-for="option in typeOptions"
:key="option.value"
:class="[
'ride-type-card',
{ 'ride-type-card--selected': selectedType === option.value }
]"
flat
bordered
@click="selectedType = option.value"
>
<q-card-section class="text-center">
<div class="ride-type-card__icon">
{{ option.value === 'offer' ? '🟢' : '🔴' }}
</div>
<div class="ride-type-card__label">{{ option.label }}</div>
<div class="ride-type-card__desc">
{{ option.value === 'offer' ? 'Ho posti disponibili' : 'Cerco un passaggio' }}
</div>
</q-card-section>
</q-card>
</div>
</div>
</template>
<script lang="ts" src="./RideTypeToggle.ts" />
<style lang="scss" src="./RideTypeToggle.scss" />

View File

@@ -1,139 +0,0 @@
import { ref, watch, defineComponent, PropType } from 'vue';
import draggable from 'vuedraggable';
import CityAutocomplete from './CityAutocomplete.vue';
import type { Waypoint, Location, SuggestedWaypoint } from '../../types';
interface WaypointItem {
id: string;
location: Location | null;
order: number;
}
export default defineComponent({
name: 'WaypointsEditor',
components: {
draggable,
CityAutocomplete
},
props: {
modelValue: {
type: Array as PropType<Waypoint[]>,
default: () => []
},
departureCity: {
type: String,
default: ''
},
destinationCity: {
type: String,
default: ''
},
suggestedWaypoints: {
type: Array as PropType<SuggestedWaypoint[]>,
default: () => []
},
showSuggestions: {
type: Boolean,
default: true
},
maxWaypoints: {
type: Number,
default: 10
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
// State
const waypoints = ref<WaypointItem[]>([]);
// Genera ID univoco
const generateId = () => `wp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Watch per sincronizzare con modelValue
watch(() => props.modelValue, (newVal) => {
if (newVal && newVal.length > 0) {
waypoints.value = newVal.map((wp, index) => ({
id: wp._id || generateId(),
location: wp.location,
order: wp.order || index + 1
}));
} else if (waypoints.value.length === 0) {
waypoints.value = [];
}
}, { immediate: true, deep: true });
// Watch per emettere update
watch(waypoints, (newVal) => {
const result: Waypoint[] = newVal
.filter(wp => wp.location)
.map((wp, index) => ({
_id: wp.id,
location: wp.location!,
order: index + 1
}));
emit('update:modelValue', result);
}, { deep: true });
// Methods
const addWaypoint = () => {
if (waypoints.value.length >= props.maxWaypoints) return;
waypoints.value.push({
id: generateId(),
location: null,
order: waypoints.value.length + 1
});
};
const removeWaypoint = (index: number) => {
waypoints.value.splice(index, 1);
// Riordina
waypoints.value.forEach((wp, i) => {
wp.order = i + 1;
});
};
const onWaypointSelect = (index: number, location: Location) => {
if (waypoints.value[index]) {
waypoints.value[index].location = location;
}
};
const onDragEnd = () => {
// Riordina dopo drag
waypoints.value.forEach((wp, index) => {
wp.order = index + 1;
});
};
const addSuggestedWaypoint = (suggestion: SuggestedWaypoint) => {
if (waypoints.value.length >= props.maxWaypoints) return;
const location: Location = {
city: suggestion.city,
province: suggestion.province,
region: suggestion.region,
coordinates: suggestion.coordinates
};
waypoints.value.push({
id: generateId(),
location,
order: waypoints.value.length + 1
});
};
return {
waypoints,
addWaypoint,
removeWaypoint,
onWaypointSelect,
onDragEnd,
addSuggestedWaypoint
};
}
});

View File

@@ -1,638 +0,0 @@
// useChat.ts
import { ref, computed } from 'vue';
import { Api } from '@api';
import type { Chat, Message } from '../types/trasporti.types';
import { tools } from 'app/src/store/Modules/tools';
// ============================================================
// STATE
// ============================================================
const chats = ref<Chat[]>([]);
const currentChat = ref<Chat | null>(null);
const messages = ref<Message[]>([]);
const totalUnreadCount = ref(0);
const loading = ref(false);
const loadingMessages = ref(false);
const sending = ref(false);
const error = ref<string | null>(null);
// Real-time state (gestito da useRealtimeChat)
const onlineUsers = ref<string[]>([]);
const typingUsers = ref<string[]>([]);
interface FetchMessagesOptions {
loadOlder?: boolean; // Carica messaggi più vecchi
loadNewer?: boolean; // Carica messaggi più recenti
reset?: boolean; // Reset completo
limit?: number;
}
const hasOlderMessages = ref(true);
const hasNewerMessages = ref(false);
// POLLING STATE
let pollingInterval: ReturnType<typeof setInterval> | null = null;
const isPolling = ref(false);
const POLLING_INTERVAL = 3000; // 3 secondi
// ============================================================
// COMPOSABLE
// ============================================================
export function useChat() {
// ID app per trasporti
// ------------------------------------------------------------
// COMPUTED
// ------------------------------------------------------------
const hasChats = computed(() => chats.value.length > 0);
const hasUnread = computed(() => totalUnreadCount.value > 0);
const sortedChats = computed(() =>
[...chats.value].sort((a, b) => {
const dateA = new Date(a.updatedAt || a.createdAt).getTime();
const dateB = new Date(b.updatedAt || b.createdAt).getTime();
return dateB - dateA;
})
);
const sortedMessages = computed(() =>
[...messages.value].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateA - dateB;
})
);
// ------------------------------------------------------------
// API CALLS
// ------------------------------------------------------------
/**
* Ottieni tutte le chat dell'utente
*/
const fetchChats = async (page = 1, limit = 20) => {
try {
loading.value = true;
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/chats?page=${page}&limit=${limit}`,
'GET'
);
if (response.success && response.data) {
chats.value = response.data;
// Calcola unread totale
totalUnreadCount.value = response.data.reduce(
(sum: number, chat: any) => sum + (chat.unreadCount || 0),
0
);
}
return response;
} catch (err: any) {
error.value = err.message || 'Errore nel recupero delle chat';
throw err;
} finally {
loading.value = false;
}
};
/**
* Ottieni o crea chat diretta
*/
const getOrCreateDirectChat = async (otherUserId: string, rideId?: string) => {
try {
loading.value = true;
error.value = null;
const response = await Api.SendReqWithData('/api/trasporti/chats/direct', 'POST', {
otherUserId,
rideId,
});
if (response.success && response.data) {
currentChat.value = response.data;
// Aggiungi alla lista se non presente
const exists = chats.value.find((c) => c._id === currentChat.value?._id);
if (!exists && currentChat.value) {
chats.value.unshift(currentChat.value);
}
}
return response;
} catch (err: any) {
error.value = err.message || 'Errore nella creazione della chat';
throw err;
} finally {
loading.value = false;
}
};
/**
* Carica singola chat (usato da ChatPage)
*/
const loadChat = async (chatId: string) => {
return await fetchChat(chatId);
};
/**
* Ottieni singola chat per ID
*/
const fetchChat = async (chatId: string) => {
try {
loading.value = true;
error.value = null;
const response = await Api.SendReqWithData(`/api/trasporti/chats/${chatId}`, 'GET');
if (response.success && response.data) {
currentChat.value = response.data;
}
return response;
} catch (err: any) {
error.value = err.message || 'Errore nel recupero della chat';
throw err;
} finally {
loading.value = false;
}
};
/**
* Carica messaggi (usato da ChatPage) - ritorna array
*/
const loadMessages = async (
chatId: string,
options?: { before?: string; after?: string; limit?: number }
): Promise<Message[]> => {
const response = await fetchMessages(chatId, options);
return response.data || [];
};
const fetchMessages = async (
chatId: string,
options: FetchMessagesOptions = {}
) => {
try {
loadingMessages.value = true;
error.value = null;
const params = new URLSearchParams({ idapp: tools.getIdApp() });
// ✅ Determina i parametri in base al tipo di caricamento
if (options.loadOlder && messages.value.length > 0) {
// Carica messaggi più vecchi del primo messaggio attuale
const oldestMessage = messages.value[0];
params.append('before', oldestMessage.createdAt);
} else if (options.loadNewer && messages.value.length > 0) {
// Carica messaggi più recenti dell'ultimo messaggio attuale
const newestMessage = messages.value[messages.value.length - 1];
params.append('after', newestMessage.createdAt);
}
if (options.limit) {
params.append('limit', options.limit.toString());
}
const response = await Api.SendReqWithData(
`/api/trasporti/chats/${chatId}/messages?${params.toString()}`,
'GET'
);
if (response.success && response.data) {
const newMessages = response.data;
// ✅ Gestione chiara dei diversi scenari
if (options.reset || messages.value.length === 0) {
// Primo caricamento o reset
messages.value = newMessages;
hasOlderMessages.value = response.hasMore;
} else if (options.loadOlder) {
// Aggiungi messaggi più vecchi all'inizio
messages.value = [...newMessages, ...messages.value];
hasOlderMessages.value = response.hasMore;
} else if (options.loadNewer) {
// Aggiungi messaggi più recenti alla fine
messages.value = [...messages.value, ...newMessages];
hasNewerMessages.value = response.hasMore;
}
}
return response;
} catch (err: any) {
error.value = err.message || 'Errore nel recupero dei messaggi';
throw err;
} finally {
loadingMessages.value = false;
}
};
// ✅ Metodi helper per chiamare facilmente
const loadInitialMessages = (chatId: string) => {
return fetchMessages(chatId, { reset: true });
};
const loadOlderMessages = (chatId: string) => {
if (!hasOlderMessages.value || loadingMessages.value) return;
return fetchMessages(chatId, { loadOlder: true });
};
const loadNewerMessages = (chatId: string) => {
if (!hasNewerMessages.value || loadingMessages.value) return;
return fetchMessages(chatId, { loadNewer: true });
};
// ✅ Aggiungi un nuovo messaggio ricevuto in tempo reale
const addNewMessage = (message: Message) => {
// Evita duplicati
if (!messages.value.find(m => m._id === message._id)) {
messages.value.push(message);
}
};
/**
* Invia messaggio - compatibile con ChatPage
*/
const sendMessage = async (
chatId: string,
payload: {
content?: string;
text?: string;
type?: string;
metadata?: any;
replyTo?: string;
}
) => {
try {
sending.value = true;
error.value = null;
// Supporta sia content che text
const messageText = payload.content || payload.text || '';
const response = await Api.SendReqWithData(
`/api/trasporti/chats/${chatId}/messages`,
'POST',
{
text: messageText,
type: payload.type || 'text',
metadata: payload.metadata,
replyTo: payload.replyTo,
}
);
if (response.success && response.data) {
const newMessage = response.data;
messages.value.push(newMessage);
// Aggiorna lastMessage nella chat
updateChatLastMessage(chatId, newMessage);
}
return response;
} catch (err: any) {
error.value = err.message || "Errore nell'invio del messaggio";
throw err;
} finally {
sending.value = false;
}
};
/**
* Aggiorna lastMessage nella chat locale
*/
const updateChatLastMessage = (chatId: string, message: Message) => {
const chatIndex = chats.value.findIndex((c) => c._id === chatId);
if (chatIndex !== -1) {
chats.value[chatIndex].lastMessage = {
text: message.text || '',
senderId: message.senderId as any,
timestamp: message.createdAt,
type: message.type || 'text',
};
chats.value[chatIndex].updatedAt = message.createdAt;
}
if (currentChat.value && currentChat.value._id === chatId) {
currentChat.value.lastMessage = {
text: message.text || '',
senderId: message.senderId as any,
timestamp: message.createdAt,
type: message.type || 'text',
};
currentChat.value.updatedAt = message.createdAt;
}
};
/**
* Marca chat come letta
*/
const markAsRead = async (chatId: string) => {
try {
const response = await Api.SendReqWithData(
`/api/trasporti/chats/${chatId}/read`,
'PUT'
);
if (response.success) {
// Aggiorna contatore locale
const chatIndex = chats.value.findIndex((c) => c._id === chatId);
if (chatIndex !== -1) {
const unread = chats.value[chatIndex].unreadCount || 0;
totalUnreadCount.value = Math.max(0, totalUnreadCount.value - unread);
chats.value[chatIndex].unreadCount = 0;
}
if (currentChat.value && currentChat.value._id === chatId) {
currentChat.value.unreadCount = 0;
}
}
return response;
} catch (err: any) {
console.error('Errore mark as read:', err);
}
};
/**
* Ottieni conteggio non letti totale
*/
const fetchUnreadCount = async () => {
try {
const response = await Api.SendReqWithData(
`/api/trasporti/chats/unread/count?idapp=${IDAPP}`,
'GET'
);
if (response.success && response.data) {
totalUnreadCount.value = response.data.total || 0;
}
return response;
} catch (err: any) {
console.error('Errore fetch unread count:', err);
}
};
/**
* Elimina messaggio
*/
const deleteMessage = async (chatId: string, messageId: string) => {
try {
const response = await Api.SendReqWithData(
`/api/trasporti/chats/${chatId}/messages/${messageId}`,
'DELETE'
);
if (response.success) {
const index = messages.value.findIndex((m) => m._id === messageId);
if (index !== -1) {
messages.value[index].isDeleted = true;
messages.value[index].text = '[Messaggio eliminato]';
}
}
return response;
} catch (err: any) {
error.value = err.message || "Errore nell'eliminazione del messaggio";
throw err;
}
};
/**
* Blocca/Sblocca chat
*/
const toggleBlockChat = async (chatId: string, block: boolean) => {
try {
const response = await Api.SendReqWithData(
`/api/trasporti/chats/${chatId}/block`,
'PUT',
{ block }
);
return response;
} catch (err: any) {
error.value = err.message || 'Errore nel blocco della chat';
throw err;
}
};
/**
* Muta/Smuta notifiche chat
*/
const toggleMuteChat = async (chatId: string, mute: boolean) => {
try {
const response = await Api.SendReqWithData(
`/api/trasporti/chats/${chatId}/mute`,
'PUT',
{ mute }
);
return response;
} catch (err: any) {
error.value = err.message || 'Errore nel mute della chat';
throw err;
}
};
// ------------------------------------------------------------
// REAL-TIME PLACEHOLDERS (implementati in useRealtimeChat)
// ------------------------------------------------------------
/**
* Invia evento typing (placeholder)
*/
const sendTyping = (chatId: string) => {
// Implementato in useRealtimeChat con polling
};
/**
* Subscribe to chat (placeholder)
*/
const subscribeToChat = (chatId: string) => {
// Implementato in useRealtimeChat
};
/**
* Unsubscribe from chat (placeholder)
*/
const unsubscribeFromChat = (chatId: string) => {
// Implementato in useRealtimeChat
};
// ------------------------------------------------------------
// UTILITIES
// ------------------------------------------------------------
/**
* Formatta timestamp messaggio
*/
const formatMessageTime = (date: Date | string) => {
const d = new Date(date);
const now = new Date();
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
} else if (diffDays === 1) {
return 'Ieri';
} else if (diffDays < 7) {
return d.toLocaleDateString('it-IT', { weekday: 'short' });
} else {
return d.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit' });
}
};
/**
* Apri chat e carica messaggi
*/
const openChat = async (chatId: string) => {
messages.value = [];
await fetchChat(chatId);
await fetchMessages(chatId, { limit: 50 });
await markAsRead(chatId);
};
/**
* Pulisci stato
*/
const clearState = () => {
chats.value = [];
currentChat.value = null;
messages.value = [];
error.value = null;
};
/**
* Chiudi chat corrente
*/
const closeCurrentChat = () => {
currentChat.value = null;
messages.value = [];
};
// ============================================================
// POLLING
// ============================================================
/**
* Avvia polling per nuovi messaggi
*/
const startPolling = (chatId: string, intervalMs = POLLING_INTERVAL) => {
stopPolling(); // Ferma eventuale polling precedente
isPolling.value = true;
console.log('[useChat] Polling avviato per chat:', chatId);
pollingInterval = setInterval(async () => {
try {
// Solo se ci sono già messaggi caricati
if (messages.value.length > 0) {
const newestMessage = messages.value[messages.value.length - 1];
const params = new URLSearchParams({
idapp: tools.getIdApp(),
after: newestMessage.createdAt
});
const response = await Api.SendReqWithData(
`/api/trasporti/chats/${chatId}/messages?${params.toString()}`,
'GET'
);
if (response.success && response.data && response.data.length > 0) {
console.log('[useChat] Polling - ricevuti', response.data.length, 'nuovi messaggi');
// Aggiungi nuovi messaggi evitando duplicati
response.data.forEach((newMsg: Message) => {
if (!messages.value.find(m => m._id === newMsg._id)) {
messages.value.push(newMsg);
}
});
}
}
} catch (err) {
console.error('[useChat] Errore polling:', err);
}
}, intervalMs);
};
/**
* Ferma polling
*/
const stopPolling = () => {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
isPolling.value = false;
console.log('[useChat] Polling fermato');
}
};
// ------------------------------------------------------------
// RETURN
// ------------------------------------------------------------
return {
// State
chats,
currentChat,
messages,
totalUnreadCount,
loading,
loadingMessages,
sending,
error,
onlineUsers,
typingUsers,
isPolling, // AGGIUNGI
// Computed
hasChats,
hasUnread,
sortedChats,
sortedMessages,
hasOlderMessages, // AGGIUNGI
hasNewerMessages, // AGGIUNGI
// API Methods
fetchChats,
getOrCreateDirectChat,
loadChat,
fetchChat,
loadMessages,
fetchMessages,
sendMessage,
markAsRead,
fetchUnreadCount,
toggleBlockChat,
toggleMuteChat,
deleteMessage,
loadOlderMessages, // AGGIUNGI
loadNewerMessages, // AGGIUNGI
addNewMessage, // AGGIUNGI
// Polling
startPolling, // AGGIUNGI
stopPolling, // AGGIUNGI
// Real-time (placeholder)
sendTyping,
subscribeToChat,
unsubscribeFromChat,
// Utilities
formatMessageTime,
openChat,
clearState,
closeCurrentChat,
};
}

View File

@@ -0,0 +1,331 @@
<!-- CityAutocomplete.vue -->
<template>
<div class="city-autocomplete">
<q-input
ref="inputRef"
v-model="inputValue"
:label="label"
:placeholder="placeholder"
:rules="rules"
:dense="dense"
outlined
clearable
:loading="loading"
@update:model-value="onInputChange"
@focus="onFocus"
@blur="onBlur"
@clear="onClear"
>
<template #prepend v-if="prependIcon">
<q-icon :name="prependIcon" :color="iconColor" />
</template>
<template #append>
<q-btn
v-if="showLocationButton && !inputValue"
flat
round
dense
icon="my_location"
color="primary"
:loading="locating"
@click.stop="getCurrentLocation"
>
<q-tooltip>Usa posizione attuale</q-tooltip>
</q-btn>
<q-icon
v-else-if="loading"
name="hourglass_empty"
class="rotating"
/>
</template>
</q-input>
<!-- Suggestions Menu -->
<q-menu
v-model="showSuggestions"
:target="inputRef?.$el"
no-parent-event
fit
no-focus
max-height="300px"
class="city-autocomplete__menu"
>
<q-list>
<!-- Geocoding Results -->
<template v-if="suggestions.length > 0">
<q-item
v-for="(suggestion, index) in suggestions"
:key="index"
clickable
v-close-popup
@click="selectSuggestion(suggestion)"
>
<q-item-section avatar>
<q-icon name="place" color="primary" />
</q-item-section>
<q-item-section>
<q-item-label>{{ suggestion.city }}</q-item-label>
<q-item-label caption v-if="suggestion.region || suggestion.country">
{{ [suggestion.region, suggestion.country].filter(Boolean).join(', ') }}
</q-item-label>
</q-item-section>
<q-item-section side v-if="suggestion.coordinates">
<q-icon name="gps_fixed" size="14px" color="grey-5" />
</q-item-section>
</q-item>
</template>
<!-- No Results -->
<q-item v-else-if="inputValue && inputValue.length >= 2 && !loading && searched">
<q-item-section>
<q-item-label class="text-grey-6 text-center">
<q-icon name="search_off" size="24px" class="q-mb-sm" />
<div>Nessun risultato per "{{ inputValue }}"</div>
</q-item-label>
</q-item-section>
</q-item>
<!-- Hint -->
<q-item v-else-if="!loading && inputValue && inputValue.length < 2">
<q-item-section>
<q-item-label class="text-grey-6 text-center text-caption">
Digita almeno 2 caratteri
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-menu>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, watch, PropType } from 'vue';
import { useGeocoding } from '../../composables/useGeocoding';
import type { Location } from '../../types';
export default defineComponent({
name: 'CityAutocomplete',
props: {
modelValue: {
type: Object as PropType<Location | undefined>,
default: undefined
},
label: {
type: String,
default: 'Città'
},
placeholder: {
type: String,
default: 'Cerca una città...'
},
prependIcon: {
type: String,
default: ''
},
iconColor: {
type: String,
default: 'primary'
},
rules: {
type: Array,
default: () => []
},
dense: {
type: Boolean,
default: false
},
showLocationButton: {
type: Boolean,
default: true
},
countryFilter: {
type: String,
default: 'IT' // Default Italia
}
},
emits: ['update:modelValue', 'select'],
setup(props, { emit }) {
const {
searchCities,
getCurrentLocation: getGeoLocation,
loading: geoLoading
} = useGeocoding();
// Refs
const inputRef = ref<any>(null);
const inputValue = ref('');
const showSuggestions = ref(false);
const loading = ref(false);
const locating = ref(false);
const searched = ref(false);
const suggestions = ref<Location[]>([]);
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null);
// Watch modelValue changes from parent
watch(() => props.modelValue, (newVal) => {
if (newVal?.city) {
inputValue.value = newVal.city;
} else if (!newVal) {
inputValue.value = '';
}
}, { immediate: true });
// Methods
const onInputChange = async (val: string | number | null) => {
const searchVal = String(val || '');
inputValue.value = searchVal;
searched.value = false;
// Clear previous timeout
if (searchTimeout.value) {
clearTimeout(searchTimeout.value);
searchTimeout.value = null;
}
// If input is empty or too short
if (!searchVal || searchVal.length < 2) {
suggestions.value = [];
showSuggestions.value = false;
if (!searchVal) {
emit('update:modelValue', undefined);
}
return;
}
// Show menu immediately with loading state
showSuggestions.value = true;
// Debounce search
searchTimeout.value = setTimeout(async () => {
loading.value = true;
try {
const results = await searchCities(searchVal);
suggestions.value = results || [];
searched.value = true;
} catch (error) {
console.error('Error searching cities:', error);
suggestions.value = [];
} finally {
loading.value = false;
}
}, 350);
};
const selectSuggestion = (suggestion: Location) => {
// Verifica che abbia le coordinate
if (!suggestion.coordinates) {
console.warn('Location selected without coordinates:', suggestion);
}
inputValue.value = suggestion.city;
suggestions.value = [];
showSuggestions.value = false;
searched.value = false;
emit('update:modelValue', suggestion);
emit('select', suggestion);
};
const getCurrentLocation = async () => {
locating.value = true;
try {
const location = await getGeoLocation();
if (location) {
selectSuggestion(location);
}
} catch (error) {
console.error('Error getting current location:', error);
} finally {
locating.value = false;
}
};
const onFocus = () => {
// Show suggestions if we have results
if (suggestions.value.length > 0) {
showSuggestions.value = true;
}
};
const onBlur = () => {
// Delay to allow click on suggestion
setTimeout(() => {
showSuggestions.value = false;
}, 250);
};
const onClear = () => {
inputValue.value = '';
suggestions.value = [];
showSuggestions.value = false;
searched.value = false;
emit('update:modelValue', undefined);
};
return {
// Refs
inputRef,
inputValue,
showSuggestions,
loading,
locating,
searched,
suggestions,
// Methods
onInputChange,
selectSuggestion,
getCurrentLocation,
onFocus,
onBlur,
onClear
};
}
});
</script>
<style lang="scss" scoped>
.city-autocomplete {
position: relative;
width: 100%;
&__menu {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
.q-item {
border-radius: 8px;
margin: 4px;
&:hover {
background: rgba(var(--q-primary-rgb), 0.08);
}
}
}
.rotating {
animation: rotate 1.5s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
}
// Dark mode
.body--dark {
.city-autocomplete__menu {
.q-item:hover {
background: rgba(255, 255, 255, 0.08);
}
}
}
</style>

View File

@@ -7,6 +7,18 @@
.q-card__section {
padding: 12px 16px;
}
// FIX: Assicura che gli input abbiano spazio per le icone
.q-field {
&__prepend {
padding-right: 8px;
padding-left: 8px;
}
&__control {
padding-left: 0;
}
}
}
&__full {
@@ -15,6 +27,12 @@
&__date-input {
max-width: 150px;
// FIX: Anche per il date input
.q-field__prepend {
padding-right: 8px;
padding-left: 8px;
}
}
&__section {
@@ -48,13 +66,16 @@
.q-card__section {
flex-wrap: wrap;
.col {
min-width: 100%;
// FIX: Usa flex-basis invece di min-width per evitare problemi
> .col {
flex: 1 1 100%;
min-width: 0; // Previene overflow
}
.ride-filters__date-input {
max-width: 100%;
width: 100%;
flex: 1 1 100%;
}
}
}

View File

@@ -5,7 +5,7 @@
<q-card-section class="row items-center q-gutter-sm">
<q-input
v-model="localFilters.from"
placeholder="Da..."
placeholder="Città di Partenza..."
dense
outlined
class="col"
@@ -16,11 +16,11 @@
</template>
</q-input>
<q-icon name="arrow_forward" color="grey" />
<q-icon name="arrow_forward" color="grey" class="gt-xs" />
<q-input
v-model="localFilters.to"
placeholder="A..."
placeholder="Città di Arrivo..."
dense
outlined
class="col"

View File

@@ -150,4 +150,18 @@
color: white !important;
}
}
}
.ride-map-marker {
background: transparent;
border: none;
.marker-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
}
}

View File

@@ -0,0 +1,568 @@
import {
ref,
computed,
watch,
onMounted,
onBeforeUnmount,
defineComponent,
PropType,
shallowRef,
} from 'vue';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useGeocoding } from '../../composables/useGeocoding';
import type { Location, Waypoint, Coordinates } from '../../types';
// Fix per icone Leaflet con Vite
import iconUrl from 'leaflet/dist/images/marker-icon.png';
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
// @ts-ignore
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl,
iconRetinaUrl,
shadowUrl,
});
interface LocalRouteInfo {
distance: number;
duration: number;
durationFormatted: string;
}
export default defineComponent({
name: 'RideMap',
props: {
departure: {
type: Object as PropType<Location | null>,
default: null,
},
destination: {
type: Object as PropType<Location | null>,
default: null,
},
waypoints: {
type: Array as PropType<Waypoint[]>,
default: () => [],
},
mapHeight: {
type: String,
default: '400px',
},
showRoute: {
type: Boolean,
default: true,
},
showRouteInfo: {
type: Boolean,
default: true,
},
showLegend: {
type: Boolean,
default: true,
},
showFullscreenButton: {
type: Boolean,
default: true,
},
interactive: {
type: Boolean,
default: true,
},
autoFit: {
type: Boolean,
default: true,
},
},
emits: ['route-calculated', 'marker-click'],
setup(props, { emit }) {
const { calculateRoute } = useGeocoding();
// State
const mapContainer = ref<HTMLElement | null>(null);
const loading = ref(false);
const isFullscreen = ref(false);
const routeInfo = shallowRef<LocalRouteInfo | null>(null);
// NON reactive - evita problemi con Leaflet
let map: L.Map | null = null;
let routeLayer: L.Polyline | null = null;
let markersLayer: L.LayerGroup | null = null;
// Flags per prevenire loop
let isUpdating = false;
let lastRouteKey = '';
// Custom icons
const createIcon = (emoji: string, size: number = 32) => {
return L.divIcon({
className: 'ride-map-marker',
html: `<div class="marker-inner" style="font-size: ${size * 0.6}px; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">${emoji}</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size],
popupAnchor: [0, -size],
});
};
const startIcon = createIcon('🟢', 36);
const endIcon = createIcon('🔴', 36);
const waypointIcon = createIcon('📍', 30);
// Computed
const formattedDuration = computed(() => {
if (!routeInfo.value) return '';
return routeInfo.value.durationFormatted;
});
// Helper: format duration
const formatDuration = (seconds: number): string => {
const totalMinutes = Math.round(seconds / 60);
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes % 60;
if (hours === 0) return `${mins} min`;
if (mins === 0) return `${hours} h`;
return `${hours} h ${mins} min`;
};
// Helper: genera chiave unica per il percorso
const getRouteKey = (): string => {
const dep = props.departure?.coordinates;
const dest = props.destination?.coordinates;
if (!dep || !dest) return '';
const wps = props.waypoints
.filter(wp => wp.location?.coordinates)
.map(wp => `${wp.location!.coordinates.lat.toFixed(4)},${wp.location!.coordinates.lng.toFixed(4)}`)
.join('|');
return `${dep.lat.toFixed(4)},${dep.lng.toFixed(4)}-${wps}-${dest.lat.toFixed(4)},${dest.lng.toFixed(4)}`;
};
// Initialize map
const initMap = () => {
if (!mapContainer.value || map) return;
console.log('🗺️ Initializing map...');
map = L.map(mapContainer.value, {
center: [41.9028, 12.4964],
zoom: 6,
zoomControl: true,
attributionControl: true,
dragging: props.interactive,
touchZoom: props.interactive,
scrollWheelZoom: props.interactive,
doubleClickZoom: props.interactive,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map);
markersLayer = L.layerGroup().addTo(map);
// Update iniziale
updateMapContent();
};
// Separato updateMarkers da calculateRoute
const updateMapContent = () => {
if (isUpdating) return;
isUpdating = true;
console.log('📍 Updating map content...', {
hasDeparture: !!props.departure?.coordinates,
hasDestination: !!props.destination?.coordinates,
waypointsCount: props.waypoints.length
});
try {
updateMarkersOnly();
const currentKey = getRouteKey();
if (props.showRoute && currentKey && currentKey !== lastRouteKey) {
console.log('🛣️ Route key changed, calculating new route...');
lastRouteKey = currentKey;
setTimeout(() => {
calculateAndShowRoute();
}, 0);
} else if (props.autoFit) {
fitBounds();
}
} finally {
isUpdating = false;
}
};
// Solo aggiornamento markers
const updateMarkersOnly = () => {
if (!map || !markersLayer) return;
markersLayer.clearLayers();
if (props.departure?.coordinates) {
const { lat, lng } = props.departure.coordinates;
const marker = L.marker([lat, lng], { icon: startIcon });
marker.bindPopup(`<strong>Partenza</strong><br>${props.departure.city}`);
marker.on('click', () => emit('marker-click', 'departure', props.departure));
markersLayer.addLayer(marker);
console.log('✅ Added departure marker:', lat, lng);
}
props.waypoints.forEach((wp, index) => {
if (wp.location?.coordinates) {
const { lat, lng } = wp.location.coordinates;
const marker = L.marker([lat, lng], { icon: waypointIcon });
marker.bindPopup(`<strong>Tappa ${index + 1}</strong><br>${wp.location.city}`);
marker.on('click', () => emit('marker-click', 'waypoint', wp, index));
markersLayer.addLayer(marker);
}
});
if (props.destination?.coordinates) {
const { lat, lng } = props.destination.coordinates;
const marker = L.marker([lat, lng], { icon: endIcon });
marker.bindPopup(`<strong>Arrivo</strong><br>${props.destination.city}`);
marker.on('click', () => emit('marker-click', 'destination', props.destination));
markersLayer.addLayer(marker);
console.log('✅ Added destination marker:', lat, lng);
}
};
// Calculate and show route
const calculateAndShowRoute = async () => {
if (!map) {
console.warn('❌ Map not initialized');
return;
}
if (!props.departure?.coordinates || !props.destination?.coordinates) {
console.warn('❌ Missing coordinates');
return;
}
loading.value = true;
console.log('🚗 Calculating route...');
try {
const waypointCoords: Coordinates[] = props.waypoints
.filter((wp) => wp.location?.coordinates)
.sort((a, b) => a.order - b.order)
.map((wp) => wp.location!.coordinates);
console.log('📍 Route params:', {
from: props.departure.coordinates,
to: props.destination.coordinates,
waypoints: waypointCoords
});
const result = await calculateRoute(
props.departure.coordinates,
props.destination.coordinates,
waypointCoords.length > 0 ? waypointCoords : undefined
);
// console.log('📦 Route API result:', JSON.stringify(result, null, 2));
if (!result) {
console.warn('❌ No route result, drawing fallback line');
drawFallbackLine(waypointCoords);
return;
}
// Estrai distance e duration dal risultato
// Il backend restituisce: { distance: km, duration: minuti, geometry: {...} }
const distanceKm = result.distance || 0;
const durationMinutes = result.duration || 0;
const newRouteInfo: LocalRouteInfo = {
distance: Math.round(distanceKm * 10) / 10,
duration: durationMinutes,
durationFormatted: formatDuration(durationMinutes * 60), // Converti in secondi per formatDuration
};
console.log('📊 Route info:', newRouteInfo);
routeInfo.value = newRouteInfo;
emit('route-calculated', { ...newRouteInfo });
// Rimuovi percorso precedente
if (routeLayer && map) {
map.removeLayer(routeLayer);
routeLayer = null;
}
// Disegna il percorso
const coordinates = extractCoordinates(result);
console.log(`📐 Extracted ${coordinates.length} coordinates`);
if (coordinates.length > 0) {
routeLayer = L.polyline(coordinates, {
color: '#1976D2',
weight: 5,
opacity: 0.8,
smoothFactor: 1,
lineJoin: 'round',
lineCap: 'round',
}).addTo(map);
console.log('✅ Route drawn successfully');
if (props.autoFit) {
fitBounds();
}
} else {
console.warn('❌ No coordinates extracted, drawing fallback');
drawFallbackLine(waypointCoords);
}
} catch (error) {
console.error('❌ Errore calcolo percorso:', error);
drawFallbackLine([]);
} finally {
loading.value = false;
}
};
// Extract coordinates from various API response formats
const extractCoordinates = (result: any): L.LatLngTuple[] => {
console.log('🔍 Extracting coordinates from:', Object.keys(result));
// Formato 1: geometry.coordinates (GeoJSON LineString)
if (result.geometry?.coordinates && Array.isArray(result.geometry.coordinates)) {
console.log('📐 Format: GeoJSON LineString');
return result.geometry.coordinates.map(
(coord: number[]) => [coord[1], coord[0]] as L.LatLngTuple // [lng, lat] -> [lat, lng]
);
}
// Formato 2: geometry è una stringa (polyline encoded)
if (typeof result.geometry === 'string') {
console.log('📐 Format: Encoded polyline string');
return decodePolyline(result.geometry);
}
// Formato 3: polyline separato
if (typeof result.polyline === 'string' && result.polyline.length > 0) {
console.log('📐 Format: Separate polyline field');
return decodePolyline(result.polyline);
}
// Formato 4: coordinates array diretto
if (Array.isArray(result.coordinates)) {
console.log('📐 Format: Direct coordinates array');
return result.coordinates.map((coord: number[]) => {
if (Math.abs(coord[0]) <= 90) {
return [coord[0], coord[1]] as L.LatLngTuple;
}
return [coord[1], coord[0]] as L.LatLngTuple;
});
}
// Formato 5: routes array (OSRM format)
if (result.routes?.[0]?.geometry) {
const geom = result.routes[0].geometry;
console.log('📐 Format: OSRM routes array');
if (typeof geom === 'string') {
return decodePolyline(geom);
}
if (geom.coordinates) {
return geom.coordinates.map(
(coord: number[]) => [coord[1], coord[0]] as L.LatLngTuple
);
}
}
// Formato 6: segments con steps (OpenRouteService format)
if (result.segments && Array.isArray(result.segments)) {
console.log('📐 Format: ORS segments');
// Se abbiamo segments ma non geometry, probabilmente manca qualcosa
console.warn('⚠️ Segments found but no geometry');
}
console.warn('❌ Could not extract coordinates, keys:', Object.keys(result));
return [];
};
// Draw fallback straight line
const drawFallbackLine = (waypointCoords: Coordinates[]) => {
if (!map || !props.departure?.coordinates || !props.destination?.coordinates) return;
console.log('📏 Drawing fallback straight line');
if (routeLayer) {
map.removeLayer(routeLayer);
routeLayer = null;
}
const points: L.LatLngTuple[] = [
[props.departure.coordinates.lat, props.departure.coordinates.lng],
...waypointCoords.map((c) => [c.lat, c.lng] as L.LatLngTuple),
[props.destination.coordinates.lat, props.destination.coordinates.lng],
];
routeLayer = L.polyline(points, {
color: '#FF9800',
weight: 3,
opacity: 0.7,
dashArray: '10, 10',
}).addTo(map);
if (props.autoFit) {
fitBounds();
}
};
// Decode Google/OSRM polyline format
const decodePolyline = (encoded: string): L.LatLngTuple[] => {
const points: L.LatLngTuple[] = [];
let index = 0;
let lat = 0;
let lng = 0;
while (index < encoded.length) {
let shift = 0;
let result = 0;
let byte: number;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
lat += result & 1 ? ~(result >> 1) : result >> 1;
shift = 0;
result = 0;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
lng += result & 1 ? ~(result >> 1) : result >> 1;
points.push([lat / 1e5, lng / 1e5]);
}
console.log(`📐 Decoded ${points.length} points from polyline`);
return points;
};
// Fit bounds
const fitBounds = () => {
if (!map) return;
const allPoints: L.LatLngTuple[] = [];
if (props.departure?.coordinates) {
allPoints.push([props.departure.coordinates.lat, props.departure.coordinates.lng]);
}
props.waypoints.forEach((wp) => {
if (wp.location?.coordinates) {
allPoints.push([wp.location.coordinates.lat, wp.location.coordinates.lng]);
}
});
if (props.destination?.coordinates) {
allPoints.push([props.destination.coordinates.lat, props.destination.coordinates.lng]);
}
if (allPoints.length === 0) return;
if (allPoints.length === 1) {
map.setView(allPoints[0], 13);
} else {
map.fitBounds(L.latLngBounds(allPoints), {
padding: [50, 50],
maxZoom: 14,
animate: true,
});
}
};
// Center on user
const centerOnUser = () => {
if (!map || !navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(pos) => map!.setView([pos.coords.latitude, pos.coords.longitude], 13),
(err) => console.error('Geolocation error:', err)
);
};
// Toggle fullscreen
const toggleFullscreen = () => {
const container = mapContainer.value?.parentElement;
if (!container) return;
if (!document.fullscreenElement) {
container.requestFullscreen().then(() => {
isFullscreen.value = true;
setTimeout(() => map?.invalidateSize(), 100);
});
} else {
document.exitFullscreen().then(() => {
isFullscreen.value = false;
setTimeout(() => map?.invalidateSize(), 100);
});
}
};
// Watch con debounce
let watchTimeout: ReturnType<typeof setTimeout> | null = null;
watch(
() => [
props.departure?.coordinates?.lat,
props.departure?.coordinates?.lng,
props.destination?.coordinates?.lat,
props.destination?.coordinates?.lng,
props.waypoints.length,
props.waypoints.map(w => w.location?.coordinates?.lat).join(','),
],
() => {
if (watchTimeout) {
clearTimeout(watchTimeout);
}
watchTimeout = setTimeout(() => {
updateMapContent();
}, 200);
},
{ flush: 'post' }
);
// Lifecycle
onMounted(() => {
setTimeout(initMap, 100);
});
onBeforeUnmount(() => {
if (watchTimeout) clearTimeout(watchTimeout);
if (map) {
map.remove();
map = null;
}
});
return {
mapContainer,
loading,
isFullscreen,
routeInfo,
formattedDuration,
fitBounds,
centerOnUser,
toggleFullscreen,
};
},
});

View File

@@ -0,0 +1,631 @@
// ================================
// Variables
// ================================
$offer-color: #10b981;
$offer-light: #d1fae5;
$offer-gradient: linear-gradient(135deg, #10b981, #059669);
$request-color: #ef4444;
$request-light: #fee2e2;
$request-gradient: linear-gradient(135deg, #ef4444, #dc2626);
$transition-fast: 0.2s ease;
$transition-smooth: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// ================================
// Base
// ================================
.ride-type-toggle {
width: 100%;
}
// ================================
// VARIANT: Cards
// ================================
.toggle-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem; // 16px → 1rem
}
.toggle-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem 1rem; // 24px → 1.5rem, 16px → 1rem
background: #fff;
border-radius: 1.25rem; // 20px → 1.25rem
cursor: pointer;
transition: all $transition-smooth;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
border: 2px solid #e5e7eb;
border-radius: 1.25rem;
transition: all $transition-smooth;
}
&:hover:not(&--disabled) {
transform: translateY(-0.25rem); // -4px → -0.25rem
box-shadow: 0 0.75rem 1.5rem rgba(0, 0, 0, 0.1); // 12px → 0.75rem, 24px → 1.5rem
}
&--disabled {
opacity: 0.5;
cursor: not-allowed;
}
// Offer styles
&--offer {
&.toggle-card--selected {
&::before {
border-color: $offer-color;
border-width: 2px;
}
.toggle-card__icon-wrapper {
.q-icon {
color: $offer-color;
}
}
.toggle-card__icon-bg {
background: $offer-light;
transform: scale(1);
}
.toggle-card__check {
background: $offer-gradient;
}
}
&:hover:not(.toggle-card--disabled) {
.toggle-card__icon-bg {
background: $offer-light;
transform: scale(1);
}
}
}
// Request styles
&--request {
&.toggle-card--selected {
&::before {
border-color: $request-color;
border-width: 2px;
}
.toggle-card__icon-wrapper {
.q-icon {
color: $request-color;
}
}
.toggle-card__icon-bg {
background: $request-light;
transform: scale(1);
}
.toggle-card__check {
background: $request-gradient;
}
}
&:hover:not(.toggle-card--disabled) {
.toggle-card__icon-bg {
background: $request-light;
transform: scale(1);
}
}
}
&__icon-wrapper {
position: relative;
width: 4rem; // 64px → 4rem
height: 4rem;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem; // 16px → 1rem
.q-icon {
position: relative;
z-index: 1;
color: #6b7280;
transition: color $transition-smooth;
}
}
&__icon-bg {
position: absolute;
inset: 0;
border-radius: 1rem; // 16px → 1rem
background: #f3f4f6;
transform: scale(0.9);
transition: all $transition-smooth;
}
&__content {
text-align: center;
}
&__label {
display: block;
font-size: 1.2rem; // 16px → 1rem
font-weight: 600;
color: #111827;
margin-bottom: 0.25rem; // 4px → 0.25rem
}
&__desc {
display: block;
font-size: 1.1rem; // 13px → 0.8125rem
color: #6b7280;
}
&__check {
position: absolute;
top: 0.75rem; // 12px → 0.75rem
right: 0.75rem;
width: 1.5rem; // 24px → 1.5rem
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.2); // 2px → 0.125rem, 8px → 0.5rem
}
}
// Scale transition
.scale-enter-active,
.scale-leave-active {
transition: all 0.2s ease;
}
.scale-enter-from,
.scale-leave-to {
transform: scale(0);
opacity: 0;
}
// ================================
// VARIANT: Pills
// ================================
.toggle-pills {
&__track {
position: relative;
display: flex;
background: #f3f4f6;
border-radius: 1rem; // 16px → 1rem
padding: 0.375rem; // 6px → 0.375rem
}
&__slider {
position: absolute;
top: 0.375rem;
bottom: 0.375rem;
width: calc(50% - 0.375rem);
border-radius: 0.75rem; // 12px → 0.75rem
transition: all $transition-smooth;
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.15);
&--offer {
left: 0.375rem;
background: $offer-gradient;
}
&--request {
left: calc(50%);
background: $request-gradient;
}
}
&__btn {
flex: 1;
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem; // 8px → 0.5rem
padding: 0.875rem 1.25rem; // 14px → 0.875rem, 20px → 1.25rem
border: none;
background: transparent;
font-size: 1rem; // 14px → 0.875rem
font-weight: 600;
color: #6b7280;
cursor: pointer;
transition: color $transition-fast;
&--active {
color: white;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
}
// ================================
// VARIANT: Minimal
// ================================
.toggle-minimal {
display: flex;
gap: 0.75rem; // 12px → 0.75rem
&__btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem; // 10px → 0.625rem
padding: 1rem 1.5rem; // 16px → 1rem, 24px → 1.5rem
border: 2px solid #e5e7eb;
border-radius: 0.875rem; // 14px → 0.875rem
background: white;
cursor: pointer;
transition: all $transition-smooth;
&:hover:not(:disabled) {
border-color: #d1d5db;
background: #f9fafb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--offer.toggle-minimal__btn--active {
border-color: $offer-color;
background: $offer-light;
.toggle-minimal__icon {
background: $offer-gradient;
color: white;
}
.toggle-minimal__label {
color: color.adjust($offer-color, $lightness: -10%);
}
}
&--request.toggle-minimal__btn--active {
border-color: $request-color;
background: $request-light;
.toggle-minimal__icon {
background: $request-gradient;
color: white;
}
.toggle-minimal__label {
color: color.adjust($request-color, $lightness: -10%);
}
}
}
&__icon {
width: 2.5rem; // 40px → 2.5rem
height: 2.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.625rem; // 10px → 0.625rem
background: #f3f4f6;
color: #6b7280;
transition: all $transition-smooth;
}
&__label {
font-size: 0.9375rem; // 15px → 0.9375rem
font-weight: 600;
color: #374151;
transition: color $transition-smooth;
}
}
// ================================
// VARIANT: Elegant
// ================================
.toggle-elegant {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.25rem; // 20px → 1.25rem
&__option {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1.5rem; // 32px → 2rem, 24px → 1.5rem
background: white;
border: 2px solid #e5e7eb;
border-radius: 1.5rem; // 24px → 1.5rem
cursor: pointer;
transition: all $transition-smooth;
&:hover:not(&--disabled) {
border-color: #d1d5db;
box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.08); // 8px → 0.5rem, 24px → 1.5rem
}
&--disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--offer.toggle-elegant__option--selected {
border-color: $offer-color;
background: linear-gradient(180deg, $offer-light 0%, white 100%);
.toggle-elegant__circle {
background: $offer-gradient;
color: white;
box-shadow: 0 0.5rem 1.5rem rgba($offer-color, 0.4);
}
.toggle-elegant__decoration {
background: $offer-color;
}
.toggle-elegant__radio-inner {
transform: scale(1);
background: $offer-gradient;
}
}
&--request.toggle-elegant__option--selected {
border-color: $request-color;
background: linear-gradient(180deg, $request-light 0%, white 100%);
.toggle-elegant__circle {
background: $request-gradient;
color: white;
box-shadow: 0 0.5rem 1.5rem rgba($request-color, 0.4);
}
.toggle-elegant__decoration {
background: $request-color;
}
.toggle-elegant__radio-inner {
transform: scale(1);
background: $request-gradient;
}
}
}
&__visual {
position: relative;
margin-bottom: 1.25rem; // 20px → 1.25rem
}
&__circle {
width: 5rem; // 80px → 5rem
height: 5rem;
display: flex;
align-items: center;
justify-content: center;
background: #f3f4f6;
border-radius: 50%;
color: #6b7280;
transition: all $transition-smooth;
}
&__decoration {
position: absolute;
bottom: -0.25rem; // -4px → -0.25rem
right: -0.25rem;
width: 1.75rem; // 28px → 1.75rem
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
background: #d1d5db;
border-radius: 50%;
color: white;
border: 3px solid white;
transition: background $transition-smooth;
}
&__title {
margin: 0 0 0.25rem; // 4px → 0.25rem
font-size: 1.125rem; // 18px → 1.125rem
font-weight: 700;
color: #111827;
}
&__subtitle {
margin: 0;
font-size: 0.8125rem; // 13px → 0.8125rem
color: #6b7280;
text-align: center;
}
&__radio {
position: absolute;
top: 1rem; // 16px → 1rem
right: 1rem;
width: 1.375rem; // 22px → 1.375rem
height: 1.375rem;
border: 2px solid #d1d5db;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: border-color $transition-smooth;
}
&__radio-inner {
width: 0.75rem; // 12px → 0.75rem
height: 0.75rem;
border-radius: 50%;
transform: scale(0);
transition: transform $transition-smooth;
}
}
// ================================
// Responsive
// ================================
@media (max-width: 37.4375rem) { // 599px → 599/16 = 37.4375rem
.toggle-cards {
gap: 0.75rem; // 12px → 0.75rem
}
.toggle-card {
padding: 1.25rem 0.75rem; // 20px → 1.25rem, 12px → 0.75rem
&__icon-wrapper {
width: 3.5rem; // 56px → 3.5rem
height: 3.5rem;
margin-bottom: 0.75rem; // 12px → 0.75rem
.q-icon {
font-size: 1.75rem !important; // 28px → 1.75rem
}
}
&__label {
font-size: 1.1rem; // 14px → 0.875rem
}
&__desc {
font-size: 0.85rem; // 11px → 0.6875rem
}
}
.toggle-pills {
&__btn {
padding: 0.75rem 1rem; // 12px → 0.75rem, 16px → 1rem
font-size: 0.8125rem; // 13px → 0.8125rem
.q-icon {
font-size: 1.125rem !important; // 18px → 1.125rem
}
}
}
.toggle-minimal {
flex-direction: column;
}
.toggle-elegant {
gap: 0.75rem; // 12px → 0.75rem
&__option {
padding: 1.5rem 1rem; // 24px → 1.5rem, 16px → 1rem
}
&__circle {
width: 4rem; // 64px → 4rem
height: 4rem;
.q-icon {
font-size: 2rem !important; // 32px → 2rem
}
}
&__title {
font-size: 0.9375rem; // 15px → 0.9375rem
}
&__subtitle {
font-size: 0.75rem; // 12px → 0.75rem
}
}
}
// ================================
// Dark Mode
// ================================
.body--dark {
.toggle-card {
background: #1f2937;
&::before {
border-color: #374151;
}
&__label {
color: #f9fafb;
}
&__desc {
color: #9ca3af;
}
&__icon-bg {
background: #374151;
}
&--offer.toggle-card--selected .toggle-card__icon-bg {
background: rgba($offer-color, 0.2);
}
&--request.toggle-card--selected .toggle-card__icon-bg {
background: rgba($request-color, 0.2);
}
}
.toggle-pills__track {
background: #374151;
}
.toggle-pills__btn {
color: #9ca3af;
}
.toggle-minimal__btn {
background: #1f2937;
border-color: #374151;
&:hover:not(:disabled) {
background: #374151;
}
}
.toggle-minimal__icon {
background: #374151;
}
.toggle-minimal__label {
color: #f9fafb;
}
.toggle-elegant__option {
background: #1f2937;
border-color: #374151;
&--offer.toggle-elegant__option--selected {
background: linear-gradient(180deg, rgba($offer-color, 0.15) 0%, #1f2937 100%);
}
&--request.toggle-elegant__option--selected {
background: linear-gradient(180deg, rgba($request-color, 0.15) 0%, #1f2937 100%);
}
}
.toggle-elegant__circle {
background: #374151;
}
.toggle-elegant__title {
color: #f9fafb;
}
.toggle-elegant__subtitle {
color: #9ca3af;
}
.toggle-elegant__radio {
border-color: #4b5563;
}
}

View File

@@ -0,0 +1,64 @@
import { defineComponent, PropType, computed } from 'vue';
import type { RideType } from '../../types/viaggi.types';
type ToggleVariant = 'cards' | 'pills' | 'minimal' | 'elegant';
interface ToggleOption {
value: RideType;
label: string;
description: string;
icon: string;
decorIcon: string;
}
export default defineComponent({
name: 'RideTypeToggle',
props: {
modelValue: {
type: String as PropType<RideType>,
default: 'offer'
},
variant: {
type: String as PropType<ToggleVariant>,
default: 'cards'
},
disabled: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue', 'change'],
setup(props, { emit }) {
const options = computed<ToggleOption[]>(() => [
{
value: 'offer',
label: 'Offro passaggio',
description: 'Ho posti liberi in auto',
icon: 'directions_car',
decorIcon: 'person_add'
},
{
value: 'request',
label: 'Cerco passaggio',
description: 'Ho bisogno di un passaggio',
icon: 'hail',
decorIcon: 'search'
}
]);
const select = (value: RideType) => {
if (!props.disabled) {
emit('update:modelValue', value);
emit('change', value);
}
};
return {
options,
select
};
}
});

View File

@@ -0,0 +1,136 @@
<template>
<div :class="['ride-type-toggle', `ride-type-toggle--${variant}`]">
<!-- ==================== -->
<!-- VARIANT: Cards (default) -->
<!-- ==================== -->
<div v-if="variant === 'cards'" class="toggle-cards">
<div
v-for="option in options"
:key="option.value"
:class="[
'toggle-card',
`toggle-card--${option.value}`,
{
'toggle-card--selected': modelValue === option.value,
'toggle-card--disabled': disabled
}
]"
@click="select(option.value)"
>
<!-- Icona principale -->
<div class="toggle-card__icon-wrapper">
<q-icon :name="option.icon" size="32px" />
<div class="toggle-card__icon-bg"></div>
</div>
<!-- Contenuto -->
<div class="toggle-card__content">
<span class="toggle-card__label">{{ option.label }}</span>
<span class="toggle-card__desc">{{ option.description }}</span>
</div>
<!-- Check di selezione -->
<transition name="scale">
<div v-if="modelValue === option.value" class="toggle-card__check">
<q-icon name="check" size="16px" color="white" />
</div>
</transition>
<!-- Bordo animato -->
<div class="toggle-card__border"></div>
</div>
</div>
<!-- ==================== -->
<!-- VARIANT: Pills -->
<!-- ==================== -->
<div v-else-if="variant === 'pills'" class="toggle-pills">
<div class="toggle-pills__track">
<div
class="toggle-pills__slider"
:class="`toggle-pills__slider--${modelValue}`"
></div>
<button
v-for="option in options"
:key="option.value"
:class="[
'toggle-pills__btn',
{ 'toggle-pills__btn--active': modelValue === option.value }
]"
:disabled="disabled"
@click="select(option.value)"
>
<q-icon :name="option.icon" size="20px" />
<span>{{ option.label }}</span>
</button>
</div>
</div>
<!-- ==================== -->
<!-- VARIANT: Minimal -->
<!-- ==================== -->
<div v-else-if="variant === 'minimal'" class="toggle-minimal">
<button
v-for="option in options"
:key="option.value"
:class="[
'toggle-minimal__btn',
`toggle-minimal__btn--${option.value}`,
{ 'toggle-minimal__btn--active': modelValue === option.value }
]"
:disabled="disabled"
@click="select(option.value)"
>
<div class="toggle-minimal__icon">
<q-icon :name="option.icon" size="24px" />
</div>
<span class="toggle-minimal__label">{{ option.label }}</span>
</button>
</div>
<!-- ==================== -->
<!-- VARIANT: Elegant -->
<!-- ==================== -->
<div v-else-if="variant === 'elegant'" class="toggle-elegant">
<div
v-for="option in options"
:key="option.value"
:class="[
'toggle-elegant__option',
`toggle-elegant__option--${option.value}`,
{
'toggle-elegant__option--selected': modelValue === option.value,
'toggle-elegant__option--disabled': disabled
}
]"
@click="select(option.value)"
>
<!-- Illustrazione/Icona grande -->
<div class="toggle-elegant__visual">
<div class="toggle-elegant__circle">
<q-icon :name="option.icon" size="40px" />
</div>
<!-- Decorazione -->
<div class="toggle-elegant__decoration">
<q-icon :name="option.decorIcon" size="16px" />
</div>
</div>
<!-- Testo -->
<h3 class="toggle-elegant__title">{{ option.label }}</h3>
<p class="toggle-elegant__subtitle">{{ option.description }}</p>
<!-- Radio indicator -->
<div class="toggle-elegant__radio">
<div class="toggle-elegant__radio-inner"></div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" src="./RideTypeToggle.ts" />
<style lang="scss" src="./RideTypeToggle.scss" />

View File

@@ -0,0 +1,193 @@
import { ref, watch, defineComponent, PropType, nextTick } from 'vue';
import draggable from 'vuedraggable';
import CityAutocomplete from './CityAutocomplete.vue';
import type { Waypoint, Location, SuggestedWaypoint } from '../../types';
interface WaypointItem {
id: string;
location: Location | null;
order: number;
}
export default defineComponent({
name: 'WaypointsEditor',
components: {
draggable,
CityAutocomplete
},
props: {
modelValue: {
type: Array as PropType<Waypoint[]>,
default: () => []
},
departureCity: {
type: String,
default: ''
},
destinationCity: {
type: String,
default: ''
},
suggestedWaypoints: {
type: Array as PropType<SuggestedWaypoint[]>,
default: () => []
},
showSuggestions: {
type: Boolean,
default: true
},
maxWaypoints: {
type: Number,
default: 10
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
// State
const waypoints = ref<WaypointItem[]>([]);
// ✅ Flag per prevenire loop infiniti
let isInternalUpdate = false;
let isExternalUpdate = false;
// Genera ID univoco
const generateId = () => `wp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// ✅ Helper per emettere update in modo sicuro
const emitUpdate = () => {
if (isExternalUpdate) return; // Non emettere se stiamo ricevendo dal parent
isInternalUpdate = true;
const result: Waypoint[] = waypoints.value
.filter(wp => wp.location)
.map((wp, index) => ({
_id: wp.id,
location: wp.location!,
order: index + 1
}));
emit('update:modelValue', result);
nextTick(() => {
isInternalUpdate = false;
});
};
// ✅ Watch per sincronizzare con modelValue (dal parent)
watch(
() => props.modelValue,
(newVal) => {
// Se è un update interno, ignora
if (isInternalUpdate) return;
isExternalUpdate = true;
if (newVal && newVal.length > 0) {
waypoints.value = newVal.map((wp, index) => ({
id: wp._id || generateId(),
location: wp.location,
order: wp.order || index + 1
}));
} else {
waypoints.value = [];
}
nextTick(() => {
isExternalUpdate = false;
});
},
{ immediate: true, deep: true }
);
// Methods
const addWaypoint = () => {
if (waypoints.value.length >= props.maxWaypoints) return;
// ✅ Usa spread per creare nuovo array
waypoints.value = [
...waypoints.value,
{
id: generateId(),
location: null,
order: waypoints.value.length + 1
}
];
emitUpdate();
};
const removeWaypoint = (index: number) => {
// ✅ Crea nuovo array invece di splice
const updated = waypoints.value.filter((_, i) => i !== index);
// Riordina
updated.forEach((wp, i) => {
wp.order = i + 1;
});
waypoints.value = updated;
emitUpdate();
};
const onWaypointSelect = (index: number, location: Location) => {
if (!waypoints.value[index]) return;
// ✅ Crea nuovo array con l'elemento aggiornato
waypoints.value = waypoints.value.map((wp, i) => {
if (i === index) {
return { ...wp, location };
}
return wp;
});
emitUpdate();
};
const onDragEnd = () => {
// ✅ Riordina e crea nuovo array
waypoints.value = waypoints.value.map((wp, index) => ({
...wp,
order: index + 1
}));
emitUpdate();
};
const addSuggestedWaypoint = (suggestion: SuggestedWaypoint) => {
if (waypoints.value.length >= props.maxWaypoints) return;
const location: Location = {
city: suggestion.city,
province: suggestion.province,
region: suggestion.region,
coordinates: suggestion.coordinates
};
// ✅ Usa spread
waypoints.value = [
...waypoints.value,
{
id: generateId(),
location,
order: waypoints.value.length + 1
}
];
emitUpdate();
};
return {
waypoints,
addWaypoint,
removeWaypoint,
onWaypointSelect,
onDragEnd,
addSuggestedWaypoint
};
}
});

View File

@@ -1,5 +1,5 @@
<template>
<div class="waypoints-editor">
<div v-if="waypoints" class="waypoints-editor">
<div class="waypoints-editor__header">
<q-icon name="add_location" size="20px" color="primary" />
<span class="waypoints-editor__title">Tappe Intermedie</span>

View File

@@ -2,7 +2,7 @@
import { defineComponent, ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { Api } from '@api';
import type { Ride, ContribType } from '../../types/trasporti.types';
import type { Ride, ContribType } from '../../types/viaggi.types';
interface WidgetStats {
offers: number;
@@ -72,7 +72,7 @@ export default defineComponent({
loading.value = true;
try {
const response = await Api.SendReqWithData('/api/trasporti/widget/data', 'GET', {});
const response = await Api.SendReqWithData('/api/viaggi/widget/data', 'GET', {});
if (response.success) {
const data: WidgetData = response.data;
@@ -95,7 +95,7 @@ export default defineComponent({
const loadStats = async () => {
try {
const response = await Api.SendReqWithData('/api/trasporti/stats/summary', 'GET');
const response = await Api.SendReqWithData('/api/viaggi/stats/summary', 'GET');
if (response.success) {
stats.value = response.data;
@@ -181,37 +181,37 @@ export default defineComponent({
// Navigation
const goToCreate = (type: 'offer' | 'request') => {
router.push({
path: '/trasporti/crea',
path: '/viaggi/crea',
query: { type }
});
};
const goToList = () => {
router.push('/trasporti');
router.push('/viaggi');
};
const goToRide = (rideId: string) => {
router.push(`/trasporti/ride/${rideId}`);
router.push(`/viaggi/ride/${rideId}`);
};
const goToMyRides = () => {
router.push('/trasporti/rides/my');
router.push('/viaggi/rides/my');
};
const goToSearch = () => {
router.push('/trasporti/cerca');
router.push('/viaggi/cerca');
};
const goToMap = () => {
router.push('/trasporti/mappa');
router.push('/viaggi/mappa');
};
const goToHistory = () => {
router.push('/trasporti/storico');
router.push('/viaggi/storico');
};
const goToChat = () => {
router.push('/trasporti/chat');
router.push('/viaggi/chat');
};
// Auto-refresh

View File

@@ -9,7 +9,7 @@
<span class="ride-widget__icon-badge" v-if="totalCount > 0">{{ totalCount }}</span>
</div>
<div class="ride-widget__title-section">
<h3 class="ride-widget__title">Trasporti</h3>
<h3 class="ride-widget__title">Viaggi</h3>
<p class="ride-widget__subtitle">Viaggi solidali</p>
</div>
</div>

View File

@@ -4,6 +4,6 @@ export { useRides } from './useRides';
export { useRideRequests } from './useRideRequests';
export { useChat } from './useChat';
export { useFeedback } from './useFeedback';
export { useGeocoding } from './useGeocoding';
export { useGeocoding } from './useGeocoding_OLD';
export { useDriverProfile } from './useDriverProfile';
export { useContribTypes } from './useContribTypes';

View File

@@ -0,0 +1,791 @@
// useChat.ts
import { ref, computed } from 'vue';
import { Api } from '@api';
import type { Chat, Message } from '../types/viaggi.types';
import { tools } from 'app/src/store/Modules/tools';
// ============================================================
// STATE
// ============================================================
const chats = ref<Chat[]>([]);
const currentChat = ref<Chat | null>(null);
const messages = ref<Message[]>([]);
const totalUnreadCount = ref(0);
const loading = ref(false);
const loadingMessages = ref(false);
const sending = ref(false);
const error = ref<string | null>(null);
// Real-time state
const onlineUsers = ref<string[]>([]);
const typingUsers = ref<string[]>([]);
interface FetchMessagesOptions {
loadOlder?: boolean;
loadNewer?: boolean;
reset?: boolean;
limit?: number;
}
const hasOlderMessages = ref(true);
const hasNewerMessages = ref(false);
// POLLING STATE
let pollingInterval: ReturnType<typeof setInterval> | null = null;
const isPolling = ref(false);
const POLLING_INTERVAL = 3000;
// ✅ Set per tracciare gli ID dei messaggi già presenti
const messageIds = new Set<string>();
// ============================================================
// COMPOSABLE
// ============================================================
export function useChat() {
// ------------------------------------------------------------
// COMPUTED
// ------------------------------------------------------------
const hasChats = computed(() => chats.value.length > 0);
const hasUnread = computed(() => totalUnreadCount.value > 0);
const sortedChats = computed(() =>
[...chats.value].sort((a, b) => {
const dateA = new Date(a.updatedAt || a.createdAt).getTime();
const dateB = new Date(b.updatedAt || b.createdAt).getTime();
return dateB - dateA;
})
);
const sortedMessages = computed(() =>
[...messages.value].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateA - dateB;
})
);
// ------------------------------------------------------------
// HELPER: Reset messaggi
// ------------------------------------------------------------
/**
* ✅ Helper per resettare lo stato dei messaggi
*/
const resetMessagesState = () => {
messages.value = [];
messageIds.clear();
hasOlderMessages.value = true;
hasNewerMessages.value = false;
};
// ------------------------------------------------------------
// API CALLS
// ------------------------------------------------------------
/**
* Ottieni tutte le chat dell'utente
*/
const fetchChats = async (page = 1, limit = 20) => {
try {
loading.value = true;
error.value = null;
const response = await Api.SendReqWithData(
`/api/viaggi/chats?page=${page}&limit=${limit}`,
'GET'
);
if (response.success && response.data) {
const newChats = response.data;
if (page === 1) {
chats.value = newChats;
} else {
chats.value = [...chats.value, ...newChats];
}
totalUnreadCount.value = response.data.reduce(
(sum: number, chat: any) => sum + (chat.unreadCount || 0),
0
);
}
return response;
} catch (err: any) {
error.value = err.message || 'Errore nel recupero delle chat';
throw err;
} finally {
loading.value = false;
}
};
/**
* ✅ Ottieni o crea chat diretta - FIXED
*/
const getOrCreateDirectChat = async (otherUserId: string, rideId?: string) => {
try {
loading.value = true;
error.value = null;
const response = await Api.SendReqWithData('/api/viaggi/chats/direct', 'POST', {
otherUserId,
rideId,
});
if (response.success && response.data) {
const newChat = response.data;
// ✅ Se stiamo aprendo una chat DIVERSA, pulisci i messaggi
if (currentChat.value?._id !== newChat._id) {
console.log('[useChat] Cambio chat, reset messaggi');
resetMessagesState();
}
currentChat.value = newChat;
// Aggiorna o aggiungi alla lista
const existingIndex = chats.value.findIndex((c) => c._id === newChat._id);
if (existingIndex === -1) {
chats.value = [newChat, ...chats.value];
} else {
chats.value = chats.value.map((c, i) =>
i === existingIndex ? { ...c, ...newChat } : c
);
}
}
return response.data;
} catch (err: any) {
error.value = err.message || 'Errore nella creazione della chat';
throw err;
} finally {
loading.value = false;
}
};
/**
* ✅ Carica singola chat - FIXED
*/
const loadChat = async (chatId: string) => {
// ✅ Se stiamo cambiando chat, pulisci i messaggi
if (currentChat.value?._id !== chatId) {
console.log('[useChat] loadChat: cambio chat, reset messaggi');
resetMessagesState();
}
return await fetchChat(chatId);
};
/**
* Ottieni singola chat per ID
*/
const fetchChat = async (chatId: string) => {
try {
loading.value = true;
error.value = null;
const response = await Api.SendReqWithData(`/api/viaggi/chats/${chatId}`, 'GET');
if (response.success && response.data) {
currentChat.value = response.data;
}
return response;
} catch (err: any) {
error.value = err.message || 'Errore nel recupero della chat';
throw err;
} finally {
loading.value = false;
}
};
/**
* Carica messaggi - ritorna array
*/
const loadMessages = async (
chatId: string,
options?: { before?: string; after?: string; limit?: number }
): Promise<Message[]> => {
const response = await fetchMessages(chatId, options);
return response.data || [];
};
/**
* ✅ Fetch messaggi - FIXED
*/
const fetchMessages = async (chatId: string, options: FetchMessagesOptions = {}) => {
try {
loadingMessages.value = true;
error.value = null;
const params = new URLSearchParams({ idapp: tools.getIdApp() });
// ✅ Se è un reset, NON passare before/after
if (!options.reset) {
if (options.loadOlder && messages.value.length > 0) {
const oldestMessage = messages.value[0];
params.append('before', oldestMessage.createdAt);
} else if (options.loadNewer && messages.value.length > 0) {
const newestMessage = messages.value[messages.value.length - 1];
params.append('after', newestMessage.createdAt);
}
}
if (options.limit) {
params.append('limit', options.limit.toString());
}
console.log('[useChat] fetchMessages:', {
chatId,
reset: options.reset,
currentMessages: messages.value.length
});
const response = await Api.SendReqWithData(
`/api/viaggi/chats/${chatId}/messages?${params.toString()}`,
'GET'
);
if (response.success && response.data) {
const newMessages = response.data;
console.log('[useChat] Ricevuti messaggi:', newMessages.length);
if (options.reset) {
// ✅ RESET COMPLETO
messageIds.clear();
newMessages.forEach((m: Message) => messageIds.add(m._id));
messages.value = [...newMessages]; // Nuovo array
hasOlderMessages.value = response.hasMore ?? true;
hasNewerMessages.value = false;
console.log('[useChat] Reset completato, messaggi:', messages.value.length);
} else if (options.loadOlder) {
const uniqueMessages = newMessages.filter((m: Message) => !messageIds.has(m._id));
uniqueMessages.forEach((m: Message) => messageIds.add(m._id));
messages.value = [...uniqueMessages, ...messages.value];
hasOlderMessages.value = response.hasMore ?? false;
} else if (options.loadNewer) {
const uniqueMessages = newMessages.filter((m: Message) => !messageIds.has(m._id));
uniqueMessages.forEach((m: Message) => messageIds.add(m._id));
messages.value = [...messages.value, ...uniqueMessages];
hasNewerMessages.value = response.hasMore ?? false;
} else {
// Default: se non ci sono messaggi, tratta come reset
if (messages.value.length === 0) {
messageIds.clear();
newMessages.forEach((m: Message) => messageIds.add(m._id));
messages.value = [...newMessages];
hasOlderMessages.value = response.hasMore ?? true;
}
}
}
return response;
} catch (err: any) {
error.value = err.message || 'Errore nel recupero dei messaggi';
throw err;
} finally {
loadingMessages.value = false;
}
};
const loadInitialMessages = (chatId: string) => {
return fetchMessages(chatId, { reset: true });
};
const loadOlderMessages = (chatId: string) => {
if (!hasOlderMessages.value || loadingMessages.value) return;
return fetchMessages(chatId, { loadOlder: true });
};
const loadNewerMessages = (chatId: string) => {
if (!hasNewerMessages.value || loadingMessages.value) return;
return fetchMessages(chatId, { loadNewer: true });
};
/**
* ✅ Aggiungi un nuovo messaggio (evita duplicati)
*/
const addNewMessage = (message: Message) => {
if (!messageIds.has(message._id)) {
messageIds.add(message._id);
messages.value = [...messages.value, message];
}
};
/**
* ✅ Invia messaggio
*/
const sendMessage = async (
chatId: string,
payload: {
content?: string;
text?: string;
type?: string;
metadata?: any;
replyTo?: string;
}
) => {
try {
sending.value = true;
error.value = null;
const messageText = payload.content || payload.text || '';
const response = await Api.SendReqWithData(
`/api/viaggi/chats/${chatId}/messages`,
'POST',
{
text: messageText,
type: payload.type || 'text',
metadata: payload.metadata,
replyTo: payload.replyTo,
}
);
if (response.success && response.data) {
const newMessage = response.data;
addNewMessage(newMessage);
updateChatLastMessage(chatId, newMessage);
}
return response;
} catch (err: any) {
error.value = err.message || "Errore nell'invio del messaggio";
throw err;
} finally {
sending.value = false;
}
};
/**
* Aggiorna lastMessage nella chat locale
*/
const updateChatLastMessage = (chatId: string, message: Message) => {
chats.value = chats.value.map((chat) => {
if (chat._id === chatId) {
return {
...chat,
lastMessage: {
text: message.text || '',
senderId: message.senderId as any,
timestamp: message.createdAt,
type: message.type || 'text',
},
updatedAt: message.createdAt,
};
}
return chat;
});
if (currentChat.value && currentChat.value._id === chatId) {
currentChat.value = {
...currentChat.value,
lastMessage: {
text: message.text || '',
senderId: message.senderId as any,
timestamp: message.createdAt,
type: message.type || 'text',
},
updatedAt: message.createdAt,
};
}
};
/**
* Marca chat come letta
*/
const markAsRead = async (chatId: string) => {
try {
const response = await Api.SendReqWithData(
`/api/viaggi/chats/${chatId}/read`,
'PUT'
);
if (response.success) {
const chatIndex = chats.value.findIndex((c) => c._id === chatId);
if (chatIndex !== -1) {
const unread = chats.value[chatIndex].unreadCount || 0;
totalUnreadCount.value = Math.max(0, totalUnreadCount.value - unread);
chats.value = chats.value.map((chat, i) =>
i === chatIndex ? { ...chat, unreadCount: 0 } : chat
);
}
if (currentChat.value && currentChat.value._id === chatId) {
currentChat.value = { ...currentChat.value, unreadCount: 0 };
}
}
return response;
} catch (err: any) {
console.error('Errore mark as read:', err);
}
};
/**
* Ottieni conteggio non letti totale
*/
const fetchUnreadCount = async () => {
try {
const response = await Api.SendReqWithData(
`/api/viaggi/chats/unread/count`,
'GET'
);
if (response.success && response.data) {
totalUnreadCount.value = response.data.total || 0;
}
return response;
} catch (err: any) {
console.error('Errore fetch unread count:', err);
}
};
/**
* ✅ Elimina chat - FIXED
*/
const deleteChat = async (chatId: string): Promise<void> => {
try {
const response = await Api.SendReqWithData(`/api/viaggi/chats/${chatId}`, 'DELETE');
if (response.success) {
console.log('[useChat] deleteChat: eliminazione riuscita');
// ✅ 1. Rimuovi la chat dalla lista
chats.value = chats.value.filter((chat) => chat._id !== chatId);
// ✅ 2. Se era la chat corrente, pulisci TUTTO
if (currentChat.value?._id === chatId) {
console.log('[useChat] deleteChat: era la chat corrente, reset completo');
currentChat.value = null;
resetMessagesState();
}
// ✅ 3. Ferma polling
stopPolling();
} else {
throw new Error(response.message || "Errore durante l'eliminazione");
}
} catch (error) {
console.error('Errore deleteChat:', error);
throw error;
}
};
/**
* Elimina messaggio
*/
const deleteMessage = async (chatId: string, messageId: string) => {
try {
const response = await Api.SendReqWithData(
`/api/viaggi/chats/${chatId}/messages/${messageId}`,
'DELETE'
);
if (response.success) {
messages.value = messages.value.map((m) =>
m._id === messageId
? { ...m, isDeleted: true, text: '[Messaggio eliminato]' }
: m
);
}
return response;
} catch (err: any) {
error.value = err.message || "Errore nell'eliminazione del messaggio";
throw err;
}
};
/**
* Blocca/Sblocca chat
*/
const toggleBlockChat = async (chatId: string, block: boolean) => {
try {
const response = await Api.SendReqWithData(
`/api/viaggi/chats/${chatId}/block`,
'PUT',
{ block }
);
return response;
} catch (err: any) {
error.value = err.message || 'Errore nel blocco della chat';
throw err;
}
};
/**
* Muta/Smuta notifiche chat
*/
const toggleMuteChat = async (chatId: string, mute: boolean) => {
try {
const response = await Api.SendReqWithData(
`/api/viaggi/chats/${chatId}/mute`,
'PUT',
{ mute }
);
return response;
} catch (err: any) {
error.value = err.message || 'Errore nel mute della chat';
throw err;
}
};
// ------------------------------------------------------------
// REAL-TIME PLACEHOLDERS
// ------------------------------------------------------------
const sendTyping = (chatId: string) => {};
const subscribeToChat = (chatId: string) => {};
const unsubscribeFromChat = (chatId: string) => {};
// ------------------------------------------------------------
// UTILITIES
// ------------------------------------------------------------
const formatMessageTime = (date: Date | string) => {
const d = new Date(date);
const now = new Date();
const diffDays = Math.floor((now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
} else if (diffDays === 1) {
return 'Ieri';
} else if (diffDays < 7) {
return d.toLocaleDateString('it-IT', { weekday: 'short' });
} else {
return d.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit' });
}
};
/**
* ✅ Apri chat e carica messaggi - FIXED
*/
const openChat = async (chatId: string) => {
console.log('[useChat] openChat:', chatId);
// ✅ SEMPRE reset messaggi quando si apre una chat
resetMessagesState();
// Ferma polling precedente
stopPolling();
// Carica la chat
await fetchChat(chatId);
// ✅ Carica messaggi FRESCHI con reset: true
await fetchMessages(chatId, { reset: true, limit: 50 });
// Marca come letta
await markAsRead(chatId);
// Avvia polling
startPolling(chatId);
console.log('[useChat] openChat completato, messaggi:', messages.value.length);
};
/**
* ✅ Pulisci stato completo
*/
const clearState = () => {
chats.value = [];
currentChat.value = null;
resetMessagesState();
totalUnreadCount.value = 0;
error.value = null;
stopPolling();
};
/**
* ✅ Chiudi chat corrente
*/
const closeCurrentChat = () => {
currentChat.value = null;
resetMessagesState();
stopPolling();
};
// ============================================================
// POLLING
// ============================================================
/**
* Avvia polling per nuovi messaggi
*/
const startPolling = (chatId: string, intervalMs = POLLING_INTERVAL) => {
stopPolling();
isPolling.value = true;
console.log('[useChat] Polling avviato per chat:', chatId);
pollingInterval = setInterval(async () => {
try {
if (messages.value.length === 0) return;
const newestMessage = messages.value[messages.value.length - 1];
const params = new URLSearchParams({
idapp: tools.getIdApp(),
after: newestMessage.createdAt,
});
const response = await Api.SendReqWithData(
`/api/viaggi/chats/${chatId}/messages?${params.toString()}`,
'GET'
);
if (response.success && response.data && response.data.length > 0) {
const actuallyNewMessages = response.data.filter(
(newMsg: Message) => !messageIds.has(newMsg._id)
);
if (actuallyNewMessages.length > 0) {
console.log('[useChat] Polling - nuovi messaggi:', actuallyNewMessages.length);
actuallyNewMessages.forEach((newMsg: Message) => {
messageIds.add(newMsg._id);
});
messages.value = [...messages.value, ...actuallyNewMessages];
}
}
} catch (err) {
console.error('[useChat] Errore polling:', err);
}
}, intervalMs);
};
/**
* Ferma polling
*/
const stopPolling = () => {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
isPolling.value = false;
console.log('[useChat] Polling fermato');
}
};
/**
* Polling per la lista delle chat
*/
const pollChatsList = async () => {
try {
const response = await Api.SendReqWithData(
`/api/viaggi/chats?page=1&limit=20`,
'GET'
);
if (response.success && response.data) {
const freshChats = response.data;
const existingIds = new Set(chats.value.map((c) => c._id));
let updatedChats = chats.value.map((existingChat) => {
const freshChat = freshChats.find((f: Chat) => f._id === existingChat._id);
if (freshChat) {
return {
...existingChat,
lastMessage: freshChat.lastMessage,
unreadCount: freshChat.unreadCount,
updatedAt: freshChat.updatedAt,
};
}
return existingChat;
});
freshChats.forEach((freshChat: Chat) => {
if (!existingIds.has(freshChat._id)) {
updatedChats = [freshChat, ...updatedChats];
}
});
updatedChats.sort((a, b) => {
const dateA = new Date(a.updatedAt || a.createdAt).getTime();
const dateB = new Date(b.updatedAt || b.createdAt).getTime();
return dateB - dateA;
});
chats.value = updatedChats;
totalUnreadCount.value = updatedChats.reduce(
(sum, chat) => sum + (chat.unreadCount || 0),
0
);
}
} catch (err) {
console.error('[useChat] Errore polling chats list:', err);
}
};
// ------------------------------------------------------------
// RETURN
// ------------------------------------------------------------
return {
// State
chats,
currentChat,
messages,
totalUnreadCount,
loading,
loadingMessages,
sending,
error,
onlineUsers,
typingUsers,
isPolling,
// Computed
hasChats,
hasUnread,
sortedChats,
sortedMessages,
hasOlderMessages,
hasNewerMessages,
// API Methods
fetchChats,
getOrCreateDirectChat,
loadChat,
fetchChat,
loadMessages,
fetchMessages,
sendMessage,
markAsRead,
fetchUnreadCount,
toggleBlockChat,
toggleMuteChat,
deleteMessage,
loadOlderMessages,
loadNewerMessages,
addNewMessage,
deleteChat,
// Polling
startPolling,
stopPolling,
pollChatsList,
// Real-time (placeholder)
sendTyping,
subscribeToChat,
unsubscribeFromChat,
// Utilities
formatMessageTime,
openChat,
clearState,
closeCurrentChat,
};
}

View File

@@ -56,7 +56,7 @@ export function useCitySuggestions() {
try {
const response = await Api.SendReqWithData(
`/api/trasporti/cities/suggestions?q=${encodeURIComponent(query)}`,
`/api/viaggi/cities/suggestions?q=${encodeURIComponent(query)}`,
'GET'
);

View File

@@ -69,7 +69,7 @@ export function useContribTypes() {
error.value = null;
const response = await Api.SendReqWithData(
'/api/trasporti/contrib-types',
'/api/viaggi/contrib-types',
'GET'
) as ApiResponse<ContribType[]>;

View File

@@ -5,7 +5,7 @@ import type {
Vehicle,
UserPreferences,
DriverPublicProfile
} from '../types/trasporti.types';
} from '../types/viaggi.types';
// ============================================================
// STATE
@@ -53,7 +53,7 @@ export function useDriverProfile() {
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/driver/user/${userId}`,
`/api/viaggi/driver/user/${userId}`,
'GET'
);
@@ -82,10 +82,9 @@ export function useDriverProfile() {
error.value = null;
const response = await Api.SendReqWithData(
'/api/trasporti/driver/profile',
'/api/viaggi/driver/profile',
'PUT',
{
idapp: 'trasporti',
...data
}
);
@@ -125,7 +124,7 @@ export function useDriverProfile() {
error.value = null;
const response = await Api.SendReqWithData(
'/api/trasporti/driver/vehicles',
'/api/viaggi/driver/vehicles',
'POST',
{ vehicle: vehicleData }
);
@@ -152,7 +151,7 @@ export function useDriverProfile() {
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/driver/vehicles/${vehicleId}`,
`/api/viaggi/driver/vehicles/${vehicleId}`,
'PUT',
{ vehicle: vehicleData }
);
@@ -179,7 +178,7 @@ export function useDriverProfile() {
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/driver/vehicles/${vehicleId}`,
`/api/viaggi/driver/vehicles/${vehicleId}`,
'DELETE'
);
@@ -205,7 +204,7 @@ export function useDriverProfile() {
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/driver/vehicles/${vehicleId}/default`,
`/api/viaggi/driver/vehicles/${vehicleId}/default`,
'POST'
);

View File

@@ -60,7 +60,7 @@ export function useFeedback() {
error.value = null;
const response = await Api.SendReqWithData(
'/api/trasporti/feedback',
'/api/viaggi/feedback',
'POST',
feedbackData
) as ApiResponse<Feedback>;
@@ -92,7 +92,7 @@ export function useFeedback() {
if (options?.limit) queryParams.append('limit', options.limit.toString());
const response = await Api.SendReqWithData(
`/api/trasporti/feedback/user/${userId}?${queryParams.toString()}`,
`/api/viaggi/feedback/user/${userId}?${queryParams.toString()}`,
'GET'
) as ApiResponse<{
feedbacks: Feedback[];
@@ -121,7 +121,7 @@ export function useFeedback() {
const fetchUserStats = async (userId: string) => {
try {
const response = await Api.SendReqWithData(
`/api/trasporti/feedback/user/${userId}/stats`,
`/api/viaggi/feedback/user/${userId}/stats`,
'GET'
) as ApiResponse<{
stats: FeedbackStats;
@@ -149,7 +149,7 @@ export function useFeedback() {
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/feedback/ride/${rideId}`,
`/api/viaggi/feedback/ride/${rideId}`,
'GET'
) as ApiResponse<Feedback[]>;
@@ -172,7 +172,7 @@ export function useFeedback() {
const canLeaveFeedback = async (rideId: string, toUserId: string) => {
try {
const response = await Api.SendReqWithData(
`/api/trasporti/feedback/can-leave/${rideId}/${toUserId}`,
`/api/viaggi/feedback/can-leave/${rideId}/${toUserId}`,
'GET'
) as ApiResponse<{ canLeave: boolean; reason?: string }>;
@@ -195,7 +195,7 @@ export function useFeedback() {
if (role) queryParams.append('role', role);
const response = await Api.SendReqWithData(
`/api/trasporti/feedback/my/received?${queryParams.toString()}`,
`/api/viaggi/feedback/my/received?${queryParams.toString()}`,
'GET'
) as ApiResponse<{ feedbacks: Feedback[]; stats: FeedbackStats }>;
@@ -222,7 +222,7 @@ export function useFeedback() {
error.value = null;
const response = await Api.SendReqWithData(
'/api/trasporti/feedback/my/given',
'/api/viaggi/feedback/my/given',
'GET'
) as PaginatedResponse<Feedback>;
@@ -248,7 +248,7 @@ export function useFeedback() {
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/feedback/${feedbackId}/response`,
`/api/viaggi/feedback/${feedbackId}/response`,
'POST',
{ text }
) as ApiResponse<Feedback>;
@@ -276,7 +276,7 @@ export function useFeedback() {
const reportFeedback = async (feedbackId: string, reason: string) => {
try {
const response = await Api.SendReqWithData(
`/api/trasporti/feedback/${feedbackId}/report`,
`/api/viaggi/feedback/${feedbackId}/report`,
'POST',
{ reason }
) as ApiResponse<void>;
@@ -294,7 +294,7 @@ export function useFeedback() {
const markAsHelpful = async (feedbackId: string) => {
try {
const response = await Api.SendReqWithData(
`/api/trasporti/feedback/${feedbackId}/helpful`,
`/api/viaggi/feedback/${feedbackId}/helpful`,
'POST'
) as ApiResponse<{ helpfulCount: number }>;

View File

@@ -0,0 +1,399 @@
// useGeocoding.ts
import { ref } from 'vue';
import { api } from 'src/boot/axios';
import type { Location, Coordinates } from '../types';
interface AutocompleteResult {
id: string;
city: string;
locality?: string;
county?: string;
region?: string;
country: string;
postalCode?: string;
coordinates: Coordinates;
displayName: string;
type: string;
confidence: number;
}
interface RouteResult {
distance: number;
duration: number;
durationFormatted: string;
geometry: string;
segments: RouteSegment[];
}
interface RouteSegment {
distance: number;
duration: number;
steps: RouteStep[];
}
interface RouteStep {
instruction: string;
name: string;
distance: number;
duration: number;
type: number;
}
interface DistanceResult {
distance: number;
duration: number;
durationFormatted: string;
}
export function useGeocoding() {
const loading = ref(false);
const error = ref<string | null>(null);
/**
* Autocomplete città - usa l'API backend
*/
const autocomplete = async (
query: string,
options: { limit?: number; country?: string; lang?: string } = {}
): Promise<AutocompleteResult[]> => {
if (!query || query.length < 2) return [];
loading.value = true;
error.value = null;
try {
const { limit = 5, country = 'IT', lang = 'it' } = options;
const response = await api.get('/api/viaggi/geo/autocomplete', {
params: { q: query, limit, country, lang }
});
return response.data.data || [];
} catch (err: any) {
error.value = err.response?.data?.message || 'Errore nella ricerca';
console.error('Autocomplete error:', err);
return [];
} finally {
loading.value = false;
}
};
/**
* Cerca città (per retrocompatibilità)
*/
const searchCities = async (query: string): Promise<Location[]> => {
const results = await autocomplete(query);
return results.map(item => ({
city: item.city || item.locality || item.displayName.split(',')[0],
region: item.region,
country: item.country || 'Italia',
coordinates: item.coordinates,
address: item.displayName
}));
};
/**
* Cerca città italiane (ottimizzato)
*/
const searchItalianCities = async (
query: string,
options: { limit?: number; region?: string } = {}
): Promise<Location[]> => {
if (!query || query.length < 2) return [];
loading.value = true;
error.value = null;
try {
const { limit = 10, region } = options;
const response = await api.get('/api/viaggi/geo/cities/it', {
params: { q: query, limit, region }
});
return response.data.data.map((item: any) => ({
city: item.city,
region: item.region,
country: 'Italia',
coordinates: item.coordinates,
address: item.displayName
}));
} catch (err: any) {
error.value = err.response?.data?.message || 'Errore nella ricerca';
return [];
} finally {
loading.value = false;
}
};
/**
* Geocoding - indirizzo a coordinate
*/
const geocode = async (
address: string,
city?: string
): Promise<Location | null> => {
loading.value = true;
error.value = null;
try {
const response = await api.get('/api/viaggi/geo/geocode', {
params: { address, city, country: 'IT' }
});
if (response.data.data?.length > 0) {
const item = response.data.data[0];
return {
city: item.city || item.name,
region: item.region,
country: item.country || 'Italia',
coordinates: item.coordinates,
address: item.displayName
};
}
return null;
} catch (err: any) {
error.value = err.response?.data?.message || 'Errore nel geocoding';
return null;
} finally {
loading.value = false;
}
};
/**
* Reverse geocoding - coordinate a indirizzo
*/
const getAddressFromCoordinates = async (
coordinates: Coordinates
): Promise<Location | null> => {
loading.value = true;
error.value = null;
try {
const response = await api.get('/api/viaggi/geo/reverse', {
params: { lat: coordinates.lat, lng: coordinates.lng }
});
const data = response.data.data;
return {
city: data.city || 'Sconosciuto',
region: data.region,
country: data.country || 'Italia',
coordinates,
address: data.displayName
};
} catch (err: any) {
error.value = err.response?.data?.message || 'Errore nel reverse geocoding';
return null;
} finally {
loading.value = false;
}
};
/**
* Calcola percorso tra due punti
*/
const calculateRoute = async (
start: Coordinates,
end: Coordinates,
waypoints?: Coordinates[]
): Promise<RouteResult | null> => {
loading.value = true;
error.value = null;
try {
// Formatta waypoints per la query
let waypointsParam: string | undefined;
if (waypoints && waypoints.length > 0) {
waypointsParam = waypoints
.map(wp => `${wp.lat},${wp.lng}`)
.join(';');
}
const response = await api.get('/api/viaggi/geo/route', {
params: {
startLat: start.lat,
startLng: start.lng,
endLat: end.lat,
endLng: end.lng,
waypoints: waypointsParam
}
});
return response.data.data;
} catch (err: any) {
error.value = err.response?.data?.message || 'Errore nel calcolo percorso';
console.error('Route calculation error:', err);
return null;
} finally {
loading.value = false;
}
};
/**
* Calcola distanza e durata tra due punti
*/
const getDistance = async (
start: Coordinates,
end: Coordinates
): Promise<DistanceResult | null> => {
try {
const response = await api.get('/api/viaggi/geo/distance', {
params: {
startLat: start.lat,
startLng: start.lng,
endLat: end.lat,
endLng: end.lng
}
});
return response.data.data;
} catch (err: any) {
console.error('Distance calculation error:', err);
return null;
}
};
/**
* Suggerisci città intermedie su un percorso
*/
const suggestWaypoints = async (
start: Coordinates,
end: Coordinates,
count: number = 3
): Promise<Location[]> => {
loading.value = true;
error.value = null;
try {
const response = await api.get('/api/viaggi/geo/suggest-waypoints', {
params: {
startLat: start.lat,
startLng: start.lng,
endLat: end.lat,
endLng: end.lng,
count
}
});
return response.data.data.map((item: any) => ({
city: item.city,
region: item.region,
country: 'Italia',
coordinates: item.coordinates,
address: item.displayName
}));
} catch (err: any) {
error.value = err.response?.data?.message || 'Errore nel suggerimento waypoints';
return [];
} finally {
loading.value = false;
}
};
/**
* Ottieni isocrone (aree raggiungibili in X minuti)
*/
const getIsochrone = async (
center: Coordinates,
minutes: number = 30
): Promise<any> => {
try {
const response = await api.get('/api/viaggi/geo/isochrone', {
params: {
lat: center.lat,
lng: center.lng,
minutes
}
});
return response.data.data;
} catch (err: any) {
console.error('Isochrone error:', err);
return null;
}
};
/**
* Ottieni posizione corrente dell'utente
*/
const getCurrentPosition = (): Promise<Coordinates> => {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocalizzazione non supportata'));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
lat: position.coords.latitude,
lng: position.coords.longitude
});
},
(err) => {
reject(new Error(getGeolocationErrorMessage(err)));
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 300000 // 5 minuti cache
}
);
});
};
/**
* Ottieni location corrente con indirizzo
*/
const getCurrentLocation = async (): Promise<Location | null> => {
try {
const coords = await getCurrentPosition();
return await getAddressFromCoordinates(coords);
} catch (err) {
console.error('Get current location error:', err);
return null;
}
};
return {
// State
loading,
error,
// Autocomplete & Search
autocomplete,
searchCities,
searchItalianCities,
// Geocoding
geocode,
getAddressFromCoordinates,
// Routing
calculateRoute,
getDistance,
suggestWaypoints,
getIsochrone,
// Geolocation
getCurrentPosition,
getCurrentLocation
};
}
// Helper per messaggi errore geolocalizzazione
function getGeolocationErrorMessage(error: GeolocationPositionError): string {
switch (error.code) {
case error.PERMISSION_DENIED:
return 'Permesso di geolocalizzazione negato';
case error.POSITION_UNAVAILABLE:
return 'Posizione non disponibile';
case error.TIMEOUT:
return 'Timeout richiesta posizione';
default:
return 'Errore geolocalizzazione sconosciuto';
}
}

View File

@@ -127,7 +127,7 @@ export function useRealtimeChat() {
console.log(`Typing in chat: ${chatId}`);
// In produzione:
// await Api.SendReqWithData(`/api/trasporti/chats/${chatId}/typing`, 'POST');
// await Api.SendReqWithData(`/api/viaggi/chats/${chatId}/typing`, 'POST');
};
/**

View File

@@ -70,7 +70,7 @@ export function useRideRequests() {
error.value = null;
const response = await Api.SendReqWithData(
'/api/trasporti/requests',
'/api/viaggi/requests',
'POST',
requestData,
false, true
@@ -103,7 +103,7 @@ export function useRideRequests() {
queryParams.append('limit', pagination.limit.toString());
const response = await Api.SendReqWithData(
`/api/trasporti/requests/received?${queryParams.toString()}`,
`/api/viaggi/requests/received?${queryParams.toString()}`,
'GET'
) as RequestsReceivedResponse;
@@ -136,7 +136,7 @@ export function useRideRequests() {
queryParams.append('limit', pagination.limit.toString());
const response = await Api.SendReqWithData(
`/api/trasporti/requests/sent?${queryParams.toString()}`,
`/api/viaggi/requests/sent?${queryParams.toString()}`,
'GET'
) as PaginatedResponse<RideRequest>;
@@ -166,7 +166,7 @@ export function useRideRequests() {
if (status) queryParams.append('status', status);
const response = await Api.SendReqWithData(
`/api/trasporti/requests/ride/${rideId}?${queryParams.toString()}`,
`/api/viaggi/requests/ride/${rideId}?${queryParams.toString()}`,
'GET'
) as ApiResponse<RideRequest[]>;
@@ -188,7 +188,7 @@ export function useRideRequests() {
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/requests/${requestId}`,
`/api/viaggi/requests/${requestId}`,
'GET'
) as ApiResponse<RideRequest>;
@@ -214,7 +214,7 @@ export function useRideRequests() {
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/requests/${requestId}/accept`,
`/api/viaggi/requests/${requestId}/accept`,
'POST',
{ responseMessage }
) as ApiResponse<RideRequest>;
@@ -242,7 +242,7 @@ export function useRideRequests() {
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/requests/${requestId}/reject`,
`/api/viaggi/requests/${requestId}/reject`,
'POST',
{ responseMessage }
) as ApiResponse<RideRequest>;
@@ -269,7 +269,7 @@ export function useRideRequests() {
error.value = null;
const response = await Api.SendReqWithData(
`/api/trasporti/requests/${requestId}/cancel`,
`/api/viaggi/requests/${requestId}/cancel`,
'POST',
{ reason }
) as ApiResponse<RideRequest>;

View File

@@ -117,7 +117,7 @@ export function useRides() {
queryParams.append('passingThrough', filters.passingThrough);
const response = (await Api.SendReqWithData(
`/api/trasporti/rides?${queryParams.toString()}`,
`/api/viaggi/rides?${queryParams.toString()}`,
'GET'
)) as PaginatedResponse<Ride>;
@@ -167,7 +167,7 @@ export function useRides() {
queryParams.append('limit', pagination.limit.toString());
const response = (await Api.SendReqWithData(
`/api/trasporti/rides/search?${queryParams.toString()}`,
`/api/viaggi/rides/search?${queryParams.toString()}`,
'GET'
)) as PaginatedResponse<Ride>;
@@ -199,7 +199,7 @@ export function useRides() {
error.value = null;
const response = (await Api.SendReqWithData(
`/api/trasporti/rides/${rideId}`,
`/api/viaggi/rides/${rideId}`,
'GET'
)) as ApiResponse<Ride>;
@@ -227,7 +227,7 @@ export function useRides() {
error.value = null;
const response = (await Api.SendReqWithData(
'/api/trasporti/rides',
'/api/viaggi/rides',
'POST',
rideData
)) as ApiResponse<Ride>;
@@ -256,7 +256,7 @@ export function useRides() {
error.value = null;
const response = (await Api.SendReqWithData(
`/api/trasporti/rides/${rideId}`,
`/api/viaggi/rides/${rideId}`,
'PUT',
updateData
)) as ApiResponse<Ride>;
@@ -289,7 +289,7 @@ export function useRides() {
loading.value = true;
error.value = null;
const response = (await Api.SendReqWithData(`/api/trasporti/rides/${rideId}`, 'DELETE', {
const response = (await Api.SendReqWithData(`/api/viaggi/rides/${rideId}`, 'DELETE', {
reason,
})) as ApiResponse<void>;
@@ -319,7 +319,7 @@ export function useRides() {
error.value = null;
const response = (await Api.SendReqWithData(
`/api/trasporti/rides/${rideId}/complete`,
`/api/viaggi/rides/${rideId}/complete`,
'POST'
)) as ApiResponse<Ride>;
@@ -362,7 +362,7 @@ export function useRides() {
queryParams.append('limit', pagination.limit.toString());
const response = (await Api.SendReqWithData(
`/api/trasporti/rides/my?${queryParams.toString()}`,
`/api/viaggi/rides/my?${queryParams.toString()}`,
'GET'
)) as MyRidesResponse;
@@ -388,7 +388,7 @@ export function useRides() {
const fetchStats = async () => {
try {
const response = (await Api.SendReqWithData(
'/api/trasporti/rides/stats',
'/api/viaggi/rides/stats',
'GET'
)) as ApiResponse<RidesStatsResponse>;

View File

@@ -1,5 +1,5 @@
// ChatListPage.ts
import { defineComponent, ref, computed, onMounted } from 'vue';
import { defineComponent, ref, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import { useChat } from '../composables/useChat';
@@ -22,7 +22,8 @@ export default defineComponent({
toggleMuteChat,
deleteChat,
onlineUsers,
totalUnreadCount
totalUnreadCount,
pollChatsList, // ✅ AGGIUNGI
} = useChat();
// State
@@ -32,6 +33,9 @@ export default defineComponent({
const hasMore = ref(true);
const page = ref(1);
// ✅ Polling interval
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
// ✅ User search
const showUserSearch = ref(false);
const userSearchQuery = ref('');
@@ -46,57 +50,69 @@ export default defineComponent({
const unreadCount = computed(() => totalUnreadCount.value);
const filteredChats = computed(() => {
let result = [...chats.value];
const currentId = currentUserId.value;
// Filter by tab
// Filtra prima le chat cancellate dall'utente corrente
let result = chats.value.filter((chat) => {
// Se deletedBy non esiste o è vuoto, la chat è visibile
if (!chat.deletedBy || !Array.isArray(chat.deletedBy)) return true;
// Se l'utente corrente l'ha cancellata, nascondila
return !currentId || !chat.deletedBy.includes(currentId);
});
// Poi applica i filtri per tab
switch (activeTab.value) {
case 'unread':
result = result.filter(chat => (chat.unreadCount || 0) > 0);
result = result.filter((chat) => (chat.unreadCount || 0) > 0);
break;
case 'rides':
result = result.filter(chat => chat.rideId);
result = result.filter((chat) => chat.rideId);
break;
case 'archived':
result = result.filter(chat => chat.archived);
result = result.filter((chat) => chat.archived);
break;
default:
result = result.filter(chat => !chat.archived);
result = result.filter((chat) => !chat.archived);
}
// Filter by search
// Poi applica la ricerca testuale
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter(chat => {
result = result.filter((chat) => {
const otherUser = getOtherParticipant(chat);
const fullName = `${otherUser?.name || ''} ${otherUser?.surname || ''}`.toLowerCase();
const fullName =
`${otherUser?.name || ''} ${otherUser?.surname || ''}`.toLowerCase();
const username = otherUser?.username?.toLowerCase() || '';
let rideInfo = '';
if (chat.rideId) {
const rideData = typeof chat.rideId === 'object' ? chat.rideId : null;
if (rideData) {
const departure = typeof rideData.departure === 'string'
? rideData.departure
: rideData.departure?.city || '';
const destination = typeof rideData.destination === 'string'
? rideData.destination
: rideData.destination?.city || '';
const departure =
typeof rideData.departure === 'string'
? rideData.departure
: rideData.departure?.city || '';
const destination =
typeof rideData.destination === 'string'
? rideData.destination
: rideData.destination?.city || '';
rideInfo = `${departure} ${destination}`.toLowerCase();
}
}
const lastMessageText = chat.lastMessage?.text?.toLowerCase() || '';
return fullName.includes(query) ||
username.includes(query) ||
rideInfo.includes(query) ||
lastMessageText.includes(query);
return (
fullName.includes(query) ||
username.includes(query) ||
rideInfo.includes(query) ||
lastMessageText.includes(query)
);
});
}
// Sort by last message date
// Ordinamento: pinned in cima, poi per data decrescente
result.sort((a, b) => {
// Pinned chats first
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
@@ -107,41 +123,52 @@ export default defineComponent({
return result;
});
const emptyStateIcon = computed(() => {
if (searchQuery.value) return 'search_off';
switch (activeTab.value) {
case 'unread': return 'mark_email_read';
case 'rides': return 'no_transfer';
case 'archived': return 'unarchive';
default: return 'forum';
case 'unread':
return 'mark_email_read';
case 'rides':
return 'no_transfer';
case 'archived':
return 'unarchive';
default:
return 'forum';
}
});
const emptyStateTitle = computed(() => {
if (searchQuery.value) return 'Nessun risultato';
switch (activeTab.value) {
case 'unread': return 'Tutto letto!';
case 'rides': return 'Nessuna chat viaggio';
case 'archived': return 'Nessuna chat archiviata';
default: return 'Nessuna conversazione';
case 'unread':
return 'Tutto letto!';
case 'rides':
return 'Nessuna chat viaggio';
case 'archived':
return 'Nessuna chat archiviata';
default:
return 'Nessuna conversazione';
}
});
const emptyStateMessage = computed(() => {
if (searchQuery.value) return 'Prova con altri termini di ricerca';
switch (activeTab.value) {
case 'unread': return 'Non hai messaggi da leggere';
case 'rides': return 'Le chat relative ai viaggi appariranno qui';
case 'archived': return 'Le conversazioni archiviate appariranno qui';
default: return 'Inizia a cercare viaggi per connetterti con altri utenti';
case 'unread':
return 'Non hai messaggi da leggere';
case 'rides':
return 'Le chat relative ai viaggi appariranno qui';
case 'archived':
return 'Le conversazioni archiviate appariranno qui';
default:
return 'Inizia a cercare viaggi per connetterti con altri utenti';
}
});
// Methods
const getOtherParticipant = (chat: Chat): User | undefined => {
if (!chat.participants || chat.participants.length === 0) return undefined;
return chat.participants.find(p => {
return chat.participants.find((p) => {
const pId = typeof p === 'string' ? p : p._id;
return pId !== currentUserId.value;
}) as User | undefined;
@@ -170,7 +197,7 @@ export default defineComponent({
if (days === 0) {
return messageDate.toLocaleTimeString('it-IT', {
hour: '2-digit',
minute: '2-digit'
minute: '2-digit',
});
} else if (days === 1) {
return 'Ieri';
@@ -179,7 +206,7 @@ export default defineComponent({
} else {
return messageDate.toLocaleDateString('it-IT', {
day: '2-digit',
month: '2-digit'
month: '2-digit',
});
}
};
@@ -203,9 +230,10 @@ export default defineComponent({
const getMessageStatusIcon = (lastMessage?: Message | null): string => {
if (!lastMessage) return '';
const senderId = typeof lastMessage.senderId === 'string'
? lastMessage.senderId
: lastMessage.senderId?._id;
const senderId =
typeof lastMessage.senderId === 'string'
? lastMessage.senderId
: lastMessage.senderId?._id;
if (senderId !== currentUserId.value) return '';
@@ -219,7 +247,7 @@ export default defineComponent({
};
const openChat = (chat: Chat) => {
router.push(`/trasporti/chat/${chat._id}`);
router.push(`/viaggi/chat/${chat._id}`);
};
// ✅ Added: Mute chat
@@ -231,14 +259,14 @@ export default defineComponent({
$q.notify({
type: 'info',
message: isMuted ? 'Notifiche attivate' : 'Notifiche silenziate',
icon: isMuted ? 'notifications' : 'notifications_off'
icon: isMuted ? 'notifications' : 'notifications_off',
});
await fetchChats();
} catch (error) {
$q.notify({
type: 'negative',
message: 'Errore nell\'aggiornamento'
message: "Errore nell'aggiornamento",
});
}
};
@@ -249,13 +277,13 @@ export default defineComponent({
// TODO: Implementa nel backend
$q.notify({
type: 'positive',
message: 'Conversazione archiviata'
message: 'Conversazione archiviata',
});
await fetchChats();
} catch (error) {
$q.notify({
type: 'negative',
message: 'Errore durante l\'archiviazione'
message: "Errore durante l'archiviazione",
});
}
};
@@ -267,25 +295,25 @@ export default defineComponent({
message: 'Sei sicuro di voler eliminare questa conversazione?',
cancel: {
label: 'Annulla',
flat: true
flat: true,
},
ok: {
label: 'Elimina',
color: 'negative'
color: 'negative',
},
persistent: true
persistent: true,
}).onOk(async () => {
try {
await deleteChat(chat._id);
$q.notify({
type: 'positive',
message: 'Conversazione eliminata'
message: 'Conversazione eliminata',
});
await fetchChats();
} catch (error) {
$q.notify({
type: 'negative',
message: 'Errore durante l\'eliminazione'
message: "Errore durante l'eliminazione",
});
}
});
@@ -300,7 +328,7 @@ export default defineComponent({
searchingUsers.value = true;
try {
const response = await Api.SendReq(
const response = await Api.SendReqWithData(
`/api/users/search?q=${encodeURIComponent(userSearchQuery.value)}`,
'GET'
);
@@ -328,12 +356,12 @@ export default defineComponent({
searchedUsers.value = [];
if (chat) {
router.push(`/trasporti/chat/${chat._id}`);
router.push(`/viaggi/chat/${chat._id}`);
}
} catch (error) {
$q.notify({
type: 'negative',
message: 'Errore nella creazione della chat'
message: 'Errore nella creazione della chat',
});
}
};
@@ -346,9 +374,23 @@ export default defineComponent({
page.value++;
const result = await fetchChats(page.value, 20);
if (!result || result.data.length < 20) {
// ✅ Assicurati che fetchChats NON sovrascriva l'array,
// ma restituisca i nuovi dati.
// Se useChat.fetchChats sovrascrive, devi cambiarlo.
// Per ora, assumiamo che fetchChats restituisca i dati della pagina.
if (!result?.data || result.data.length === 0) {
hasMore.value = false;
page.value--; // ripristina pagina
} else if (result.data.length < 20) {
hasMore.value = false;
}
// Se result.data è valido e non vuoto, fetchChats deve averlo **aggiunto** ai chats esistenti
// → quindi non fai nulla qui, perché la reattività gestisce il resto.
} catch (error) {
console.error('Errore caricamento più conversazioni:', error);
page.value--; // ripristina pagina in caso di errore
hasMore.value = false;
} finally {
loadingMore.value = false;
}
@@ -358,9 +400,32 @@ export default defineComponent({
showUserSearch.value = true;
};
// ✅ Avvia polling per nuove chat
const startChatsPolling = () => {
// Poll ogni 5 secondi
pollingInterval.value = setInterval(async () => {
await pollChatsList();
}, 5000);
};
// ✅ Ferma polling
const stopChatsPolling = () => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value);
pollingInterval.value = null;
}
};
// Lifecycle
onMounted(async () => {
await fetchChats(1, 20);
// ✅ Avvia polling
startChatsPolling();
});
onUnmounted(() => {
// ✅ Pulisci polling quando il componente viene distrutto
stopChatsPolling();
});
return {
@@ -398,7 +463,7 @@ export default defineComponent({
loadMore,
startNewChat,
searchUsers,
startChatWith
startChatWith,
};
}
},
});

View File

@@ -76,7 +76,7 @@
rounded
unelevated
class="q-mt-md"
to="/trasporti"
to="/viaggi"
/>
</div>

View File

@@ -14,7 +14,7 @@ import { useChat } from '../composables/useChat';
import { useRealtimeChat } from '../composables/useRealtimeChat';
import { useAuth } from '../composables/useAuth';
import MessageBubble from '../components/chat/MessageBubble.vue';
import type { Message, RideInfo } from '../types/trasporti.types';
import type { Message, RideInfo } from '../types/viaggi.types';
import { debounce } from 'quasar';
interface MessageGroup {
@@ -46,6 +46,8 @@ export default defineComponent({
deleteMessage: deleteMsg,
onlineUsers,
typingUsers,
deleteChat,
fetchChats,
toggleMuteChat,
startPolling, // AGGIUNGI
stopPolling, // AGGIUNGI
@@ -313,13 +315,13 @@ export default defineComponent({
const viewRide = () => {
if (rideInfo.value?.rideId) {
router.push(`/trasporti/ride/${rideInfo.value.rideId}`);
router.push(`/viaggi/ride/${rideInfo.value.rideId}`);
}
};
const viewDriverProfile = () => {
if (otherUser.value) {
router.push(`/trasporti/profilo/${otherUser.value._id}`);
router.push(`/viaggi/profilo/${otherUser.value._id}`);
}
};
@@ -341,7 +343,7 @@ export default defineComponent({
}
};
const deleteConversation = () => {
const deleteConversation = async () => {
$q.dialog({
title: 'Elimina conversazione',
message: 'Sei sicuro? Questa azione non è reversibile.',
@@ -349,8 +351,13 @@ export default defineComponent({
persistent: true,
}).onOk(async () => {
try {
// TODO: Implementa eliminazione chat
router.push('/trasporti/chat');
await deleteChat(currentChat.value._id);
$q.notify({
type: 'positive',
message: 'Conversazione eliminata',
});
await fetchChats();
router.push('/viaggi/chat')
} catch (error) {
$q.notify({
type: 'negative',
@@ -368,7 +375,7 @@ export default defineComponent({
}).onOk(() => {
// TODO: Implementa blocco
showUserProfile.value = false;
router.push('/trasporti/chat');
router.push('/viaggi/chat');
});
};

View File

@@ -68,19 +68,19 @@ export default defineComponent({
const goToProfile = (profileUserId: string) => {
if (profileUserId !== userId.value) {
router.push(`/trasporti/profilo/${profileUserId}`);
router.push(`/viaggi/profilo/${profileUserId}`);
}
};
const goToRide = (rideId: string) => {
router.push(`/trasporti/ride/${rideId}`);
router.push(`/viaggi/ride/${rideId}`);
};
const contactUser = async () => {
try {
const response = await getOrCreateDirectChat(userId.value);
if (response?.data) {
router.push(`/trasporti/chat/${response.data._id}`);
router.push(`/viaggi/chat/${response.data._id}`);
}
} catch (error: any) {
$q.notify({ type: 'negative', message: error.data?.message || error.message });
@@ -92,11 +92,11 @@ export default defineComponent({
};
const editProfile = () => {
router.push('/trasporti/profilo/modifica');
router.push('/viaggi/profilo/modifica');
};
const viewAllReviews = () => {
router.push(`/trasporti/recensioni/${userId.value}`);
router.push(`/viaggi/recensioni/${userId.value}`);
};
const getStarIcon = (star: number, rating: number): string => {

View File

@@ -6,7 +6,7 @@
<q-btn flat round icon="arrow_back" color="white" @click="goBack" />
<div>
<h1>Come Funziona</h1>
<p>Guida ai Trasporti Solidali</p>
<p>Guida ai Viaggi Solidali</p>
</div>
</div>
@@ -21,19 +21,19 @@
<!-- Quick Actions -->
<div class="help-page__quick-actions">
<div class="help-page__action" @click="router.push('/trasporti/offri')">
<div class="help-page__action" @click="router.push('/viaggi/offri')">
<div class="help-page__action-icon help-page__action-icon--offer">
<q-icon name="directions_car" size="28px" />
</div>
<span>Offri passaggio</span>
</div>
<div class="help-page__action" @click="router.push('/trasporti/cerca')">
<div class="help-page__action" @click="router.push('/viaggi/cerca')">
<div class="help-page__action-icon help-page__action-icon--search">
<q-icon name="search" size="28px" />
</div>
<span>Cerca passaggio</span>
</div>
<div class="help-page__action" @click="router.push('/trasporti/richiedi')">
<div class="help-page__action" @click="router.push('/viaggi/richiedi')">
<div class="help-page__action-icon help-page__action-icon--request">
<q-icon name="hail" size="28px" />
</div>

Some files were not shown because too many files have changed in this diff Show More