- Parte 3 : Viaggi
- Chat
This commit is contained in:
@@ -2141,13 +2141,33 @@
|
|||||||
<li>Trasporti</li>
|
<li>Trasporti</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</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>
|
||||||
</div>
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -715,7 +715,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.q-btn--unelevated {
|
&.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 & {
|
.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-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
|
|
||||||
strong {
|
strong {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: color-darken($grey-text, 10%);
|
color: color.adjust($grey-text, $lightness: -10%);
|
||||||
}
|
}
|
||||||
|
|
||||||
em {
|
em {
|
||||||
|
|||||||
@@ -100,10 +100,9 @@ export default defineComponent({
|
|||||||
$router.push('/hosps')
|
$router.push('/hosps')
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToTransport = () => {
|
const goToViaggi = () => {
|
||||||
showAnnunciDialog.value = false;
|
showAnnunciDialog.value = false;
|
||||||
// TODO: navigare a /transport (da creare?)
|
$router.push('/viaggi')
|
||||||
// $router.push('/transport')
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hero Cards
|
// Hero Cards
|
||||||
@@ -275,7 +274,7 @@ export default defineComponent({
|
|||||||
goToGoods,
|
goToGoods,
|
||||||
goToServices,
|
goToServices,
|
||||||
goToHospitality,
|
goToHospitality,
|
||||||
goToTransport,
|
goToViaggi,
|
||||||
goToWallet,
|
goToWallet,
|
||||||
goToEvents,
|
goToEvents,
|
||||||
goToProfile,
|
goToProfile,
|
||||||
|
|||||||
@@ -221,14 +221,14 @@
|
|||||||
<span class="option-subtitle">Ospitare · Viaggi · Accoglienza</span>
|
<span class="option-subtitle">Ospitare · Viaggi · Accoglienza</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="annuncio-option gradient-teal">
|
<div class="annuncio-option gradient-teal"
|
||||||
|
@click="goToViaggi">
|
||||||
<q-icon
|
<q-icon
|
||||||
name="directions_car"
|
name="directions_car"
|
||||||
size="2.5rem"
|
size="2.5rem"
|
||||||
/>
|
/>
|
||||||
<span class="option-title">Trasporti</span>
|
<span class="option-title">Viaggi</span>
|
||||||
<span class="option-subtitle">Condivisione viaggi</span>
|
<span class="option-subtitle">Condivisione passaggi e trasporti</span>
|
||||||
<span class="option-subtitle">⚠️ (IN ARRIVO...)</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|||||||
@@ -86,28 +86,6 @@ export default defineComponent({
|
|||||||
showAnnunciDialog.value = true;
|
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
|
// Hero Cards
|
||||||
const goToWallet = () => {
|
const goToWallet = () => {
|
||||||
// TODO: navigare al portafoglio dettagliato
|
// TODO: navigare al portafoglio dettagliato
|
||||||
@@ -570,10 +548,6 @@ export default defineComponent({
|
|||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
openAnnunciDialog,
|
openAnnunciDialog,
|
||||||
goToGoods,
|
|
||||||
goToServices,
|
|
||||||
goToHospitality,
|
|
||||||
goToTransport,
|
|
||||||
goToWallet,
|
goToWallet,
|
||||||
goToEvents,
|
goToEvents,
|
||||||
goToProfile,
|
goToProfile,
|
||||||
|
|||||||
@@ -256,75 +256,6 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</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>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ h1 {
|
|||||||
font-weight: $heading-primary-weight;
|
font-weight: $heading-primary-weight;
|
||||||
letter-spacing: $heading-primary-letter-spacing;
|
letter-spacing: $heading-primary-letter-spacing;
|
||||||
line-height: $heading-primary-line-height;
|
line-height: $heading-primary-line-height;
|
||||||
color: var(--q-primary, #1976d2);
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
transition: color 0.3s ease;
|
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 {
|
.q-card__section {
|
||||||
padding: 12px 16px;
|
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 {
|
&__full {
|
||||||
@@ -15,6 +27,12 @@
|
|||||||
|
|
||||||
&__date-input {
|
&__date-input {
|
||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
|
|
||||||
|
// FIX: Anche per il date input
|
||||||
|
.q-field__prepend {
|
||||||
|
padding-right: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__section {
|
&__section {
|
||||||
@@ -48,13 +66,16 @@
|
|||||||
.q-card__section {
|
.q-card__section {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.col {
|
// FIX: Usa flex-basis invece di min-width per evitare problemi
|
||||||
min-width: 100%;
|
> .col {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-width: 0; // Previene overflow
|
||||||
}
|
}
|
||||||
|
|
||||||
.ride-filters__date-input {
|
.ride-filters__date-input {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex: 1 1 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<q-card-section class="row items-center q-gutter-sm">
|
<q-card-section class="row items-center q-gutter-sm">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="localFilters.from"
|
v-model="localFilters.from"
|
||||||
placeholder="Da..."
|
placeholder="Città di Partenza..."
|
||||||
dense
|
dense
|
||||||
outlined
|
outlined
|
||||||
class="col"
|
class="col"
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
<q-icon name="arrow_forward" color="grey" />
|
<q-icon name="arrow_forward" color="grey" class="gt-xs" />
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="localFilters.to"
|
v-model="localFilters.to"
|
||||||
placeholder="A..."
|
placeholder="Città di Arrivo..."
|
||||||
dense
|
dense
|
||||||
outlined
|
outlined
|
||||||
class="col"
|
class="col"
|
||||||
@@ -151,3 +151,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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>
|
<template>
|
||||||
<div class="waypoints-editor">
|
<div v-if="waypoints" class="waypoints-editor">
|
||||||
<div class="waypoints-editor__header">
|
<div class="waypoints-editor__header">
|
||||||
<q-icon name="add_location" size="20px" color="primary" />
|
<q-icon name="add_location" size="20px" color="primary" />
|
||||||
<span class="waypoints-editor__title">Tappe Intermedie</span>
|
<span class="waypoints-editor__title">Tappe Intermedie</span>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import { defineComponent, ref, computed, onMounted, watch } from 'vue';
|
import { defineComponent, ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { Api } from '@api';
|
import { Api } from '@api';
|
||||||
import type { Ride, ContribType } from '../../types/trasporti.types';
|
import type { Ride, ContribType } from '../../types/viaggi.types';
|
||||||
|
|
||||||
interface WidgetStats {
|
interface WidgetStats {
|
||||||
offers: number;
|
offers: number;
|
||||||
@@ -72,7 +72,7 @@ export default defineComponent({
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await Api.SendReqWithData('/api/trasporti/widget/data', 'GET', {});
|
const response = await Api.SendReqWithData('/api/viaggi/widget/data', 'GET', {});
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
const data: WidgetData = response.data;
|
const data: WidgetData = response.data;
|
||||||
@@ -95,7 +95,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await Api.SendReqWithData('/api/trasporti/stats/summary', 'GET');
|
const response = await Api.SendReqWithData('/api/viaggi/stats/summary', 'GET');
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
stats.value = response.data;
|
stats.value = response.data;
|
||||||
@@ -181,37 +181,37 @@ export default defineComponent({
|
|||||||
// Navigation
|
// Navigation
|
||||||
const goToCreate = (type: 'offer' | 'request') => {
|
const goToCreate = (type: 'offer' | 'request') => {
|
||||||
router.push({
|
router.push({
|
||||||
path: '/trasporti/crea',
|
path: '/viaggi/crea',
|
||||||
query: { type }
|
query: { type }
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToList = () => {
|
const goToList = () => {
|
||||||
router.push('/trasporti');
|
router.push('/viaggi');
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToRide = (rideId: string) => {
|
const goToRide = (rideId: string) => {
|
||||||
router.push(`/trasporti/ride/${rideId}`);
|
router.push(`/viaggi/ride/${rideId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToMyRides = () => {
|
const goToMyRides = () => {
|
||||||
router.push('/trasporti/rides/my');
|
router.push('/viaggi/rides/my');
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToSearch = () => {
|
const goToSearch = () => {
|
||||||
router.push('/trasporti/cerca');
|
router.push('/viaggi/cerca');
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToMap = () => {
|
const goToMap = () => {
|
||||||
router.push('/trasporti/mappa');
|
router.push('/viaggi/mappa');
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToHistory = () => {
|
const goToHistory = () => {
|
||||||
router.push('/trasporti/storico');
|
router.push('/viaggi/storico');
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToChat = () => {
|
const goToChat = () => {
|
||||||
router.push('/trasporti/chat');
|
router.push('/viaggi/chat');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-refresh
|
// Auto-refresh
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<span class="ride-widget__icon-badge" v-if="totalCount > 0">{{ totalCount }}</span>
|
<span class="ride-widget__icon-badge" v-if="totalCount > 0">{{ totalCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ride-widget__title-section">
|
<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>
|
<p class="ride-widget__subtitle">Viaggi solidali</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4,6 +4,6 @@ export { useRides } from './useRides';
|
|||||||
export { useRideRequests } from './useRideRequests';
|
export { useRideRequests } from './useRideRequests';
|
||||||
export { useChat } from './useChat';
|
export { useChat } from './useChat';
|
||||||
export { useFeedback } from './useFeedback';
|
export { useFeedback } from './useFeedback';
|
||||||
export { useGeocoding } from './useGeocoding';
|
export { useGeocoding } from './useGeocoding_OLD';
|
||||||
export { useDriverProfile } from './useDriverProfile';
|
export { useDriverProfile } from './useDriverProfile';
|
||||||
export { useContribTypes } from './useContribTypes';
|
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 {
|
try {
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/cities/suggestions?q=${encodeURIComponent(query)}`,
|
`/api/viaggi/cities/suggestions?q=${encodeURIComponent(query)}`,
|
||||||
'GET'
|
'GET'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export function useContribTypes() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
'/api/trasporti/contrib-types',
|
'/api/viaggi/contrib-types',
|
||||||
'GET'
|
'GET'
|
||||||
) as ApiResponse<ContribType[]>;
|
) as ApiResponse<ContribType[]>;
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
Vehicle,
|
Vehicle,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
DriverPublicProfile
|
DriverPublicProfile
|
||||||
} from '../types/trasporti.types';
|
} from '../types/viaggi.types';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// STATE
|
// STATE
|
||||||
@@ -53,7 +53,7 @@ export function useDriverProfile() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/driver/user/${userId}`,
|
`/api/viaggi/driver/user/${userId}`,
|
||||||
'GET'
|
'GET'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -82,10 +82,9 @@ export function useDriverProfile() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
'/api/trasporti/driver/profile',
|
'/api/viaggi/driver/profile',
|
||||||
'PUT',
|
'PUT',
|
||||||
{
|
{
|
||||||
idapp: 'trasporti',
|
|
||||||
...data
|
...data
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -125,7 +124,7 @@ export function useDriverProfile() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
'/api/trasporti/driver/vehicles',
|
'/api/viaggi/driver/vehicles',
|
||||||
'POST',
|
'POST',
|
||||||
{ vehicle: vehicleData }
|
{ vehicle: vehicleData }
|
||||||
);
|
);
|
||||||
@@ -152,7 +151,7 @@ export function useDriverProfile() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/driver/vehicles/${vehicleId}`,
|
`/api/viaggi/driver/vehicles/${vehicleId}`,
|
||||||
'PUT',
|
'PUT',
|
||||||
{ vehicle: vehicleData }
|
{ vehicle: vehicleData }
|
||||||
);
|
);
|
||||||
@@ -179,7 +178,7 @@ export function useDriverProfile() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/driver/vehicles/${vehicleId}`,
|
`/api/viaggi/driver/vehicles/${vehicleId}`,
|
||||||
'DELETE'
|
'DELETE'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -205,7 +204,7 @@ export function useDriverProfile() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/driver/vehicles/${vehicleId}/default`,
|
`/api/viaggi/driver/vehicles/${vehicleId}/default`,
|
||||||
'POST'
|
'POST'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export function useFeedback() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
'/api/trasporti/feedback',
|
'/api/viaggi/feedback',
|
||||||
'POST',
|
'POST',
|
||||||
feedbackData
|
feedbackData
|
||||||
) as ApiResponse<Feedback>;
|
) as ApiResponse<Feedback>;
|
||||||
@@ -92,7 +92,7 @@ export function useFeedback() {
|
|||||||
if (options?.limit) queryParams.append('limit', options.limit.toString());
|
if (options?.limit) queryParams.append('limit', options.limit.toString());
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/feedback/user/${userId}?${queryParams.toString()}`,
|
`/api/viaggi/feedback/user/${userId}?${queryParams.toString()}`,
|
||||||
'GET'
|
'GET'
|
||||||
) as ApiResponse<{
|
) as ApiResponse<{
|
||||||
feedbacks: Feedback[];
|
feedbacks: Feedback[];
|
||||||
@@ -121,7 +121,7 @@ export function useFeedback() {
|
|||||||
const fetchUserStats = async (userId: string) => {
|
const fetchUserStats = async (userId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/feedback/user/${userId}/stats`,
|
`/api/viaggi/feedback/user/${userId}/stats`,
|
||||||
'GET'
|
'GET'
|
||||||
) as ApiResponse<{
|
) as ApiResponse<{
|
||||||
stats: FeedbackStats;
|
stats: FeedbackStats;
|
||||||
@@ -149,7 +149,7 @@ export function useFeedback() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/feedback/ride/${rideId}`,
|
`/api/viaggi/feedback/ride/${rideId}`,
|
||||||
'GET'
|
'GET'
|
||||||
) as ApiResponse<Feedback[]>;
|
) as ApiResponse<Feedback[]>;
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ export function useFeedback() {
|
|||||||
const canLeaveFeedback = async (rideId: string, toUserId: string) => {
|
const canLeaveFeedback = async (rideId: string, toUserId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/feedback/can-leave/${rideId}/${toUserId}`,
|
`/api/viaggi/feedback/can-leave/${rideId}/${toUserId}`,
|
||||||
'GET'
|
'GET'
|
||||||
) as ApiResponse<{ canLeave: boolean; reason?: string }>;
|
) as ApiResponse<{ canLeave: boolean; reason?: string }>;
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ export function useFeedback() {
|
|||||||
if (role) queryParams.append('role', role);
|
if (role) queryParams.append('role', role);
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/feedback/my/received?${queryParams.toString()}`,
|
`/api/viaggi/feedback/my/received?${queryParams.toString()}`,
|
||||||
'GET'
|
'GET'
|
||||||
) as ApiResponse<{ feedbacks: Feedback[]; stats: FeedbackStats }>;
|
) as ApiResponse<{ feedbacks: Feedback[]; stats: FeedbackStats }>;
|
||||||
|
|
||||||
@@ -222,7 +222,7 @@ export function useFeedback() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
'/api/trasporti/feedback/my/given',
|
'/api/viaggi/feedback/my/given',
|
||||||
'GET'
|
'GET'
|
||||||
) as PaginatedResponse<Feedback>;
|
) as PaginatedResponse<Feedback>;
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ export function useFeedback() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/feedback/${feedbackId}/response`,
|
`/api/viaggi/feedback/${feedbackId}/response`,
|
||||||
'POST',
|
'POST',
|
||||||
{ text }
|
{ text }
|
||||||
) as ApiResponse<Feedback>;
|
) as ApiResponse<Feedback>;
|
||||||
@@ -276,7 +276,7 @@ export function useFeedback() {
|
|||||||
const reportFeedback = async (feedbackId: string, reason: string) => {
|
const reportFeedback = async (feedbackId: string, reason: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/feedback/${feedbackId}/report`,
|
`/api/viaggi/feedback/${feedbackId}/report`,
|
||||||
'POST',
|
'POST',
|
||||||
{ reason }
|
{ reason }
|
||||||
) as ApiResponse<void>;
|
) as ApiResponse<void>;
|
||||||
@@ -294,7 +294,7 @@ export function useFeedback() {
|
|||||||
const markAsHelpful = async (feedbackId: string) => {
|
const markAsHelpful = async (feedbackId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/feedback/${feedbackId}/helpful`,
|
`/api/viaggi/feedback/${feedbackId}/helpful`,
|
||||||
'POST'
|
'POST'
|
||||||
) as ApiResponse<{ helpfulCount: number }>;
|
) 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}`);
|
console.log(`Typing in chat: ${chatId}`);
|
||||||
|
|
||||||
// In produzione:
|
// 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;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
'/api/trasporti/requests',
|
'/api/viaggi/requests',
|
||||||
'POST',
|
'POST',
|
||||||
requestData,
|
requestData,
|
||||||
false, true
|
false, true
|
||||||
@@ -103,7 +103,7 @@ export function useRideRequests() {
|
|||||||
queryParams.append('limit', pagination.limit.toString());
|
queryParams.append('limit', pagination.limit.toString());
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/requests/received?${queryParams.toString()}`,
|
`/api/viaggi/requests/received?${queryParams.toString()}`,
|
||||||
'GET'
|
'GET'
|
||||||
) as RequestsReceivedResponse;
|
) as RequestsReceivedResponse;
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export function useRideRequests() {
|
|||||||
queryParams.append('limit', pagination.limit.toString());
|
queryParams.append('limit', pagination.limit.toString());
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/requests/sent?${queryParams.toString()}`,
|
`/api/viaggi/requests/sent?${queryParams.toString()}`,
|
||||||
'GET'
|
'GET'
|
||||||
) as PaginatedResponse<RideRequest>;
|
) as PaginatedResponse<RideRequest>;
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ export function useRideRequests() {
|
|||||||
if (status) queryParams.append('status', status);
|
if (status) queryParams.append('status', status);
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/requests/ride/${rideId}?${queryParams.toString()}`,
|
`/api/viaggi/requests/ride/${rideId}?${queryParams.toString()}`,
|
||||||
'GET'
|
'GET'
|
||||||
) as ApiResponse<RideRequest[]>;
|
) as ApiResponse<RideRequest[]>;
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ export function useRideRequests() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/requests/${requestId}`,
|
`/api/viaggi/requests/${requestId}`,
|
||||||
'GET'
|
'GET'
|
||||||
) as ApiResponse<RideRequest>;
|
) as ApiResponse<RideRequest>;
|
||||||
|
|
||||||
@@ -214,7 +214,7 @@ export function useRideRequests() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/requests/${requestId}/accept`,
|
`/api/viaggi/requests/${requestId}/accept`,
|
||||||
'POST',
|
'POST',
|
||||||
{ responseMessage }
|
{ responseMessage }
|
||||||
) as ApiResponse<RideRequest>;
|
) as ApiResponse<RideRequest>;
|
||||||
@@ -242,7 +242,7 @@ export function useRideRequests() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/requests/${requestId}/reject`,
|
`/api/viaggi/requests/${requestId}/reject`,
|
||||||
'POST',
|
'POST',
|
||||||
{ responseMessage }
|
{ responseMessage }
|
||||||
) as ApiResponse<RideRequest>;
|
) as ApiResponse<RideRequest>;
|
||||||
@@ -269,7 +269,7 @@ export function useRideRequests() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = await Api.SendReqWithData(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/trasporti/requests/${requestId}/cancel`,
|
`/api/viaggi/requests/${requestId}/cancel`,
|
||||||
'POST',
|
'POST',
|
||||||
{ reason }
|
{ reason }
|
||||||
) as ApiResponse<RideRequest>;
|
) as ApiResponse<RideRequest>;
|
||||||
@@ -117,7 +117,7 @@ export function useRides() {
|
|||||||
queryParams.append('passingThrough', filters.passingThrough);
|
queryParams.append('passingThrough', filters.passingThrough);
|
||||||
|
|
||||||
const response = (await Api.SendReqWithData(
|
const response = (await Api.SendReqWithData(
|
||||||
`/api/trasporti/rides?${queryParams.toString()}`,
|
`/api/viaggi/rides?${queryParams.toString()}`,
|
||||||
'GET'
|
'GET'
|
||||||
)) as PaginatedResponse<Ride>;
|
)) as PaginatedResponse<Ride>;
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export function useRides() {
|
|||||||
queryParams.append('limit', pagination.limit.toString());
|
queryParams.append('limit', pagination.limit.toString());
|
||||||
|
|
||||||
const response = (await Api.SendReqWithData(
|
const response = (await Api.SendReqWithData(
|
||||||
`/api/trasporti/rides/search?${queryParams.toString()}`,
|
`/api/viaggi/rides/search?${queryParams.toString()}`,
|
||||||
'GET'
|
'GET'
|
||||||
)) as PaginatedResponse<Ride>;
|
)) as PaginatedResponse<Ride>;
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ export function useRides() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = (await Api.SendReqWithData(
|
const response = (await Api.SendReqWithData(
|
||||||
`/api/trasporti/rides/${rideId}`,
|
`/api/viaggi/rides/${rideId}`,
|
||||||
'GET'
|
'GET'
|
||||||
)) as ApiResponse<Ride>;
|
)) as ApiResponse<Ride>;
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ export function useRides() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = (await Api.SendReqWithData(
|
const response = (await Api.SendReqWithData(
|
||||||
'/api/trasporti/rides',
|
'/api/viaggi/rides',
|
||||||
'POST',
|
'POST',
|
||||||
rideData
|
rideData
|
||||||
)) as ApiResponse<Ride>;
|
)) as ApiResponse<Ride>;
|
||||||
@@ -256,7 +256,7 @@ export function useRides() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = (await Api.SendReqWithData(
|
const response = (await Api.SendReqWithData(
|
||||||
`/api/trasporti/rides/${rideId}`,
|
`/api/viaggi/rides/${rideId}`,
|
||||||
'PUT',
|
'PUT',
|
||||||
updateData
|
updateData
|
||||||
)) as ApiResponse<Ride>;
|
)) as ApiResponse<Ride>;
|
||||||
@@ -289,7 +289,7 @@ export function useRides() {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = (await Api.SendReqWithData(`/api/trasporti/rides/${rideId}`, 'DELETE', {
|
const response = (await Api.SendReqWithData(`/api/viaggi/rides/${rideId}`, 'DELETE', {
|
||||||
reason,
|
reason,
|
||||||
})) as ApiResponse<void>;
|
})) as ApiResponse<void>;
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ export function useRides() {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
const response = (await Api.SendReqWithData(
|
const response = (await Api.SendReqWithData(
|
||||||
`/api/trasporti/rides/${rideId}/complete`,
|
`/api/viaggi/rides/${rideId}/complete`,
|
||||||
'POST'
|
'POST'
|
||||||
)) as ApiResponse<Ride>;
|
)) as ApiResponse<Ride>;
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ export function useRides() {
|
|||||||
queryParams.append('limit', pagination.limit.toString());
|
queryParams.append('limit', pagination.limit.toString());
|
||||||
|
|
||||||
const response = (await Api.SendReqWithData(
|
const response = (await Api.SendReqWithData(
|
||||||
`/api/trasporti/rides/my?${queryParams.toString()}`,
|
`/api/viaggi/rides/my?${queryParams.toString()}`,
|
||||||
'GET'
|
'GET'
|
||||||
)) as MyRidesResponse;
|
)) as MyRidesResponse;
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ export function useRides() {
|
|||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
const response = (await Api.SendReqWithData(
|
const response = (await Api.SendReqWithData(
|
||||||
'/api/trasporti/rides/stats',
|
'/api/viaggi/rides/stats',
|
||||||
'GET'
|
'GET'
|
||||||
)) as ApiResponse<RidesStatsResponse>;
|
)) as ApiResponse<RidesStatsResponse>;
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// ChatListPage.ts
|
// ChatListPage.ts
|
||||||
import { defineComponent, ref, computed, onMounted } from 'vue';
|
import { defineComponent, ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { useChat } from '../composables/useChat';
|
import { useChat } from '../composables/useChat';
|
||||||
@@ -22,7 +22,8 @@ export default defineComponent({
|
|||||||
toggleMuteChat,
|
toggleMuteChat,
|
||||||
deleteChat,
|
deleteChat,
|
||||||
onlineUsers,
|
onlineUsers,
|
||||||
totalUnreadCount
|
totalUnreadCount,
|
||||||
|
pollChatsList, // ✅ AGGIUNGI
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -32,6 +33,9 @@ export default defineComponent({
|
|||||||
const hasMore = ref(true);
|
const hasMore = ref(true);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
|
|
||||||
|
// ✅ Polling interval
|
||||||
|
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// ✅ User search
|
// ✅ User search
|
||||||
const showUserSearch = ref(false);
|
const showUserSearch = ref(false);
|
||||||
const userSearchQuery = ref('');
|
const userSearchQuery = ref('');
|
||||||
@@ -46,57 +50,69 @@ export default defineComponent({
|
|||||||
const unreadCount = computed(() => totalUnreadCount.value);
|
const unreadCount = computed(() => totalUnreadCount.value);
|
||||||
|
|
||||||
const filteredChats = computed(() => {
|
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) {
|
switch (activeTab.value) {
|
||||||
case 'unread':
|
case 'unread':
|
||||||
result = result.filter(chat => (chat.unreadCount || 0) > 0);
|
result = result.filter((chat) => (chat.unreadCount || 0) > 0);
|
||||||
break;
|
break;
|
||||||
case 'rides':
|
case 'rides':
|
||||||
result = result.filter(chat => chat.rideId);
|
result = result.filter((chat) => chat.rideId);
|
||||||
break;
|
break;
|
||||||
case 'archived':
|
case 'archived':
|
||||||
result = result.filter(chat => chat.archived);
|
result = result.filter((chat) => chat.archived);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
result = result.filter(chat => !chat.archived);
|
result = result.filter((chat) => !chat.archived);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search
|
// Poi applica la ricerca testuale
|
||||||
if (searchQuery.value) {
|
if (searchQuery.value) {
|
||||||
const query = searchQuery.value.toLowerCase();
|
const query = searchQuery.value.toLowerCase();
|
||||||
result = result.filter(chat => {
|
result = result.filter((chat) => {
|
||||||
const otherUser = getOtherParticipant(chat);
|
const otherUser = getOtherParticipant(chat);
|
||||||
const fullName = `${otherUser?.name || ''} ${otherUser?.surname || ''}`.toLowerCase();
|
const fullName =
|
||||||
|
`${otherUser?.name || ''} ${otherUser?.surname || ''}`.toLowerCase();
|
||||||
const username = otherUser?.username?.toLowerCase() || '';
|
const username = otherUser?.username?.toLowerCase() || '';
|
||||||
|
|
||||||
let rideInfo = '';
|
let rideInfo = '';
|
||||||
if (chat.rideId) {
|
if (chat.rideId) {
|
||||||
const rideData = typeof chat.rideId === 'object' ? chat.rideId : null;
|
const rideData = typeof chat.rideId === 'object' ? chat.rideId : null;
|
||||||
if (rideData) {
|
if (rideData) {
|
||||||
const departure = typeof rideData.departure === 'string'
|
const departure =
|
||||||
? rideData.departure
|
typeof rideData.departure === 'string'
|
||||||
: rideData.departure?.city || '';
|
? rideData.departure
|
||||||
const destination = typeof rideData.destination === 'string'
|
: rideData.departure?.city || '';
|
||||||
? rideData.destination
|
const destination =
|
||||||
: rideData.destination?.city || '';
|
typeof rideData.destination === 'string'
|
||||||
|
? rideData.destination
|
||||||
|
: rideData.destination?.city || '';
|
||||||
rideInfo = `${departure} ${destination}`.toLowerCase();
|
rideInfo = `${departure} ${destination}`.toLowerCase();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastMessageText = chat.lastMessage?.text?.toLowerCase() || '';
|
const lastMessageText = chat.lastMessage?.text?.toLowerCase() || '';
|
||||||
|
|
||||||
return fullName.includes(query) ||
|
return (
|
||||||
username.includes(query) ||
|
fullName.includes(query) ||
|
||||||
rideInfo.includes(query) ||
|
username.includes(query) ||
|
||||||
lastMessageText.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) => {
|
result.sort((a, b) => {
|
||||||
// Pinned chats first
|
|
||||||
if (a.pinned && !b.pinned) return -1;
|
if (a.pinned && !b.pinned) return -1;
|
||||||
if (!a.pinned && b.pinned) return 1;
|
if (!a.pinned && b.pinned) return 1;
|
||||||
|
|
||||||
@@ -107,41 +123,52 @@ export default defineComponent({
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyStateIcon = computed(() => {
|
const emptyStateIcon = computed(() => {
|
||||||
if (searchQuery.value) return 'search_off';
|
if (searchQuery.value) return 'search_off';
|
||||||
switch (activeTab.value) {
|
switch (activeTab.value) {
|
||||||
case 'unread': return 'mark_email_read';
|
case 'unread':
|
||||||
case 'rides': return 'no_transfer';
|
return 'mark_email_read';
|
||||||
case 'archived': return 'unarchive';
|
case 'rides':
|
||||||
default: return 'forum';
|
return 'no_transfer';
|
||||||
|
case 'archived':
|
||||||
|
return 'unarchive';
|
||||||
|
default:
|
||||||
|
return 'forum';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyStateTitle = computed(() => {
|
const emptyStateTitle = computed(() => {
|
||||||
if (searchQuery.value) return 'Nessun risultato';
|
if (searchQuery.value) return 'Nessun risultato';
|
||||||
switch (activeTab.value) {
|
switch (activeTab.value) {
|
||||||
case 'unread': return 'Tutto letto!';
|
case 'unread':
|
||||||
case 'rides': return 'Nessuna chat viaggio';
|
return 'Tutto letto!';
|
||||||
case 'archived': return 'Nessuna chat archiviata';
|
case 'rides':
|
||||||
default: return 'Nessuna conversazione';
|
return 'Nessuna chat viaggio';
|
||||||
|
case 'archived':
|
||||||
|
return 'Nessuna chat archiviata';
|
||||||
|
default:
|
||||||
|
return 'Nessuna conversazione';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyStateMessage = computed(() => {
|
const emptyStateMessage = computed(() => {
|
||||||
if (searchQuery.value) return 'Prova con altri termini di ricerca';
|
if (searchQuery.value) return 'Prova con altri termini di ricerca';
|
||||||
switch (activeTab.value) {
|
switch (activeTab.value) {
|
||||||
case 'unread': return 'Non hai messaggi da leggere';
|
case 'unread':
|
||||||
case 'rides': return 'Le chat relative ai viaggi appariranno qui';
|
return 'Non hai messaggi da leggere';
|
||||||
case 'archived': return 'Le conversazioni archiviate appariranno qui';
|
case 'rides':
|
||||||
default: return 'Inizia a cercare viaggi per connetterti con altri utenti';
|
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
|
// Methods
|
||||||
const getOtherParticipant = (chat: Chat): User | undefined => {
|
const getOtherParticipant = (chat: Chat): User | undefined => {
|
||||||
if (!chat.participants || chat.participants.length === 0) return 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;
|
const pId = typeof p === 'string' ? p : p._id;
|
||||||
return pId !== currentUserId.value;
|
return pId !== currentUserId.value;
|
||||||
}) as User | undefined;
|
}) as User | undefined;
|
||||||
@@ -170,7 +197,7 @@ export default defineComponent({
|
|||||||
if (days === 0) {
|
if (days === 0) {
|
||||||
return messageDate.toLocaleTimeString('it-IT', {
|
return messageDate.toLocaleTimeString('it-IT', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
} else if (days === 1) {
|
} else if (days === 1) {
|
||||||
return 'Ieri';
|
return 'Ieri';
|
||||||
@@ -179,7 +206,7 @@ export default defineComponent({
|
|||||||
} else {
|
} else {
|
||||||
return messageDate.toLocaleDateString('it-IT', {
|
return messageDate.toLocaleDateString('it-IT', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: '2-digit'
|
month: '2-digit',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -203,9 +230,10 @@ export default defineComponent({
|
|||||||
const getMessageStatusIcon = (lastMessage?: Message | null): string => {
|
const getMessageStatusIcon = (lastMessage?: Message | null): string => {
|
||||||
if (!lastMessage) return '';
|
if (!lastMessage) return '';
|
||||||
|
|
||||||
const senderId = typeof lastMessage.senderId === 'string'
|
const senderId =
|
||||||
? lastMessage.senderId
|
typeof lastMessage.senderId === 'string'
|
||||||
: lastMessage.senderId?._id;
|
? lastMessage.senderId
|
||||||
|
: lastMessage.senderId?._id;
|
||||||
|
|
||||||
if (senderId !== currentUserId.value) return '';
|
if (senderId !== currentUserId.value) return '';
|
||||||
|
|
||||||
@@ -219,7 +247,7 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openChat = (chat: Chat) => {
|
const openChat = (chat: Chat) => {
|
||||||
router.push(`/trasporti/chat/${chat._id}`);
|
router.push(`/viaggi/chat/${chat._id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ Added: Mute chat
|
// ✅ Added: Mute chat
|
||||||
@@ -231,14 +259,14 @@ export default defineComponent({
|
|||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
message: isMuted ? 'Notifiche attivate' : 'Notifiche silenziate',
|
message: isMuted ? 'Notifiche attivate' : 'Notifiche silenziate',
|
||||||
icon: isMuted ? 'notifications' : 'notifications_off'
|
icon: isMuted ? 'notifications' : 'notifications_off',
|
||||||
});
|
});
|
||||||
|
|
||||||
await fetchChats();
|
await fetchChats();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'Errore nell\'aggiornamento'
|
message: "Errore nell'aggiornamento",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -249,13 +277,13 @@ export default defineComponent({
|
|||||||
// TODO: Implementa nel backend
|
// TODO: Implementa nel backend
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Conversazione archiviata'
|
message: 'Conversazione archiviata',
|
||||||
});
|
});
|
||||||
await fetchChats();
|
await fetchChats();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
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?',
|
message: 'Sei sicuro di voler eliminare questa conversazione?',
|
||||||
cancel: {
|
cancel: {
|
||||||
label: 'Annulla',
|
label: 'Annulla',
|
||||||
flat: true
|
flat: true,
|
||||||
},
|
},
|
||||||
ok: {
|
ok: {
|
||||||
label: 'Elimina',
|
label: 'Elimina',
|
||||||
color: 'negative'
|
color: 'negative',
|
||||||
},
|
},
|
||||||
persistent: true
|
persistent: true,
|
||||||
}).onOk(async () => {
|
}).onOk(async () => {
|
||||||
try {
|
try {
|
||||||
await deleteChat(chat._id);
|
await deleteChat(chat._id);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'Conversazione eliminata'
|
message: 'Conversazione eliminata',
|
||||||
});
|
});
|
||||||
await fetchChats();
|
await fetchChats();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'Errore durante l\'eliminazione'
|
message: "Errore durante l'eliminazione",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -300,7 +328,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
searchingUsers.value = true;
|
searchingUsers.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await Api.SendReq(
|
const response = await Api.SendReqWithData(
|
||||||
`/api/users/search?q=${encodeURIComponent(userSearchQuery.value)}`,
|
`/api/users/search?q=${encodeURIComponent(userSearchQuery.value)}`,
|
||||||
'GET'
|
'GET'
|
||||||
);
|
);
|
||||||
@@ -328,12 +356,12 @@ export default defineComponent({
|
|||||||
searchedUsers.value = [];
|
searchedUsers.value = [];
|
||||||
|
|
||||||
if (chat) {
|
if (chat) {
|
||||||
router.push(`/trasporti/chat/${chat._id}`);
|
router.push(`/viaggi/chat/${chat._id}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'Errore nella creazione della chat'
|
message: 'Errore nella creazione della chat',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -346,9 +374,23 @@ export default defineComponent({
|
|||||||
page.value++;
|
page.value++;
|
||||||
const result = await fetchChats(page.value, 20);
|
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;
|
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 {
|
} finally {
|
||||||
loadingMore.value = false;
|
loadingMore.value = false;
|
||||||
}
|
}
|
||||||
@@ -358,9 +400,32 @@ export default defineComponent({
|
|||||||
showUserSearch.value = true;
|
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
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchChats(1, 20);
|
await fetchChats(1, 20);
|
||||||
|
// ✅ Avvia polling
|
||||||
|
startChatsPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// ✅ Pulisci polling quando il componente viene distrutto
|
||||||
|
stopChatsPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -398,7 +463,7 @@ export default defineComponent({
|
|||||||
loadMore,
|
loadMore,
|
||||||
startNewChat,
|
startNewChat,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
startChatWith
|
startChatWith,
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
rounded
|
rounded
|
||||||
unelevated
|
unelevated
|
||||||
class="q-mt-md"
|
class="q-mt-md"
|
||||||
to="/trasporti"
|
to="/viaggi"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import { useChat } from '../composables/useChat';
|
|||||||
import { useRealtimeChat } from '../composables/useRealtimeChat';
|
import { useRealtimeChat } from '../composables/useRealtimeChat';
|
||||||
import { useAuth } from '../composables/useAuth';
|
import { useAuth } from '../composables/useAuth';
|
||||||
import MessageBubble from '../components/chat/MessageBubble.vue';
|
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';
|
import { debounce } from 'quasar';
|
||||||
|
|
||||||
interface MessageGroup {
|
interface MessageGroup {
|
||||||
@@ -46,6 +46,8 @@ export default defineComponent({
|
|||||||
deleteMessage: deleteMsg,
|
deleteMessage: deleteMsg,
|
||||||
onlineUsers,
|
onlineUsers,
|
||||||
typingUsers,
|
typingUsers,
|
||||||
|
deleteChat,
|
||||||
|
fetchChats,
|
||||||
toggleMuteChat,
|
toggleMuteChat,
|
||||||
startPolling, // AGGIUNGI
|
startPolling, // AGGIUNGI
|
||||||
stopPolling, // AGGIUNGI
|
stopPolling, // AGGIUNGI
|
||||||
@@ -313,13 +315,13 @@ export default defineComponent({
|
|||||||
|
|
||||||
const viewRide = () => {
|
const viewRide = () => {
|
||||||
if (rideInfo.value?.rideId) {
|
if (rideInfo.value?.rideId) {
|
||||||
router.push(`/trasporti/ride/${rideInfo.value.rideId}`);
|
router.push(`/viaggi/ride/${rideInfo.value.rideId}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewDriverProfile = () => {
|
const viewDriverProfile = () => {
|
||||||
if (otherUser.value) {
|
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({
|
$q.dialog({
|
||||||
title: 'Elimina conversazione',
|
title: 'Elimina conversazione',
|
||||||
message: 'Sei sicuro? Questa azione non è reversibile.',
|
message: 'Sei sicuro? Questa azione non è reversibile.',
|
||||||
@@ -349,8 +351,13 @@ export default defineComponent({
|
|||||||
persistent: true,
|
persistent: true,
|
||||||
}).onOk(async () => {
|
}).onOk(async () => {
|
||||||
try {
|
try {
|
||||||
// TODO: Implementa eliminazione chat
|
await deleteChat(currentChat.value._id);
|
||||||
router.push('/trasporti/chat');
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Conversazione eliminata',
|
||||||
|
});
|
||||||
|
await fetchChats();
|
||||||
|
router.push('/viaggi/chat')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
@@ -368,7 +375,7 @@ export default defineComponent({
|
|||||||
}).onOk(() => {
|
}).onOk(() => {
|
||||||
// TODO: Implementa blocco
|
// TODO: Implementa blocco
|
||||||
showUserProfile.value = false;
|
showUserProfile.value = false;
|
||||||
router.push('/trasporti/chat');
|
router.push('/viaggi/chat');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,19 +68,19 @@ export default defineComponent({
|
|||||||
|
|
||||||
const goToProfile = (profileUserId: string) => {
|
const goToProfile = (profileUserId: string) => {
|
||||||
if (profileUserId !== userId.value) {
|
if (profileUserId !== userId.value) {
|
||||||
router.push(`/trasporti/profilo/${profileUserId}`);
|
router.push(`/viaggi/profilo/${profileUserId}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goToRide = (rideId: string) => {
|
const goToRide = (rideId: string) => {
|
||||||
router.push(`/trasporti/ride/${rideId}`);
|
router.push(`/viaggi/ride/${rideId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const contactUser = async () => {
|
const contactUser = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await getOrCreateDirectChat(userId.value);
|
const response = await getOrCreateDirectChat(userId.value);
|
||||||
if (response?.data) {
|
if (response?.data) {
|
||||||
router.push(`/trasporti/chat/${response.data._id}`);
|
router.push(`/viaggi/chat/${response.data._id}`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
$q.notify({ type: 'negative', message: error.data?.message || error.message });
|
$q.notify({ type: 'negative', message: error.data?.message || error.message });
|
||||||
@@ -92,11 +92,11 @@ export default defineComponent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editProfile = () => {
|
const editProfile = () => {
|
||||||
router.push('/trasporti/profilo/modifica');
|
router.push('/viaggi/profilo/modifica');
|
||||||
};
|
};
|
||||||
|
|
||||||
const viewAllReviews = () => {
|
const viewAllReviews = () => {
|
||||||
router.push(`/trasporti/recensioni/${userId.value}`);
|
router.push(`/viaggi/recensioni/${userId.value}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStarIcon = (star: number, rating: number): string => {
|
const getStarIcon = (star: number, rating: number): string => {
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<q-btn flat round icon="arrow_back" color="white" @click="goBack" />
|
<q-btn flat round icon="arrow_back" color="white" @click="goBack" />
|
||||||
<div>
|
<div>
|
||||||
<h1>Come Funziona</h1>
|
<h1>Come Funziona</h1>
|
||||||
<p>Guida ai Trasporti Solidali</p>
|
<p>Guida ai Viaggi Solidali</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,19 +21,19 @@
|
|||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="help-page__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">
|
<div class="help-page__action-icon help-page__action-icon--offer">
|
||||||
<q-icon name="directions_car" size="28px" />
|
<q-icon name="directions_car" size="28px" />
|
||||||
</div>
|
</div>
|
||||||
<span>Offri passaggio</span>
|
<span>Offri passaggio</span>
|
||||||
</div>
|
</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">
|
<div class="help-page__action-icon help-page__action-icon--search">
|
||||||
<q-icon name="search" size="28px" />
|
<q-icon name="search" size="28px" />
|
||||||
</div>
|
</div>
|
||||||
<span>Cerca passaggio</span>
|
<span>Cerca passaggio</span>
|
||||||
</div>
|
</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">
|
<div class="help-page__action-icon help-page__action-icon--request">
|
||||||
<q-icon name="hail" size="28px" />
|
<q-icon name="hail" size="28px" />
|
||||||
</div>
|
</div>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user