- Parte 3 : Viaggi
- Chat
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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%));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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:
|
||||
'© <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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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" />
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
331
src/modules/viaggi/components/ride/CityAutocomplete.vue
Normal file
331
src/modules/viaggi/components/ride/CityAutocomplete.vue
Normal 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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
568
src/modules/viaggi/components/ride/RideMap.ts
Normal file
568
src/modules/viaggi/components/ride/RideMap.ts
Normal 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: '© <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,
|
||||
};
|
||||
},
|
||||
});
|
||||
631
src/modules/viaggi/components/ride/RideTypeToggle.scss
Normal file
631
src/modules/viaggi/components/ride/RideTypeToggle.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
64
src/modules/viaggi/components/ride/RideTypeToggle.ts
Normal file
64
src/modules/viaggi/components/ride/RideTypeToggle.ts
Normal 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
|
||||
};
|
||||
}
|
||||
});
|
||||
136
src/modules/viaggi/components/ride/RideTypeToggle.vue
Normal file
136
src/modules/viaggi/components/ride/RideTypeToggle.vue
Normal 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" />
|
||||
193
src/modules/viaggi/components/ride/WaypointsEditor.ts
Normal file
193
src/modules/viaggi/components/ride/WaypointsEditor.ts
Normal 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
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
791
src/modules/viaggi/composables/useChat.ts
Normal file
791
src/modules/viaggi/composables/useChat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
399
src/modules/viaggi/composables/useGeocoding.ts
Normal file
399
src/modules/viaggi/composables/useGeocoding.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -76,7 +76,7 @@
|
||||
rounded
|
||||
unelevated
|
||||
class="q-mt-md"
|
||||
to="/trasporti"
|
||||
to="/viaggi"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
@@ -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
Reference in New Issue
Block a user