- Implementazione TRASPORTI ! Passo 1
This commit is contained in:
@@ -60,6 +60,7 @@ export default defineConfig((ctx) => {
|
|||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
'@components': path.resolve(__dirname, 'src/components'),
|
'@components': path.resolve(__dirname, 'src/components'),
|
||||||
'@views': path.resolve(__dirname, 'src/views'),
|
'@views': path.resolve(__dirname, 'src/views'),
|
||||||
|
'@modules': path.resolve(__dirname, 'src/modules'),
|
||||||
'@boot': path.resolve(__dirname, 'src/boot'),
|
'@boot': path.resolve(__dirname, 'src/boot'),
|
||||||
'@store': path.resolve(__dirname, 'src/store'),
|
'@store': path.resolve(__dirname, 'src/store'),
|
||||||
'@storemod': path.resolve(__dirname, 'src/store/Modules'),
|
'@storemod': path.resolve(__dirname, 'src/store/Modules'),
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ const generateImage = async () => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.message || 'Errore durante la generazione',
|
message: error.data?.message || error.message || 'Errore durante la generazione',
|
||||||
icon: 'error'
|
icon: 'error'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -219,7 +219,7 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<q-item-section>{{
|
<q-item-section>{{
|
||||||
circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.Iscriviti')
|
circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.iscriviti')
|
||||||
}}</q-item-section>
|
}}</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
@@ -436,7 +436,7 @@
|
|||||||
"
|
"
|
||||||
icon="fas fa-user-plus"
|
icon="fas fa-user-plus"
|
||||||
color="primary"
|
color="primary"
|
||||||
:label="circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.Iscriviti')"
|
:label="circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.iscriviti')"
|
||||||
rounded
|
rounded
|
||||||
size="lg"
|
size="lg"
|
||||||
@click="
|
@click="
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ export default defineComponent({
|
|||||||
console.error('Errore download QR:', error);
|
console.error('Errore download QR:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.message || 'Errore durante il download',
|
message: error.data?.message || error.message || 'Errore durante il download',
|
||||||
position: 'top',
|
position: 'top',
|
||||||
});
|
});
|
||||||
emit('error', error);
|
emit('error', error);
|
||||||
|
|||||||
@@ -262,9 +262,6 @@
|
|||||||
ref="inputUsername"
|
ref="inputUsername"
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
v-model="signup.username"
|
v-model="signup.username"
|
||||||
:readonly="
|
|
||||||
tools.getAskToVerifyReg() && !site.confpages?.enableRegMultiChoice
|
|
||||||
"
|
|
||||||
filled
|
filled
|
||||||
class="modern-input"
|
class="modern-input"
|
||||||
@blur="v$.username.$touch"
|
@blur="v$.username.$touch"
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ export function usePosterGenerator() {
|
|||||||
|
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.message || 'Errore durante la generazione',
|
message: error.data?.message || error.message || 'Errore durante la generazione',
|
||||||
icon: 'error'
|
icon: 'error'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ const generateImage = async () => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.message || 'Errore durante la generazione',
|
message: error.data?.message || error.message || 'Errore durante la generazione',
|
||||||
icon: 'error'
|
icon: 'error'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ const uploadAsset = async (type: 'backgroundImage' | 'mainImage', file: File) =>
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.message || 'Errore durante il caricamento'
|
message: error.data?.message || error.message || 'Errore durante il caricamento'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -214,7 +214,7 @@ const handleLogoUpload = async (event: Event) => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.message || 'Errore durante il caricamento'
|
message: error.data?.message || error.message || 'Errore durante il caricamento'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ export function useTemplateBuilder() {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.message || 'Errore durante il salvataggio',
|
message: error.data?.message || error.message || 'Errore durante il salvataggio',
|
||||||
icon: 'error'
|
icon: 'error'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -448,7 +448,7 @@ export function useTemplateBuilder() {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: error.message || 'Errore durante la pubblicazione'
|
message: error.data?.message || error.message || 'Errore durante la pubblicazione'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
isPublishing.value = false;
|
isPublishing.value = false;
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ export interface IConfPages {
|
|||||||
enableEcommerce: boolean
|
enableEcommerce: boolean
|
||||||
enableAI: boolean
|
enableAI: boolean
|
||||||
enablePoster: boolean
|
enablePoster: boolean
|
||||||
|
enableTrasporti: boolean
|
||||||
enableGroups: boolean
|
enableGroups: boolean
|
||||||
enableCircuits: boolean
|
enableCircuits: boolean
|
||||||
enableProj?: boolean
|
enableProj?: boolean
|
||||||
|
|||||||
160
src/modules/trasporti/components/chat/ChatInput.scss
Normal file
160
src/modules/trasporti/components/chat/ChatInput.scss
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
.chat-input {
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
&__reply {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-sender {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--q-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
flex: 1;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
|
||||||
|
:deep(.q-field__control) {
|
||||||
|
height: auto;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.q-field__native) {
|
||||||
|
padding: 0;
|
||||||
|
min-height: 20px;
|
||||||
|
max-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(textarea) {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__send {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__emoji-picker {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__emoji-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__emoji {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animations
|
||||||
|
.slide-down-enter-active,
|
||||||
|
.slide-down-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-down-enter-from,
|
||||||
|
.slide-down-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-from,
|
||||||
|
.slide-up-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.chat-input {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
&__reply {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border-color: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-text {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
background: #2d2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__emoji-picker {
|
||||||
|
background: #252525;
|
||||||
|
border-color: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__emoji:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/modules/trasporti/components/chat/ChatInput.ts
Normal file
114
src/modules/trasporti/components/chat/ChatInput.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { ref, computed, defineComponent, PropType } from 'vue';
|
||||||
|
import type { Message, UserBasic } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'ChatInput',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
replyTo: {
|
||||||
|
type: Object as PropType<Message | null>,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
sending: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['send', 'cancel-reply', 'share-location', 'share-ride'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const inputRef = ref<any>(null);
|
||||||
|
const message = ref('');
|
||||||
|
const showAttachMenu = ref(false);
|
||||||
|
const showEmojiPicker = ref(false);
|
||||||
|
|
||||||
|
const commonEmojis = [
|
||||||
|
'😊', '😂', '❤️', '👍', '🙏', '😍', '🎉', '🔥',
|
||||||
|
'😢', '😮', '🤔', '👏', '💪', '✨', '🚗', '📍',
|
||||||
|
'⏰', '✅', '❌', '👋', '🙂', '😉', '🤝', '💯'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const canSend = computed(() => {
|
||||||
|
return message.value.trim().length > 0 && !props.sending && !props.disabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyToSenderName = computed(() => {
|
||||||
|
if (!props.replyTo) return '';
|
||||||
|
const sender = props.replyTo.senderId;
|
||||||
|
if (typeof sender === 'object') {
|
||||||
|
return (sender as UserBasic).name || (sender as UserBasic).username || 'Utente';
|
||||||
|
}
|
||||||
|
return 'Utente';
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyToText = computed(() => {
|
||||||
|
if (!props.replyTo) return '';
|
||||||
|
const text = props.replyTo.text || '';
|
||||||
|
return text.length > 60 ? text.substring(0, 60) + '...' : text;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const send = () => {
|
||||||
|
if (!canSend.value) return;
|
||||||
|
|
||||||
|
emit('send', {
|
||||||
|
text: message.value.trim(),
|
||||||
|
replyTo: props.replyTo?._id
|
||||||
|
});
|
||||||
|
|
||||||
|
message.value = '';
|
||||||
|
showEmojiPicker.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLine = () => {
|
||||||
|
message.value += '\n';
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertEmoji = (emoji: string) => {
|
||||||
|
message.value += emoji;
|
||||||
|
inputRef.value?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareLocation = () => {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
emit('share-location', {
|
||||||
|
lat: position.coords.latitude,
|
||||||
|
lng: position.coords.longitude
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('Errore geolocalizzazione:', error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const focus = () => {
|
||||||
|
inputRef.value?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
inputRef,
|
||||||
|
message,
|
||||||
|
showAttachMenu,
|
||||||
|
showEmojiPicker,
|
||||||
|
commonEmojis,
|
||||||
|
canSend,
|
||||||
|
replyToSenderName,
|
||||||
|
replyToText,
|
||||||
|
send,
|
||||||
|
newLine,
|
||||||
|
insertEmoji,
|
||||||
|
shareLocation,
|
||||||
|
focus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
112
src/modules/trasporti/components/chat/ChatInput.vue
Normal file
112
src/modules/trasporti/components/chat/ChatInput.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-input">
|
||||||
|
<!-- Reply preview -->
|
||||||
|
<transition name="slide-down">
|
||||||
|
<div v-if="replyTo" class="chat-input__reply">
|
||||||
|
<div class="chat-input__reply-content">
|
||||||
|
<q-icon name="reply" size="16px" color="primary" />
|
||||||
|
<div class="chat-input__reply-info">
|
||||||
|
<span class="chat-input__reply-sender">{{ replyToSenderName }}</span>
|
||||||
|
<span class="chat-input__reply-text">{{ replyToText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="close"
|
||||||
|
size="sm"
|
||||||
|
@click="$emit('cancel-reply')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Input area -->
|
||||||
|
<div class="chat-input__main">
|
||||||
|
<!-- Attachment button -->
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="add"
|
||||||
|
color="grey-7"
|
||||||
|
@click="showAttachMenu = true"
|
||||||
|
>
|
||||||
|
<q-menu v-model="showAttachMenu">
|
||||||
|
<q-list dense style="min-width: 180px">
|
||||||
|
<q-item clickable v-close-popup @click="shareLocation">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="location_on" color="negative" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Posizione</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item clickable v-close-popup @click="$emit('share-ride')">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="directions_car" color="primary" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Condividi viaggio</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<!-- Text input -->
|
||||||
|
<q-input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="message"
|
||||||
|
placeholder="Scrivi un messaggio..."
|
||||||
|
dense
|
||||||
|
borderless
|
||||||
|
autogrow
|
||||||
|
:maxlength="2000"
|
||||||
|
class="chat-input__field"
|
||||||
|
@keydown.enter.exact.prevent="send"
|
||||||
|
@keydown.enter.shift.exact="newLine"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<!-- Emoji button -->
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="sentiment_satisfied_alt"
|
||||||
|
color="grey-7"
|
||||||
|
@click="showEmojiPicker = !showEmojiPicker"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<!-- Send button -->
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
:color="canSend ? 'primary' : 'grey-4'"
|
||||||
|
:icon="sending ? undefined : 'send'"
|
||||||
|
:disable="!canSend"
|
||||||
|
unelevated
|
||||||
|
class="chat-input__send"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
<q-spinner v-if="sending" color="white" size="20px" />
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emoji picker -->
|
||||||
|
<transition name="slide-up">
|
||||||
|
<div v-if="showEmojiPicker" class="chat-input__emoji-picker">
|
||||||
|
<div class="chat-input__emoji-grid">
|
||||||
|
<button
|
||||||
|
v-for="emoji in commonEmojis"
|
||||||
|
:key="emoji"
|
||||||
|
class="chat-input__emoji"
|
||||||
|
@click="insertEmoji(emoji)"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./ChatInput.ts" />
|
||||||
|
<style lang="scss" src="./ChatInput.scss" />
|
||||||
180
src/modules/trasporti/components/chat/ChatList.scss
Normal file
180
src/modules/trasporti/components/chat/ChatList.scss
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
.chat-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
:deep(.q-field__control) {
|
||||||
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
|
||||||
|
.q-icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 8px;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__items {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--unread {
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
|
||||||
|
.chat-list__name {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-list__preview {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--q-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
background: linear-gradient(135deg, var(--q-primary), var(--q-secondary));
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__online-badge {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
min-height: 12px;
|
||||||
|
border: 2px solid white;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
// Truncate text
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--q-primary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__unread-badge {
|
||||||
|
margin-top: 4px;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.chat-list {
|
||||||
|
background: #1e1e1e;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--unread {
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.08);
|
||||||
|
|
||||||
|
.chat-list__preview {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/modules/trasporti/components/chat/ChatList.ts
Normal file
185
src/modules/trasporti/components/chat/ChatList.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { ref, computed, defineComponent, PropType } from 'vue';
|
||||||
|
import type { ChatWithUnread, UserBasic, Ride, MessageType } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'ChatList',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
chats: {
|
||||||
|
type: Array as PropType<ChatWithUnread[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
activeChat: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
showSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['select', 'search'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const filteredChats = computed(() => {
|
||||||
|
if (!searchQuery.value) return props.chats;
|
||||||
|
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
return props.chats.filter(chat => {
|
||||||
|
const otherUser = getOtherParticipant(chat);
|
||||||
|
const name = getDisplayName(otherUser).toLowerCase();
|
||||||
|
const lastMessage = chat.lastMessage?.text?.toLowerCase() || '';
|
||||||
|
return name.includes(query) || lastMessage.includes(query);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalUnread = computed(() => {
|
||||||
|
return props.chats.reduce((sum, chat) => sum + getUnreadCount(chat), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const getOtherParticipant = (chat: ChatWithUnread): UserBasic | null => {
|
||||||
|
if (chat.otherParticipant) {
|
||||||
|
return chat.otherParticipant;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.participants) {
|
||||||
|
const other = chat.participants.find(p => {
|
||||||
|
const id = typeof p === 'string' ? p : p._id;
|
||||||
|
return id !== props.currentUserId;
|
||||||
|
});
|
||||||
|
return typeof other === 'object' ? other : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayName = (user: UserBasic | null): string => {
|
||||||
|
if (!user) return 'Utente';
|
||||||
|
if (user.name) {
|
||||||
|
return `${user.name} ${user.surname?.[0] || ''}`.trim();
|
||||||
|
}
|
||||||
|
return user.username || 'Utente';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (user: UserBasic | null): string => {
|
||||||
|
const name = getDisplayName(user);
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUnreadCount = (chat: ChatWithUnread): number => {
|
||||||
|
if (typeof chat.unreadCount === 'number') {
|
||||||
|
return chat.unreadCount;
|
||||||
|
}
|
||||||
|
if (chat.unreadCount instanceof Map) {
|
||||||
|
return chat.unreadCount.get(props.currentUserId) || 0;
|
||||||
|
}
|
||||||
|
if (typeof chat.unreadCount === 'object') {
|
||||||
|
return (chat.unreadCount as Record<string, number>)[props.currentUserId] || 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLastMessagePreview = (chat: ChatWithUnread): string => {
|
||||||
|
if (!chat.lastMessage) return 'Nessun messaggio';
|
||||||
|
|
||||||
|
const text = chat.lastMessage.text || '';
|
||||||
|
const maxLength = 40;
|
||||||
|
|
||||||
|
if (text.length > maxLength) {
|
||||||
|
return text.substring(0, maxLength) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return text || getMessageTypePlaceholder(chat.lastMessage.type);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageTypePlaceholder = (type: MessageType): string => {
|
||||||
|
const placeholders: Record<MessageType, string> = {
|
||||||
|
text: '',
|
||||||
|
ride_share: '🚗 Viaggio condiviso',
|
||||||
|
location: '📍 Posizione',
|
||||||
|
image: '🖼️ Immagine',
|
||||||
|
voice: '🎤 Messaggio vocale',
|
||||||
|
system: 'ℹ️ Messaggio di sistema',
|
||||||
|
ride_request: '🙋 Richiesta passaggio',
|
||||||
|
ride_accepted: '✅ Richiesta accettata',
|
||||||
|
ride_rejected: '❌ Richiesta rifiutata'
|
||||||
|
};
|
||||||
|
return placeholders[type] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageTypeIcon = (type: MessageType): string => {
|
||||||
|
const icons: Record<MessageType, string> = {
|
||||||
|
text: '',
|
||||||
|
ride_share: 'directions_car',
|
||||||
|
location: 'location_on',
|
||||||
|
image: 'image',
|
||||||
|
voice: 'mic',
|
||||||
|
system: 'info',
|
||||||
|
ride_request: 'person_add',
|
||||||
|
ride_accepted: 'check_circle',
|
||||||
|
ride_rejected: 'cancel'
|
||||||
|
};
|
||||||
|
return icons[type] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRideInfo = (rideId: string | Ride): string => {
|
||||||
|
if (typeof rideId === 'object' && rideId) {
|
||||||
|
return `${rideId.departure?.city} → ${rideId.destination?.city}`;
|
||||||
|
}
|
||||||
|
return 'Viaggio collegato';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date | string): string => {
|
||||||
|
const d = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Ora';
|
||||||
|
if (diffMins < 60) return `${diffMins} min`;
|
||||||
|
if (diffHours < 24) return d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
if (diffDays === 1) return 'Ieri';
|
||||||
|
if (diffDays < 7) return d.toLocaleDateString('it-IT', { weekday: 'short' });
|
||||||
|
return d.toLocaleDateString('it-IT', { day: '2-digit', month: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectChat = (chat: ChatWithUnread) => {
|
||||||
|
emit('select', chat);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchQuery,
|
||||||
|
filteredChats,
|
||||||
|
totalUnread,
|
||||||
|
getOtherParticipant,
|
||||||
|
getDisplayName,
|
||||||
|
getInitials,
|
||||||
|
getUnreadCount,
|
||||||
|
getLastMessagePreview,
|
||||||
|
getMessageTypeIcon,
|
||||||
|
getRideInfo,
|
||||||
|
formatTime,
|
||||||
|
selectChat
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
133
src/modules/trasporti/components/chat/ChatList.vue
Normal file
133
src/modules/trasporti/components/chat/ChatList.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-list">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="chat-list__header">
|
||||||
|
<div class="chat-list__title">
|
||||||
|
<q-icon name="chat" size="24px" color="primary" />
|
||||||
|
<span>Messaggi</span>
|
||||||
|
<q-badge v-if="totalUnread > 0" color="negative" rounded>
|
||||||
|
{{ totalUnread }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-if="showSearch"
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Cerca conversazione..."
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
class="chat-list__search"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-icon
|
||||||
|
v-if="searchQuery"
|
||||||
|
name="close"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="searchQuery = ''"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="chat-list__loading">
|
||||||
|
<q-skeleton v-for="i in 4" :key="i" type="QItem" class="q-mb-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="filteredChats.length === 0" class="chat-list__empty">
|
||||||
|
<q-icon name="chat_bubble_outline" size="64px" color="grey-4" />
|
||||||
|
<span v-if="searchQuery">Nessun risultato per "{{ searchQuery }}"</span>
|
||||||
|
<span v-else>Nessuna conversazione</span>
|
||||||
|
<p class="text-caption text-grey">
|
||||||
|
Le conversazioni con i conducenti e passeggeri appariranno qui
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista Chat -->
|
||||||
|
<q-list v-else class="chat-list__items">
|
||||||
|
<q-item
|
||||||
|
v-for="chat in filteredChats"
|
||||||
|
:key="chat._id"
|
||||||
|
:class="[
|
||||||
|
'chat-list__item',
|
||||||
|
{ 'chat-list__item--unread': getUnreadCount(chat) > 0 },
|
||||||
|
{ 'chat-list__item--active': activeChat === chat._id }
|
||||||
|
]"
|
||||||
|
clickable
|
||||||
|
v-ripple
|
||||||
|
@click="selectChat(chat)"
|
||||||
|
>
|
||||||
|
<!-- Avatar -->
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar size="48px" class="chat-list__avatar">
|
||||||
|
<img
|
||||||
|
v-if="getOtherParticipant(chat)?.profile?.img"
|
||||||
|
:src="getOtherParticipant(chat).profile.img"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ getInitials(getOtherParticipant(chat)) }}</span>
|
||||||
|
|
||||||
|
<!-- Online indicator (placeholder) -->
|
||||||
|
<q-badge
|
||||||
|
v-if="false"
|
||||||
|
floating
|
||||||
|
rounded
|
||||||
|
color="positive"
|
||||||
|
class="chat-list__online-badge"
|
||||||
|
/>
|
||||||
|
</q-avatar>
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="chat-list__name">
|
||||||
|
{{ getDisplayName(getOtherParticipant(chat)) }}
|
||||||
|
</q-item-label>
|
||||||
|
|
||||||
|
<q-item-label caption class="chat-list__preview">
|
||||||
|
<!-- Icona tipo messaggio -->
|
||||||
|
<q-icon
|
||||||
|
v-if="chat.lastMessage?.type && chat.lastMessage.type !== 'text'"
|
||||||
|
:name="getMessageTypeIcon(chat.lastMessage.type)"
|
||||||
|
size="14px"
|
||||||
|
class="q-mr-xs"
|
||||||
|
/>
|
||||||
|
{{ getLastMessagePreview(chat) }}
|
||||||
|
</q-item-label>
|
||||||
|
|
||||||
|
<!-- Info viaggio collegato -->
|
||||||
|
<q-item-label
|
||||||
|
v-if="chat.rideId"
|
||||||
|
caption
|
||||||
|
class="chat-list__ride-info"
|
||||||
|
>
|
||||||
|
<q-icon name="directions_car" size="12px" />
|
||||||
|
{{ getRideInfo(chat.rideId) }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<!-- Side info -->
|
||||||
|
<q-item-section side>
|
||||||
|
<q-item-label caption class="chat-list__time">
|
||||||
|
{{ formatTime(chat.lastMessage?.timestamp || chat.updatedAt) }}
|
||||||
|
</q-item-label>
|
||||||
|
|
||||||
|
<q-badge
|
||||||
|
v-if="getUnreadCount(chat) > 0"
|
||||||
|
color="primary"
|
||||||
|
rounded
|
||||||
|
class="chat-list__unread-badge"
|
||||||
|
>
|
||||||
|
{{ getUnreadCount(chat) > 99 ? '99+' : getUnreadCount(chat) }}
|
||||||
|
</q-badge>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./ChatList.ts" />
|
||||||
|
<style lang="scss" src="./ChatList.scss" />
|
||||||
159
src/modules/trasporti/components/chat/ChatWindow.scss
Normal file
159
src/modules/trasporti/components/chat/ChatWindow.scss
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
.chat-window {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: #f5f5f5;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
background: linear-gradient(135deg, var(--q-primary), var(--q-secondary));
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-ride {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--q-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
max-width: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date-separator {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__scroll-btn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__blocked {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(var(--q-negative-rgb), 0.1);
|
||||||
|
color: var(--q-negative);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fade animation
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.chat-window {
|
||||||
|
background: #121212;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date-separator span {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.chat-window {
|
||||||
|
&__header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
290
src/modules/trasporti/components/chat/ChatWindow.ts
Normal file
290
src/modules/trasporti/components/chat/ChatWindow.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import {
|
||||||
|
ref,
|
||||||
|
computed,
|
||||||
|
watch,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
defineComponent,
|
||||||
|
PropType
|
||||||
|
} from 'vue';
|
||||||
|
import MessageBubble from './MessageBubble.vue';
|
||||||
|
import ChatInput from './ChatInput.vue';
|
||||||
|
import type { Chat, Message, UserBasic, Ride, Coordinates } from '../../types';
|
||||||
|
|
||||||
|
interface MessageGroup {
|
||||||
|
date: string;
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'ChatWindow',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
MessageBubble,
|
||||||
|
ChatInput
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
chat: {
|
||||||
|
type: Object as PropType<Chat | null>,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
type: Array as PropType<Message[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
loadingMore: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
sending: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
hasMoreMessages: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showBackButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: [
|
||||||
|
'back',
|
||||||
|
'send',
|
||||||
|
'delete',
|
||||||
|
'load-more',
|
||||||
|
'user-click',
|
||||||
|
'view-profile',
|
||||||
|
'view-ride',
|
||||||
|
'share-ride',
|
||||||
|
'block'
|
||||||
|
],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const messagesContainer = ref<HTMLElement | null>(null);
|
||||||
|
const replyTo = ref<Message | null>(null);
|
||||||
|
const showScrollButton = ref(false);
|
||||||
|
const newMessagesCount = ref(0);
|
||||||
|
const isAtBottom = ref(true);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const otherUser = computed(() => {
|
||||||
|
if (!props.chat) return null;
|
||||||
|
|
||||||
|
if ((props.chat as any).otherParticipant) {
|
||||||
|
return (props.chat as any).otherParticipant;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.chat.participants) {
|
||||||
|
const other = props.chat.participants.find(p => {
|
||||||
|
const id = typeof p === 'string' ? p : (p as UserBasic)._id;
|
||||||
|
return id !== props.currentUserId;
|
||||||
|
});
|
||||||
|
return typeof other === 'object' ? other : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const userName = computed(() => {
|
||||||
|
if (!otherUser.value) return 'Utente';
|
||||||
|
const user = otherUser.value as UserBasic & { profile?: { img?: string } };
|
||||||
|
if (user.name) {
|
||||||
|
return `${user.name} ${user.surname?.[0] || ''}`.trim();
|
||||||
|
}
|
||||||
|
return user.username || 'Utente';
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
return userName.value
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rideInfo = computed(() => {
|
||||||
|
if (!props.chat?.rideId) return null;
|
||||||
|
|
||||||
|
const ride = props.chat.rideId as Ride;
|
||||||
|
if (typeof ride === 'object' && ride.departure && ride.destination) {
|
||||||
|
return `${ride.departure.city} → ${ride.destination.city}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBlocked = computed(() => {
|
||||||
|
if (!props.chat) return false;
|
||||||
|
return props.chat.blockedBy?.includes(props.currentUserId) || false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedMessages = computed((): MessageGroup[] => {
|
||||||
|
const groups: MessageGroup[] = [];
|
||||||
|
let currentDate = '';
|
||||||
|
|
||||||
|
const sortedMessages = [...props.messages].sort((a, b) =>
|
||||||
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
sortedMessages.forEach(message => {
|
||||||
|
const msgDate = formatMessageDate(message.createdAt);
|
||||||
|
|
||||||
|
if (msgDate !== currentDate) {
|
||||||
|
currentDate = msgDate;
|
||||||
|
groups.push({ date: msgDate, messages: [message] });
|
||||||
|
} else {
|
||||||
|
groups[groups.length - 1].messages.push(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const formatMessageDate = (date: Date | string): 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 'Oggi';
|
||||||
|
if (diffDays === 1) return 'Ieri';
|
||||||
|
|
||||||
|
return d.toLocaleDateString('it-IT', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOwnMessage = (message: Message): boolean => {
|
||||||
|
const senderId = typeof message.senderId === 'string'
|
||||||
|
? message.senderId
|
||||||
|
: (message.senderId as UserBasic)?._id;
|
||||||
|
return senderId === props.currentUserId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldShowAvatar = (messages: Message[], index: number): boolean => {
|
||||||
|
if (index === 0) return true;
|
||||||
|
|
||||||
|
const currentMsg = messages[index];
|
||||||
|
const prevMsg = messages[index - 1];
|
||||||
|
|
||||||
|
const currentSenderId = typeof currentMsg.senderId === 'string'
|
||||||
|
? currentMsg.senderId
|
||||||
|
: (currentMsg.senderId as UserBasic)?._id;
|
||||||
|
const prevSenderId = typeof prevMsg.senderId === 'string'
|
||||||
|
? prevMsg.senderId
|
||||||
|
: (prevMsg.senderId as UserBasic)?._id;
|
||||||
|
|
||||||
|
return currentSenderId !== prevSenderId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReplyMessage = (replyToId?: string | Message): Message | null => {
|
||||||
|
if (!replyToId) return null;
|
||||||
|
|
||||||
|
if (typeof replyToId === 'object') {
|
||||||
|
return replyToId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.messages.find(m => m._id === replyToId) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReplyTo = (message: Message) => {
|
||||||
|
replyTo.value = message;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = (data: { text: string; replyTo?: string }) => {
|
||||||
|
emit('send', {
|
||||||
|
text: data.text,
|
||||||
|
replyTo: replyTo.value?._id
|
||||||
|
});
|
||||||
|
replyTo.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteMessage = (message: Message) => {
|
||||||
|
emit('delete', message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareLocation = (coords: Coordinates) => {
|
||||||
|
emit('send', {
|
||||||
|
text: '',
|
||||||
|
type: 'location',
|
||||||
|
metadata: {
|
||||||
|
location: coords
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToBottom = (smooth = true) => {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTo({
|
||||||
|
top: messagesContainer.value.scrollHeight,
|
||||||
|
behavior: smooth ? 'smooth' : 'auto'
|
||||||
|
});
|
||||||
|
newMessagesCount.value = 0;
|
||||||
|
showScrollButton.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!messagesContainer.value) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value;
|
||||||
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
|
|
||||||
|
isAtBottom.value = distanceFromBottom < 100;
|
||||||
|
showScrollButton.value = distanceFromBottom > 300;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for new messages
|
||||||
|
watch(() => props.messages.length, (newLength, oldLength) => {
|
||||||
|
if (newLength > oldLength) {
|
||||||
|
if (isAtBottom.value) {
|
||||||
|
nextTick(() => scrollToBottom(true));
|
||||||
|
} else {
|
||||||
|
newMessagesCount.value += newLength - oldLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial scroll to bottom
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => scrollToBottom(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
messagesContainer,
|
||||||
|
replyTo,
|
||||||
|
showScrollButton,
|
||||||
|
newMessagesCount,
|
||||||
|
otherUser,
|
||||||
|
userName,
|
||||||
|
userInitials,
|
||||||
|
rideInfo,
|
||||||
|
isBlocked,
|
||||||
|
groupedMessages,
|
||||||
|
isOwnMessage,
|
||||||
|
shouldShowAvatar,
|
||||||
|
getReplyMessage,
|
||||||
|
setReplyTo,
|
||||||
|
sendMessage,
|
||||||
|
deleteMessage,
|
||||||
|
shareLocation,
|
||||||
|
scrollToBottom,
|
||||||
|
onScroll
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
148
src/modules/trasporti/components/chat/ChatWindow.vue
Normal file
148
src/modules/trasporti/components/chat/ChatWindow.vue
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-window">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="chat-window__header">
|
||||||
|
<q-btn
|
||||||
|
v-if="showBackButton"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="arrow_back"
|
||||||
|
@click="$emit('back')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-avatar size="40px" class="chat-window__avatar" @click="$emit('user-click', otherUser)">
|
||||||
|
<img v-if="otherUser?.profile?.img" :src="otherUser.profile.img" />
|
||||||
|
<span v-else>{{ userInitials }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
|
||||||
|
<div class="chat-window__header-info">
|
||||||
|
<span class="chat-window__header-name">{{ userName }}</span>
|
||||||
|
<span v-if="rideInfo" class="chat-window__header-ride">
|
||||||
|
<q-icon name="directions_car" size="12px" />
|
||||||
|
{{ rideInfo }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-space />
|
||||||
|
|
||||||
|
<q-btn flat round dense icon="more_vert">
|
||||||
|
<q-menu>
|
||||||
|
<q-list dense style="min-width: 180px">
|
||||||
|
<q-item clickable v-close-popup @click="$emit('view-profile', otherUser)">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="person" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Vedi profilo</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item v-if="chat?.rideId" clickable v-close-popup @click="$emit('view-ride', chat.rideId)">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="directions_car" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Vedi viaggio</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-separator />
|
||||||
|
<q-item clickable v-close-popup @click="$emit('block')">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="block" color="negative" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section class="text-negative">Blocca utente</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages area -->
|
||||||
|
<div
|
||||||
|
ref="messagesContainer"
|
||||||
|
class="chat-window__messages"
|
||||||
|
@scroll="onScroll"
|
||||||
|
>
|
||||||
|
<!-- Load more -->
|
||||||
|
<div v-if="hasMoreMessages" class="chat-window__load-more">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
label="Carica messaggi precedenti"
|
||||||
|
:loading="loadingMore"
|
||||||
|
@click="$emit('load-more')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="chat-window__loading">
|
||||||
|
<q-spinner color="primary" size="32px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="messages.length === 0" class="chat-window__empty">
|
||||||
|
<q-icon name="chat_bubble_outline" size="64px" color="grey-4" />
|
||||||
|
<span>Inizia la conversazione</span>
|
||||||
|
<p>Scrivi un messaggio per iniziare a chattare con {{ userName }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages grouped by date -->
|
||||||
|
<template v-else>
|
||||||
|
<template v-for="(group, groupIndex) in groupedMessages" :key="groupIndex">
|
||||||
|
<!-- Date separator -->
|
||||||
|
<div class="chat-window__date-separator">
|
||||||
|
<span>{{ group.date }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<MessageBubble
|
||||||
|
v-for="(message, msgIndex) in group.messages"
|
||||||
|
:key="message._id"
|
||||||
|
:message="message"
|
||||||
|
:is-own="isOwnMessage(message)"
|
||||||
|
:show-avatar="shouldShowAvatar(group.messages, msgIndex)"
|
||||||
|
:show-sender-name="chat?.type === 'group'"
|
||||||
|
:reply-to="getReplyMessage(message.replyTo)"
|
||||||
|
@reply="setReplyTo"
|
||||||
|
@delete="deleteMessage"
|
||||||
|
@ride-click="(id) => $emit('view-ride', id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Scroll to bottom button -->
|
||||||
|
<transition name="fade">
|
||||||
|
<q-btn
|
||||||
|
v-if="showScrollButton"
|
||||||
|
round
|
||||||
|
color="primary"
|
||||||
|
icon="keyboard_arrow_down"
|
||||||
|
size="sm"
|
||||||
|
class="chat-window__scroll-btn"
|
||||||
|
@click="scrollToBottom"
|
||||||
|
>
|
||||||
|
<q-badge v-if="newMessagesCount > 0" color="negative" floating rounded>
|
||||||
|
{{ newMessagesCount }}
|
||||||
|
</q-badge>
|
||||||
|
</q-btn>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<ChatInput
|
||||||
|
:reply-to="replyTo"
|
||||||
|
:sending="sending"
|
||||||
|
:disabled="isBlocked"
|
||||||
|
@send="sendMessage"
|
||||||
|
@cancel-reply="replyTo = null"
|
||||||
|
@share-location="shareLocation"
|
||||||
|
@share-ride="$emit('share-ride')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Blocked banner -->
|
||||||
|
<div v-if="isBlocked" class="chat-window__blocked">
|
||||||
|
<q-icon name="block" size="20px" />
|
||||||
|
<span>Questa conversazione è stata bloccata</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./ChatWindow.ts" />
|
||||||
|
<style lang="scss" src="./ChatWindow.scss" />
|
||||||
299
src/modules/trasporti/components/chat/MessageBubble.scss
Normal file
299
src/modules/trasporti/components/chat/MessageBubble.scss
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
.message-bubble {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&--own {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
|
.message-bubble__bubble {
|
||||||
|
background: linear-gradient(135deg, var(--q-primary), var(--q-primary-dark, #1565c0));
|
||||||
|
color: white;
|
||||||
|
border-radius: 18px 18px 4px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble__time {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble__footer {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble__reactions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--system {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__system {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
background: linear-gradient(135deg, var(--q-secondary), var(--q-primary));
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
max-width: 70%;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sender {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--q-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-bar {
|
||||||
|
width: 3px;
|
||||||
|
background: var(--q-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-sender {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--q-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bubble {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 18px 18px 18px 4px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-wrap: break-word;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__deleted {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__edited {
|
||||||
|
font-size: 10px;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--q-grey-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reaction {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__menu-btn {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover &__menu-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special messages
|
||||||
|
&__special {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
background: rgba(var(--q-positive-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
background: rgba(var(--q-negative-rgb), 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__special-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__special-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__special-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__location {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__location-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, #e8f5e9, #c8e6c9);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-share {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.message-bubble {
|
||||||
|
&__system {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bubble {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--own .message-bubble__bubble {
|
||||||
|
background: linear-gradient(135deg, var(--q-primary), #1565c0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-text {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reaction {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.message-bubble {
|
||||||
|
&__content {
|
||||||
|
max-width: 85%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/modules/trasporti/components/chat/MessageBubble.ts
Normal file
166
src/modules/trasporti/components/chat/MessageBubble.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { computed, defineComponent, PropType } from 'vue';
|
||||||
|
import { copyToClipboard } from 'quasar';
|
||||||
|
import type { Message, UserBasic } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'MessageBubble',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
type: Object as PropType<Message>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isOwn: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showAvatar: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showSenderName: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showActions: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
replyTo: {
|
||||||
|
type: Object as PropType<Message | null>,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['reply', 'delete', 'reply-click', 'reaction-click', 'ride-click'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
// Sender info
|
||||||
|
const sender = computed(() => {
|
||||||
|
if (typeof props.message.senderId === 'object') {
|
||||||
|
return props.message.senderId as UserBasic;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const senderName = computed(() => {
|
||||||
|
if (sender.value?.name) {
|
||||||
|
return `${sender.value.name} ${sender.value.surname?.[0] || ''}`.trim();
|
||||||
|
}
|
||||||
|
return sender.value?.username || 'Utente';
|
||||||
|
});
|
||||||
|
|
||||||
|
const senderImg = computed(() => {
|
||||||
|
return (sender.value as any)?.profile?.img;
|
||||||
|
});
|
||||||
|
|
||||||
|
const senderInitials = computed(() => {
|
||||||
|
return senderName.value
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reply info
|
||||||
|
const replyToSenderName = computed(() => {
|
||||||
|
if (!props.replyTo) return '';
|
||||||
|
const replySender = props.replyTo.senderId;
|
||||||
|
if (typeof replySender === 'object') {
|
||||||
|
return (replySender as UserBasic).name || (replySender as UserBasic).username || 'Utente';
|
||||||
|
}
|
||||||
|
return 'Utente';
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyToText = computed(() => {
|
||||||
|
if (!props.replyTo) return '';
|
||||||
|
const text = props.replyTo.text || '';
|
||||||
|
return text.length > 50 ? text.substring(0, 50) + '...' : text;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Formatted text with links
|
||||||
|
const formattedText = computed(() => {
|
||||||
|
if (!props.message.text) return '';
|
||||||
|
|
||||||
|
// Convert URLs to links
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g;
|
||||||
|
let text = props.message.text.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener">$1</a>');
|
||||||
|
|
||||||
|
// Convert newlines to <br>
|
||||||
|
text = text.replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
return text;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Time formatting
|
||||||
|
const formattedTime = computed(() => {
|
||||||
|
const date = new Date(props.message.createdAt);
|
||||||
|
return date.toLocaleTimeString('it-IT', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read status
|
||||||
|
const readStatusIcon = computed(() => {
|
||||||
|
if (!props.message.readBy || props.message.readBy.length === 0) {
|
||||||
|
return 'check'; // Inviato
|
||||||
|
}
|
||||||
|
return 'done_all'; // Letto
|
||||||
|
});
|
||||||
|
|
||||||
|
const readStatusColor = computed(() => {
|
||||||
|
if (!props.message.readBy || props.message.readBy.length === 0) {
|
||||||
|
return 'grey';
|
||||||
|
}
|
||||||
|
return 'primary';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Grouped reactions
|
||||||
|
const groupedReactions = computed(() => {
|
||||||
|
if (!props.message.reactions) return [];
|
||||||
|
|
||||||
|
const groups: Record<string, { emoji: string; count: number }> = {};
|
||||||
|
props.message.reactions.forEach(r => {
|
||||||
|
if (!groups[r.emoji]) {
|
||||||
|
groups[r.emoji] = { emoji: r.emoji, count: 0 };
|
||||||
|
}
|
||||||
|
groups[r.emoji].count++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.values(groups);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const copyText = async () => {
|
||||||
|
if (props.message.text) {
|
||||||
|
await copyToClipboard(props.message.text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openLocation = () => {
|
||||||
|
const location = props.message.metadata?.location;
|
||||||
|
if (location) {
|
||||||
|
const url = `https://www.openstreetmap.org/?mlat=${location.lat}&mlon=${location.lng}#map=15/${location.lat}/${location.lng}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
sender,
|
||||||
|
senderName,
|
||||||
|
senderImg,
|
||||||
|
senderInitials,
|
||||||
|
replyToSenderName,
|
||||||
|
replyToText,
|
||||||
|
formattedText,
|
||||||
|
formattedTime,
|
||||||
|
readStatusIcon,
|
||||||
|
readStatusColor,
|
||||||
|
groupedReactions,
|
||||||
|
copyText,
|
||||||
|
openLocation
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
194
src/modules/trasporti/components/chat/MessageBubble.vue
Normal file
194
src/modules/trasporti/components/chat/MessageBubble.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'message-bubble',
|
||||||
|
{ 'message-bubble--own': isOwn },
|
||||||
|
{ 'message-bubble--system': message.type === 'system' },
|
||||||
|
`message-bubble--${message.type}`
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- System message -->
|
||||||
|
<div v-if="message.type === 'system'" class="message-bubble__system">
|
||||||
|
<q-icon name="info" size="16px" />
|
||||||
|
<span>{{ message.text }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Regular message -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Avatar (solo per messaggi non propri) -->
|
||||||
|
<q-avatar
|
||||||
|
v-if="!isOwn && showAvatar"
|
||||||
|
size="32px"
|
||||||
|
class="message-bubble__avatar"
|
||||||
|
>
|
||||||
|
<img v-if="senderImg" :src="senderImg" />
|
||||||
|
<span v-else>{{ senderInitials }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
|
||||||
|
<div class="message-bubble__content">
|
||||||
|
<!-- Nome mittente (per chat di gruppo) -->
|
||||||
|
<span v-if="!isOwn && showSenderName" class="message-bubble__sender">
|
||||||
|
{{ senderName }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Reply preview -->
|
||||||
|
<div v-if="replyTo" class="message-bubble__reply" @click="$emit('reply-click', replyTo)">
|
||||||
|
<div class="message-bubble__reply-bar"></div>
|
||||||
|
<div class="message-bubble__reply-content">
|
||||||
|
<span class="message-bubble__reply-sender">{{ replyToSenderName }}</span>
|
||||||
|
<span class="message-bubble__reply-text">{{ replyToText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bubble container -->
|
||||||
|
<div class="message-bubble__bubble">
|
||||||
|
<!-- Messaggio speciale: Ride Request -->
|
||||||
|
<div v-if="message.type === 'ride_request'" class="message-bubble__special">
|
||||||
|
<q-icon name="directions_car" size="24px" color="primary" />
|
||||||
|
<div class="message-bubble__special-content">
|
||||||
|
<span class="message-bubble__special-title">Richiesta Passaggio</span>
|
||||||
|
<span class="message-bubble__special-text">{{ message.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messaggio speciale: Ride Accepted -->
|
||||||
|
<div v-else-if="message.type === 'ride_accepted'" class="message-bubble__special message-bubble__special--success">
|
||||||
|
<q-icon name="check_circle" size="24px" color="positive" />
|
||||||
|
<div class="message-bubble__special-content">
|
||||||
|
<span class="message-bubble__special-title">Richiesta Accettata!</span>
|
||||||
|
<span class="message-bubble__special-text">{{ message.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messaggio speciale: Ride Rejected -->
|
||||||
|
<div v-else-if="message.type === 'ride_rejected'" class="message-bubble__special message-bubble__special--error">
|
||||||
|
<q-icon name="cancel" size="24px" color="negative" />
|
||||||
|
<div class="message-bubble__special-content">
|
||||||
|
<span class="message-bubble__special-title">Richiesta Non Accettata</span>
|
||||||
|
<span class="message-bubble__special-text">{{ message.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messaggio speciale: Location -->
|
||||||
|
<div v-else-if="message.type === 'location'" class="message-bubble__location">
|
||||||
|
<div class="message-bubble__location-preview">
|
||||||
|
<q-icon name="place" size="32px" color="negative" />
|
||||||
|
</div>
|
||||||
|
<span>{{ message.metadata?.location?.address || 'Posizione condivisa' }}</span>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
label="Apri mappa"
|
||||||
|
icon-right="open_in_new"
|
||||||
|
@click="openLocation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messaggio speciale: Ride Share -->
|
||||||
|
<div v-else-if="message.type === 'ride_share'" class="message-bubble__ride-share">
|
||||||
|
<q-icon name="directions_car" size="20px" />
|
||||||
|
<span>Ha condiviso un viaggio</span>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
label="Vedi viaggio"
|
||||||
|
@click="$emit('ride-click', message.metadata?.rideId)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messaggio testo normale -->
|
||||||
|
<template v-else>
|
||||||
|
<p
|
||||||
|
v-if="message.text && !message.isDeleted"
|
||||||
|
class="message-bubble__text"
|
||||||
|
v-html="formattedText"
|
||||||
|
></p>
|
||||||
|
<p v-else-if="message.isDeleted" class="message-bubble__deleted">
|
||||||
|
<q-icon name="block" size="14px" />
|
||||||
|
Messaggio eliminato
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Footer: ora + stato -->
|
||||||
|
<div class="message-bubble__footer">
|
||||||
|
<span class="message-bubble__time">{{ formattedTime }}</span>
|
||||||
|
|
||||||
|
<!-- Edited indicator -->
|
||||||
|
<span v-if="message.isEdited" class="message-bubble__edited">
|
||||||
|
modificato
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Read status (solo propri messaggi) -->
|
||||||
|
<q-icon
|
||||||
|
v-if="isOwn && !message.isDeleted"
|
||||||
|
:name="readStatusIcon"
|
||||||
|
:color="readStatusColor"
|
||||||
|
size="16px"
|
||||||
|
class="message-bubble__status"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reactions -->
|
||||||
|
<div v-if="message.reactions?.length" class="message-bubble__reactions">
|
||||||
|
<span
|
||||||
|
v-for="(reaction, index) in groupedReactions"
|
||||||
|
:key="index"
|
||||||
|
class="message-bubble__reaction"
|
||||||
|
@click="$emit('reaction-click', reaction.emoji)"
|
||||||
|
>
|
||||||
|
{{ reaction.emoji }} {{ reaction.count > 1 ? reaction.count : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Menu azioni -->
|
||||||
|
<q-btn
|
||||||
|
v-if="showActions && !message.isDeleted"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="more_vert"
|
||||||
|
size="sm"
|
||||||
|
class="message-bubble__menu-btn"
|
||||||
|
>
|
||||||
|
<q-menu>
|
||||||
|
<q-list dense style="min-width: 150px">
|
||||||
|
<q-item clickable v-close-popup @click="$emit('reply', message)">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="reply" size="20px" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Rispondi</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-close-popup @click="copyText">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="content_copy" size="20px" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Copia</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-if="isOwn"
|
||||||
|
clickable
|
||||||
|
v-close-popup
|
||||||
|
@click="$emit('delete', message)"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="delete" size="20px" color="negative" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section class="text-negative">Elimina</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./MessageBubble.ts" />
|
||||||
|
<style lang="scss" src="./MessageBubble.scss" />
|
||||||
5
src/modules/trasporti/components/chat/index.ts
Normal file
5
src/modules/trasporti/components/chat/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Export all chat components
|
||||||
|
export { default as ChatList } from './ChatList.vue';
|
||||||
|
export { default as ChatWindow } from './ChatWindow.vue';
|
||||||
|
export { default as MessageBubble } from './MessageBubble.vue';
|
||||||
|
export { default as ChatInput } from './ChatInput.vue';
|
||||||
225
src/modules/trasporti/components/feedback/FeedbackCard.scss
Normal file
225
src/modules/trasporti/components/feedback/FeedbackCard.scss
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
.feedback-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover .feedback-card__user-name {
|
||||||
|
color: var(--q-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
background: linear-gradient(135deg, var(--q-primary), var(--q-secondary));
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__role {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-value {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-date {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__comment {
|
||||||
|
padding-top: 0;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--q-grey-8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__categories-expand {
|
||||||
|
:deep(.q-expansion-item__container) {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__categories {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category-label {
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ffc107, #ff9800);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category-value {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__response {
|
||||||
|
margin: 0 16px 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-left: 3px solid var(--q-primary);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__response-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--q-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__response p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__verified {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.feedback-card {
|
||||||
|
&__ride {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__comment p,
|
||||||
|
&__response p {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__response {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category-bar {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__categories-expand {
|
||||||
|
:deep(.q-expansion-item__container) {
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/modules/trasporti/components/feedback/FeedbackCard.ts
Normal file
166
src/modules/trasporti/components/feedback/FeedbackCard.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { ref, computed, defineComponent, PropType } from 'vue';
|
||||||
|
import type { Feedback, FeedbackTag, FeedbackCategories, UserBasic, Ride } from '../../types';
|
||||||
|
import { FEEDBACK_TAGS_OPTIONS } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'FeedbackCard',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
feedback: {
|
||||||
|
type: Object as PropType<Feedback>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
canRespond: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['user-click', 'respond', 'report', 'helpful'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const isHelpful = ref(false);
|
||||||
|
|
||||||
|
// User computed
|
||||||
|
const fromUser = computed(() => {
|
||||||
|
if (typeof props.feedback.fromUserId === 'object') {
|
||||||
|
return props.feedback.fromUserId as UserBasic;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const userName = computed(() => {
|
||||||
|
if (fromUser.value?.name) {
|
||||||
|
return `${fromUser.value.name} ${fromUser.value.surname?.[0] || ''}`.trim();
|
||||||
|
}
|
||||||
|
return fromUser.value?.username || 'Utente';
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
return userName.value
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const userImg = computed(() => {
|
||||||
|
return (fromUser.value as any)?.profile?.img;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Date
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
const date = new Date(props.feedback.createdAt);
|
||||||
|
return date.toLocaleDateString('it-IT', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ride info
|
||||||
|
const ride = computed(() => {
|
||||||
|
if (typeof props.feedback.rideId === 'object') {
|
||||||
|
return props.feedback.rideId as Ride;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rideInfo = computed(() => {
|
||||||
|
if (!ride.value) return null;
|
||||||
|
return `${ride.value.departure?.city} → ${ride.value.destination?.city}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rideDate = computed(() => {
|
||||||
|
if (!ride.value?.dateTime) return '';
|
||||||
|
const date = new Date(ride.value.dateTime);
|
||||||
|
return date.toLocaleDateString('it-IT', { day: 'numeric', month: 'short' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
const hasCategories = computed(() => {
|
||||||
|
if (!props.feedback.categories) return false;
|
||||||
|
return Object.values(props.feedback.categories).some(v => v && v > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredCategories = computed(() => {
|
||||||
|
if (!props.feedback.categories) return {};
|
||||||
|
const filtered: Partial<FeedbackCategories> = {};
|
||||||
|
Object.entries(props.feedback.categories).forEach(([key, value]) => {
|
||||||
|
if (value && value > 0) {
|
||||||
|
(filtered as any)[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getCategoryIcon = (key: string): string => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
punctuality: '⏰',
|
||||||
|
cleanliness: '✨',
|
||||||
|
communication: '💬',
|
||||||
|
driving: '🚗',
|
||||||
|
respect: '🙏',
|
||||||
|
reliability: '💯'
|
||||||
|
};
|
||||||
|
return icons[key] || '📊';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryLabel = (key: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
punctuality: 'Puntualità',
|
||||||
|
cleanliness: 'Pulizia',
|
||||||
|
communication: 'Comunicazione',
|
||||||
|
driving: 'Guida',
|
||||||
|
respect: 'Rispetto',
|
||||||
|
reliability: 'Affidabilità'
|
||||||
|
};
|
||||||
|
return labels[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
const isPositiveTag = (tag: FeedbackTag): boolean => {
|
||||||
|
const option = FEEDBACK_TAGS_OPTIONS.find(t => t.value === tag);
|
||||||
|
return option?.isPositive ?? true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTagIcon = (tag: FeedbackTag): string => {
|
||||||
|
const option = FEEDBACK_TAGS_OPTIONS.find(t => t.value === tag);
|
||||||
|
return option?.icon || '📝';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTagLabel = (tag: FeedbackTag): string => {
|
||||||
|
const option = FEEDBACK_TAGS_OPTIONS.find(t => t.value === tag);
|
||||||
|
return option?.label || tag;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helpful
|
||||||
|
const toggleHelpful = () => {
|
||||||
|
isHelpful.value = !isHelpful.value;
|
||||||
|
emit('helpful', props.feedback._id, isHelpful.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isHelpful,
|
||||||
|
userName,
|
||||||
|
userInitials,
|
||||||
|
userImg,
|
||||||
|
formattedDate,
|
||||||
|
rideInfo,
|
||||||
|
rideDate,
|
||||||
|
hasCategories,
|
||||||
|
filteredCategories,
|
||||||
|
getCategoryIcon,
|
||||||
|
getCategoryLabel,
|
||||||
|
isPositiveTag,
|
||||||
|
getTagIcon,
|
||||||
|
getTagLabel,
|
||||||
|
toggleHelpful
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
158
src/modules/trasporti/components/feedback/FeedbackCard.vue
Normal file
158
src/modules/trasporti/components/feedback/FeedbackCard.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<q-card class="feedback-card" flat bordered>
|
||||||
|
<!-- Header -->
|
||||||
|
<q-card-section class="feedback-card__header">
|
||||||
|
<div class="feedback-card__user" @click="$emit('user-click', feedback.fromUserId)">
|
||||||
|
<q-avatar size="44px" class="feedback-card__avatar">
|
||||||
|
<img v-if="userImg" :src="userImg" />
|
||||||
|
<span v-else>{{ userInitials }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
<div class="feedback-card__user-info">
|
||||||
|
<span class="feedback-card__user-name">{{ userName }}</span>
|
||||||
|
<div class="feedback-card__meta">
|
||||||
|
<span class="feedback-card__role">
|
||||||
|
{{ feedback.role === 'driver' ? 'come conducente' : 'come passeggero' }}
|
||||||
|
</span>
|
||||||
|
<span class="feedback-card__date">• {{ formattedDate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rating -->
|
||||||
|
<div class="feedback-card__rating">
|
||||||
|
<div class="feedback-card__stars">
|
||||||
|
<q-icon
|
||||||
|
v-for="star in 5"
|
||||||
|
:key="star"
|
||||||
|
:name="star <= feedback.rating ? 'star' : 'star_outline'"
|
||||||
|
:color="star <= feedback.rating ? 'amber' : 'grey-4'"
|
||||||
|
size="20px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="feedback-card__rating-value">{{ feedback.rating }}.0</span>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Ride info -->
|
||||||
|
<div v-if="rideInfo" class="feedback-card__ride">
|
||||||
|
<q-icon name="directions_car" size="14px" />
|
||||||
|
<span>{{ rideInfo }}</span>
|
||||||
|
<span class="feedback-card__ride-date">{{ rideDate }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div v-if="feedback.tags?.length" class="feedback-card__tags">
|
||||||
|
<q-chip
|
||||||
|
v-for="tag in feedback.tags"
|
||||||
|
:key="tag"
|
||||||
|
:color="isPositiveTag(tag) ? 'positive' : 'negative'"
|
||||||
|
text-color="white"
|
||||||
|
size="sm"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
{{ getTagIcon(tag) }} {{ getTagLabel(tag) }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment -->
|
||||||
|
<q-card-section v-if="feedback.comment" class="feedback-card__comment">
|
||||||
|
<p>{{ feedback.comment }}</p>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Categories breakdown -->
|
||||||
|
<q-expansion-item
|
||||||
|
v-if="hasCategories"
|
||||||
|
dense
|
||||||
|
label="Vedi dettagli valutazione"
|
||||||
|
header-class="text-primary"
|
||||||
|
class="feedback-card__categories-expand"
|
||||||
|
>
|
||||||
|
<div class="feedback-card__categories">
|
||||||
|
<div
|
||||||
|
v-for="(value, key) in filteredCategories"
|
||||||
|
:key="key"
|
||||||
|
class="feedback-card__category"
|
||||||
|
>
|
||||||
|
<span class="feedback-card__category-label">
|
||||||
|
{{ getCategoryIcon(key) }} {{ getCategoryLabel(key) }}
|
||||||
|
</span>
|
||||||
|
<div class="feedback-card__category-bar">
|
||||||
|
<div
|
||||||
|
class="feedback-card__category-fill"
|
||||||
|
:style="{ width: `${(value / 5) * 100}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="feedback-card__category-value">{{ value }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-expansion-item>
|
||||||
|
|
||||||
|
<!-- Response -->
|
||||||
|
<div v-if="feedback.response?.text" class="feedback-card__response">
|
||||||
|
<div class="feedback-card__response-header">
|
||||||
|
<q-icon name="reply" size="16px" />
|
||||||
|
<span>Risposta del {{ feedback.role === 'driver' ? 'conducente' : 'passeggero' }}</span>
|
||||||
|
</div>
|
||||||
|
<p>{{ feedback.response.text }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<q-card-section class="feedback-card__footer">
|
||||||
|
<div class="feedback-card__helpful">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
:color="isHelpful ? 'primary' : 'grey'"
|
||||||
|
@click="toggleHelpful"
|
||||||
|
>
|
||||||
|
<q-icon name="thumb_up" size="16px" class="q-mr-xs" />
|
||||||
|
Utile {{ feedback.helpful?.count ? `(${feedback.helpful.count})` : '' }}
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feedback-card__actions">
|
||||||
|
<q-btn
|
||||||
|
v-if="canRespond"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
label="Rispondi"
|
||||||
|
@click="$emit('respond')"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
icon="more_vert"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<q-menu>
|
||||||
|
<q-list dense style="min-width: 150px">
|
||||||
|
<q-item clickable v-close-popup @click="$emit('report', feedback)">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="flag" size="20px" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Segnala</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Verified badge -->
|
||||||
|
<q-badge
|
||||||
|
v-if="feedback.isVerified"
|
||||||
|
color="positive"
|
||||||
|
class="feedback-card__verified"
|
||||||
|
>
|
||||||
|
<q-icon name="verified" size="12px" class="q-mr-xs" />
|
||||||
|
Verificato
|
||||||
|
</q-badge>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./FeedbackCard.ts" />
|
||||||
|
<style lang="scss" src="./FeedbackCard.scss" />
|
||||||
224
src/modules/trasporti/components/feedback/FeedbackForm.scss
Normal file
224
src/modules/trasporti/components/feedback/FeedbackForm.scss
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
.feedback-form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
background: linear-gradient(135deg, var(--q-primary), var(--q-secondary));
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__role {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--q-grey-8);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main rating
|
||||||
|
&__main-rating {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stars {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__star {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: #e0e0e0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--active,
|
||||||
|
&--hover {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-icon {
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(255, 193, 7, 0.3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-label {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
&__categories {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category-label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
&__tags {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tags-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment
|
||||||
|
&__comment {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
:deep(.q-field__control) {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pros/Cons
|
||||||
|
&__pros-cons {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility
|
||||||
|
&__visibility {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand animation
|
||||||
|
.expand-enter-active,
|
||||||
|
.expand-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-enter-from,
|
||||||
|
.expand-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.feedback-form {
|
||||||
|
&__categories {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category {
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__star {
|
||||||
|
color: #424242;
|
||||||
|
|
||||||
|
&--active,
|
||||||
|
&--hover {
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__visibility {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
249
src/modules/trasporti/components/feedback/FeedbackForm.ts
Normal file
249
src/modules/trasporti/components/feedback/FeedbackForm.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { ref, reactive, computed, watch, defineComponent, PropType } from 'vue';
|
||||||
|
import type {
|
||||||
|
FeedbackFormData,
|
||||||
|
FeedbackRole,
|
||||||
|
FeedbackTag,
|
||||||
|
FeedbackCategories,
|
||||||
|
UserBasic,
|
||||||
|
Ride
|
||||||
|
} from '../../types';
|
||||||
|
import { FEEDBACK_TAGS_OPTIONS } from '../../types';
|
||||||
|
|
||||||
|
interface LocalFeedback {
|
||||||
|
rating: number;
|
||||||
|
categories: FeedbackCategories;
|
||||||
|
comment: string;
|
||||||
|
pros: string[];
|
||||||
|
cons: string[];
|
||||||
|
tags: FeedbackTag[];
|
||||||
|
isPublic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'FeedbackForm',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
rideId: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
toUser: {
|
||||||
|
type: Object as PropType<UserBasic | null>,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
type: String as PropType<FeedbackRole>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
ride: {
|
||||||
|
type: Object as PropType<Ride | null>,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
submitting: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showCancel: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showProsCons: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
submitLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'Invia Recensione'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['submit', 'cancel'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
// State
|
||||||
|
const hoverRating = ref(0);
|
||||||
|
const newPro = ref('');
|
||||||
|
const newCon = ref('');
|
||||||
|
|
||||||
|
const localFeedback = reactive<LocalFeedback>({
|
||||||
|
rating: 0,
|
||||||
|
categories: {
|
||||||
|
punctuality: 0,
|
||||||
|
cleanliness: 0,
|
||||||
|
communication: 0,
|
||||||
|
driving: 0,
|
||||||
|
respect: 0,
|
||||||
|
reliability: 0
|
||||||
|
},
|
||||||
|
comment: '',
|
||||||
|
pros: [],
|
||||||
|
cons: [],
|
||||||
|
tags: [],
|
||||||
|
isPublic: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Categories based on role
|
||||||
|
const allCategories = [
|
||||||
|
{ key: 'punctuality', label: 'Puntualità', icon: '⏰', roles: ['driver', 'passenger'] },
|
||||||
|
{ key: 'communication', label: 'Comunicazione', icon: '💬', roles: ['driver', 'passenger'] },
|
||||||
|
{ key: 'respect', label: 'Rispetto', icon: '🙏', roles: ['driver', 'passenger'] },
|
||||||
|
{ key: 'reliability', label: 'Affidabilità', icon: '💯', roles: ['driver', 'passenger'] },
|
||||||
|
{ key: 'cleanliness', label: 'Pulizia auto', icon: '✨', roles: ['driver'] },
|
||||||
|
{ key: 'driving', label: 'Guida', icon: '🚗', roles: ['driver'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const visibleCategories = computed(() => {
|
||||||
|
return allCategories.filter(cat => cat.roles.includes(props.role));
|
||||||
|
});
|
||||||
|
|
||||||
|
const relevantTags = computed(() => {
|
||||||
|
const isPositive = localFeedback.rating >= 4;
|
||||||
|
return FEEDBACK_TAGS_OPTIONS.filter(tag => tag.isPositive === isPositive);
|
||||||
|
});
|
||||||
|
|
||||||
|
const userName = computed(() => {
|
||||||
|
if (!props.toUser) return 'Utente';
|
||||||
|
if (props.toUser.name) {
|
||||||
|
return `${props.toUser.name} ${props.toUser.surname || ''}`.trim();
|
||||||
|
}
|
||||||
|
return props.toUser.username || 'Utente';
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
return userName.value
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const userImg = computed(() => {
|
||||||
|
return (props.toUser as any)?.profile?.img;
|
||||||
|
});
|
||||||
|
|
||||||
|
const rideInfo = computed(() => {
|
||||||
|
if (!props.ride) return null;
|
||||||
|
return `${props.ride.departure?.city} → ${props.ride.destination?.city}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ratingLabel = computed(() => {
|
||||||
|
const rating = hoverRating.value || localFeedback.rating;
|
||||||
|
if (rating === 0) return 'Seleziona una valutazione';
|
||||||
|
if (rating === 1) return '😞 Pessimo';
|
||||||
|
if (rating === 2) return '😕 Scarso';
|
||||||
|
if (rating === 3) return '😐 Nella media';
|
||||||
|
if (rating === 4) return '😊 Buono';
|
||||||
|
return '🤩 Eccellente!';
|
||||||
|
});
|
||||||
|
|
||||||
|
const commentPlaceholder = computed(() => {
|
||||||
|
if (localFeedback.rating >= 4) {
|
||||||
|
return 'Racconta cosa ti è piaciuto del viaggio...';
|
||||||
|
}
|
||||||
|
return 'Racconta cosa si potrebbe migliorare...';
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
return localFeedback.rating > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const setRating = (rating: number) => {
|
||||||
|
localFeedback.rating = rating;
|
||||||
|
|
||||||
|
// Reset tags quando cambia il rating
|
||||||
|
if ((rating >= 4) !== (localFeedback.tags.some(t =>
|
||||||
|
FEEDBACK_TAGS_OPTIONS.find(opt => opt.value === t)?.isPositive
|
||||||
|
))) {
|
||||||
|
localFeedback.tags = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = (tag: FeedbackTag) => {
|
||||||
|
const index = localFeedback.tags.indexOf(tag);
|
||||||
|
if (index === -1) {
|
||||||
|
localFeedback.tags.push(tag);
|
||||||
|
} else {
|
||||||
|
localFeedback.tags.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPro = () => {
|
||||||
|
if (newPro.value.trim()) {
|
||||||
|
localFeedback.pros.push(newPro.value.trim());
|
||||||
|
newPro.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePro = (index: number) => {
|
||||||
|
localFeedback.pros.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCon = () => {
|
||||||
|
if (newCon.value.trim()) {
|
||||||
|
localFeedback.cons.push(newCon.value.trim());
|
||||||
|
newCon.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCon = (index: number) => {
|
||||||
|
localFeedback.cons.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (!canSubmit.value) return;
|
||||||
|
|
||||||
|
const feedbackData: FeedbackFormData = {
|
||||||
|
rideId: props.rideId,
|
||||||
|
toUserId: props.toUser?._id || '',
|
||||||
|
role: props.role,
|
||||||
|
rating: localFeedback.rating,
|
||||||
|
categories: { ...localFeedback.categories },
|
||||||
|
comment: localFeedback.comment,
|
||||||
|
pros: [...localFeedback.pros],
|
||||||
|
cons: [...localFeedback.cons],
|
||||||
|
tags: [...localFeedback.tags],
|
||||||
|
isPublic: localFeedback.isPublic
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rimuovi categorie con valore 0
|
||||||
|
Object.keys(feedbackData.categories).forEach(key => {
|
||||||
|
if ((feedbackData.categories as any)[key] === 0) {
|
||||||
|
delete (feedbackData.categories as any)[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
emit('submit', feedbackData);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
hoverRating,
|
||||||
|
newPro,
|
||||||
|
newCon,
|
||||||
|
localFeedback,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
visibleCategories,
|
||||||
|
relevantTags,
|
||||||
|
userName,
|
||||||
|
userInitials,
|
||||||
|
userImg,
|
||||||
|
rideInfo,
|
||||||
|
ratingLabel,
|
||||||
|
commentPlaceholder,
|
||||||
|
canSubmit,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
setRating,
|
||||||
|
toggleTag,
|
||||||
|
addPro,
|
||||||
|
removePro,
|
||||||
|
addCon,
|
||||||
|
removeCon,
|
||||||
|
submit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
223
src/modules/trasporti/components/feedback/FeedbackForm.vue
Normal file
223
src/modules/trasporti/components/feedback/FeedbackForm.vue
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<template>
|
||||||
|
<div class="feedback-form">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="feedback-form__header">
|
||||||
|
<div class="feedback-form__user">
|
||||||
|
<q-avatar size="56px" class="feedback-form__avatar">
|
||||||
|
<img v-if="userImg" :src="userImg" />
|
||||||
|
<span v-else>{{ userInitials }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
<div class="feedback-form__user-info">
|
||||||
|
<span class="feedback-form__user-name">{{ userName }}</span>
|
||||||
|
<span class="feedback-form__role">
|
||||||
|
{{ role === 'driver' ? '🚗 Conducente' : '👤 Passeggero' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rideInfo" class="feedback-form__ride">
|
||||||
|
<q-icon name="directions_car" size="16px" />
|
||||||
|
<span>{{ rideInfo }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
|
<!-- Rating principale -->
|
||||||
|
<div class="feedback-form__main-rating">
|
||||||
|
<div class="feedback-form__label">Come è andato il viaggio?</div>
|
||||||
|
<div class="feedback-form__stars">
|
||||||
|
<button
|
||||||
|
v-for="star in 5"
|
||||||
|
:key="star"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'feedback-form__star',
|
||||||
|
{ 'feedback-form__star--active': star <= localFeedback.rating },
|
||||||
|
{ 'feedback-form__star--hover': star <= hoverRating }
|
||||||
|
]"
|
||||||
|
@click="setRating(star)"
|
||||||
|
@mouseenter="hoverRating = star"
|
||||||
|
@mouseleave="hoverRating = 0"
|
||||||
|
>
|
||||||
|
<q-icon
|
||||||
|
:name="star <= (hoverRating || localFeedback.rating) ? 'star' : 'star_outline'"
|
||||||
|
size="48px"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="feedback-form__rating-label">
|
||||||
|
{{ ratingLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categorie dettagliate -->
|
||||||
|
<transition name="expand">
|
||||||
|
<div v-if="localFeedback.rating > 0" class="feedback-form__categories">
|
||||||
|
<div class="feedback-form__label">Valuta in dettaglio (opzionale)</div>
|
||||||
|
|
||||||
|
<div class="feedback-form__category" v-for="category in visibleCategories" :key="category.key">
|
||||||
|
<div class="feedback-form__category-header">
|
||||||
|
<span class="feedback-form__category-icon">{{ category.icon }}</span>
|
||||||
|
<span class="feedback-form__category-label">{{ category.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="feedback-form__category-stars">
|
||||||
|
<q-rating
|
||||||
|
v-model="localFeedback.categories[category.key]"
|
||||||
|
size="24px"
|
||||||
|
color="amber"
|
||||||
|
icon="star_outline"
|
||||||
|
icon-selected="star"
|
||||||
|
icon-half="star_half"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<transition name="expand">
|
||||||
|
<div v-if="localFeedback.rating > 0" class="feedback-form__tags">
|
||||||
|
<div class="feedback-form__label">
|
||||||
|
{{ localFeedback.rating >= 4 ? 'Cosa ti è piaciuto?' : 'Cosa si può migliorare?' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="feedback-form__tags-grid">
|
||||||
|
<q-chip
|
||||||
|
v-for="tag in relevantTags"
|
||||||
|
:key="tag.value"
|
||||||
|
:selected="localFeedback.tags.includes(tag.value)"
|
||||||
|
:color="localFeedback.tags.includes(tag.value) ? (tag.isPositive ? 'positive' : 'negative') : undefined"
|
||||||
|
:text-color="localFeedback.tags.includes(tag.value) ? 'white' : undefined"
|
||||||
|
:outline="!localFeedback.tags.includes(tag.value)"
|
||||||
|
clickable
|
||||||
|
@click="toggleTag(tag.value)"
|
||||||
|
>
|
||||||
|
{{ tag.icon }} {{ tag.label }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Commento -->
|
||||||
|
<transition name="expand">
|
||||||
|
<div v-if="localFeedback.rating > 0" class="feedback-form__comment">
|
||||||
|
<div class="feedback-form__label">Racconta la tua esperienza (opzionale)</div>
|
||||||
|
<q-input
|
||||||
|
v-model="localFeedback.comment"
|
||||||
|
type="textarea"
|
||||||
|
:placeholder="commentPlaceholder"
|
||||||
|
outlined
|
||||||
|
autogrow
|
||||||
|
:maxlength="1000"
|
||||||
|
counter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Pro e Contro -->
|
||||||
|
<transition name="expand">
|
||||||
|
<div v-if="showProsCons && localFeedback.rating > 0" class="feedback-form__pros-cons">
|
||||||
|
<div class="row q-gutter-md">
|
||||||
|
<!-- Pro -->
|
||||||
|
<div class="col-12 col-sm-6">
|
||||||
|
<div class="feedback-form__label text-positive">
|
||||||
|
<q-icon name="thumb_up" /> Punti di forza
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model="newPro"
|
||||||
|
placeholder="Aggiungi un punto di forza..."
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
@keyup.enter="addPro"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-btn flat round dense icon="add" color="positive" @click="addPro" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<div class="feedback-form__list">
|
||||||
|
<q-chip
|
||||||
|
v-for="(pro, index) in localFeedback.pros"
|
||||||
|
:key="index"
|
||||||
|
removable
|
||||||
|
color="positive"
|
||||||
|
text-color="white"
|
||||||
|
icon="add"
|
||||||
|
@remove="removePro(index)"
|
||||||
|
>
|
||||||
|
{{ pro }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contro -->
|
||||||
|
<div class="col-12 col-sm-6">
|
||||||
|
<div class="feedback-form__label text-negative">
|
||||||
|
<q-icon name="thumb_down" /> Aree di miglioramento
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model="newCon"
|
||||||
|
placeholder="Aggiungi un'area di miglioramento..."
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
@keyup.enter="addCon"
|
||||||
|
>
|
||||||
|
<template v-slot:append>
|
||||||
|
<q-btn flat round dense icon="add" color="negative" @click="addCon" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
<div class="feedback-form__list">
|
||||||
|
<q-chip
|
||||||
|
v-for="(con, index) in localFeedback.cons"
|
||||||
|
:key="index"
|
||||||
|
removable
|
||||||
|
color="negative"
|
||||||
|
text-color="white"
|
||||||
|
icon="remove"
|
||||||
|
@remove="removeCon(index)"
|
||||||
|
>
|
||||||
|
{{ con }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Visibilità -->
|
||||||
|
<transition name="expand">
|
||||||
|
<div v-if="localFeedback.rating > 0" class="feedback-form__visibility">
|
||||||
|
<q-toggle
|
||||||
|
v-model="localFeedback.isPublic"
|
||||||
|
label="Rendi visibile pubblicamente questa recensione"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<p class="text-caption text-grey">
|
||||||
|
Le recensioni pubbliche aiutano gli altri utenti a scegliere con chi viaggiare
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="feedback-form__actions">
|
||||||
|
<q-btn
|
||||||
|
v-if="showCancel"
|
||||||
|
flat
|
||||||
|
label="Annulla"
|
||||||
|
color="grey"
|
||||||
|
@click="$emit('cancel')"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
:label="submitLabel"
|
||||||
|
color="primary"
|
||||||
|
unelevated
|
||||||
|
:disable="!canSubmit"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./FeedbackForm.ts" />
|
||||||
|
<style lang="scss" src="./FeedbackForm.scss" />
|
||||||
231
src/modules/trasporti/components/feedback/FeedbackList.scss
Normal file
231
src/modules/trasporti/components/feedback/FeedbackList.scss
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
.feedback-list {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
&__stats {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-rating {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-value {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--q-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-stars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__distribution {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__distribution-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__distribution-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__distribution-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__distribution-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ffc107, #ff9800);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__distribution-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats roles
|
||||||
|
&__stats-roles {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-role {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-role-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-role-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-role-rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
&__filters {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__role-filter {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
.q-btn {
|
||||||
|
border-radius: 20px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sort {
|
||||||
|
min-width: 160px;
|
||||||
|
|
||||||
|
:deep(.q-field__control) {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
&__loading {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items
|
||||||
|
&__items {
|
||||||
|
// Styling handled by FeedbackCard
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load more
|
||||||
|
&__load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.feedback-list {
|
||||||
|
&__stats {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__distribution-bar {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-roles {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__role-filter {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.feedback-list {
|
||||||
|
&__stats-main {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__distribution {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-roles {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__filters {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__role-filter {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sort {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/modules/trasporti/components/feedback/FeedbackList.ts
Normal file
143
src/modules/trasporti/components/feedback/FeedbackList.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { ref, computed, defineComponent, PropType } from 'vue';
|
||||||
|
import FeedbackCard from './FeedbackCard.vue';
|
||||||
|
import type { Feedback, FeedbackStats, FeedbackRole, RatingDistribution } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'FeedbackList',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
FeedbackCard
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
feedbacks: {
|
||||||
|
type: Array as PropType<Feedback[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
type: Object as PropType<FeedbackStats | null>,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
distribution: {
|
||||||
|
type: Array as PropType<RatingDistribution[]>,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
loadingMore: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
hasMore: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showStats: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showFilters: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
profileUserId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['load-more', 'user-click', 'respond', 'report', 'helpful', 'filter-change'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const roleFilter = ref<FeedbackRole | 'all'>('all');
|
||||||
|
const sortBy = ref('recent');
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ label: 'Tutte', value: 'all' },
|
||||||
|
{ label: '🚗 Conducente', value: 'driver' },
|
||||||
|
{ label: '👤 Passeggero', value: 'passenger' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const sortOptions = [
|
||||||
|
{ label: 'Più recenti', value: 'recent' },
|
||||||
|
{ label: 'Più utili', value: 'helpful' },
|
||||||
|
{ label: 'Rating più alto', value: 'rating_high' },
|
||||||
|
{ label: 'Rating più basso', value: 'rating_low' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const filteredFeedbacks = computed(() => {
|
||||||
|
let result = [...props.feedbacks];
|
||||||
|
|
||||||
|
// Filter by role
|
||||||
|
if (roleFilter.value !== 'all') {
|
||||||
|
result = result.filter(f => f.role === roleFilter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
switch (sortBy.value) {
|
||||||
|
case 'helpful':
|
||||||
|
result.sort((a, b) => (b.helpful?.count || 0) - (a.helpful?.count || 0));
|
||||||
|
break;
|
||||||
|
case 'rating_high':
|
||||||
|
result.sort((a, b) => b.rating - a.rating);
|
||||||
|
break;
|
||||||
|
case 'rating_low':
|
||||||
|
result.sort((a, b) => a.rating - b.rating);
|
||||||
|
break;
|
||||||
|
case 'recent':
|
||||||
|
default:
|
||||||
|
result.sort((a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const getStarIcon = (star: number, rating: number): string => {
|
||||||
|
if (star <= Math.floor(rating)) return 'star';
|
||||||
|
if (star === Math.ceil(rating) && rating % 1 >= 0.5) return 'star_half';
|
||||||
|
return 'star_outline';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDistributionCount = (rating: number): number => {
|
||||||
|
const item = props.distribution.find(d => d._id === rating);
|
||||||
|
return item?.count || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDistributionPercentage = (rating: number): number => {
|
||||||
|
const total = props.distribution.reduce((sum, d) => sum + d.count, 0);
|
||||||
|
if (total === 0) return 0;
|
||||||
|
const count = getDistributionCount(rating);
|
||||||
|
return Math.round((count / total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canRespond = (feedback: Feedback): boolean => {
|
||||||
|
// Può rispondere solo il destinatario del feedback
|
||||||
|
const toUserId = typeof feedback.toUserId === 'string'
|
||||||
|
? feedback.toUserId
|
||||||
|
: feedback.toUserId._id;
|
||||||
|
return toUserId === props.currentUserId && !feedback.response?.text;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleFilter,
|
||||||
|
sortBy,
|
||||||
|
roleOptions,
|
||||||
|
sortOptions,
|
||||||
|
filteredFeedbacks,
|
||||||
|
getStarIcon,
|
||||||
|
getDistributionCount,
|
||||||
|
getDistributionPercentage,
|
||||||
|
canRespond
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
138
src/modules/trasporti/components/feedback/FeedbackList.vue
Normal file
138
src/modules/trasporti/components/feedback/FeedbackList.vue
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<div class="feedback-list">
|
||||||
|
<!-- Header con statistiche -->
|
||||||
|
<div v-if="showStats && stats" class="feedback-list__stats">
|
||||||
|
<div class="feedback-list__stats-main">
|
||||||
|
<div class="feedback-list__stats-rating">
|
||||||
|
<span class="feedback-list__stats-value">{{ stats.overall.averageRating.toFixed(1) }}</span>
|
||||||
|
<div class="feedback-list__stats-stars">
|
||||||
|
<q-icon
|
||||||
|
v-for="star in 5"
|
||||||
|
:key="star"
|
||||||
|
:name="getStarIcon(star, stats.overall.averageRating)"
|
||||||
|
color="amber"
|
||||||
|
size="24px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="feedback-list__stats-count">
|
||||||
|
{{ stats.overall.totalFeedbacks }} {{ stats.overall.totalFeedbacks === 1 ? 'recensione' : 'recensioni' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distribution -->
|
||||||
|
<div class="feedback-list__distribution">
|
||||||
|
<div
|
||||||
|
v-for="rating in [5, 4, 3, 2, 1]"
|
||||||
|
:key="rating"
|
||||||
|
class="feedback-list__distribution-row"
|
||||||
|
>
|
||||||
|
<span class="feedback-list__distribution-label">{{ rating }}</span>
|
||||||
|
<q-icon name="star" size="14px" color="amber" />
|
||||||
|
<div class="feedback-list__distribution-bar">
|
||||||
|
<div
|
||||||
|
class="feedback-list__distribution-fill"
|
||||||
|
:style="{ width: `${getDistributionPercentage(rating)}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="feedback-list__distribution-count">
|
||||||
|
{{ getDistributionCount(rating) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats per ruolo -->
|
||||||
|
<div v-if="stats.asDriver || stats.asPassenger" class="feedback-list__stats-roles">
|
||||||
|
<div v-if="stats.asDriver" class="feedback-list__stats-role">
|
||||||
|
<span class="feedback-list__stats-role-icon">🚗</span>
|
||||||
|
<span class="feedback-list__stats-role-label">Come conducente</span>
|
||||||
|
<div class="feedback-list__stats-role-rating">
|
||||||
|
<q-icon name="star" color="amber" size="16px" />
|
||||||
|
<span>{{ stats.asDriver.averageRating.toFixed(1) }}</span>
|
||||||
|
<span class="text-grey">({{ stats.asDriver.totalFeedbacks }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="stats.asPassenger" class="feedback-list__stats-role">
|
||||||
|
<span class="feedback-list__stats-role-icon">👤</span>
|
||||||
|
<span class="feedback-list__stats-role-label">Come passeggero</span>
|
||||||
|
<div class="feedback-list__stats-role-rating">
|
||||||
|
<q-icon name="star" color="amber" size="16px" />
|
||||||
|
<span>{{ stats.asPassenger.averageRating.toFixed(1) }}</span>
|
||||||
|
<span class="text-grey">({{ stats.asPassenger.totalFeedbacks }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator v-if="showStats && stats" class="q-my-md" />
|
||||||
|
|
||||||
|
<!-- Filtri -->
|
||||||
|
<div v-if="showFilters" class="feedback-list__filters">
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="roleFilter"
|
||||||
|
:options="roleOptions"
|
||||||
|
no-caps
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
toggle-color="primary"
|
||||||
|
class="feedback-list__role-filter"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-select
|
||||||
|
v-model="sortBy"
|
||||||
|
:options="sortOptions"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
class="feedback-list__sort"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="feedback-list__loading">
|
||||||
|
<q-skeleton v-for="i in 3" :key="i" type="rect" height="200px" class="q-mb-md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div v-else-if="filteredFeedbacks.length === 0" class="feedback-list__empty">
|
||||||
|
<q-icon name="rate_review" size="64px" color="grey-4" />
|
||||||
|
<span>Nessuna recensione</span>
|
||||||
|
<p v-if="roleFilter !== 'all'">
|
||||||
|
Prova a cambiare i filtri per vedere altre recensioni
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista feedback -->
|
||||||
|
<div v-else class="feedback-list__items">
|
||||||
|
<FeedbackCard
|
||||||
|
v-for="feedback in filteredFeedbacks"
|
||||||
|
:key="feedback._id"
|
||||||
|
:feedback="feedback"
|
||||||
|
:current-user-id="currentUserId"
|
||||||
|
:can-respond="canRespond(feedback)"
|
||||||
|
class="q-mb-md"
|
||||||
|
@user-click="(user) => $emit('user-click', user)"
|
||||||
|
@respond="$emit('respond', feedback)"
|
||||||
|
@report="(fb) => $emit('report', fb)"
|
||||||
|
@helpful="(id, value) => $emit('helpful', id, value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load more -->
|
||||||
|
<div v-if="hasMore && !loading" class="feedback-list__load-more">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
label="Carica altre recensioni"
|
||||||
|
:loading="loadingMore"
|
||||||
|
@click="$emit('load-more')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./FeedbackList.ts" />
|
||||||
|
<style lang="scss" src="./FeedbackList.scss" />
|
||||||
116
src/modules/trasporti/components/feedback/FeedbackSummary.vue
Normal file
116
src/modules/trasporti/components/feedback/FeedbackSummary.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div class="feedback-summary">
|
||||||
|
<!-- Rating compatto -->
|
||||||
|
<div class="feedback-summary__rating">
|
||||||
|
<q-icon name="star" color="amber" size="20px" />
|
||||||
|
<span class="feedback-summary__value">{{ rating.toFixed(1) }}</span>
|
||||||
|
<span class="feedback-summary__count">({{ totalCount }})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barra progresso -->
|
||||||
|
<div v-if="showProgress" class="feedback-summary__progress">
|
||||||
|
<div
|
||||||
|
class="feedback-summary__progress-fill"
|
||||||
|
:style="{ width: `${(rating / 5) * 100}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<span v-if="showLabel" class="feedback-summary__label">
|
||||||
|
{{ ratingLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { computed, defineComponent } from 'vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'FeedbackSummary',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
rating: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
totalCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
showProgress: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showLabel: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setup(props) {
|
||||||
|
const ratingLabel = computed(() => {
|
||||||
|
if (props.rating >= 4.5) return 'Eccellente';
|
||||||
|
if (props.rating >= 4) return 'Ottimo';
|
||||||
|
if (props.rating >= 3.5) return 'Molto buono';
|
||||||
|
if (props.rating >= 3) return 'Buono';
|
||||||
|
if (props.rating >= 2) return 'Sufficiente';
|
||||||
|
if (props.rating > 0) return 'Da migliorare';
|
||||||
|
return 'Non valutato';
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ratingLabel };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.feedback-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&__rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ffc107, #ff9800);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body--dark {
|
||||||
|
.feedback-summary {
|
||||||
|
&__progress {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/modules/trasporti/components/feedback/index.ts
Normal file
5
src/modules/trasporti/components/feedback/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Export all feedback components
|
||||||
|
export { default as FeedbackForm } from './FeedbackForm.vue';
|
||||||
|
export { default as FeedbackCard } from './FeedbackCard.vue';
|
||||||
|
export { default as FeedbackList } from './FeedbackList.vue';
|
||||||
|
export { default as FeedbackSummary } from './FeedbackSummary.vue';
|
||||||
376
src/modules/trasporti/components/ride/CityAutocomplete.vue
Normal file
376
src/modules/trasporti/components/ride/CityAutocomplete.vue
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
<!-- 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.SendReq('/api/trasporti/cities/recent', 'GET');
|
||||||
|
if (response.success && response.data?.data?.cities) {
|
||||||
|
serverRecentTrips.value = response.data.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>
|
||||||
138
src/modules/trasporti/components/ride/ContribTypeSelector.scss
Normal file
138
src/modules/trasporti/components/ride/ContribTypeSelector.scss
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
.contrib-selector {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--q-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__types {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type {
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
border-color: var(--q-primary);
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type-label {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price-input {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__options {
|
||||||
|
.q-toggle {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(135deg, rgba(var(--q-primary-rgb), 0.08), rgba(var(--q-secondary-rgb), 0.08));
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary-items {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand animation
|
||||||
|
.expand-enter-active,
|
||||||
|
.expand-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-enter-from,
|
||||||
|
.expand-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.contrib-selector {
|
||||||
|
&__type {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price-input {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
252
src/modules/trasporti/components/ride/ContribTypeSelector.ts
Normal file
252
src/modules/trasporti/components/ride/ContribTypeSelector.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { ref, reactive, computed, watch, onMounted, defineComponent, PropType } from 'vue';
|
||||||
|
import { useContribTypes } from '../../composables/useContribTypes';
|
||||||
|
import type { Contribution, ContributionItem, ContribType } from '../../types';
|
||||||
|
|
||||||
|
interface SelectedItem {
|
||||||
|
contribTypeId: string;
|
||||||
|
price?: number;
|
||||||
|
pricePerKm?: number;
|
||||||
|
isPerKm?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'ContribTypeSelector',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Object as PropType<Contribution>,
|
||||||
|
default: () => ({ contribTypes: [] })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const {
|
||||||
|
contribTypes,
|
||||||
|
loading,
|
||||||
|
fetchContribTypes,
|
||||||
|
findById,
|
||||||
|
formatPrice,
|
||||||
|
requiresPrice: checkRequiresPrice
|
||||||
|
} = useContribTypes();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const selectedItems = ref<SelectedItem[]>([]);
|
||||||
|
const localContribution = reactive<Partial<Contribution>>({
|
||||||
|
negotiable: true,
|
||||||
|
freeForStudents: false,
|
||||||
|
freeForElders: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Flag to prevent circular updates
|
||||||
|
let isInternalUpdate = false;
|
||||||
|
|
||||||
|
// Fetch contrib types on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchContribTypes();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch per sincronizzare con modelValue (from parent)
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
// ✅ Skip if this update was triggered by us
|
||||||
|
if (isInternalUpdate) return;
|
||||||
|
|
||||||
|
if (newVal) {
|
||||||
|
localContribution.negotiable = newVal.negotiable ?? true;
|
||||||
|
localContribution.freeForStudents = newVal.freeForStudents ?? false;
|
||||||
|
localContribution.freeForElders = newVal.freeForElders ?? false;
|
||||||
|
|
||||||
|
if (newVal.contribTypes) {
|
||||||
|
// ✅ Create new array
|
||||||
|
selectedItems.value = newVal.contribTypes.map(ct => ({
|
||||||
|
contribTypeId: typeof ct.contribTypeId === 'string' ? ct.contribTypeId : ct.contribTypeId._id,
|
||||||
|
price: ct.price ?? ct.pricePerKm,
|
||||||
|
pricePerKm: ct.pricePerKm,
|
||||||
|
isPerKm: !!ct.pricePerKm,
|
||||||
|
notes: ct.notes
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
// ✅ Emit updates to parent
|
||||||
|
const emitUpdate = () => {
|
||||||
|
isInternalUpdate = true;
|
||||||
|
|
||||||
|
const contribution: Contribution = {
|
||||||
|
contribTypes: selectedItems.value.map(item => ({
|
||||||
|
contribTypeId: item.contribTypeId,
|
||||||
|
price: item.isPerKm ? undefined : item.price,
|
||||||
|
pricePerKm: item.isPerKm ? item.price : undefined,
|
||||||
|
notes: item.notes
|
||||||
|
})),
|
||||||
|
negotiable: localContribution.negotiable,
|
||||||
|
freeForStudents: localContribution.freeForStudents,
|
||||||
|
freeForElders: localContribution.freeForElders
|
||||||
|
};
|
||||||
|
|
||||||
|
emit('update:modelValue', contribution);
|
||||||
|
|
||||||
|
// ✅ Reset flag after next tick
|
||||||
|
setTimeout(() => {
|
||||||
|
isInternalUpdate = false;
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch local changes and emit (debounced to avoid rapid updates)
|
||||||
|
watch(
|
||||||
|
[selectedItems, () => localContribution.negotiable, () => localContribution.freeForStudents, () => localContribution.freeForElders],
|
||||||
|
() => {
|
||||||
|
if (!isInternalUpdate) {
|
||||||
|
emitUpdate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const isSelected = (contribTypeId: string): boolean => {
|
||||||
|
return selectedItems.value.some(item => item.contribTypeId === contribTypeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Fixed: Create new array instead of mutating
|
||||||
|
const toggleContribType = (contribType: ContribType) => {
|
||||||
|
const exists = selectedItems.value.some(item => item.contribTypeId === contribType._id);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
// Add - create new array
|
||||||
|
selectedItems.value = [
|
||||||
|
...selectedItems.value,
|
||||||
|
{
|
||||||
|
contribTypeId: contribType._id,
|
||||||
|
price: undefined,
|
||||||
|
isPerKm: false,
|
||||||
|
notes: ''
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Remove - create new array
|
||||||
|
selectedItems.value = selectedItems.value.filter(
|
||||||
|
item => item.contribTypeId !== contribType._id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectedItem = (contribTypeId: string): SelectedItem => {
|
||||||
|
return selectedItems.value.find(item => item.contribTypeId === contribTypeId) || {
|
||||||
|
contribTypeId,
|
||||||
|
price: undefined,
|
||||||
|
isPerKm: false,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Fixed: Update item without mutating
|
||||||
|
const updateSelectedItem = (contribTypeId: string, updates: Partial<SelectedItem>) => {
|
||||||
|
selectedItems.value = selectedItems.value.map(item =>
|
||||||
|
item.contribTypeId === contribTypeId
|
||||||
|
? { ...item, ...updates }
|
||||||
|
: item
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiresPrice = (contribType: ContribType): boolean => {
|
||||||
|
const label = contribType.label.toLowerCase();
|
||||||
|
const noPriceTypes = ['dono', 'baratto', 'scambio lavoro'];
|
||||||
|
return !noPriceTypes.includes(label);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPerKmOption = (contribType: ContribType): boolean => {
|
||||||
|
const label = contribType.label.toLowerCase();
|
||||||
|
return label.includes('euro') || label === 'ris';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriceLabel = (contribType: ContribType): string => {
|
||||||
|
const label = contribType.label.toLowerCase();
|
||||||
|
if (label.includes('euro')) return 'Prezzo in Euro';
|
||||||
|
if (label === 'ris') return 'Crediti RIS';
|
||||||
|
if (label === 'banca del tempo') return 'Ore';
|
||||||
|
return 'Valore';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPricePrefix = (contribType: ContribType): string => {
|
||||||
|
const label = contribType.label.toLowerCase();
|
||||||
|
if (label.includes('euro')) return '€';
|
||||||
|
if (label.includes('bitcoin')) return '₿';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriceSuffix = (contribType: ContribType): string => {
|
||||||
|
const label = contribType.label.toLowerCase();
|
||||||
|
if (label === 'ris') return 'RIS';
|
||||||
|
if (label === 'banca del tempo') return 'ore';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContribIcon = (contribTypeId: string): string => {
|
||||||
|
const type = findById(contribTypeId);
|
||||||
|
return type?.icon || '💰';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContribColor = (contribTypeId: string): string => {
|
||||||
|
const type = findById(contribTypeId);
|
||||||
|
return type?.color || '#9e9e9e';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatContribPrice = (item: SelectedItem): string => {
|
||||||
|
const type = findById(item.contribTypeId);
|
||||||
|
if (!type) return '';
|
||||||
|
|
||||||
|
const label = type.label.toLowerCase();
|
||||||
|
|
||||||
|
if (!requiresPrice(type)) {
|
||||||
|
return type.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.price === undefined) {
|
||||||
|
return type.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
let priceStr = '';
|
||||||
|
if (label.includes('euro')) {
|
||||||
|
priceStr = `€${item.price}`;
|
||||||
|
} else if (label === 'ris') {
|
||||||
|
priceStr = `${item.price} RIS`;
|
||||||
|
} else if (label === 'banca del tempo') {
|
||||||
|
priceStr = `${item.price} ore`;
|
||||||
|
} else {
|
||||||
|
priceStr = `${item.price}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.isPerKm) {
|
||||||
|
priceStr += '/km';
|
||||||
|
}
|
||||||
|
|
||||||
|
return priceStr;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
contribTypes,
|
||||||
|
loading,
|
||||||
|
selectedItems,
|
||||||
|
localContribution,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
isSelected,
|
||||||
|
toggleContribType,
|
||||||
|
getSelectedItem,
|
||||||
|
updateSelectedItem, // ✅ Add this for template use
|
||||||
|
requiresPrice,
|
||||||
|
showPerKmOption,
|
||||||
|
getPriceLabel,
|
||||||
|
getPricePrefix,
|
||||||
|
getPriceSuffix,
|
||||||
|
getContribIcon,
|
||||||
|
getContribColor,
|
||||||
|
formatContribPrice
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
125
src/modules/trasporti/components/ride/ContribTypeSelector.vue
Normal file
125
src/modules/trasporti/components/ride/ContribTypeSelector.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contrib-selector">
|
||||||
|
<div class="contrib-selector__header">
|
||||||
|
<q-icon name="payments" size="20px" color="primary" />
|
||||||
|
<span class="contrib-selector__title">In Cambio di</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="contrib-selector__loading">
|
||||||
|
<q-spinner color="primary" size="32px" />
|
||||||
|
<span>Caricamento...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista tipi contributo -->
|
||||||
|
<div v-else class="contrib-selector__types">
|
||||||
|
<div
|
||||||
|
v-for="contribType in contribTypes"
|
||||||
|
:key="contribType._id"
|
||||||
|
:class="[
|
||||||
|
'contrib-selector__type',
|
||||||
|
{ 'contrib-selector__type--selected': isSelected(contribType._id) }
|
||||||
|
]"
|
||||||
|
@click="toggleContribType(contribType)"
|
||||||
|
>
|
||||||
|
<div class="contrib-selector__type-header">
|
||||||
|
<q-checkbox
|
||||||
|
:model-value="isSelected(contribType._id)"
|
||||||
|
color="primary"
|
||||||
|
@click.stop
|
||||||
|
@update:model-value="() => toggleContribType(contribType)"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="contrib-selector__type-icon"
|
||||||
|
:style="{ backgroundColor: contribType.color + '20' }"
|
||||||
|
>
|
||||||
|
{{ contribType.icon }}
|
||||||
|
</span>
|
||||||
|
<span class="contrib-selector__type-label">{{ contribType.label }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input prezzo (se selezionato e richiede prezzo) -->
|
||||||
|
<transition name="expand">
|
||||||
|
<div
|
||||||
|
v-if="isSelected(contribType._id) && requiresPrice(contribType)"
|
||||||
|
class="contrib-selector__price-input"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<q-input
|
||||||
|
v-model.number="getSelectedItem(contribType._id).price"
|
||||||
|
type="number"
|
||||||
|
:label="getPriceLabel(contribType)"
|
||||||
|
:prefix="getPricePrefix(contribType)"
|
||||||
|
:suffix="getPriceSuffix(contribType)"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
min="0"
|
||||||
|
step="0.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Prezzo per km toggle -->
|
||||||
|
<q-toggle
|
||||||
|
v-if="showPerKmOption(contribType)"
|
||||||
|
v-model="getSelectedItem(contribType._id).isPerKm"
|
||||||
|
label="Prezzo per km"
|
||||||
|
size="sm"
|
||||||
|
class="q-mt-xs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Note -->
|
||||||
|
<q-input
|
||||||
|
v-model="getSelectedItem(contribType._id).notes"
|
||||||
|
placeholder="Note (opzionale)"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="q-mt-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Opzioni aggiuntive -->
|
||||||
|
<div v-if="selectedItems.length > 0" class="contrib-selector__options q-mt-md">
|
||||||
|
<q-separator class="q-mb-md" />
|
||||||
|
|
||||||
|
<q-toggle
|
||||||
|
v-model="localContribution.negotiable"
|
||||||
|
label="💬 Prezzo negoziabile"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-toggle
|
||||||
|
v-model="localContribution.freeForStudents"
|
||||||
|
label="🎓 Gratuito per studenti"
|
||||||
|
color="secondary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-toggle
|
||||||
|
v-model="localContribution.freeForElders"
|
||||||
|
label="👴 Gratuito per anziani"
|
||||||
|
color="amber"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Riepilogo -->
|
||||||
|
<div v-if="selectedItems.length > 0" class="contrib-selector__summary">
|
||||||
|
<div class="contrib-selector__summary-title">Riepilogo contributo:</div>
|
||||||
|
<div class="contrib-selector__summary-items">
|
||||||
|
<q-chip
|
||||||
|
v-for="item in selectedItems"
|
||||||
|
:key="item.contribTypeId"
|
||||||
|
:style="{ backgroundColor: getContribColor(item.contribTypeId) }"
|
||||||
|
text-color="white"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{{ getContribIcon(item.contribTypeId) }}
|
||||||
|
{{ formatContribPrice(item) }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./ContribTypeSelector.ts" />
|
||||||
|
<style lang="scss" src="./ContribTypeSelector.scss" />
|
||||||
312
src/modules/trasporti/components/ride/MyRideCard.vue
Normal file
312
src/modules/trasporti/components/ride/MyRideCard.vue
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<q-card class="my-ride-card" flat bordered @click="$emit('click')">
|
||||||
|
<!-- Status indicator -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'my-ride-card__status-bar',
|
||||||
|
`my-ride-card__status-bar--${ride.status}`
|
||||||
|
]"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<q-card-section class="my-ride-card__content">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="my-ride-card__header">
|
||||||
|
<div class="my-ride-card__type">
|
||||||
|
<q-chip
|
||||||
|
:color="ride.type === 'offer' ? 'positive' : 'negative'"
|
||||||
|
text-color="white"
|
||||||
|
size="sm"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
{{ ride.type === 'offer' ? '🟢 Offerta' : '🔴 Richiesta' }}
|
||||||
|
</q-chip>
|
||||||
|
<q-chip
|
||||||
|
:color="getStatusColor(ride.status)"
|
||||||
|
text-color="white"
|
||||||
|
size="sm"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
{{ getStatusLabel(ride.status) }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-ride-card__role">
|
||||||
|
<q-icon :name="isDriver ? 'directions_car' : 'person'" size="18px" />
|
||||||
|
<span>{{ isDriver ? 'Conducente' : 'Passeggero' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Route -->
|
||||||
|
<div class="my-ride-card__route">
|
||||||
|
<div class="my-ride-card__city my-ride-card__city--start">
|
||||||
|
<span class="my-ride-card__dot my-ride-card__dot--start"></span>
|
||||||
|
<span>{{ ride.departure.city }}</span>
|
||||||
|
</div>
|
||||||
|
<q-icon name="arrow_forward" size="16px" color="grey" />
|
||||||
|
<div class="my-ride-card__city my-ride-card__city--end">
|
||||||
|
<span class="my-ride-card__dot my-ride-card__dot--end"></span>
|
||||||
|
<span>{{ ride.destination.city }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date & Info -->
|
||||||
|
<div class="my-ride-card__info">
|
||||||
|
<div class="my-ride-card__date">
|
||||||
|
<q-icon name="event" size="16px" />
|
||||||
|
<span>{{ formattedDate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="my-ride-card__time">
|
||||||
|
<q-icon name="schedule" size="16px" />
|
||||||
|
<span>{{ formattedTime }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="ride.type === 'offer'" class="my-ride-card__seats">
|
||||||
|
<q-icon name="airline_seat_recline_normal" size="16px" />
|
||||||
|
<span>{{ ride.passengers?.available }}/{{ ride.passengers?.max }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending requests badge -->
|
||||||
|
<div v-if="pendingRequests > 0" class="my-ride-card__pending">
|
||||||
|
<q-btn
|
||||||
|
color="warning"
|
||||||
|
text-color="dark"
|
||||||
|
:label="`${pendingRequests} richieste in attesa`"
|
||||||
|
icon="notifications_active"
|
||||||
|
size="sm"
|
||||||
|
unelevated
|
||||||
|
@click.stop="$emit('manage-requests')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feedback prompt -->
|
||||||
|
<div v-if="showFeedbackPrompt" class="my-ride-card__feedback-prompt">
|
||||||
|
<q-btn
|
||||||
|
color="amber"
|
||||||
|
text-color="dark"
|
||||||
|
label="Lascia una recensione"
|
||||||
|
icon="star"
|
||||||
|
size="sm"
|
||||||
|
unelevated
|
||||||
|
@click.stop="$emit('leave-feedback')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<q-card-actions v-if="showActions" class="my-ride-card__actions">
|
||||||
|
<q-btn
|
||||||
|
v-if="canEdit"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
label="Modifica"
|
||||||
|
icon="edit"
|
||||||
|
@click.stop="$emit('edit')"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
v-if="canComplete"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
color="positive"
|
||||||
|
label="Completa"
|
||||||
|
icon="check_circle"
|
||||||
|
@click.stop="$emit('complete')"
|
||||||
|
/>
|
||||||
|
<q-space />
|
||||||
|
<q-btn
|
||||||
|
v-if="canCancel"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
color="negative"
|
||||||
|
label="Cancella"
|
||||||
|
icon="cancel"
|
||||||
|
@click.stop="$emit('cancel')"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { computed, defineComponent, PropType } from 'vue';
|
||||||
|
import { useRides } from '../../composables/useRides';
|
||||||
|
import type { Ride, RideStatus } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'MyRideCard',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
ride: {
|
||||||
|
type: Object as PropType<Ride>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
isDriver: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
pendingRequests: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
showFeedbackPrompt: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['click', 'edit', 'cancel', 'complete', 'manage-requests', 'leave-feedback'],
|
||||||
|
|
||||||
|
setup(props) {
|
||||||
|
const { formatRideDate, getStatusColor, getStatusLabel } = useRides();
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
const date = new Date(props.ride.dateTime);
|
||||||
|
return date.toLocaleDateString('it-IT', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedTime = computed(() => {
|
||||||
|
const date = new Date(props.ride.dateTime);
|
||||||
|
return date.toLocaleTimeString('it-IT', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const showActions = computed(() => {
|
||||||
|
return props.isDriver && ['active', 'full'].includes(props.ride.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit = computed(() => {
|
||||||
|
return props.isDriver && props.ride.status === 'active';
|
||||||
|
});
|
||||||
|
|
||||||
|
const canComplete = computed(() => {
|
||||||
|
return props.isDriver && ['active', 'full'].includes(props.ride.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canCancel = computed(() => {
|
||||||
|
return ['active', 'full'].includes(props.ride.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
formattedDate,
|
||||||
|
formattedTime,
|
||||||
|
showActions,
|
||||||
|
canEdit,
|
||||||
|
canComplete,
|
||||||
|
canCancel,
|
||||||
|
getStatusColor,
|
||||||
|
getStatusLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.my-ride-card {
|
||||||
|
border-radius: 16px !important;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status-bar {
|
||||||
|
height: 4px;
|
||||||
|
|
||||||
|
&--active { background: var(--q-positive); }
|
||||||
|
&--full { background: var(--q-warning); }
|
||||||
|
&--completed { background: var(--q-info); }
|
||||||
|
&--cancelled { background: var(--q-negative); }
|
||||||
|
&--expired { background: var(--q-grey); }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__role {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__city {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&--start { background: #4caf50; }
|
||||||
|
&--end { background: #f44336; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pending,
|
||||||
|
&__feedback-prompt {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body--dark {
|
||||||
|
.my-ride-card {
|
||||||
|
&__actions {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
134
src/modules/trasporti/components/ride/PreferencesSelector.scss
Normal file
134
src/modules/trasporti/components/ride/PreferencesSelector.scss
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
.preferences-selector {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
&--toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-label {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__select {
|
||||||
|
:deep(.q-field__control) {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__packages-options {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(var(--q-secondary-rgb), 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toggles {
|
||||||
|
.preferences-selector__toggle-item {
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__notes {
|
||||||
|
:deep(.q-field__control) {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand animation
|
||||||
|
.expand-enter-active,
|
||||||
|
.expand-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-enter-from,
|
||||||
|
.expand-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.preferences-selector {
|
||||||
|
&__item {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-label {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/modules/trasporti/components/ride/PreferencesSelector.ts
Normal file
146
src/modules/trasporti/components/ride/PreferencesSelector.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { reactive, computed, watch, defineComponent, PropType } from 'vue';
|
||||||
|
import type { RidePreferences } from '../../types';
|
||||||
|
import {
|
||||||
|
SMOKING_OPTIONS,
|
||||||
|
PETS_OPTIONS,
|
||||||
|
LUGGAGE_OPTIONS,
|
||||||
|
MUSIC_OPTIONS,
|
||||||
|
CONVERSATION_OPTIONS
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'PreferencesSelector',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Object as PropType<RidePreferences>,
|
||||||
|
default: () => ({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
// State
|
||||||
|
const localPreferences = reactive<RidePreferences>({
|
||||||
|
smoking: 'no',
|
||||||
|
pets: 'small',
|
||||||
|
luggage: 'medium',
|
||||||
|
music: 'moderate',
|
||||||
|
conversation: 'moderate',
|
||||||
|
packages: false,
|
||||||
|
maxPackageSize: 'medium',
|
||||||
|
foodAllowed: true,
|
||||||
|
childrenFriendly: true,
|
||||||
|
wheelchairAccessible: false,
|
||||||
|
otherPreferences: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Options con icone
|
||||||
|
const smokingOptions = SMOKING_OPTIONS.map(opt => ({
|
||||||
|
label: `${opt.icon} ${opt.label}`,
|
||||||
|
value: opt.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
const petsOptions = PETS_OPTIONS.map(opt => ({
|
||||||
|
label: `${opt.icon} ${opt.label}`,
|
||||||
|
value: opt.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
const luggageOptions = LUGGAGE_OPTIONS.map(opt => ({
|
||||||
|
label: `${opt.icon} ${opt.label}`,
|
||||||
|
value: opt.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
const musicOptions = MUSIC_OPTIONS.map(opt => ({
|
||||||
|
label: `${opt.icon} ${opt.label}`,
|
||||||
|
value: opt.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
const conversationOptions = CONVERSATION_OPTIONS.map(opt => ({
|
||||||
|
label: `${opt.icon} ${opt.label}`,
|
||||||
|
value: opt.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
const packageSizeOptions = [
|
||||||
|
{ label: '📦 Piccolo', value: 'small' },
|
||||||
|
{ label: '📦📦 Medio', value: 'medium' },
|
||||||
|
{ label: '📦📦📦 Grande', value: 'large' },
|
||||||
|
{ label: '🚚 XL', value: 'xlarge' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Watch per sincronizzare con modelValue
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
Object.assign(localPreferences, newVal);
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
// Watch per emettere update
|
||||||
|
watch(localPreferences, (newVal) => {
|
||||||
|
emit('update:modelValue', { ...newVal });
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const hasPreferences = computed(() => {
|
||||||
|
return localPreferences.smoking !== 'no' ||
|
||||||
|
localPreferences.pets !== 'no' ||
|
||||||
|
localPreferences.packages ||
|
||||||
|
localPreferences.wheelchairAccessible ||
|
||||||
|
localPreferences.otherPreferences;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activePreferencesChips = computed(() => {
|
||||||
|
const chips: { key: string; icon: string; label: string; color: string }[] = [];
|
||||||
|
|
||||||
|
// Fumatori
|
||||||
|
if (localPreferences.smoking === 'no') {
|
||||||
|
chips.push({ key: 'smoking', icon: '🚭', label: 'Non fumatori', color: 'positive' });
|
||||||
|
} else if (localPreferences.smoking === 'yes') {
|
||||||
|
chips.push({ key: 'smoking', icon: '🚬', label: 'Fumatori OK', color: 'grey' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animali
|
||||||
|
if (localPreferences.pets !== 'no') {
|
||||||
|
const petLabel = localPreferences.pets === 'all' ? 'Tutti' :
|
||||||
|
localPreferences.pets === 'small' ? 'Piccoli' :
|
||||||
|
localPreferences.pets === 'medium' ? 'Medi' : 'Grandi';
|
||||||
|
chips.push({ key: 'pets', icon: '🐾', label: `Animali: ${petLabel}`, color: 'amber' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bagagli
|
||||||
|
if (localPreferences.luggage && localPreferences.luggage !== 'none') {
|
||||||
|
chips.push({ key: 'luggage', icon: '🧳', label: `Bagagli: ${localPreferences.luggage}`, color: 'info' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pacchi
|
||||||
|
if (localPreferences.packages) {
|
||||||
|
chips.push({ key: 'packages', icon: '📦', label: 'Pacchi OK', color: 'secondary' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accessibile
|
||||||
|
if (localPreferences.wheelchairAccessible) {
|
||||||
|
chips.push({ key: 'wheelchair', icon: '♿', label: 'Accessibile', color: 'primary' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return chips;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
localPreferences,
|
||||||
|
|
||||||
|
// Options
|
||||||
|
smokingOptions,
|
||||||
|
petsOptions,
|
||||||
|
luggageOptions,
|
||||||
|
musicOptions,
|
||||||
|
conversationOptions,
|
||||||
|
packageSizeOptions,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
hasPreferences,
|
||||||
|
activePreferencesChips
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
197
src/modules/trasporti/components/ride/PreferencesSelector.vue
Normal file
197
src/modules/trasporti/components/ride/PreferencesSelector.vue
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<div class="preferences-selector">
|
||||||
|
<div class="preferences-selector__header">
|
||||||
|
<q-icon name="tune" size="20px" color="primary" />
|
||||||
|
<span class="preferences-selector__title">Preferenze di Viaggio</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preferences-selector__grid">
|
||||||
|
<!-- Fumatori -->
|
||||||
|
<div class="preferences-selector__item">
|
||||||
|
<div class="preferences-selector__item-header">
|
||||||
|
<span class="preferences-selector__item-icon">🚬</span>
|
||||||
|
<span class="preferences-selector__item-label">Fumatori</span>
|
||||||
|
</div>
|
||||||
|
<q-select
|
||||||
|
v-model="localPreferences.smoking"
|
||||||
|
:options="smokingOptions"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="preferences-selector__select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Animali -->
|
||||||
|
<div class="preferences-selector__item">
|
||||||
|
<div class="preferences-selector__item-header">
|
||||||
|
<span class="preferences-selector__item-icon">🐾</span>
|
||||||
|
<span class="preferences-selector__item-label">Animali</span>
|
||||||
|
</div>
|
||||||
|
<q-select
|
||||||
|
v-model="localPreferences.pets"
|
||||||
|
:options="petsOptions"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="preferences-selector__select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bagagli -->
|
||||||
|
<div class="preferences-selector__item">
|
||||||
|
<div class="preferences-selector__item-header">
|
||||||
|
<span class="preferences-selector__item-icon">🧳</span>
|
||||||
|
<span class="preferences-selector__item-label">Bagagli</span>
|
||||||
|
</div>
|
||||||
|
<q-select
|
||||||
|
v-model="localPreferences.luggage"
|
||||||
|
:options="luggageOptions"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="preferences-selector__select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Musica -->
|
||||||
|
<div class="preferences-selector__item">
|
||||||
|
<div class="preferences-selector__item-header">
|
||||||
|
<span class="preferences-selector__item-icon">🎵</span>
|
||||||
|
<span class="preferences-selector__item-label">Musica</span>
|
||||||
|
</div>
|
||||||
|
<q-select
|
||||||
|
v-model="localPreferences.music"
|
||||||
|
:options="musicOptions"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="preferences-selector__select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conversazione -->
|
||||||
|
<div class="preferences-selector__item">
|
||||||
|
<div class="preferences-selector__item-header">
|
||||||
|
<span class="preferences-selector__item-icon">💬</span>
|
||||||
|
<span class="preferences-selector__item-label">Conversazione</span>
|
||||||
|
</div>
|
||||||
|
<q-select
|
||||||
|
v-model="localPreferences.conversation"
|
||||||
|
:options="conversationOptions"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="preferences-selector__select"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pacchi -->
|
||||||
|
<div class="preferences-selector__item preferences-selector__item--toggle">
|
||||||
|
<div class="preferences-selector__item-header">
|
||||||
|
<span class="preferences-selector__item-icon">📦</span>
|
||||||
|
<span class="preferences-selector__item-label">Pacchi / Colli</span>
|
||||||
|
</div>
|
||||||
|
<q-toggle
|
||||||
|
v-model="localPreferences.packages"
|
||||||
|
color="primary"
|
||||||
|
:label="localPreferences.packages ? 'Accetto pacchi' : 'No pacchi'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Opzioni pacchi espanse -->
|
||||||
|
<transition name="expand">
|
||||||
|
<div v-if="localPreferences.packages" class="preferences-selector__packages-options">
|
||||||
|
<div class="preferences-selector__label">Dimensione massima pacchi:</div>
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="localPreferences.maxPackageSize"
|
||||||
|
:options="packageSizeOptions"
|
||||||
|
spread
|
||||||
|
no-caps
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
toggle-color="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
|
<!-- Toggle aggiuntivi -->
|
||||||
|
<div class="preferences-selector__toggles">
|
||||||
|
<q-item tag="label" class="preferences-selector__toggle-item">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-checkbox v-model="localPreferences.foodAllowed" color="primary" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>🍕 Cibo permesso</q-item-label>
|
||||||
|
<q-item-label caption>I passeggeri possono mangiare in auto</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item tag="label" class="preferences-selector__toggle-item">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-checkbox v-model="localPreferences.childrenFriendly" color="primary" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>👶 Adatto a bambini</q-item-label>
|
||||||
|
<q-item-label caption>Viaggio adatto a famiglie con bambini</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item tag="label" class="preferences-selector__toggle-item">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-checkbox v-model="localPreferences.wheelchairAccessible" color="primary" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>♿ Accessibile</q-item-label>
|
||||||
|
<q-item-label caption>Veicolo accessibile a sedie a rotelle</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note aggiuntive -->
|
||||||
|
<div class="preferences-selector__notes q-mt-md">
|
||||||
|
<q-input
|
||||||
|
v-model="localPreferences.otherPreferences"
|
||||||
|
type="textarea"
|
||||||
|
label="Altre preferenze o note"
|
||||||
|
placeholder="Es: Preferisco non fare soste lunghe, ho bisogno di silenzio per lavorare..."
|
||||||
|
outlined
|
||||||
|
autogrow
|
||||||
|
:maxlength="500"
|
||||||
|
counter
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="notes" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Riepilogo preferenze -->
|
||||||
|
<div v-if="hasPreferences" class="preferences-selector__summary">
|
||||||
|
<div class="preferences-selector__summary-title">Riepilogo:</div>
|
||||||
|
<div class="preferences-selector__summary-chips">
|
||||||
|
<q-chip
|
||||||
|
v-for="pref in activePreferencesChips"
|
||||||
|
:key="pref.key"
|
||||||
|
:color="pref.color"
|
||||||
|
text-color="white"
|
||||||
|
size="sm"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
{{ pref.icon }} {{ pref.label }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./PreferencesSelector.ts" />
|
||||||
|
<style lang="scss" src="./PreferencesSelector.scss" />
|
||||||
129
src/modules/trasporti/components/ride/RecurrenceSelector.scss
Normal file
129
src/modules/trasporti/components/ride/RecurrenceSelector.scss
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
.recurrence-selector {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toggle {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
.q-btn {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__options {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--q-grey-8);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__days {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day-btn {
|
||||||
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__calendar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__selected-dates {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px dashed var(--q-grey-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__validity {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__exclusions {
|
||||||
|
.q-expansion-item {
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(var(--q-info-rgb), 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animazioni
|
||||||
|
.slide-fade-enter-active,
|
||||||
|
.slide-fade-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.recurrence-selector {
|
||||||
|
&__toggle {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__options {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__exclusions {
|
||||||
|
.q-expansion-item {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
199
src/modules/trasporti/components/ride/RecurrenceSelector.ts
Normal file
199
src/modules/trasporti/components/ride/RecurrenceSelector.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { ref, reactive, computed, watch, defineComponent, PropType } from 'vue';
|
||||||
|
import type { Recurrence, RecurrenceType } from '../../types';
|
||||||
|
import { DAYS_OF_WEEK, RECURRENCE_TYPE_OPTIONS } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'RecurrenceSelector',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Object as PropType<Recurrence>,
|
||||||
|
default: () => ({ type: 'once' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
// State
|
||||||
|
const localRecurrence = reactive<Recurrence>({
|
||||||
|
type: 'once',
|
||||||
|
daysOfWeek: [],
|
||||||
|
customDates: [],
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
excludedDates: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedDates = ref<string[]>([]);
|
||||||
|
const excludedDates = ref<string[]>([]);
|
||||||
|
|
||||||
|
// Opzioni
|
||||||
|
const recurrenceTypes = RECURRENCE_TYPE_OPTIONS.map(opt => ({
|
||||||
|
label: opt.label,
|
||||||
|
value: opt.value,
|
||||||
|
icon: opt.icon
|
||||||
|
}));
|
||||||
|
|
||||||
|
const daysOfWeek = DAYS_OF_WEEK;
|
||||||
|
|
||||||
|
// Watch per sincronizzare con modelValue
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
Object.assign(localRecurrence, newVal);
|
||||||
|
|
||||||
|
if (newVal.customDates) {
|
||||||
|
selectedDates.value = newVal.customDates.map(d =>
|
||||||
|
typeof d === 'string' ? d : new Date(d).toISOString().split('T')[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newVal.excludedDates) {
|
||||||
|
excludedDates.value = newVal.excludedDates.map(d =>
|
||||||
|
typeof d === 'string' ? d : new Date(d).toISOString().split('T')[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
// Watch per emettere update
|
||||||
|
watch([localRecurrence, selectedDates, excludedDates], () => {
|
||||||
|
const result: Recurrence = {
|
||||||
|
type: localRecurrence.type
|
||||||
|
};
|
||||||
|
|
||||||
|
if (localRecurrence.type !== 'once') {
|
||||||
|
result.startDate = localRecurrence.startDate;
|
||||||
|
result.endDate = localRecurrence.endDate;
|
||||||
|
|
||||||
|
if (excludedDates.value.length > 0) {
|
||||||
|
result.excludedDates = excludedDates.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localRecurrence.type === 'weekly' || localRecurrence.type === 'custom_days') {
|
||||||
|
result.daysOfWeek = localRecurrence.daysOfWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localRecurrence.type === 'custom_dates') {
|
||||||
|
result.customDates = selectedDates.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', result);
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const isDaySelected = (day: number): boolean => {
|
||||||
|
return localRecurrence.daysOfWeek?.includes(day) || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleDay = (day: number) => {
|
||||||
|
if (!localRecurrence.daysOfWeek) {
|
||||||
|
localRecurrence.daysOfWeek = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = localRecurrence.daysOfWeek.indexOf(day);
|
||||||
|
if (index === -1) {
|
||||||
|
localRecurrence.daysOfWeek.push(day);
|
||||||
|
} else {
|
||||||
|
localRecurrence.daysOfWeek.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordina i giorni
|
||||||
|
localRecurrence.daysOfWeek.sort((a, b) => a - b);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDate = (index: number) => {
|
||||||
|
selectedDates.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeExcludedDate = (index: number) => {
|
||||||
|
excludedDates.value.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('it-IT', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Date options (solo date future)
|
||||||
|
const dateOptions = (date: string): boolean => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const checkDate = new Date(date);
|
||||||
|
return checkDate >= today;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exclusionDateOptions = (date: string): boolean => {
|
||||||
|
if (!localRecurrence.startDate || !localRecurrence.endDate) {
|
||||||
|
return dateOptions(date);
|
||||||
|
}
|
||||||
|
const checkDate = new Date(date);
|
||||||
|
const start = new Date(localRecurrence.startDate);
|
||||||
|
const end = new Date(localRecurrence.endDate);
|
||||||
|
return checkDate >= start && checkDate <= end;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Riepilogo testuale
|
||||||
|
const summaryText = computed(() => {
|
||||||
|
switch (localRecurrence.type) {
|
||||||
|
case 'once':
|
||||||
|
return 'Viaggio singolo, senza ripetizioni';
|
||||||
|
|
||||||
|
case 'weekly':
|
||||||
|
if (!localRecurrence.daysOfWeek?.length) {
|
||||||
|
return 'Seleziona i giorni della settimana';
|
||||||
|
}
|
||||||
|
const weeklyDays = localRecurrence.daysOfWeek
|
||||||
|
.map(d => daysOfWeek.find(day => day.value === d)?.label)
|
||||||
|
.join(', ');
|
||||||
|
return `Ogni settimana: ${weeklyDays}`;
|
||||||
|
|
||||||
|
case 'custom_days':
|
||||||
|
if (!localRecurrence.daysOfWeek?.length) {
|
||||||
|
return 'Seleziona i giorni della settimana';
|
||||||
|
}
|
||||||
|
const customDays = localRecurrence.daysOfWeek
|
||||||
|
.map(d => daysOfWeek.find(day => day.value === d)?.label)
|
||||||
|
.join(', ');
|
||||||
|
return `Giorni selezionati: ${customDays}`;
|
||||||
|
|
||||||
|
case 'custom_dates':
|
||||||
|
if (!selectedDates.value.length) {
|
||||||
|
return 'Seleziona le date dal calendario';
|
||||||
|
}
|
||||||
|
return `${selectedDates.value.length} date selezionate`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
localRecurrence,
|
||||||
|
selectedDates,
|
||||||
|
excludedDates,
|
||||||
|
|
||||||
|
// Options
|
||||||
|
recurrenceTypes,
|
||||||
|
daysOfWeek,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
summaryText,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
isDaySelected,
|
||||||
|
toggleDay,
|
||||||
|
removeDate,
|
||||||
|
removeExcludedDate,
|
||||||
|
formatDate,
|
||||||
|
dateOptions,
|
||||||
|
exclusionDateOptions
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
194
src/modules/trasporti/components/ride/RecurrenceSelector.vue
Normal file
194
src/modules/trasporti/components/ride/RecurrenceSelector.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<div class="recurrence-selector">
|
||||||
|
<div class="recurrence-selector__header">
|
||||||
|
<q-icon name="repeat" size="20px" color="primary" />
|
||||||
|
<span class="recurrence-selector__title">Ripetizione Percorso</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tipo Ricorrenza -->
|
||||||
|
<div class="recurrence-selector__types">
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="localRecurrence.type"
|
||||||
|
:options="recurrenceTypes"
|
||||||
|
spread
|
||||||
|
no-caps
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
toggle-color="primary"
|
||||||
|
class="recurrence-selector__toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Opzioni specifiche per tipo -->
|
||||||
|
<transition name="slide-fade" mode="out-in">
|
||||||
|
<!-- Weekly: Giorni della settimana -->
|
||||||
|
<div
|
||||||
|
v-if="localRecurrence.type === 'weekly'"
|
||||||
|
key="weekly"
|
||||||
|
class="recurrence-selector__options"
|
||||||
|
>
|
||||||
|
<div class="recurrence-selector__label">Seleziona i giorni:</div>
|
||||||
|
<div class="recurrence-selector__days">
|
||||||
|
<q-btn
|
||||||
|
v-for="day in daysOfWeek"
|
||||||
|
:key="day.value"
|
||||||
|
:label="day.shortLabel"
|
||||||
|
:color="isDaySelected(day.value) ? 'primary' : 'grey-4'"
|
||||||
|
:text-color="isDaySelected(day.value) ? 'white' : 'dark'"
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
class="recurrence-selector__day-btn"
|
||||||
|
@click="toggleDay(day.value)"
|
||||||
|
>
|
||||||
|
<q-tooltip>{{ day.label }}</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Days: Giorni specifici -->
|
||||||
|
<div
|
||||||
|
v-else-if="localRecurrence.type === 'custom_days'"
|
||||||
|
key="custom_days"
|
||||||
|
class="recurrence-selector__options"
|
||||||
|
>
|
||||||
|
<div class="recurrence-selector__label">Seleziona i giorni della settimana:</div>
|
||||||
|
<div class="recurrence-selector__days">
|
||||||
|
<q-btn
|
||||||
|
v-for="day in daysOfWeek"
|
||||||
|
:key="day.value"
|
||||||
|
:label="day.shortLabel"
|
||||||
|
:color="isDaySelected(day.value) ? 'primary' : 'grey-4'"
|
||||||
|
:text-color="isDaySelected(day.value) ? 'white' : 'dark'"
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
class="recurrence-selector__day-btn"
|
||||||
|
@click="toggleDay(day.value)"
|
||||||
|
>
|
||||||
|
<q-tooltip>{{ day.label }}</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Dates: Date dal calendario -->
|
||||||
|
<div
|
||||||
|
v-else-if="localRecurrence.type === 'custom_dates'"
|
||||||
|
key="custom_dates"
|
||||||
|
class="recurrence-selector__options"
|
||||||
|
>
|
||||||
|
<div class="recurrence-selector__label">Seleziona le date:</div>
|
||||||
|
<q-date
|
||||||
|
v-model="selectedDates"
|
||||||
|
multiple
|
||||||
|
:options="dateOptions"
|
||||||
|
minimal
|
||||||
|
class="recurrence-selector__calendar"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Chips date selezionate -->
|
||||||
|
<div v-if="selectedDates.length > 0" class="recurrence-selector__selected-dates">
|
||||||
|
<q-chip
|
||||||
|
v-for="(date, index) in selectedDates"
|
||||||
|
:key="date"
|
||||||
|
removable
|
||||||
|
color="primary"
|
||||||
|
text-color="white"
|
||||||
|
size="sm"
|
||||||
|
@remove="removeDate(index)"
|
||||||
|
>
|
||||||
|
{{ formatDate(date) }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Periodo validità (per tutti tranne "once") -->
|
||||||
|
<transition name="slide-fade">
|
||||||
|
<div
|
||||||
|
v-if="localRecurrence.type !== 'once'"
|
||||||
|
class="recurrence-selector__validity"
|
||||||
|
>
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
|
<div class="recurrence-selector__label">Periodo di validità:</div>
|
||||||
|
|
||||||
|
<div class="row q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
v-model="localRecurrence.startDate"
|
||||||
|
type="date"
|
||||||
|
label="Data inizio"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="col"
|
||||||
|
:rules="[val => !!val || 'Data inizio richiesta']"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="event" color="positive" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="localRecurrence.endDate"
|
||||||
|
type="date"
|
||||||
|
label="Data fine"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="col"
|
||||||
|
:rules="[val => !!val || 'Data fine richiesta', val => !localRecurrence.startDate || val >= localRecurrence.startDate || 'Data fine deve essere dopo data inizio']"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="event" color="negative" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date da escludere -->
|
||||||
|
<div class="recurrence-selector__exclusions q-mt-md">
|
||||||
|
<q-expansion-item
|
||||||
|
icon="event_busy"
|
||||||
|
label="Date da escludere"
|
||||||
|
caption="Giorni in cui non sei disponibile"
|
||||||
|
dense
|
||||||
|
header-class="text-grey-8"
|
||||||
|
>
|
||||||
|
<q-card flat>
|
||||||
|
<q-card-section>
|
||||||
|
<q-date
|
||||||
|
v-model="excludedDates"
|
||||||
|
multiple
|
||||||
|
:options="exclusionDateOptions"
|
||||||
|
minimal
|
||||||
|
color="negative"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="excludedDates.length > 0" class="q-mt-sm">
|
||||||
|
<q-chip
|
||||||
|
v-for="(date, index) in excludedDates"
|
||||||
|
:key="date"
|
||||||
|
removable
|
||||||
|
color="negative"
|
||||||
|
text-color="white"
|
||||||
|
size="sm"
|
||||||
|
icon="event_busy"
|
||||||
|
@remove="removeExcludedDate(index)"
|
||||||
|
>
|
||||||
|
{{ formatDate(date) }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Riepilogo -->
|
||||||
|
<div v-if="summaryText" class="recurrence-selector__summary">
|
||||||
|
<q-icon name="info" color="info" />
|
||||||
|
<span>{{ summaryText }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./RecurrenceSelector.ts" />
|
||||||
|
<style lang="scss" src="./RecurrenceSelector.scss" />
|
||||||
205
src/modules/trasporti/components/ride/RequestCard.vue
Normal file
205
src/modules/trasporti/components/ride/RequestCard.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<q-item class="request-card" clickable>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar size="48px" class="request-card__avatar">
|
||||||
|
<img v-if="userImg" :src="userImg" />
|
||||||
|
<span v-else>{{ userInitials }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="request-card__name">
|
||||||
|
{{ userName }}
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption class="request-card__ride">
|
||||||
|
<template v-if="rideInfo">
|
||||||
|
{{ rideInfo.departure?.city }} → {{ rideInfo.destination?.city }}
|
||||||
|
</template>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption class="request-card__message" v-if="request.message">
|
||||||
|
"{{ truncatedMessage }}"
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label caption class="request-card__meta">
|
||||||
|
{{ request.seatsRequested }} posto/i • {{ formattedDate }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<q-item-section side>
|
||||||
|
<!-- Status badge (for sent requests) -->
|
||||||
|
<q-badge
|
||||||
|
v-if="mode === 'sent'"
|
||||||
|
:color="getStatusColor(request.status)"
|
||||||
|
:label="getStatusLabel(request.status)"
|
||||||
|
class="q-mb-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Actions (for received requests) -->
|
||||||
|
<div v-if="mode === 'received' && request.status === 'pending'" class="request-card__actions">
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
flat
|
||||||
|
color="positive"
|
||||||
|
icon="check"
|
||||||
|
size="sm"
|
||||||
|
@click.stop="$emit('accept')"
|
||||||
|
>
|
||||||
|
<q-tooltip>Accetta</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
flat
|
||||||
|
color="negative"
|
||||||
|
icon="close"
|
||||||
|
size="sm"
|
||||||
|
@click.stop="$emit('reject')"
|
||||||
|
>
|
||||||
|
<q-tooltip>Rifiuta</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cancel button (for sent pending requests) -->
|
||||||
|
<q-btn
|
||||||
|
v-if="mode === 'sent' && request.status === 'pending'"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
color="negative"
|
||||||
|
label="Annulla"
|
||||||
|
size="sm"
|
||||||
|
@click.stop="$emit('cancel')"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { computed, defineComponent, PropType } from 'vue';
|
||||||
|
import { useRideRequests } from '../../composables/useRideRequests';
|
||||||
|
import type { RideRequest, UserBasic, Ride } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'RequestCard',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
request: {
|
||||||
|
type: Object as PropType<RideRequest>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String as PropType<'received' | 'sent'>,
|
||||||
|
default: 'received'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['accept', 'reject', 'cancel', 'view-ride', 'view-user'],
|
||||||
|
|
||||||
|
setup(props) {
|
||||||
|
const { getRequestStatusColor, getRequestStatusLabel } = useRideRequests();
|
||||||
|
|
||||||
|
const user = computed(() => {
|
||||||
|
const userField = props.mode === 'received'
|
||||||
|
? props.request.passengerId
|
||||||
|
: props.request.driverId;
|
||||||
|
|
||||||
|
if (typeof userField === 'object') return userField as UserBasic;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const userName = computed(() => {
|
||||||
|
if (!user.value) return 'Utente';
|
||||||
|
if (user.value.name) {
|
||||||
|
return `${user.value.name} ${user.value.surname || ''}`.trim();
|
||||||
|
}
|
||||||
|
return user.value.username || 'Utente';
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
return userName.value
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const userImg = computed(() => (user.value as any)?.profile?.img);
|
||||||
|
|
||||||
|
const rideInfo = computed(() => {
|
||||||
|
if (typeof props.request.rideId === 'object') {
|
||||||
|
return props.request.rideId as Ride;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const truncatedMessage = computed(() => {
|
||||||
|
const msg = props.request.message || '';
|
||||||
|
return msg.length > 50 ? msg.substring(0, 50) + '...' : msg;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
const date = new Date(props.request.createdAt);
|
||||||
|
return date.toLocaleDateString('it-IT', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => getRequestStatusColor(status as any);
|
||||||
|
const getStatusLabel = (status: string) => getRequestStatusLabel(status as any);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userName,
|
||||||
|
userInitials,
|
||||||
|
userImg,
|
||||||
|
rideInfo,
|
||||||
|
truncatedMessage,
|
||||||
|
formattedDate,
|
||||||
|
getStatusColor,
|
||||||
|
getStatusLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.request-card {
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
background: linear-gradient(135deg, var(--q-primary), var(--q-secondary));
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride {
|
||||||
|
color: var(--q-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__message {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body--dark {
|
||||||
|
.request-card {
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
356
src/modules/trasporti/components/ride/RideCard.scss
Normal file
356
src/modules/trasporti/components/ride/RideCard.scss
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
.ride-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--offer {
|
||||||
|
border-left: 4px solid #4caf50 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--request {
|
||||||
|
border-left: 4px solid #f44336 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--featured {
|
||||||
|
background: linear-gradient(135deg, #fff9c4 0%, #fff 30%);
|
||||||
|
border-color: #ffc107 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
.ride-card__header {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ride-card__route {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ride-card__footer {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type Badge
|
||||||
|
&__type-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
left: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
top: 8px !important;
|
||||||
|
right: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover .ride-card__user-name {
|
||||||
|
color: var(--q-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
background: linear-gradient(135deg, var(--q-primary), var(--q-secondary));
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__datetime {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route
|
||||||
|
&__route {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-visual {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
&--start {
|
||||||
|
.ride-card__location-dot {
|
||||||
|
background: #4caf50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--end {
|
||||||
|
.ride-card__location-dot {
|
||||||
|
background: #f44336;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__location-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: inherit;
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__location-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__location-city {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__location-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--q-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 5px;
|
||||||
|
margin: 4px 0;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-line-inner {
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 24px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
var(--q-grey-4) 0px,
|
||||||
|
var(--q-grey-4) 4px,
|
||||||
|
transparent 4px,
|
||||||
|
transparent 8px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__waypoints-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--q-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__waypoints-list {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__waypoint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info
|
||||||
|
&__info {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preferences {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed var(--q-grey-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pref-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__contribution {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--q-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--q-grey);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price-free {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__contrib-types {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand animation
|
||||||
|
.expand-enter-active,
|
||||||
|
.expand-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-enter-from,
|
||||||
|
.expand-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-enter-to,
|
||||||
|
.expand-leave-from {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.ride-card {
|
||||||
|
&__type-badge {
|
||||||
|
background: #2d2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__datetime {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--featured {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 193, 7, 0.15) 0%, transparent 30%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
286
src/modules/trasporti/components/ride/RideCard.ts
Normal file
286
src/modules/trasporti/components/ride/RideCard.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { ref, computed, defineComponent, PropType } from 'vue';
|
||||||
|
import { useRides } from '../../composables/useRides';
|
||||||
|
import type { Ride, ContributionItem, RideStatus } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'RideCard',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
ride: {
|
||||||
|
type: Object as PropType<Ride>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showStatus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showBookButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showContactButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
currentUserId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
clickable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['click', 'book', 'contact', 'user-click'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const {
|
||||||
|
formatRideDate,
|
||||||
|
formatDuration,
|
||||||
|
getStatusColor,
|
||||||
|
getStatusLabel,
|
||||||
|
canBook: checkCanBook
|
||||||
|
} = useRides();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const showWaypoints = ref(false);
|
||||||
|
|
||||||
|
// User computed
|
||||||
|
const user = computed(() => {
|
||||||
|
if (typeof props.ride.userId === 'object') {
|
||||||
|
return props.ride.userId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const userName = computed(() => {
|
||||||
|
if (user.value) {
|
||||||
|
if (user.value.name) {
|
||||||
|
return `${user.value.name} ${user.value.surname?.[0] || ''}`.trim();
|
||||||
|
}
|
||||||
|
return user.value.username;
|
||||||
|
}
|
||||||
|
return 'Utente';
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
const name = userName.value;
|
||||||
|
return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const userImg = computed(() => {
|
||||||
|
return user.value?.profile?.img;
|
||||||
|
});
|
||||||
|
|
||||||
|
const userRating = computed(() => {
|
||||||
|
return user.value?.profile?.driverProfile?.averageRating || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const userRidesCount = computed(() => {
|
||||||
|
return user.value?.profile?.driverProfile?.ridesCompletedAsDriver || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Date computed
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
return formatRideDate(props.ride.dateTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDepartureTime = computed(() => {
|
||||||
|
const date = new Date(props.ride.dateTime);
|
||||||
|
return date.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const estimatedArrival = computed(() => {
|
||||||
|
if (!props.ride.estimatedDuration) return null;
|
||||||
|
const departure = new Date(props.ride.dateTime);
|
||||||
|
const arrival = new Date(departure.getTime() + props.ride.estimatedDuration * 60000);
|
||||||
|
return arrival.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDuration = computed(() => {
|
||||||
|
if (!props.ride.estimatedDuration) return '';
|
||||||
|
return formatDuration(props.ride.estimatedDuration);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route computed
|
||||||
|
const waypointsCount = computed(() => {
|
||||||
|
return props.ride.waypoints?.length || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seats computed
|
||||||
|
const seatsColor = computed(() => {
|
||||||
|
if (!props.ride.passengers) return 'grey';
|
||||||
|
const available = props.ride.passengers.available;
|
||||||
|
const max = props.ride.passengers.max;
|
||||||
|
if (available === 0) return 'negative';
|
||||||
|
if (available <= max * 0.3) return 'warning';
|
||||||
|
return 'positive';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preferences
|
||||||
|
const hasPreferences = computed(() => {
|
||||||
|
const prefs = props.ride.preferences;
|
||||||
|
if (!prefs) return false;
|
||||||
|
return prefs.smoking === 'no' || prefs.pets !== 'no' || prefs.packages;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contribution computed
|
||||||
|
const mainContribution = computed(() => {
|
||||||
|
const contribs = props.ride.contribution?.contribTypes;
|
||||||
|
if (!contribs || contribs.length === 0) return null;
|
||||||
|
|
||||||
|
// Priorità: Euro > RIS > altri con prezzo
|
||||||
|
const euroContrib = contribs.find(c => {
|
||||||
|
const label = getContribLabel(c).toLowerCase();
|
||||||
|
return label.includes('euro') && c.price;
|
||||||
|
});
|
||||||
|
if (euroContrib) return euroContrib;
|
||||||
|
|
||||||
|
const risContrib = contribs.find(c => {
|
||||||
|
const label = getContribLabel(c).toLowerCase();
|
||||||
|
return label === 'ris' && c.price;
|
||||||
|
});
|
||||||
|
if (risContrib) return risContrib;
|
||||||
|
|
||||||
|
return contribs.find(c => c.price) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedPrice = computed(() => {
|
||||||
|
if (!mainContribution.value) return '';
|
||||||
|
const price = mainContribution.value.price;
|
||||||
|
const label = getContribLabel(mainContribution.value).toLowerCase();
|
||||||
|
|
||||||
|
if (label.includes('euro')) return `€${price?.toFixed(2)}`;
|
||||||
|
if (label === 'ris') return `${price} RIS`;
|
||||||
|
return `${price}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const priceLabel = computed(() => {
|
||||||
|
if (!mainContribution.value) return '';
|
||||||
|
if (mainContribution.value.pricePerKm) return 'per km';
|
||||||
|
return 'a persona';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Book button
|
||||||
|
const canBook = computed(() => {
|
||||||
|
return checkCanBook(props.ride, props.currentUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookButtonLabel = computed(() => {
|
||||||
|
if (props.ride.status === 'full') return 'Completo';
|
||||||
|
if (!canBook.value) return 'Non disponibile';
|
||||||
|
return 'Prenota';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const toggleWaypoints = () => {
|
||||||
|
showWaypoints.value = !showWaypoints.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContribId = (contrib: ContributionItem): string => {
|
||||||
|
if (typeof contrib.contribTypeId === 'object') {
|
||||||
|
return contrib.contribTypeId._id;
|
||||||
|
}
|
||||||
|
return contrib.contribTypeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContribLabel = (contrib: ContributionItem): string => {
|
||||||
|
if (typeof contrib.contribTypeId === 'object') {
|
||||||
|
return contrib.contribTypeId.label;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContribIcon = (contrib: ContributionItem): string => {
|
||||||
|
if (typeof contrib.contribTypeId === 'object') {
|
||||||
|
return contrib.contribTypeId.icon;
|
||||||
|
}
|
||||||
|
return '💰';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContribColor = (contrib: ContributionItem): string => {
|
||||||
|
if (typeof contrib.contribTypeId === 'object') {
|
||||||
|
return contrib.contribTypeId.color;
|
||||||
|
}
|
||||||
|
return 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Events
|
||||||
|
const onClick = () => {
|
||||||
|
if (props.clickable) {
|
||||||
|
emit('click', props.ride);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBook = () => {
|
||||||
|
emit('book', props.ride);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContact = () => {
|
||||||
|
emit('contact', props.ride);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserClick = () => {
|
||||||
|
const userId = typeof props.ride.userId === 'object'
|
||||||
|
? props.ride.userId._id
|
||||||
|
: props.ride.userId;
|
||||||
|
emit('user-click', userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
showWaypoints,
|
||||||
|
|
||||||
|
// User
|
||||||
|
userName,
|
||||||
|
userInitials,
|
||||||
|
userImg,
|
||||||
|
userRating,
|
||||||
|
userRidesCount,
|
||||||
|
|
||||||
|
// Date
|
||||||
|
formattedDate,
|
||||||
|
formattedDepartureTime,
|
||||||
|
estimatedArrival,
|
||||||
|
formattedDuration,
|
||||||
|
|
||||||
|
// Route
|
||||||
|
waypointsCount,
|
||||||
|
|
||||||
|
// Seats
|
||||||
|
seatsColor,
|
||||||
|
|
||||||
|
// Preferences
|
||||||
|
hasPreferences,
|
||||||
|
|
||||||
|
// Contribution
|
||||||
|
mainContribution,
|
||||||
|
formattedPrice,
|
||||||
|
priceLabel,
|
||||||
|
|
||||||
|
// Book
|
||||||
|
canBook,
|
||||||
|
bookButtonLabel,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
toggleWaypoints,
|
||||||
|
getStatusColor,
|
||||||
|
getStatusLabel,
|
||||||
|
getContribId,
|
||||||
|
getContribLabel,
|
||||||
|
getContribIcon,
|
||||||
|
getContribColor,
|
||||||
|
|
||||||
|
// Events
|
||||||
|
onClick,
|
||||||
|
onBook,
|
||||||
|
onContact,
|
||||||
|
onUserClick
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
217
src/modules/trasporti/components/ride/RideCard.vue
Normal file
217
src/modules/trasporti/components/ride/RideCard.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<q-card
|
||||||
|
:class="[
|
||||||
|
'ride-card',
|
||||||
|
`ride-card--${ride.type}`,
|
||||||
|
{ 'ride-card--compact': compact },
|
||||||
|
{ 'ride-card--featured': ride.isFeatured }
|
||||||
|
]"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
@click="onClick"
|
||||||
|
>
|
||||||
|
<!-- Badge Tipo -->
|
||||||
|
<div class="ride-card__type-badge">
|
||||||
|
<span class="ride-card__type-icon">{{ ride.type === 'offer' ? '🟢' : '🔴' }}</span>
|
||||||
|
<span class="ride-card__type-label">{{ ride.type === 'offer' ? 'Offerta' : 'Richiesta' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Badge -->
|
||||||
|
<q-badge
|
||||||
|
v-if="showStatus"
|
||||||
|
:color="getStatusColor(ride.status)"
|
||||||
|
floating
|
||||||
|
class="ride-card__status"
|
||||||
|
>
|
||||||
|
{{ getStatusLabel(ride.status) }}
|
||||||
|
</q-badge>
|
||||||
|
|
||||||
|
<q-card-section class="ride-card__header">
|
||||||
|
<!-- User Info -->
|
||||||
|
<div class="ride-card__user" @click.stop="onUserClick">
|
||||||
|
<q-avatar size="42px" class="ride-card__avatar">
|
||||||
|
<img v-if="userImg" :src="userImg" :alt="userName" />
|
||||||
|
<span v-else>{{ userInitials }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
<div class="ride-card__user-info">
|
||||||
|
<span class="ride-card__user-name">{{ userName }}</span>
|
||||||
|
<div class="ride-card__user-rating" v-if="userRating > 0">
|
||||||
|
<q-icon name="star" color="amber" size="14px" />
|
||||||
|
<span>{{ userRating.toFixed(1) }}</span>
|
||||||
|
<span class="text-grey">({{ userRidesCount }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data/Ora -->
|
||||||
|
<div class="ride-card__datetime">
|
||||||
|
<q-icon name="schedule" size="18px" color="grey-7" />
|
||||||
|
<span>{{ formattedDate }}</span>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<!-- Route -->
|
||||||
|
<q-card-section class="ride-card__route">
|
||||||
|
<div class="ride-card__route-visual">
|
||||||
|
<!-- Partenza -->
|
||||||
|
<div class="ride-card__location ride-card__location--start">
|
||||||
|
<div class="ride-card__location-dot ride-card__location-dot--start"></div>
|
||||||
|
<div class="ride-card__location-info">
|
||||||
|
<span class="ride-card__location-city">{{ ride.departure.city }}</span>
|
||||||
|
<span class="ride-card__location-time" v-if="!compact">
|
||||||
|
{{ formattedDepartureTime }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Linea connessione -->
|
||||||
|
<div class="ride-card__route-line">
|
||||||
|
<div class="ride-card__route-line-inner"></div>
|
||||||
|
<!-- Waypoints -->
|
||||||
|
<div
|
||||||
|
v-if="!compact && waypointsCount > 0"
|
||||||
|
class="ride-card__waypoints-indicator"
|
||||||
|
@click.stop="toggleWaypoints"
|
||||||
|
>
|
||||||
|
<q-icon name="more_vert" size="16px" />
|
||||||
|
<span>{{ waypointsCount }} {{ waypointsCount === 1 ? 'tappa' : 'tappe' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Destinazione -->
|
||||||
|
<div class="ride-card__location ride-card__location--end">
|
||||||
|
<div class="ride-card__location-dot ride-card__location-dot--end"></div>
|
||||||
|
<div class="ride-card__location-info">
|
||||||
|
<span class="ride-card__location-city">{{ ride.destination.city }}</span>
|
||||||
|
<span class="ride-card__location-time" v-if="!compact && estimatedArrival">
|
||||||
|
{{ estimatedArrival }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Waypoints Espansi -->
|
||||||
|
<transition name="expand">
|
||||||
|
<div v-if="showWaypoints && ride.waypoints?.length" class="ride-card__waypoints-list">
|
||||||
|
<div
|
||||||
|
v-for="(waypoint, index) in ride.waypoints"
|
||||||
|
:key="index"
|
||||||
|
class="ride-card__waypoint"
|
||||||
|
>
|
||||||
|
<q-icon name="fiber_manual_record" size="8px" color="grey" />
|
||||||
|
<span>{{ waypoint.location.city }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-separator v-if="!compact" />
|
||||||
|
|
||||||
|
<!-- Info Aggiuntive -->
|
||||||
|
<q-card-section v-if="!compact" class="ride-card__info">
|
||||||
|
<div class="ride-card__info-grid">
|
||||||
|
<!-- Posti -->
|
||||||
|
<div v-if="ride.type === 'offer'" class="ride-card__info-item">
|
||||||
|
<q-icon name="airline_seat_recline_normal" size="20px" :color="seatsColor" />
|
||||||
|
<span>{{ ride.passengers?.available }}/{{ ride.passengers?.max }} posti</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ride-card__info-item">
|
||||||
|
<q-icon name="person" size="20px" color="primary" />
|
||||||
|
<span>{{ ride.seatsNeeded || 1 }} {{ ride.seatsNeeded === 1 ? 'posto' : 'posti' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distanza/Durata -->
|
||||||
|
<div v-if="ride.estimatedDistance" class="ride-card__info-item">
|
||||||
|
<q-icon name="route" size="20px" color="grey-7" />
|
||||||
|
<span>{{ ride.estimatedDistance }} km</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ride.estimatedDuration" class="ride-card__info-item">
|
||||||
|
<q-icon name="timer" size="20px" color="grey-7" />
|
||||||
|
<span>{{ formattedDuration }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preferenze Icone -->
|
||||||
|
<div class="ride-card__preferences" v-if="hasPreferences">
|
||||||
|
<q-icon
|
||||||
|
v-if="ride.preferences?.smoking === 'no'"
|
||||||
|
name="smoke_free"
|
||||||
|
size="18px"
|
||||||
|
color="grey-6"
|
||||||
|
>
|
||||||
|
<q-tooltip>Non fumatori</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
<span v-if="ride.preferences?.pets !== 'no'" class="ride-card__pref-icon">
|
||||||
|
🐾
|
||||||
|
<q-tooltip>Animali ammessi</q-tooltip>
|
||||||
|
</span>
|
||||||
|
<q-icon
|
||||||
|
v-if="ride.preferences?.packages"
|
||||||
|
name="inventory_2"
|
||||||
|
size="18px"
|
||||||
|
color="grey-6"
|
||||||
|
>
|
||||||
|
<q-tooltip>Pacchi ammessi</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<!-- Footer con Prezzo -->
|
||||||
|
<q-card-section class="ride-card__footer">
|
||||||
|
<div class="ride-card__contribution">
|
||||||
|
<template v-if="mainContribution">
|
||||||
|
<span class="ride-card__price">{{ formattedPrice }}</span>
|
||||||
|
<span class="ride-card__price-label">{{ priceLabel }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="ride.contribution?.contribTypes?.length">
|
||||||
|
<div class="ride-card__contrib-types">
|
||||||
|
<q-chip
|
||||||
|
v-for="contrib in ride.contribution.contribTypes.slice(0, 2)"
|
||||||
|
:key="getContribId(contrib)"
|
||||||
|
size="sm"
|
||||||
|
:color="getContribColor(contrib)"
|
||||||
|
text-color="white"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
{{ getContribIcon(contrib) }} {{ getContribLabel(contrib) }}
|
||||||
|
</q-chip>
|
||||||
|
<span v-if="ride.contribution.contribTypes.length > 2" class="text-grey">
|
||||||
|
+{{ ride.contribution.contribTypes.length - 2 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="ride-card__price-free">
|
||||||
|
🎁 Gratuito
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-if="showBookButton && ride.type === 'offer'"
|
||||||
|
color="primary"
|
||||||
|
:label="bookButtonLabel"
|
||||||
|
:disable="!canBook"
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
size="md"
|
||||||
|
@click.stop="onBook"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
v-else-if="showContactButton"
|
||||||
|
color="secondary"
|
||||||
|
label="Contatta"
|
||||||
|
rounded
|
||||||
|
outline
|
||||||
|
size="md"
|
||||||
|
@click.stop="onContact"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./RideCard.ts" />
|
||||||
|
<style lang="scss" src="./RideCard.scss" />
|
||||||
71
src/modules/trasporti/components/ride/RideFilters.scss
Normal file
71
src/modules/trasporti/components/ride/RideFilters.scss
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
.ride-filters {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&__compact {
|
||||||
|
border-radius: 16px !important;
|
||||||
|
|
||||||
|
.q-card__section {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__full {
|
||||||
|
border-radius: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date-input {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section {
|
||||||
|
&-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quick-dates {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__active {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.ride-filters {
|
||||||
|
&__compact {
|
||||||
|
.q-card__section {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.col {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ride-filters__date-input {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.ride-filters {
|
||||||
|
&__section-title {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/modules/trasporti/components/ride/RideFilters.ts
Normal file
202
src/modules/trasporti/components/ride/RideFilters.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { ref, reactive, computed, watch, defineComponent, PropType } from 'vue';
|
||||||
|
import CityAutocomplete from './CityAutocomplete.vue';
|
||||||
|
import type { RideSearchFilters, RideType, Location } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'RideFilters',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
CityAutocomplete
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Object as PropType<RideSearchFilters>,
|
||||||
|
default: () => ({})
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String as PropType<'compact' | 'full' | 'sidebar'>,
|
||||||
|
default: 'compact'
|
||||||
|
},
|
||||||
|
showActiveChips: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['update:modelValue', 'search', 'reset'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
// State
|
||||||
|
const showAdvanced = ref(false);
|
||||||
|
const departureLocation = ref<Location | null>(null);
|
||||||
|
const destinationLocation = ref<Location | null>(null);
|
||||||
|
|
||||||
|
const localFilters = reactive<RideSearchFilters>({
|
||||||
|
type: undefined,
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
date: '',
|
||||||
|
seats: 1,
|
||||||
|
passingThrough: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedPreferences = ref<string[]>([]);
|
||||||
|
|
||||||
|
const preferencesState = reactive({
|
||||||
|
noSmoking: false,
|
||||||
|
petsAllowed: false,
|
||||||
|
packagesAllowed: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Options
|
||||||
|
const typeOptions = [
|
||||||
|
{ label: 'Tutti', value: undefined },
|
||||||
|
{ label: '🟢 Offerte', value: 'offer' },
|
||||||
|
{ label: '🔴 Richieste', value: 'request' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const typeOptionsWithIcons = [
|
||||||
|
{ label: 'Tutti', value: undefined },
|
||||||
|
{ label: 'Offerte 🟢', value: 'offer' },
|
||||||
|
{ label: 'Richieste 🔴', value: 'request' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const preferenceOptions = [
|
||||||
|
{ label: '🚭 Non fumatori', value: 'noSmoking' },
|
||||||
|
{ label: '🐾 Animali ammessi', value: 'petsAllowed' },
|
||||||
|
{ label: '📦 Pacchi ammessi', value: 'packagesAllowed' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Quick dates
|
||||||
|
const quickDates = computed(() => {
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const dayAfter = new Date(today);
|
||||||
|
dayAfter.setDate(dayAfter.getDate() + 2);
|
||||||
|
const nextWeek = new Date(today);
|
||||||
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ label: 'Oggi', value: formatDateValue(today) },
|
||||||
|
{ label: 'Domani', value: formatDateValue(tomorrow) },
|
||||||
|
{ label: 'Dopodomani', value: formatDateValue(dayAfter) },
|
||||||
|
{ label: 'Prossima settimana', value: formatDateValue(nextWeek) }
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const activeFiltersCount = computed(() => {
|
||||||
|
let count = 0;
|
||||||
|
if (localFilters.from) count++;
|
||||||
|
if (localFilters.to) count++;
|
||||||
|
if (localFilters.date) count++;
|
||||||
|
if (localFilters.type) count++;
|
||||||
|
if (localFilters.passingThrough) count++;
|
||||||
|
if (localFilters.seats && localFilters.seats > 1) count++;
|
||||||
|
if (selectedPreferences.value.length > 0) count++;
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch per sincronizzare con modelValue esterno
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
Object.assign(localFilters, newVal);
|
||||||
|
}
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
// Watch per emettere update
|
||||||
|
watch(localFilters, (newVal) => {
|
||||||
|
emit('update:modelValue', { ...newVal });
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const onDepartureSelect = (location: Location) => {
|
||||||
|
localFilters.from = location.city;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDestinationSelect = (location: Location) => {
|
||||||
|
localFilters.to = location.city;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitSearch = () => {
|
||||||
|
// Costruisci preferenze
|
||||||
|
if (selectedPreferences.value.length > 0 ||
|
||||||
|
preferencesState.noSmoking ||
|
||||||
|
preferencesState.petsAllowed ||
|
||||||
|
preferencesState.packagesAllowed) {
|
||||||
|
localFilters.preferences = {};
|
||||||
|
|
||||||
|
if (preferencesState.noSmoking || selectedPreferences.value.includes('noSmoking')) {
|
||||||
|
localFilters.preferences.smoking = 'no';
|
||||||
|
}
|
||||||
|
if (preferencesState.petsAllowed || selectedPreferences.value.includes('petsAllowed')) {
|
||||||
|
localFilters.preferences.pets = 'all';
|
||||||
|
}
|
||||||
|
if (preferencesState.packagesAllowed || selectedPreferences.value.includes('packagesAllowed')) {
|
||||||
|
localFilters.preferences.packages = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('search', { ...localFilters });
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
localFilters.type = undefined;
|
||||||
|
localFilters.from = '';
|
||||||
|
localFilters.to = '';
|
||||||
|
localFilters.date = '';
|
||||||
|
localFilters.seats = 1;
|
||||||
|
localFilters.passingThrough = '';
|
||||||
|
localFilters.preferences = undefined;
|
||||||
|
selectedPreferences.value = [];
|
||||||
|
preferencesState.noSmoking = false;
|
||||||
|
preferencesState.petsAllowed = false;
|
||||||
|
preferencesState.packagesAllowed = false;
|
||||||
|
departureLocation.value = null;
|
||||||
|
destinationLocation.value = null;
|
||||||
|
|
||||||
|
emit('reset');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateValue = (date: Date): string => {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('it-IT', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
showAdvanced,
|
||||||
|
localFilters,
|
||||||
|
departureLocation,
|
||||||
|
destinationLocation,
|
||||||
|
selectedPreferences,
|
||||||
|
preferencesState,
|
||||||
|
|
||||||
|
// Options
|
||||||
|
typeOptions,
|
||||||
|
typeOptionsWithIcons,
|
||||||
|
preferenceOptions,
|
||||||
|
quickDates,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
activeFiltersCount,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
onDepartureSelect,
|
||||||
|
onDestinationSelect,
|
||||||
|
emitSearch,
|
||||||
|
resetFilters,
|
||||||
|
formatDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
365
src/modules/trasporti/components/ride/RideFilters.vue
Normal file
365
src/modules/trasporti/components/ride/RideFilters.vue
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ride-filters">
|
||||||
|
<!-- Barra ricerca compatta -->
|
||||||
|
<q-card v-if="mode === 'compact'" flat bordered class="ride-filters__compact">
|
||||||
|
<q-card-section class="row items-center q-gutter-sm">
|
||||||
|
<q-input
|
||||||
|
v-model="localFilters.from"
|
||||||
|
placeholder="Da..."
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
class="col"
|
||||||
|
@keyup.enter="emitSearch"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="trip_origin" color="positive" size="18px" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-icon name="arrow_forward" color="grey" />
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="localFilters.to"
|
||||||
|
placeholder="A..."
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
class="col"
|
||||||
|
@keyup.enter="emitSearch"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="place" color="negative" size="18px" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="localFilters.date"
|
||||||
|
type="date"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
class="ride-filters__date-input"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="event" size="18px" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="search"
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
@click="emitSearch"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
icon="tune"
|
||||||
|
@click="showAdvanced = !showAdvanced"
|
||||||
|
>
|
||||||
|
<q-badge v-if="activeFiltersCount > 0" color="primary" floating>
|
||||||
|
{{ activeFiltersCount }}
|
||||||
|
</q-badge>
|
||||||
|
</q-btn>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Filtri avanzati espandibili -->
|
||||||
|
<q-slide-transition>
|
||||||
|
<div v-if="showAdvanced">
|
||||||
|
<q-separator />
|
||||||
|
<q-card-section>
|
||||||
|
<div class="row q-gutter-md">
|
||||||
|
<!-- Tipo -->
|
||||||
|
<div class="col-12 col-sm-6 col-md-3">
|
||||||
|
<q-select
|
||||||
|
v-model="localFilters.type"
|
||||||
|
:options="typeOptions"
|
||||||
|
label="Tipo"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
clearable
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posti minimi -->
|
||||||
|
<div class="col-12 col-sm-6 col-md-3">
|
||||||
|
<q-input
|
||||||
|
v-model.number="localFilters.seats"
|
||||||
|
type="number"
|
||||||
|
label="Posti minimi"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preferenze -->
|
||||||
|
<div class="col-12 col-sm-6 col-md-3">
|
||||||
|
<q-select
|
||||||
|
v-model="selectedPreferences"
|
||||||
|
:options="preferenceOptions"
|
||||||
|
label="Preferenze"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
use-chips
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Città intermedia -->
|
||||||
|
<div class="col-12 col-sm-6 col-md-3">
|
||||||
|
<q-input
|
||||||
|
v-model="localFilters.passingThrough"
|
||||||
|
label="Passa per..."
|
||||||
|
placeholder="Città intermedia"
|
||||||
|
clearable
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row justify-end q-mt-md q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="Reset filtri"
|
||||||
|
color="grey"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
label="Applica"
|
||||||
|
color="primary"
|
||||||
|
@click="emitSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</div>
|
||||||
|
</q-slide-transition>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Versione sidebar/full -->
|
||||||
|
<q-card v-else flat bordered class="ride-filters__full">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6 q-mb-md">
|
||||||
|
<q-icon name="filter_list" class="q-mr-sm" />
|
||||||
|
Filtra Viaggi
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tipo Viaggio -->
|
||||||
|
<div class="ride-filters__section">
|
||||||
|
<div class="ride-filters__section-title">Tipo</div>
|
||||||
|
<q-option-group
|
||||||
|
v-model="localFilters.type"
|
||||||
|
:options="typeOptionsWithIcons"
|
||||||
|
type="radio"
|
||||||
|
inline
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
|
<!-- Partenza -->
|
||||||
|
<div class="ride-filters__section">
|
||||||
|
<div class="ride-filters__section-title">
|
||||||
|
<q-icon name="trip_origin" color="positive" class="q-mr-xs" />
|
||||||
|
Partenza
|
||||||
|
</div>
|
||||||
|
<CityAutocomplete
|
||||||
|
v-model="departureLocation"
|
||||||
|
label="Città di partenza"
|
||||||
|
placeholder="Da dove parti?"
|
||||||
|
prepend-icon="trip_origin"
|
||||||
|
icon-color="positive"
|
||||||
|
dense
|
||||||
|
:show-location-button="false"
|
||||||
|
@select="onDepartureSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Destinazione -->
|
||||||
|
<div class="ride-filters__section q-mt-md">
|
||||||
|
<div class="ride-filters__section-title">
|
||||||
|
<q-icon name="place" color="negative" class="q-mr-xs" />
|
||||||
|
Destinazione
|
||||||
|
</div>
|
||||||
|
<CityAutocomplete
|
||||||
|
v-model="destinationLocation"
|
||||||
|
label="Città di arrivo"
|
||||||
|
placeholder="Dove vai?"
|
||||||
|
prepend-icon="place"
|
||||||
|
icon-color="negative"
|
||||||
|
dense
|
||||||
|
:show-location-button="false"
|
||||||
|
@select="onDestinationSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Passa per -->
|
||||||
|
<div class="ride-filters__section q-mt-md">
|
||||||
|
<div class="ride-filters__section-title">
|
||||||
|
<q-icon name="more_vert" class="q-mr-xs" />
|
||||||
|
Passa per
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model="localFilters.passingThrough"
|
||||||
|
placeholder="Città intermedia (opzionale)"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
|
<!-- Data -->
|
||||||
|
<div class="ride-filters__section">
|
||||||
|
<div class="ride-filters__section-title">
|
||||||
|
<q-icon name="event" class="q-mr-xs" />
|
||||||
|
Data
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model="localFilters.date"
|
||||||
|
type="date"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
<div class="ride-filters__quick-dates q-mt-sm">
|
||||||
|
<q-chip
|
||||||
|
v-for="quickDate in quickDates"
|
||||||
|
:key="quickDate.value"
|
||||||
|
clickable
|
||||||
|
:outline="localFilters.date !== quickDate.value"
|
||||||
|
:color="localFilters.date === quickDate.value ? 'primary' : undefined"
|
||||||
|
:text-color="localFilters.date === quickDate.value ? 'white' : undefined"
|
||||||
|
size="sm"
|
||||||
|
@click="localFilters.date = quickDate.value"
|
||||||
|
>
|
||||||
|
{{ quickDate.label }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
|
<!-- Posti -->
|
||||||
|
<div class="ride-filters__section">
|
||||||
|
<div class="ride-filters__section-title">
|
||||||
|
<q-icon name="airline_seat_recline_normal" class="q-mr-xs" />
|
||||||
|
Posti necessari
|
||||||
|
</div>
|
||||||
|
<q-slider
|
||||||
|
v-model="localFilters.seats"
|
||||||
|
:min="1"
|
||||||
|
:max="8"
|
||||||
|
:step="1"
|
||||||
|
label
|
||||||
|
label-always
|
||||||
|
markers
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
|
<!-- Preferenze -->
|
||||||
|
<div class="ride-filters__section">
|
||||||
|
<div class="ride-filters__section-title">Preferenze</div>
|
||||||
|
|
||||||
|
<q-checkbox
|
||||||
|
v-model="preferencesState.noSmoking"
|
||||||
|
label="🚭 Non fumatori"
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="preferencesState.petsAllowed"
|
||||||
|
label="🐾 Animali ammessi"
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
<q-checkbox
|
||||||
|
v-model="preferencesState.packagesAllowed"
|
||||||
|
label="📦 Pacchi ammessi"
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
|
||||||
|
<!-- Azioni -->
|
||||||
|
<div class="row q-gutter-sm">
|
||||||
|
<q-btn
|
||||||
|
class="col"
|
||||||
|
outline
|
||||||
|
label="Reset"
|
||||||
|
color="grey"
|
||||||
|
@click="resetFilters"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
class="col"
|
||||||
|
unelevated
|
||||||
|
label="Cerca"
|
||||||
|
color="primary"
|
||||||
|
icon="search"
|
||||||
|
@click="emitSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Chips filtri attivi -->
|
||||||
|
<div v-if="showActiveChips && activeFiltersCount > 0" class="ride-filters__active q-mt-sm">
|
||||||
|
<q-chip
|
||||||
|
v-if="localFilters.from"
|
||||||
|
removable
|
||||||
|
color="primary"
|
||||||
|
text-color="white"
|
||||||
|
icon="trip_origin"
|
||||||
|
size="sm"
|
||||||
|
@remove="localFilters.from = ''"
|
||||||
|
>
|
||||||
|
Da: {{ localFilters.from }}
|
||||||
|
</q-chip>
|
||||||
|
<q-chip
|
||||||
|
v-if="localFilters.to"
|
||||||
|
removable
|
||||||
|
color="negative"
|
||||||
|
text-color="white"
|
||||||
|
icon="place"
|
||||||
|
size="sm"
|
||||||
|
@remove="localFilters.to = ''"
|
||||||
|
>
|
||||||
|
A: {{ localFilters.to }}
|
||||||
|
</q-chip>
|
||||||
|
<q-chip
|
||||||
|
v-if="localFilters.date"
|
||||||
|
removable
|
||||||
|
color="secondary"
|
||||||
|
text-color="white"
|
||||||
|
icon="event"
|
||||||
|
size="sm"
|
||||||
|
@remove="localFilters.date = ''"
|
||||||
|
>
|
||||||
|
{{ formatDate(localFilters.date) }}
|
||||||
|
</q-chip>
|
||||||
|
<q-chip
|
||||||
|
v-if="localFilters.type"
|
||||||
|
removable
|
||||||
|
:color="localFilters.type === 'offer' ? 'positive' : 'negative'"
|
||||||
|
text-color="white"
|
||||||
|
size="sm"
|
||||||
|
@remove="localFilters.type = undefined"
|
||||||
|
>
|
||||||
|
{{ localFilters.type === 'offer' ? '🟢 Offerte' : '🔴 Richieste' }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./RideFilters.ts" />
|
||||||
|
<style lang="scss" src="./RideFilters.scss" />
|
||||||
153
src/modules/trasporti/components/ride/RideMap.scss
Normal file
153
src/modules/trasporti/components/ride/RideMap.scss
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
.ride-map {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #e0e0e0;
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
z-index: 1000;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
.q-btn {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__legend-marker {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&--start {
|
||||||
|
background: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--end {
|
||||||
|
background: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--waypoint {
|
||||||
|
background: #ff9800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom marker styles
|
||||||
|
:global(.ride-map-marker) {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
.marker-inner {
|
||||||
|
font-size: 24px;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaflet customizations
|
||||||
|
:global(.leaflet-popup-content-wrapper) {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.leaflet-popup-content) {
|
||||||
|
margin: 12px 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fullscreen mode
|
||||||
|
.ride-map:-webkit-full-screen,
|
||||||
|
.ride-map:fullscreen {
|
||||||
|
height: 100vh !important;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.ride-map {
|
||||||
|
&__loading {
|
||||||
|
background: rgba(30, 30, 30, 0.9);
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-info,
|
||||||
|
&__legend {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__controls .q-btn {
|
||||||
|
background: #2d2d2d !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
440
src/modules/trasporti/components/ride/RideMap.ts
Normal file
440
src/modules/trasporti/components/ride/RideMap.ts
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
80
src/modules/trasporti/components/ride/RideMap.vue
Normal file
80
src/modules/trasporti/components/ride/RideMap.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ride-map" :style="{ height: mapHeight }">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="ride-map__loading">
|
||||||
|
<q-spinner color="primary" size="48px" />
|
||||||
|
<span>Caricamento mappa...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mappa Leaflet -->
|
||||||
|
<div ref="mapContainer" class="ride-map__container"></div>
|
||||||
|
|
||||||
|
<!-- Controlli -->
|
||||||
|
<div class="ride-map__controls">
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
color="white"
|
||||||
|
text-color="dark"
|
||||||
|
icon="my_location"
|
||||||
|
size="sm"
|
||||||
|
@click="centerOnUser"
|
||||||
|
>
|
||||||
|
<q-tooltip>La mia posizione</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
color="white"
|
||||||
|
text-color="dark"
|
||||||
|
icon="zoom_out_map"
|
||||||
|
size="sm"
|
||||||
|
@click="fitBounds"
|
||||||
|
>
|
||||||
|
<q-tooltip>Mostra tutto il percorso</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-if="showFullscreenButton"
|
||||||
|
round
|
||||||
|
color="white"
|
||||||
|
text-color="dark"
|
||||||
|
:icon="isFullscreen ? 'fullscreen_exit' : 'fullscreen'"
|
||||||
|
size="sm"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<q-tooltip>{{ isFullscreen ? 'Esci' : 'Schermo intero' }}</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info percorso -->
|
||||||
|
<div v-if="routeInfo && showRouteInfo" class="ride-map__route-info">
|
||||||
|
<div class="ride-map__route-info-item">
|
||||||
|
<q-icon name="route" size="18px" />
|
||||||
|
<span>{{ routeInfo.distance }} km</span>
|
||||||
|
</div>
|
||||||
|
<div class="ride-map__route-info-item">
|
||||||
|
<q-icon name="schedule" size="18px" />
|
||||||
|
<span>{{ formattedDuration }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legenda -->
|
||||||
|
<div v-if="showLegend" class="ride-map__legend">
|
||||||
|
<div class="ride-map__legend-item">
|
||||||
|
<div class="ride-map__legend-marker ride-map__legend-marker--start"></div>
|
||||||
|
<span>Partenza</span>
|
||||||
|
</div>
|
||||||
|
<div class="ride-map__legend-item">
|
||||||
|
<div class="ride-map__legend-marker ride-map__legend-marker--end"></div>
|
||||||
|
<span>Arrivo</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="waypoints.length > 0" class="ride-map__legend-item">
|
||||||
|
<div class="ride-map__legend-marker ride-map__legend-marker--waypoint"></div>
|
||||||
|
<span>Tappe</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./RideMap.ts" />
|
||||||
|
<style lang="scss" src="./RideMap.scss" />
|
||||||
132
src/modules/trasporti/components/ride/RideTypeToggle.scss
Normal file
132
src/modules/trasporti/components/ride/RideTypeToggle.scss
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/modules/trasporti/components/ride/RideTypeToggle.ts
Normal file
59
src/modules/trasporti/components/ride/RideTypeToggle.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
66
src/modules/trasporti/components/ride/RideTypeToggle.vue
Normal file
66
src/modules/trasporti/components/ride/RideTypeToggle.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<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" />
|
||||||
81
src/modules/trasporti/components/ride/StarRating.scss
Normal file
81
src/modules/trasporti/components/ride/StarRating.scss
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
.star-rating {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&--readonly {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--large {
|
||||||
|
.star-rating__stars {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--small {
|
||||||
|
.star-rating__stars {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-rating__label,
|
||||||
|
.star-rating__value {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stars {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__star {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--filled,
|
||||||
|
&--hovered {
|
||||||
|
.q-icon {
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(255, 193, 7, 0.3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--q-grey-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.star-rating {
|
||||||
|
&__label,
|
||||||
|
&__value {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/modules/trasporti/components/ride/StarRating.ts
Normal file
109
src/modules/trasporti/components/ride/StarRating.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { ref, computed, defineComponent, PropType } from 'vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'StarRating',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String as PropType<'small' | 'medium' | 'large'>,
|
||||||
|
default: 'medium'
|
||||||
|
},
|
||||||
|
showLabel: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: 'amber'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['update:modelValue', 'change'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const hoverValue = ref(0);
|
||||||
|
|
||||||
|
const displayValue = computed(() => {
|
||||||
|
if (!props.readonly && hoverValue.value > 0) {
|
||||||
|
return hoverValue.value;
|
||||||
|
}
|
||||||
|
return props.modelValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const starSize = computed(() => {
|
||||||
|
switch (props.size) {
|
||||||
|
case 'small': return '16px';
|
||||||
|
case 'large': return '32px';
|
||||||
|
default: return '24px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayLabel = computed(() => {
|
||||||
|
const value = displayValue.value;
|
||||||
|
if (value >= 4.5) return 'Eccellente';
|
||||||
|
if (value >= 4) return 'Ottimo';
|
||||||
|
if (value >= 3.5) return 'Molto buono';
|
||||||
|
if (value >= 3) return 'Buono';
|
||||||
|
if (value >= 2) return 'Sufficiente';
|
||||||
|
if (value >= 1) return 'Scarso';
|
||||||
|
return 'Non valutato';
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStarIcon = (star: number): string => {
|
||||||
|
const value = displayValue.value;
|
||||||
|
if (star <= Math.floor(value)) {
|
||||||
|
return 'star';
|
||||||
|
} else if (star === Math.ceil(value) && value % 1 >= 0.5) {
|
||||||
|
return 'star_half';
|
||||||
|
}
|
||||||
|
return 'star_outline';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStarColor = (star: number): string => {
|
||||||
|
const value = displayValue.value;
|
||||||
|
if (star <= value) {
|
||||||
|
return props.color;
|
||||||
|
}
|
||||||
|
return 'grey-4';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setValue = (star: number) => {
|
||||||
|
if (props.readonly) return;
|
||||||
|
emit('update:modelValue', star);
|
||||||
|
emit('change', star);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setHover = (star: number) => {
|
||||||
|
if (props.readonly) return;
|
||||||
|
hoverValue.value = star;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearHover = () => {
|
||||||
|
hoverValue.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
hoverValue,
|
||||||
|
displayValue,
|
||||||
|
starSize,
|
||||||
|
displayLabel,
|
||||||
|
getStarIcon,
|
||||||
|
getStarColor,
|
||||||
|
setValue,
|
||||||
|
setHover,
|
||||||
|
clearHover
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
47
src/modules/trasporti/components/ride/StarRating.vue
Normal file
47
src/modules/trasporti/components/ride/StarRating.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'star-rating',
|
||||||
|
{ 'star-rating--readonly': readonly },
|
||||||
|
{ 'star-rating--large': size === 'large' },
|
||||||
|
{ 'star-rating--small': size === 'small' }
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="star-rating__stars">
|
||||||
|
<button
|
||||||
|
v-for="star in 5"
|
||||||
|
:key="star"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'star-rating__star',
|
||||||
|
{ 'star-rating__star--filled': star <= displayValue },
|
||||||
|
{ 'star-rating__star--half': star === Math.ceil(displayValue) && displayValue % 1 !== 0 },
|
||||||
|
{ 'star-rating__star--hovered': !readonly && star <= hoverValue }
|
||||||
|
]"
|
||||||
|
:disabled="readonly"
|
||||||
|
@click="setValue(star)"
|
||||||
|
@mouseenter="setHover(star)"
|
||||||
|
@mouseleave="clearHover"
|
||||||
|
>
|
||||||
|
<q-icon
|
||||||
|
:name="getStarIcon(star)"
|
||||||
|
:color="getStarColor(star)"
|
||||||
|
:size="starSize"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label valore -->
|
||||||
|
<span v-if="showLabel" class="star-rating__label">
|
||||||
|
{{ displayLabel }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Valore numerico -->
|
||||||
|
<span v-if="showValue" class="star-rating__value">
|
||||||
|
{{ displayValue.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./StarRating.ts" />
|
||||||
|
<style lang="scss" src="./StarRating.scss" />
|
||||||
186
src/modules/trasporti/components/ride/VehicleSelector.scss
Normal file
186
src/modules/trasporti/components/ride/VehicleSelector.scss
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
.vehicle-selector {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Veicoli salvati
|
||||||
|
&__saved {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__saved-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__saved-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
border-color: var(--q-primary);
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle-details {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__color-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form
|
||||||
|
&__form {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
&--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type-btn {
|
||||||
|
min-width: 50px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__color-preview {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features
|
||||||
|
&__features-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
&__preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(var(--q-positive-rgb), 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animazione
|
||||||
|
.slide-fade-enter-active,
|
||||||
|
.slide-fade-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-fade-enter-from,
|
||||||
|
.slide-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.vehicle-selector {
|
||||||
|
&__form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.vehicle-selector {
|
||||||
|
&__saved-item {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/modules/trasporti/components/ride/VehicleSelector.ts
Normal file
144
src/modules/trasporti/components/ride/VehicleSelector.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { ref, reactive, computed, watch, defineComponent, PropType } from 'vue';
|
||||||
|
import type { Vehicle, VehicleType, VehicleFeature } from '../../types';
|
||||||
|
import { VEHICLE_TYPES, VEHICLE_COLORS, VEHICLE_FEATURES_OPTIONS } from '../../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'VehicleSelector',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Object as PropType<Vehicle>,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
savedVehicles: {
|
||||||
|
type: Array as PropType<Vehicle[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
canSaveVehicle: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['update:modelValue', 'save-vehicle'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
// State
|
||||||
|
const showNewVehicleForm = ref(false);
|
||||||
|
const saveVehicleToProfile = ref(false);
|
||||||
|
const selectedFeatures = ref<VehicleFeature[]>([]);
|
||||||
|
|
||||||
|
const localVehicle = reactive<Vehicle>({
|
||||||
|
type: 'auto',
|
||||||
|
brand: '',
|
||||||
|
model: '',
|
||||||
|
color: '',
|
||||||
|
colorHex: '',
|
||||||
|
year: undefined,
|
||||||
|
seats: 4,
|
||||||
|
licensePlate: '',
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Options
|
||||||
|
const vehicleTypes = VEHICLE_TYPES;
|
||||||
|
const vehicleFeatures = VEHICLE_FEATURES_OPTIONS;
|
||||||
|
const colorOptions = VEHICLE_COLORS.map((c) => ({
|
||||||
|
label: c.name,
|
||||||
|
value: c.name,
|
||||||
|
hex: c.hex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Watch per sincronizzare con modelValue
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal && Object.keys(newVal).length > 0) {
|
||||||
|
Object.assign(localVehicle, newVal);
|
||||||
|
selectedFeatures.value = newVal.features || [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Watch per emettere update
|
||||||
|
watch(
|
||||||
|
[localVehicle, selectedFeatures],
|
||||||
|
() => {
|
||||||
|
const vehicle: Vehicle = {
|
||||||
|
...localVehicle,
|
||||||
|
features: selectedFeatures.value,
|
||||||
|
colorHex: getColorHex(localVehicle.color),
|
||||||
|
};
|
||||||
|
emit('update:modelValue', vehicle);
|
||||||
|
|
||||||
|
if (saveVehicleToProfile.value && hasValidVehicle.value) {
|
||||||
|
emit('save-vehicle', vehicle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const hasValidVehicle = computed(() => {
|
||||||
|
return localVehicle.type && localVehicle.seats && localVehicle.seats > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const isVehicleSelected = (vehicle: Vehicle): boolean => {
|
||||||
|
return (
|
||||||
|
localVehicle.brand === vehicle.brand &&
|
||||||
|
localVehicle.model === vehicle.model &&
|
||||||
|
localVehicle.color === vehicle.color
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSavedVehicle = (vehicle: Vehicle) => {
|
||||||
|
Object.assign(localVehicle, vehicle);
|
||||||
|
selectedFeatures.value = vehicle.features || [];
|
||||||
|
showNewVehicleForm.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVehicleTypeIcon = (type?: VehicleType): string => {
|
||||||
|
const icons: Record<VehicleType, string> = {
|
||||||
|
auto: '🚗',
|
||||||
|
moto: '🏍️',
|
||||||
|
furgone: '🚐',
|
||||||
|
minibus: '🚌',
|
||||||
|
altro: '🚙',
|
||||||
|
};
|
||||||
|
return icons[type || 'auto'] || '🚗';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColorHex = (colorName?: string): string => {
|
||||||
|
// console.log('colorName received:', colorName, typeof colorName); // Debug
|
||||||
|
if (!colorName) return '#9e9e9e';
|
||||||
|
const color = VEHICLE_COLORS.find(
|
||||||
|
(c) => c.name.toLowerCase() === colorName.toLowerCase()
|
||||||
|
);
|
||||||
|
return color?.hex || '#9e9e9e';
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
showNewVehicleForm,
|
||||||
|
saveVehicleToProfile,
|
||||||
|
selectedFeatures,
|
||||||
|
localVehicle,
|
||||||
|
|
||||||
|
// Options
|
||||||
|
vehicleTypes,
|
||||||
|
vehicleFeatures,
|
||||||
|
colorOptions,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
hasValidVehicle,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
isVehicleSelected,
|
||||||
|
selectSavedVehicle,
|
||||||
|
getVehicleTypeIcon,
|
||||||
|
getColorHex,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
224
src/modules/trasporti/components/ride/VehicleSelector.vue
Normal file
224
src/modules/trasporti/components/ride/VehicleSelector.vue
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<template>
|
||||||
|
<div class="vehicle-selector">
|
||||||
|
<div class="vehicle-selector__header">
|
||||||
|
<q-icon name="directions_car" size="20px" color="primary" />
|
||||||
|
<span class="vehicle-selector__title">Veicolo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Veicoli salvati -->
|
||||||
|
<div v-if="savedVehicles.length > 0" class="vehicle-selector__saved">
|
||||||
|
<div class="vehicle-selector__label">I tuoi veicoli:</div>
|
||||||
|
<div class="vehicle-selector__saved-list">
|
||||||
|
<div
|
||||||
|
v-for="vehicle in savedVehicles"
|
||||||
|
:key="vehicle._id"
|
||||||
|
:class="[
|
||||||
|
'vehicle-selector__saved-item',
|
||||||
|
{ 'vehicle-selector__saved-item--selected': isVehicleSelected(vehicle) }
|
||||||
|
]"
|
||||||
|
@click="selectSavedVehicle(vehicle)"
|
||||||
|
>
|
||||||
|
<div class="vehicle-selector__vehicle-icon">
|
||||||
|
{{ getVehicleTypeIcon(vehicle.type) }}
|
||||||
|
</div>
|
||||||
|
<div class="vehicle-selector__vehicle-info">
|
||||||
|
<span class="vehicle-selector__vehicle-name">
|
||||||
|
{{ vehicle.brand }} {{ vehicle.model }}
|
||||||
|
</span>
|
||||||
|
<span class="vehicle-selector__vehicle-details">
|
||||||
|
<span
|
||||||
|
class="vehicle-selector__color-dot"
|
||||||
|
:style="{ backgroundColor: vehicle.colorHex || getColorHex(vehicle.color) }"
|
||||||
|
></span>
|
||||||
|
{{ vehicle.color }} • {{ vehicle.seats }} posti
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<q-icon
|
||||||
|
v-if="vehicle.isDefault"
|
||||||
|
name="star"
|
||||||
|
color="amber"
|
||||||
|
size="20px"
|
||||||
|
>
|
||||||
|
<q-tooltip>Veicolo predefinito</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
icon="add"
|
||||||
|
label="Aggiungi nuovo veicolo"
|
||||||
|
class="q-mt-sm"
|
||||||
|
@click="showNewVehicleForm = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form nuovo veicolo -->
|
||||||
|
<transition name="slide-fade">
|
||||||
|
<div v-if="showNewVehicleForm || savedVehicles.length === 0" class="vehicle-selector__form">
|
||||||
|
<div v-if="savedVehicles.length > 0" class="vehicle-selector__form-header">
|
||||||
|
<span>Nuovo veicolo</span>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="close"
|
||||||
|
size="sm"
|
||||||
|
@click="showNewVehicleForm = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vehicle-selector__form-grid">
|
||||||
|
<!-- Tipo veicolo -->
|
||||||
|
<div class="vehicle-selector__field vehicle-selector__field--full">
|
||||||
|
<div class="vehicle-selector__label">Tipo veicolo:</div>
|
||||||
|
<div class="vehicle-selector__type-buttons">
|
||||||
|
<q-btn
|
||||||
|
v-for="type in vehicleTypes"
|
||||||
|
:key="type.value"
|
||||||
|
:color="localVehicle.type === type.value ? 'primary' : 'grey-4'"
|
||||||
|
:text-color="localVehicle.type === type.value ? 'white' : 'dark'"
|
||||||
|
:label="type.icon"
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
class="vehicle-selector__type-btn"
|
||||||
|
@click="localVehicle.type = type.value"
|
||||||
|
>
|
||||||
|
<q-tooltip>{{ type.label }}</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Marca -->
|
||||||
|
<q-input
|
||||||
|
v-model="localVehicle.brand"
|
||||||
|
label="Marca"
|
||||||
|
placeholder="Es: Fiat, Volkswagen..."
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="vehicle-selector__field"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modello -->
|
||||||
|
<q-input
|
||||||
|
v-model="localVehicle.model"
|
||||||
|
label="Modello"
|
||||||
|
placeholder="Es: Panda, Golf..."
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="vehicle-selector__field"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Colore -->
|
||||||
|
<div class="vehicle-selector__field">
|
||||||
|
<q-select
|
||||||
|
v-model="localVehicle.color"
|
||||||
|
:options="colorOptions"
|
||||||
|
label="Colore"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
<template v-slot:option="{ itemProps, opt }">
|
||||||
|
<q-item v-bind="itemProps">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<div
|
||||||
|
class="vehicle-selector__color-preview"
|
||||||
|
:style="{ backgroundColor: opt.hex }"
|
||||||
|
></div>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>{{ opt.label }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
|
<template v-slot:selected-item="{ opt }">
|
||||||
|
<div class="row items-center">
|
||||||
|
<div
|
||||||
|
class="vehicle-selector__color-preview q-mr-sm"
|
||||||
|
:style="{ backgroundColor: getColorHex(opt.value) }"
|
||||||
|
></div>
|
||||||
|
{{ opt.label }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Posti -->
|
||||||
|
<q-input
|
||||||
|
v-model.number="localVehicle.seats"
|
||||||
|
type="number"
|
||||||
|
label="Posti disponibili"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="vehicle-selector__field"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Anno (opzionale) -->
|
||||||
|
<q-input
|
||||||
|
v-model.number="localVehicle.year"
|
||||||
|
type="number"
|
||||||
|
label="Anno (opzionale)"
|
||||||
|
:min="1990"
|
||||||
|
:max="new Date().getFullYear() + 1"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="vehicle-selector__field"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Targa (opzionale) -->
|
||||||
|
<q-input
|
||||||
|
v-model="localVehicle.licensePlate"
|
||||||
|
label="Targa (opzionale)"
|
||||||
|
placeholder="AA000BB"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="vehicle-selector__field"
|
||||||
|
:rules="[val => !val || /^[A-Z]{2}\d{3}[A-Z]{2}$/i.test(val) || 'Formato targa non valido']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<div class="vehicle-selector__features q-mt-md">
|
||||||
|
<div class="vehicle-selector__label">Caratteristiche:</div>
|
||||||
|
<div class="vehicle-selector__features-grid">
|
||||||
|
<q-checkbox
|
||||||
|
v-for="feature in vehicleFeatures"
|
||||||
|
:key="feature.value"
|
||||||
|
v-model="selectedFeatures"
|
||||||
|
:val="feature.value"
|
||||||
|
:label="`${feature.icon} ${feature.label}`"
|
||||||
|
dense
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Salva veicolo -->
|
||||||
|
<div v-if="canSaveVehicle" class="vehicle-selector__save q-mt-md">
|
||||||
|
<q-checkbox
|
||||||
|
v-model="saveVehicleToProfile"
|
||||||
|
label="Salva questo veicolo nel mio profilo"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Anteprima veicolo selezionato -->
|
||||||
|
<div v-if="hasValidVehicle" class="vehicle-selector__preview">
|
||||||
|
<q-icon name="check_circle" color="positive" size="20px" />
|
||||||
|
<span>
|
||||||
|
{{ getVehicleTypeIcon(localVehicle.type) }}
|
||||||
|
{{ localVehicle.brand }} {{ localVehicle.model }}
|
||||||
|
<span v-if="localVehicle.color">({{ localVehicle.color }})</span>
|
||||||
|
- {{ localVehicle.seats }} posti
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./VehicleSelector.ts" />
|
||||||
|
<style lang="scss" src="./VehicleSelector.scss" />
|
||||||
141
src/modules/trasporti/components/ride/WaypointsEditor.scss
Normal file
141
src/modules/trasporti/components/ride/WaypointsEditor.scss
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
.waypoints-editor {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__order {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--q-primary);
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ghost {
|
||||||
|
opacity: 0.5;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggerimenti
|
||||||
|
&__suggestions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__suggestions-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__suggestions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Riepilogo percorso
|
||||||
|
&__summary {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-point {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&--start {
|
||||||
|
background: rgba(76, 175, 80, 0.1);
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--end {
|
||||||
|
background: rgba(244, 67, 54, 0.1);
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.waypoints-editor {
|
||||||
|
&__item {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-point {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/modules/trasporti/components/ride/WaypointsEditor.ts
Normal file
139
src/modules/trasporti/components/ride/WaypointsEditor.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
117
src/modules/trasporti/components/ride/WaypointsEditor.vue
Normal file
117
src/modules/trasporti/components/ride/WaypointsEditor.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div class="waypoints-editor">
|
||||||
|
<div class="waypoints-editor__header">
|
||||||
|
<q-icon name="add_location" size="20px" color="primary" />
|
||||||
|
<span class="waypoints-editor__title">Tappe Intermedie</span>
|
||||||
|
<q-badge v-if="waypoints.length > 0" color="primary">
|
||||||
|
{{ waypoints.length }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista waypoints -->
|
||||||
|
<draggable
|
||||||
|
v-model="waypoints"
|
||||||
|
item-key="id"
|
||||||
|
handle=".waypoints-editor__drag-handle"
|
||||||
|
ghost-class="waypoints-editor__ghost"
|
||||||
|
@end="onDragEnd"
|
||||||
|
>
|
||||||
|
<template #item="{ element, index }">
|
||||||
|
<div class="waypoints-editor__item">
|
||||||
|
<div class="waypoints-editor__drag-handle">
|
||||||
|
<q-icon name="drag_indicator" color="grey" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="waypoints-editor__order">{{ index + 1 }}</div>
|
||||||
|
|
||||||
|
<div class="waypoints-editor__content">
|
||||||
|
<CityAutocomplete
|
||||||
|
v-model="element.location"
|
||||||
|
:label="`Tappa ${index + 1}`"
|
||||||
|
placeholder="Cerca città..."
|
||||||
|
dense
|
||||||
|
:show-location-button="false"
|
||||||
|
:show-favorites="false"
|
||||||
|
@select="(loc) => onWaypointSelect(index, loc)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="close"
|
||||||
|
color="negative"
|
||||||
|
size="sm"
|
||||||
|
@click="removeWaypoint(index)"
|
||||||
|
>
|
||||||
|
<q-tooltip>Rimuovi tappa</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
|
||||||
|
<!-- Aggiungi waypoint -->
|
||||||
|
<div class="waypoints-editor__add">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
icon="add_location"
|
||||||
|
label="Aggiungi tappa"
|
||||||
|
:disable="waypoints.length >= maxWaypoints"
|
||||||
|
@click="addWaypoint"
|
||||||
|
/>
|
||||||
|
<span v-if="waypoints.length >= maxWaypoints" class="text-caption text-grey">
|
||||||
|
Massimo {{ maxWaypoints }} tappe
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suggerimenti -->
|
||||||
|
<div v-if="showSuggestions && suggestedWaypoints.length > 0" class="waypoints-editor__suggestions">
|
||||||
|
<q-separator class="q-my-md" />
|
||||||
|
<div class="waypoints-editor__suggestions-header">
|
||||||
|
<q-icon name="lightbulb" color="amber" />
|
||||||
|
<span>Città suggerite sul percorso:</span>
|
||||||
|
</div>
|
||||||
|
<div class="waypoints-editor__suggestions-list">
|
||||||
|
<q-chip
|
||||||
|
v-for="(suggestion, index) in suggestedWaypoints"
|
||||||
|
:key="index"
|
||||||
|
clickable
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
icon="add"
|
||||||
|
@click="addSuggestedWaypoint(suggestion)"
|
||||||
|
>
|
||||||
|
{{ suggestion.city }}
|
||||||
|
<span v-if="suggestion.province" class="text-caption q-ml-xs">
|
||||||
|
({{ suggestion.province }})
|
||||||
|
</span>
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Riepilogo percorso -->
|
||||||
|
<div v-if="waypoints.length > 0" class="waypoints-editor__summary">
|
||||||
|
<div class="waypoints-editor__route">
|
||||||
|
<span class="waypoints-editor__route-point waypoints-editor__route-point--start">
|
||||||
|
🟢 {{ departureCity || 'Partenza' }}
|
||||||
|
</span>
|
||||||
|
<template v-for="(wp, index) in waypoints" :key="index">
|
||||||
|
<q-icon name="arrow_forward" color="grey" size="16px" />
|
||||||
|
<span class="waypoints-editor__route-point">
|
||||||
|
📍 {{ wp.location?.city || `Tappa ${index + 1}` }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<q-icon name="arrow_forward" color="grey" size="16px" />
|
||||||
|
<span class="waypoints-editor__route-point waypoints-editor__route-point--end">
|
||||||
|
🔴 {{ destinationCity || 'Arrivo' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./WaypointsEditor.ts" />
|
||||||
|
<style lang="scss" src="./WaypointsEditor.scss" />
|
||||||
12
src/modules/trasporti/components/ride/index.ts
Normal file
12
src/modules/trasporti/components/ride/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Export all ride components
|
||||||
|
export { default as RideTypeToggle } from './RideTypeToggle.vue';
|
||||||
|
export { default as CityAutocomplete } from './CityAutocomplete.vue';
|
||||||
|
export { default as RideCard } from './RideCard.vue';
|
||||||
|
export { default as RideFilters } from './RideFilters.vue';
|
||||||
|
export { default as RecurrenceSelector } from './RecurrenceSelector.vue';
|
||||||
|
export { default as PreferencesSelector } from './PreferencesSelector.vue';
|
||||||
|
export { default as ContribTypeSelector } from './ContribTypeSelector.vue';
|
||||||
|
export { default as VehicleSelector } from './VehicleSelector.vue';
|
||||||
|
export { default as WaypointsEditor } from './WaypointsEditor.vue';
|
||||||
|
export { default as RideMap } from './RideMap.vue';
|
||||||
|
export { default as StarRating } from './StarRating.vue';
|
||||||
549
src/modules/trasporti/components/widgets/RideWidget.scss
Normal file
549
src/modules/trasporti/components/widgets/RideWidget.scss
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
// RideWidget.scss
|
||||||
|
.ride-widget {
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 8px 30px rgba(102, 126, 234, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--expanded {
|
||||||
|
box-shadow: 0 8px 40px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, #5a6fd6 0%, #6a4190 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
background: #ff5252;
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 2px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-mini {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-dot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__expand-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
&__content {
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
&__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--offers {
|
||||||
|
border-left: 3px solid #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--requests {
|
||||||
|
border-left: 3px solid #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--matches {
|
||||||
|
border-left: 3px solid #ff9800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
&__actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action-btn {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&--offer {
|
||||||
|
background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, #43a047 0%, #256427 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--request {
|
||||||
|
background: linear-gradient(135deg, #f44336 0%, #c62828 100%);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, #e53935 0%, #b71c1c 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Rides
|
||||||
|
&__recent {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__recent-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rides-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-item {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8f9ff;
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-type {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type-badge {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-route {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-city {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-price {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty State
|
||||||
|
&__empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #888;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// My Rides
|
||||||
|
&__my-rides {
|
||||||
|
background: white;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__my-rides-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__my-ride-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f8f9fc;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0f2ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__my-ride-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__my-ride-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__my-ride-route {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__my-ride-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__see-all {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert
|
||||||
|
&__alert {
|
||||||
|
background: linear-gradient(135deg, #fff8e1 0%, #ffecb3 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8d6e00;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #e65100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
&__messages {
|
||||||
|
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1565c0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-icon:last-child {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
&__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
.q-btn {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
:deep(.q-icon) {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.q-btn__content) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide Transition
|
||||||
|
.slide-enter-active,
|
||||||
|
.slide-leave-active {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
max-height: 600px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-from,
|
||||||
|
.slide-leave-to {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark Mode
|
||||||
|
.body--dark {
|
||||||
|
.ride-widget {
|
||||||
|
background: #1e1e30;
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
background: #16162a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-card,
|
||||||
|
&__ride-item,
|
||||||
|
&__my-rides {
|
||||||
|
background: #1e1e30;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-value,
|
||||||
|
&__ride-route,
|
||||||
|
&__my-ride-route {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__my-ride-item {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__my-ride-icon {
|
||||||
|
background: #2d2d44;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.ride-widget {
|
||||||
|
&__stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.q-btn {
|
||||||
|
flex: 0 0 50%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
286
src/modules/trasporti/components/widgets/RideWidget.ts
Normal file
286
src/modules/trasporti/components/widgets/RideWidget.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
// RideWidget.ts
|
||||||
|
import { defineComponent, ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { Api } from '@api';
|
||||||
|
import type { Ride, ContribType } from '../../types/trasporti.types';
|
||||||
|
|
||||||
|
interface WidgetStats {
|
||||||
|
offers: number;
|
||||||
|
requests: number;
|
||||||
|
matches: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WidgetData {
|
||||||
|
stats: WidgetStats;
|
||||||
|
recentRides: Ride[];
|
||||||
|
myActiveRides: Ride[];
|
||||||
|
pendingRequests: number;
|
||||||
|
unreadMessages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'RideWidget',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
// Se vuoi che il widget parta espanso
|
||||||
|
defaultExpanded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// Numero massimo di ride da mostrare
|
||||||
|
maxRides: {
|
||||||
|
type: Number,
|
||||||
|
default: 3
|
||||||
|
},
|
||||||
|
// Auto-refresh interval in ms (0 = disabled)
|
||||||
|
refreshInterval: {
|
||||||
|
type: Number,
|
||||||
|
default: 60000 // 1 minuto
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ['loaded', 'error'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const isExpanded = ref(props.defaultExpanded);
|
||||||
|
const loading = ref(false);
|
||||||
|
const stats = ref<WidgetStats>({
|
||||||
|
offers: 0,
|
||||||
|
requests: 0,
|
||||||
|
matches: 0
|
||||||
|
});
|
||||||
|
const recentRides = ref<Ride[]>([]);
|
||||||
|
const myActiveRides = ref<Ride[]>([]);
|
||||||
|
const pendingRequests = ref(0);
|
||||||
|
const unreadMessages = ref(0);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const totalCount = computed(() => stats.value.offers + stats.value.requests);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const toggleExpand = () => {
|
||||||
|
isExpanded.value = !isExpanded.value;
|
||||||
|
if (isExpanded.value && recentRides.value.length === 0) {
|
||||||
|
loadWidgetData();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadWidgetData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await Api.SendReq('/api/trasporti/widget/data', 'GET', {});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const data: WidgetData = response.data.data;
|
||||||
|
|
||||||
|
stats.value = data.stats || { offers: 0, requests: 0, matches: 0 };
|
||||||
|
recentRides.value = data.recentRides || [];
|
||||||
|
myActiveRides.value = data.myActiveRides || [];
|
||||||
|
pendingRequests.value = data.pendingRequests || 0;
|
||||||
|
unreadMessages.value = data.unreadMessages || 0;
|
||||||
|
|
||||||
|
emit('loaded', data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore caricamento widget trasporti:', error);
|
||||||
|
emit('error', error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await Api.SendReq('/api/trasporti/stats/summary', 'GET');
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
stats.value = response.data.data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore caricamento stats:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: string | Date): string => {
|
||||||
|
const d = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = d.getTime() - now.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
return `Oggi, ${d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
} else if (days === 1) {
|
||||||
|
return `Domani, ${d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
} else if (days > 1 && days <= 6) {
|
||||||
|
return d.toLocaleDateString('it-IT', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return d.toLocaleDateString('it-IT', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContribIcon = (contribution: any): string => {
|
||||||
|
if (!contribution?.types?.length) return '💶';
|
||||||
|
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
'Dono': '🎁',
|
||||||
|
'Offerta Libera': '💸',
|
||||||
|
'Baratto': '🤝',
|
||||||
|
'Scambio Lavoro': '💪',
|
||||||
|
'Monete Alternative': '🪙',
|
||||||
|
'RIS': '🍚',
|
||||||
|
'Euro': '💶',
|
||||||
|
'Bitcoin': '₿',
|
||||||
|
'Banca del Tempo': '⏳'
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstType = contribution.types[0];
|
||||||
|
return iconMap[firstType?.label] || '💶';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContribLabel = (contribution: any): string => {
|
||||||
|
if (!contribution?.types?.length) return 'Gratuito';
|
||||||
|
|
||||||
|
const labels = contribution.types.map((t: ContribType) => t.label);
|
||||||
|
return labels.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string): string => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
'active': 'positive',
|
||||||
|
'pending': 'warning',
|
||||||
|
'completed': 'info',
|
||||||
|
'cancelled': 'negative'
|
||||||
|
};
|
||||||
|
return colors[status] || 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'active': 'Attivo',
|
||||||
|
'pending': 'In attesa',
|
||||||
|
'completed': 'Completato',
|
||||||
|
'cancelled': 'Annullato'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
const goToCreate = (type: 'offer' | 'request') => {
|
||||||
|
router.push({
|
||||||
|
path: '/trasporti/crea',
|
||||||
|
query: { type }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToList = () => {
|
||||||
|
router.push('/trasporti');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToRide = (rideId: string) => {
|
||||||
|
router.push(`/trasporti/ride/${rideId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToMyRides = () => {
|
||||||
|
router.push('/trasporti/rides/my');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToSearch = () => {
|
||||||
|
router.push('/trasporti/cerca');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToMap = () => {
|
||||||
|
router.push('/trasporti/mappa');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToHistory = () => {
|
||||||
|
router.push('/trasporti/storico');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToChat = () => {
|
||||||
|
router.push('/trasporti/chat');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-refresh
|
||||||
|
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const startAutoRefresh = () => {
|
||||||
|
if (props.refreshInterval > 0) {
|
||||||
|
refreshTimer = setInterval(() => {
|
||||||
|
if (isExpanded.value) {
|
||||||
|
loadWidgetData();
|
||||||
|
} else {
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
}, props.refreshInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAutoRefresh = () => {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearInterval(refreshTimer);
|
||||||
|
refreshTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadStats();
|
||||||
|
if (props.defaultExpanded) {
|
||||||
|
loadWidgetData();
|
||||||
|
}
|
||||||
|
startAutoRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
watch(() => props.refreshInterval, () => {
|
||||||
|
stopAutoRefresh();
|
||||||
|
startAutoRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isExpanded,
|
||||||
|
loading,
|
||||||
|
stats,
|
||||||
|
recentRides,
|
||||||
|
myActiveRides,
|
||||||
|
pendingRequests,
|
||||||
|
unreadMessages,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
totalCount,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
toggleExpand,
|
||||||
|
formatDate,
|
||||||
|
getContribIcon,
|
||||||
|
getContribLabel,
|
||||||
|
getStatusColor,
|
||||||
|
getStatusLabel,
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
goToCreate,
|
||||||
|
goToList,
|
||||||
|
goToRide,
|
||||||
|
goToMyRides,
|
||||||
|
goToSearch,
|
||||||
|
goToMap,
|
||||||
|
goToHistory,
|
||||||
|
goToChat
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
241
src/modules/trasporti/components/widgets/RideWidget.vue
Normal file
241
src/modules/trasporti/components/widgets/RideWidget.vue
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<!-- RideWidget.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="ride-widget" :class="{ 'ride-widget--expanded': isExpanded }">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="ride-widget__header" @click="toggleExpand">
|
||||||
|
<div class="ride-widget__header-left">
|
||||||
|
<div class="ride-widget__icon-wrapper">
|
||||||
|
<q-icon name="directions_car" class="ride-widget__icon" />
|
||||||
|
<span class="ride-widget__icon-badge" v-if="totalCount > 0">{{ totalCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ride-widget__title-section">
|
||||||
|
<h3 class="ride-widget__title">Trasporti</h3>
|
||||||
|
<p class="ride-widget__subtitle">Viaggi solidali</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ride-widget__header-right">
|
||||||
|
<div class="ride-widget__stats-mini" v-if="!isExpanded">
|
||||||
|
<span class="ride-widget__stat-dot ride-widget__stat-dot--offer">
|
||||||
|
🟢 {{ stats.offers }}
|
||||||
|
</span>
|
||||||
|
<span class="ride-widget__stat-dot ride-widget__stat-dot--request">
|
||||||
|
🔴 {{ stats.requests }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<q-icon
|
||||||
|
:name="isExpanded ? 'expand_less' : 'expand_more'"
|
||||||
|
class="ride-widget__expand-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded Content -->
|
||||||
|
<transition name="slide">
|
||||||
|
<div v-if="isExpanded" class="ride-widget__content">
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="ride-widget__stats">
|
||||||
|
<div class="ride-widget__stat-card ride-widget__stat-card--offers">
|
||||||
|
<div class="ride-widget__stat-icon">🟢</div>
|
||||||
|
<div class="ride-widget__stat-info">
|
||||||
|
<span class="ride-widget__stat-value">{{ stats.offers }}</span>
|
||||||
|
<span class="ride-widget__stat-label">Offerte</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ride-widget__stat-card ride-widget__stat-card--requests">
|
||||||
|
<div class="ride-widget__stat-icon">🔴</div>
|
||||||
|
<div class="ride-widget__stat-info">
|
||||||
|
<span class="ride-widget__stat-value">{{ stats.requests }}</span>
|
||||||
|
<span class="ride-widget__stat-label">Richieste</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ride-widget__stat-card ride-widget__stat-card--matches">
|
||||||
|
<div class="ride-widget__stat-icon">🤝</div>
|
||||||
|
<div class="ride-widget__stat-info">
|
||||||
|
<span class="ride-widget__stat-value">{{ stats.matches }}</span>
|
||||||
|
<span class="ride-widget__stat-label">Match</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="ride-widget__actions">
|
||||||
|
<q-btn
|
||||||
|
class="ride-widget__action-btn ride-widget__action-btn--offer"
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
@click="goToCreate('offer')"
|
||||||
|
>
|
||||||
|
<q-icon name="add_circle" size="20px" />
|
||||||
|
<span>Offri passaggio</span>
|
||||||
|
</q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
class="ride-widget__action-btn ride-widget__action-btn--request"
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
@click="goToCreate('request')"
|
||||||
|
>
|
||||||
|
<q-icon name="hail" size="20px" />
|
||||||
|
<span>Cerca passaggio</span>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Rides Preview -->
|
||||||
|
<div class="ride-widget__recent" v-if="recentRides.length > 0">
|
||||||
|
<div class="ride-widget__recent-header">
|
||||||
|
<span>Ultimi viaggi disponibili</span>
|
||||||
|
<q-btn flat dense no-caps color="primary" label="Vedi tutti" @click="goToList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ride-widget__rides-list">
|
||||||
|
<div
|
||||||
|
v-for="ride in recentRides"
|
||||||
|
:key="ride._id"
|
||||||
|
class="ride-widget__ride-item"
|
||||||
|
@click="goToRide(ride._id)"
|
||||||
|
>
|
||||||
|
<div class="ride-widget__ride-type">
|
||||||
|
<span v-if="ride.type === 'offer'" class="ride-widget__type-badge ride-widget__type-badge--offer">
|
||||||
|
🟢
|
||||||
|
</span>
|
||||||
|
<span v-else class="ride-widget__type-badge ride-widget__type-badge--request">
|
||||||
|
🔴
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ride-widget__ride-info">
|
||||||
|
<div class="ride-widget__ride-route">
|
||||||
|
<span class="ride-widget__ride-city">{{ ride.departure?.city }}</span>
|
||||||
|
<q-icon name="arrow_forward" size="14px" color="grey" />
|
||||||
|
<span class="ride-widget__ride-city">{{ ride.destination?.city }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ride-widget__ride-meta">
|
||||||
|
<q-icon name="event" size="12px" />
|
||||||
|
<span>{{ formatDate(ride.departureDate) }}</span>
|
||||||
|
<template v-if="ride.availableSeats">
|
||||||
|
<q-icon name="person" size="12px" class="q-ml-sm" />
|
||||||
|
<span>{{ ride.availableSeats }} posti</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ride-widget__ride-price" v-if="ride.contribution">
|
||||||
|
<span class="ride-widget__price-icon">{{ getContribIcon(ride.contribution) }}</span>
|
||||||
|
<span class="ride-widget__price-value" v-if="ride.contribution.euroPrice">
|
||||||
|
€{{ ride.contribution.euroPrice }}
|
||||||
|
</span>
|
||||||
|
<span class="ride-widget__price-value" v-else-if="ride.contribution.risPrice">
|
||||||
|
{{ ride.contribution.risPrice }} RIS
|
||||||
|
</span>
|
||||||
|
<span class="ride-widget__price-value" v-else>
|
||||||
|
{{ getContribLabel(ride.contribution) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-icon name="chevron_right" color="grey" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="!loading" class="ride-widget__empty">
|
||||||
|
<q-icon name="no_transfer" size="40px" color="grey-4" />
|
||||||
|
<p>Nessun viaggio disponibile</p>
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="primary"
|
||||||
|
label="Esplora"
|
||||||
|
no-caps
|
||||||
|
@click="goToList"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- My Active Rides -->
|
||||||
|
<div class="ride-widget__my-rides" v-if="myActiveRides.length > 0">
|
||||||
|
<div class="ride-widget__section-title">
|
||||||
|
<q-icon name="person" size="18px" />
|
||||||
|
<span>I miei viaggi attivi</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ride-widget__my-rides-list">
|
||||||
|
<div
|
||||||
|
v-for="ride in myActiveRides.slice(0, 2)"
|
||||||
|
:key="ride._id"
|
||||||
|
class="ride-widget__my-ride-item"
|
||||||
|
@click="goToRide(ride._id)"
|
||||||
|
>
|
||||||
|
<div class="ride-widget__my-ride-icon">
|
||||||
|
<q-icon
|
||||||
|
:name="ride.type === 'offer' ? 'directions_car' : 'hail'"
|
||||||
|
:color="ride.type === 'offer' ? 'positive' : 'negative'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ride-widget__my-ride-info">
|
||||||
|
<span class="ride-widget__my-ride-route">
|
||||||
|
{{ ride.departure?.city }} → {{ ride.destination?.city }}
|
||||||
|
</span>
|
||||||
|
<span class="ride-widget__my-ride-date">
|
||||||
|
{{ formatDate(ride.departureDate) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<q-badge
|
||||||
|
:color="getStatusColor(ride.status)"
|
||||||
|
:label="getStatusLabel(ride.status)"
|
||||||
|
rounded
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-if="myActiveRides.length > 2"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
class="ride-widget__see-all"
|
||||||
|
@click="goToMyRides"
|
||||||
|
>
|
||||||
|
Vedi tutti ({{ myActiveRides.length }})
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Requests Alert -->
|
||||||
|
<div class="ride-widget__alert" v-if="pendingRequests > 0">
|
||||||
|
<q-icon name="notifications_active" color="warning" size="20px" />
|
||||||
|
<span>Hai <strong>{{ pendingRequests }}</strong> richieste in attesa</span>
|
||||||
|
<q-btn flat dense no-caps color="warning" label="Gestisci" @click="goToMyRides" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unread Messages -->
|
||||||
|
<div class="ride-widget__messages" v-if="unreadMessages > 0" @click="goToChat">
|
||||||
|
<q-icon name="chat_bubble" color="primary" />
|
||||||
|
<span>{{ unreadMessages }} messaggi non letti</span>
|
||||||
|
<q-icon name="chevron_right" color="grey" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Links -->
|
||||||
|
<div class="ride-widget__footer">
|
||||||
|
<q-btn flat dense no-caps icon="search" label="Cerca" @click="goToSearch" />
|
||||||
|
<q-btn flat dense no-caps icon="map" label="Mappa" @click="goToMap" />
|
||||||
|
<q-btn flat dense no-caps icon="history" label="Storico" @click="goToHistory" />
|
||||||
|
<q-btn flat dense no-caps icon="chat" label="Chat" @click="goToChat">
|
||||||
|
<q-badge v-if="unreadMessages > 0" color="negative" floating>
|
||||||
|
{{ unreadMessages }}
|
||||||
|
</q-badge>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<q-inner-loading :showing="loading">
|
||||||
|
<q-spinner-dots size="40px" color="primary" />
|
||||||
|
</q-inner-loading>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./RideWidget.ts" />
|
||||||
|
<style lang="scss" src="./RideWidget.scss" />
|
||||||
9
src/modules/trasporti/composables/index.ts
Normal file
9
src/modules/trasporti/composables/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// Re-export all composables from a single entry point
|
||||||
|
|
||||||
|
export { useRides } from './useRides';
|
||||||
|
export { useRideRequests } from './useRideRequests';
|
||||||
|
export { useChat } from './useChat';
|
||||||
|
export { useFeedback } from './useFeedback';
|
||||||
|
export { useGeocoding } from './useGeocoding';
|
||||||
|
export { useDriverProfile } from './useDriverProfile';
|
||||||
|
export { useContribTypes } from './useContribTypes';
|
||||||
524
src/modules/trasporti/composables/useChat.ts
Normal file
524
src/modules/trasporti/composables/useChat.ts
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
// useChat.ts
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { Api } from '@api';
|
||||||
|
import type { Chat, Message } from '../types/trasporti.types';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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[]>([]);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// COMPOSABLE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useChat() {
|
||||||
|
// ID app per trasporti
|
||||||
|
const IDAPP = '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.SendReq(
|
||||||
|
`/api/trasporti/chats?idapp=${IDAPP}&page=${page}&limit=${limit}`,
|
||||||
|
'GET'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data.data) {
|
||||||
|
chats.value = response.data.data;
|
||||||
|
|
||||||
|
// Calcola unread totale
|
||||||
|
totalUnreadCount.value = response.data.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.SendReq(
|
||||||
|
'/api/trasporti/chats/direct',
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
idapp: IDAPP,
|
||||||
|
otherUserId,
|
||||||
|
rideId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data.data) {
|
||||||
|
currentChat.value = response.data.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.SendReq(
|
||||||
|
`/api/trasporti/chats/${chatId}`,
|
||||||
|
'GET'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
currentChat.value = response.data?.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 || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni messaggi di una chat
|
||||||
|
*/
|
||||||
|
const fetchMessages = async (
|
||||||
|
chatId: string,
|
||||||
|
options?: { before?: string; after?: string; limit?: number }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
loadingMessages.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ idapp: IDAPP });
|
||||||
|
if (options?.before) params.append('before', options.before);
|
||||||
|
if (options?.after) params.append('after', options.after);
|
||||||
|
if (options?.limit) params.append('limit', options.limit.toString());
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/chats/${chatId}/messages?${params.toString()}`,
|
||||||
|
'GET'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
const newMessages = response.data?.data;
|
||||||
|
|
||||||
|
if (options?.before) {
|
||||||
|
// Caricamento messaggi precedenti - aggiungi all'inizio
|
||||||
|
messages.value = [...newMessages, ...messages.value];
|
||||||
|
} else {
|
||||||
|
// Primo caricamento
|
||||||
|
messages.value = newMessages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message || 'Errore nel recupero dei messaggi';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loadingMessages.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.SendReq(
|
||||||
|
`/api/trasporti/chats/${chatId}/messages`,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
idapp: IDAPP,
|
||||||
|
text: messageText,
|
||||||
|
type: payload.type || 'text',
|
||||||
|
metadata: payload.metadata,
|
||||||
|
replyTo: payload.replyTo
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
const newMessage = response.data?.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.SendReq(
|
||||||
|
`/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.SendReq(
|
||||||
|
`/api/trasporti/chats/unread/count?idapp=${IDAPP}`,
|
||||||
|
'GET'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
totalUnreadCount.value = response.data?.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.SendReq(
|
||||||
|
`/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.SendReq(
|
||||||
|
`/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.SendReq(
|
||||||
|
`/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 = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// RETURN
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
chats,
|
||||||
|
currentChat,
|
||||||
|
messages,
|
||||||
|
totalUnreadCount,
|
||||||
|
loading,
|
||||||
|
loadingMessages,
|
||||||
|
sending,
|
||||||
|
error,
|
||||||
|
onlineUsers,
|
||||||
|
typingUsers,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
hasChats,
|
||||||
|
hasUnread,
|
||||||
|
sortedChats,
|
||||||
|
sortedMessages,
|
||||||
|
|
||||||
|
// API Methods
|
||||||
|
fetchChats,
|
||||||
|
getOrCreateDirectChat,
|
||||||
|
loadChat,
|
||||||
|
fetchChat,
|
||||||
|
loadMessages,
|
||||||
|
fetchMessages,
|
||||||
|
sendMessage,
|
||||||
|
markAsRead,
|
||||||
|
fetchUnreadCount,
|
||||||
|
toggleBlockChat,
|
||||||
|
toggleMuteChat,
|
||||||
|
deleteMessage,
|
||||||
|
|
||||||
|
// Real-time (placeholder)
|
||||||
|
sendTyping,
|
||||||
|
subscribeToChat,
|
||||||
|
unsubscribeFromChat,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
formatMessageTime,
|
||||||
|
openChat,
|
||||||
|
clearState,
|
||||||
|
closeCurrentChat
|
||||||
|
};
|
||||||
|
}
|
||||||
136
src/modules/trasporti/composables/useCitySuggestions.ts
Normal file
136
src/modules/trasporti/composables/useCitySuggestions.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// src/composables/useCitySuggestions.ts
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { Api } from '@api';
|
||||||
|
|
||||||
|
interface CitySuggestion {
|
||||||
|
city: string;
|
||||||
|
region: string;
|
||||||
|
country: string;
|
||||||
|
fullName: string;
|
||||||
|
popularity: number;
|
||||||
|
verified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CitySuggestionsResponse {
|
||||||
|
query: string;
|
||||||
|
suggestions: CitySuggestion[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCitySuggestions() {
|
||||||
|
const suggestions = ref<CitySuggestion[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
const lastQuery = ref('');
|
||||||
|
|
||||||
|
// Debounce timeout
|
||||||
|
let debounceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for city suggestions
|
||||||
|
*/
|
||||||
|
const searchCities = async (query: string, debounceMs = 300): Promise<void> => {
|
||||||
|
// Clear previous timeout
|
||||||
|
if (debounceTimeout) {
|
||||||
|
clearTimeout(debounceTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset if query is too short
|
||||||
|
if (!query || query.trim().length < 2) {
|
||||||
|
suggestions.value = [];
|
||||||
|
lastQuery.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't search if same query
|
||||||
|
if (query === lastQuery.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce the search
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
debounceTimeout = setTimeout(async () => {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
lastQuery.value = query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/cities/suggestions?q=${encodeURIComponent(query)}`,
|
||||||
|
'GET'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
suggestions.value = response.data?.data.suggestions || [];
|
||||||
|
} else {
|
||||||
|
error.value = response.message || 'Errore nel caricamento dei suggerimenti';
|
||||||
|
suggestions.value = [];
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore di rete';
|
||||||
|
suggestions.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, debounceMs);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear suggestions
|
||||||
|
*/
|
||||||
|
const clearSuggestions = () => {
|
||||||
|
suggestions.value = [];
|
||||||
|
lastQuery.value = '';
|
||||||
|
error.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format city for display
|
||||||
|
*/
|
||||||
|
const formatCity = (suggestion: CitySuggestion): string => {
|
||||||
|
return suggestion.fullName;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get city object for selection
|
||||||
|
*/
|
||||||
|
const getCityObject = (suggestion: CitySuggestion) => {
|
||||||
|
return {
|
||||||
|
city: suggestion.city,
|
||||||
|
region: suggestion.region,
|
||||||
|
country: suggestion.country
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const hasSuggestions = computed(() => suggestions.value.length > 0);
|
||||||
|
const verifiedSuggestions = computed(() =>
|
||||||
|
suggestions.value.filter(s => s.verified)
|
||||||
|
);
|
||||||
|
const popularSuggestions = computed(() =>
|
||||||
|
suggestions.value
|
||||||
|
.filter(s => s.popularity > 0)
|
||||||
|
.sort((a, b) => b.popularity - a.popularity)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
suggestions,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
lastQuery,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
hasSuggestions,
|
||||||
|
verifiedSuggestions,
|
||||||
|
popularSuggestions,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
searchCities,
|
||||||
|
clearSuggestions,
|
||||||
|
formatCity,
|
||||||
|
getCityObject
|
||||||
|
};
|
||||||
|
}
|
||||||
208
src/modules/trasporti/composables/useContribTypes.ts
Normal file
208
src/modules/trasporti/composables/useContribTypes.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { Api } from '@api';
|
||||||
|
import type { ContribType, ApiResponse } from '../types';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STATE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const contribTypes = ref<ContribType[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Flag per evitare chiamate multiple
|
||||||
|
let fetched = false;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// COMPOSABLE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useContribTypes() {
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// COMPUTED
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const hasContribTypes = computed(() => contribTypes.value.length > 0);
|
||||||
|
|
||||||
|
const euroType = computed(() =>
|
||||||
|
contribTypes.value.find(c => c.label.toLowerCase().includes('euro'))
|
||||||
|
);
|
||||||
|
|
||||||
|
const risType = computed(() =>
|
||||||
|
contribTypes.value.find(c => c.label.toLowerCase() === 'ris')
|
||||||
|
);
|
||||||
|
|
||||||
|
const donoType = computed(() =>
|
||||||
|
contribTypes.value.find(c => c.label.toLowerCase() === 'dono')
|
||||||
|
);
|
||||||
|
|
||||||
|
const barterTypes = computed(() =>
|
||||||
|
contribTypes.value.filter(c =>
|
||||||
|
['baratto', 'scambio lavoro', 'banca del tempo'].includes(c.label.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const moneyTypes = computed(() =>
|
||||||
|
contribTypes.value.filter(c =>
|
||||||
|
['euro', 'ris', 'bitcoin', 'monete alternative'].some(m =>
|
||||||
|
c.label.toLowerCase().includes(m)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// API CALLS
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carica tipi di contributo
|
||||||
|
*/
|
||||||
|
const fetchContribTypes = async (force = false) => {
|
||||||
|
// Evita chiamate ripetute se già caricato
|
||||||
|
if (fetched && !force && contribTypes.value.length > 0) {
|
||||||
|
return { success: true, data: contribTypes.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
'/api/trasporti/contrib-types',
|
||||||
|
'GET'
|
||||||
|
) as ApiResponse<ContribType[]>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
contribTypes.value = response.data.data;
|
||||||
|
fetched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel caricamento tipi contributo';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// UTILITIES
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trova tipo per ID
|
||||||
|
*/
|
||||||
|
const findById = (id: string): ContribType | undefined => {
|
||||||
|
return contribTypes.value.find(c => c._id === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trova tipo per label
|
||||||
|
*/
|
||||||
|
const findByLabel = (label: string): ContribType | undefined => {
|
||||||
|
return contribTypes.value.find(c =>
|
||||||
|
c.label.toLowerCase() === label.toLowerCase()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatta prezzo con tipo
|
||||||
|
*/
|
||||||
|
const formatPrice = (contribTypeId: string, price?: number): string => {
|
||||||
|
const type = findById(contribTypeId);
|
||||||
|
if (!type) return '';
|
||||||
|
|
||||||
|
if (price === undefined || price === null) {
|
||||||
|
return type.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gestione casi speciali
|
||||||
|
switch (type.label.toLowerCase()) {
|
||||||
|
case 'euro':
|
||||||
|
return `€${price.toFixed(2)}`;
|
||||||
|
case 'ris':
|
||||||
|
return `${price} RIS`;
|
||||||
|
case 'bitcoin':
|
||||||
|
return `₿${price}`;
|
||||||
|
case 'dono':
|
||||||
|
return '🎁 Dono';
|
||||||
|
case 'offerta libera':
|
||||||
|
return '💸 Offerta libera';
|
||||||
|
case 'baratto':
|
||||||
|
return '🤝 Baratto';
|
||||||
|
case 'banca del tempo':
|
||||||
|
return `⏳ ${price} ore`;
|
||||||
|
default:
|
||||||
|
return `${type.icon} ${price} ${type.label}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni icona per tipo
|
||||||
|
*/
|
||||||
|
const getIcon = (contribTypeId: string): string => {
|
||||||
|
const type = findById(contribTypeId);
|
||||||
|
return type?.icon || '💰';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni colore per tipo
|
||||||
|
*/
|
||||||
|
const getColor = (contribTypeId: string): string => {
|
||||||
|
const type = findById(contribTypeId);
|
||||||
|
return type?.color || '#9e9e9e';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se tipo richiede prezzo
|
||||||
|
*/
|
||||||
|
const requiresPrice = (contribTypeId: string): boolean => {
|
||||||
|
const type = findById(contribTypeId);
|
||||||
|
if (!type) return false;
|
||||||
|
|
||||||
|
const noPriceTypes = ['dono', 'baratto', 'scambio lavoro'];
|
||||||
|
return !noPriceTypes.includes(type.label.toLowerCase());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulisci stato
|
||||||
|
*/
|
||||||
|
const clearState = () => {
|
||||||
|
contribTypes.value = [];
|
||||||
|
fetched = false;
|
||||||
|
error.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// RETURN
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
contribTypes,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
hasContribTypes,
|
||||||
|
euroType,
|
||||||
|
risType,
|
||||||
|
donoType,
|
||||||
|
barterTypes,
|
||||||
|
moneyTypes,
|
||||||
|
|
||||||
|
// API Methods
|
||||||
|
fetchContribTypes,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
findById,
|
||||||
|
findByLabel,
|
||||||
|
formatPrice,
|
||||||
|
getIcon,
|
||||||
|
getColor,
|
||||||
|
requiresPrice,
|
||||||
|
clearState
|
||||||
|
};
|
||||||
|
}
|
||||||
397
src/modules/trasporti/composables/useDriverProfile.ts
Normal file
397
src/modules/trasporti/composables/useDriverProfile.ts
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { Api } from '@api';
|
||||||
|
import type {
|
||||||
|
DriverProfile,
|
||||||
|
Vehicle,
|
||||||
|
UserPreferences,
|
||||||
|
DriverPublicProfile,
|
||||||
|
ApiResponse
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STATE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const driverProfile = ref<DriverPublicProfile | null>(null);
|
||||||
|
const myDriverProfile = ref<DriverProfile | null>(null);
|
||||||
|
const myVehicles = ref<Vehicle[]>([]);
|
||||||
|
const myPreferences = ref<UserPreferences | null>(null);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// COMPOSABLE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useDriverProfile() {
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// COMPUTED
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const isDriver = computed(() => myDriverProfile.value?.isDriver ?? false);
|
||||||
|
const hasVehicles = computed(() => myVehicles.value.length > 0);
|
||||||
|
const defaultVehicle = computed(() =>
|
||||||
|
myVehicles.value.find(v => v.isDefault) || myVehicles.value[0]
|
||||||
|
);
|
||||||
|
const averageRating = computed(() => myDriverProfile.value?.averageRating ?? 0);
|
||||||
|
const totalRides = computed(() =>
|
||||||
|
(myDriverProfile.value?.ridesCompletedAsDriver ?? 0) +
|
||||||
|
(myDriverProfile.value?.ridesCompletedAsPassenger ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// API CALLS
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni profilo pubblico di un conducente
|
||||||
|
*/
|
||||||
|
const fetchDriverProfile = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/driver/${userId}`,
|
||||||
|
'GET'
|
||||||
|
) as ApiResponse<DriverPublicProfile>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
driverProfile.value = response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero del profilo';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna il mio profilo conducente
|
||||||
|
*/
|
||||||
|
const updateDriverProfile = async (profileData: Partial<DriverProfile>) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
'/api/trasporti/driver/profile',
|
||||||
|
'PUT',
|
||||||
|
{ driverProfile: profileData }
|
||||||
|
) as ApiResponse<{ driverProfile: DriverProfile; preferences: UserPreferences }>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
myDriverProfile.value = response.data?.data.driverProfile;
|
||||||
|
myPreferences.value = response.data?.data.preferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nell\'aggiornamento del profilo';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna le mie preferenze
|
||||||
|
*/
|
||||||
|
const updatePreferences = async (preferences: Partial<UserPreferences>) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
'/api/trasporti/driver/profile',
|
||||||
|
'PUT',
|
||||||
|
{ preferences }
|
||||||
|
) as ApiResponse<{ driverProfile: DriverProfile; preferences: UserPreferences }>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
myPreferences.value = response.data?.data.preferences;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nell\'aggiornamento delle preferenze';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiungi veicolo
|
||||||
|
*/
|
||||||
|
const addVehicle = async (vehicle: Omit<Vehicle, '_id'>) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
'/api/trasporti/driver/vehicles',
|
||||||
|
'POST',
|
||||||
|
{ vehicle }
|
||||||
|
) as ApiResponse<Vehicle[]>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
myVehicles.value = response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nell\'aggiunta del veicolo';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna veicolo
|
||||||
|
*/
|
||||||
|
const updateVehicle = async (vehicleId: string, vehicle: Partial<Vehicle>) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/driver/vehicles/${vehicleId}`,
|
||||||
|
'PUT',
|
||||||
|
{ vehicle }
|
||||||
|
) as ApiResponse<Vehicle[]>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
myVehicles.value = response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nell\'aggiornamento del veicolo';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rimuovi veicolo
|
||||||
|
*/
|
||||||
|
const removeVehicle = async (vehicleId: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/driver/vehicles/${vehicleId}`,
|
||||||
|
'DELETE'
|
||||||
|
) as ApiResponse<void>;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
myVehicles.value = myVehicles.value.filter(v => v._id !== vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nella rimozione del veicolo';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imposta veicolo predefinito
|
||||||
|
*/
|
||||||
|
const setDefaultVehicle = async (vehicleId: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/driver/vehicles/${vehicleId}/default`,
|
||||||
|
'POST'
|
||||||
|
) as ApiResponse<void>;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// Aggiorna localmente
|
||||||
|
myVehicles.value = myVehicles.value.map(v => ({
|
||||||
|
...v,
|
||||||
|
isDefault: v._id === vehicleId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nell\'impostazione del veicolo predefinito';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// UTILITIES
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatta tipo veicolo
|
||||||
|
*/
|
||||||
|
const formatVehicleType = (type: string): string => {
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
auto: '🚗 Auto',
|
||||||
|
moto: '🏍️ Moto',
|
||||||
|
furgone: '🚐 Furgone',
|
||||||
|
minibus: '🚌 Minibus',
|
||||||
|
altro: '🚙 Altro'
|
||||||
|
};
|
||||||
|
return types[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatta veicolo completo
|
||||||
|
*/
|
||||||
|
const formatVehicle = (vehicle: Vehicle): string => {
|
||||||
|
const parts = [];
|
||||||
|
if (vehicle.brand) parts.push(vehicle.brand);
|
||||||
|
if (vehicle.model) parts.push(vehicle.model);
|
||||||
|
if (vehicle.color) parts.push(`(${vehicle.color})`);
|
||||||
|
return parts.join(' ') || 'Veicolo';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatta response time
|
||||||
|
*/
|
||||||
|
const formatResponseTime = (time?: string): string => {
|
||||||
|
const times: Record<string, string> = {
|
||||||
|
within_hour: 'Entro un\'ora',
|
||||||
|
within_day: 'Entro un giorno',
|
||||||
|
within_days: 'Entro qualche giorno'
|
||||||
|
};
|
||||||
|
return times[time || 'within_day'] || 'N/D';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatta member since
|
||||||
|
*/
|
||||||
|
const formatMemberSince = (date?: Date | string): string => {
|
||||||
|
if (!date) return 'N/D';
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleDateString('it-IT', { month: 'long', year: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcola livello utente
|
||||||
|
*/
|
||||||
|
const calculateLevel = (points: number): { level: number; progress: number; nextLevel: number } => {
|
||||||
|
const levels = [0, 100, 300, 600, 1000, 1500, 2500, 4000, 6000, 10000];
|
||||||
|
let level = 1;
|
||||||
|
|
||||||
|
for (let i = 1; i < levels.length; i++) {
|
||||||
|
if (points >= levels[i]) {
|
||||||
|
level = i + 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLevelPoints = levels[level - 1] || 0;
|
||||||
|
const nextLevelPoints = levels[level] || levels[levels.length - 1];
|
||||||
|
const progress = ((points - currentLevelPoints) / (nextLevelPoints - currentLevelPoints)) * 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
progress: Math.min(100, Math.max(0, progress)),
|
||||||
|
nextLevel: nextLevelPoints
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni badge icon
|
||||||
|
*/
|
||||||
|
const getBadgeIcon = (badgeName: string): string => {
|
||||||
|
const badges: Record<string, string> = {
|
||||||
|
first_ride: '🎉',
|
||||||
|
five_rides: '🚗',
|
||||||
|
ten_rides: '🏆',
|
||||||
|
fifty_rides: '⭐',
|
||||||
|
hundred_rides: '👑',
|
||||||
|
eco_warrior: '🌱',
|
||||||
|
super_driver: '🦸',
|
||||||
|
top_rated: '💯',
|
||||||
|
fast_responder: '⚡',
|
||||||
|
friendly: '😊'
|
||||||
|
};
|
||||||
|
return badges[badgeName] || '🏅';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inizializza profilo dal user corrente
|
||||||
|
*/
|
||||||
|
const initFromUser = (user: any) => {
|
||||||
|
if (user?.profile?.driverProfile) {
|
||||||
|
myDriverProfile.value = user.profile.driverProfile;
|
||||||
|
myVehicles.value = user.profile.driverProfile.vehicles || [];
|
||||||
|
}
|
||||||
|
if (user?.profile?.preferences) {
|
||||||
|
myPreferences.value = user.profile.preferences;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulisci stato
|
||||||
|
*/
|
||||||
|
const clearState = () => {
|
||||||
|
driverProfile.value = null;
|
||||||
|
myDriverProfile.value = null;
|
||||||
|
myVehicles.value = [];
|
||||||
|
myPreferences.value = null;
|
||||||
|
error.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// RETURN
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
driverProfile,
|
||||||
|
myDriverProfile,
|
||||||
|
myVehicles,
|
||||||
|
myPreferences,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
isDriver,
|
||||||
|
hasVehicles,
|
||||||
|
defaultVehicle,
|
||||||
|
averageRating,
|
||||||
|
totalRides,
|
||||||
|
|
||||||
|
// API Methods
|
||||||
|
fetchDriverProfile,
|
||||||
|
updateDriverProfile,
|
||||||
|
updatePreferences,
|
||||||
|
addVehicle,
|
||||||
|
updateVehicle,
|
||||||
|
removeVehicle,
|
||||||
|
setDefaultVehicle,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
formatVehicleType,
|
||||||
|
formatVehicle,
|
||||||
|
formatResponseTime,
|
||||||
|
formatMemberSince,
|
||||||
|
calculateLevel,
|
||||||
|
getBadgeIcon,
|
||||||
|
initFromUser,
|
||||||
|
clearState
|
||||||
|
};
|
||||||
|
}
|
||||||
490
src/modules/trasporti/composables/useFeedback.ts
Normal file
490
src/modules/trasporti/composables/useFeedback.ts
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { Api } from '@api';
|
||||||
|
import type {
|
||||||
|
Feedback,
|
||||||
|
FeedbackFormData,
|
||||||
|
FeedbackStats,
|
||||||
|
FeedbackRole,
|
||||||
|
FeedbackTag,
|
||||||
|
RatingDistribution,
|
||||||
|
ApiResponse,
|
||||||
|
PaginatedResponse,
|
||||||
|
FEEDBACK_TAGS_OPTIONS
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STATE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const feedbacks = ref<Feedback[]>([]);
|
||||||
|
const myReceivedFeedback = ref<Feedback[]>([]);
|
||||||
|
const myGivenFeedback = ref<Feedback[]>([]);
|
||||||
|
const currentUserStats = ref<FeedbackStats | null>(null);
|
||||||
|
const ratingDistribution = ref<RatingDistribution[]>([]);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// COMPOSABLE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useFeedback() {
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// COMPUTED
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const averageRating = computed(() => {
|
||||||
|
if (!currentUserStats.value) return 0;
|
||||||
|
return currentUserStats.value.overall.averageRating;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalFeedbacks = computed(() => {
|
||||||
|
if (!currentUserStats.value) return 0;
|
||||||
|
return currentUserStats.value.overall.totalFeedbacks;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasGoodRating = computed(() => averageRating.value >= 4);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// API CALLS
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea feedback
|
||||||
|
*/
|
||||||
|
const createFeedback = async (feedbackData: Partial<FeedbackFormData>) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
'/api/trasporti/feedback',
|
||||||
|
'POST',
|
||||||
|
feedbackData
|
||||||
|
) as ApiResponse<Feedback>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
myGivenFeedback.value.unshift(response.data?.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nella creazione del feedback';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni feedback di un utente
|
||||||
|
*/
|
||||||
|
const fetchUserFeedback = async (userId: string, options?: { role?: FeedbackRole; page?: number; limit?: number }) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (options?.role) queryParams.append('role', options.role);
|
||||||
|
if (options?.page) queryParams.append('page', options.page.toString());
|
||||||
|
if (options?.limit) queryParams.append('limit', options.limit.toString());
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/feedback/user/${userId}?${queryParams.toString()}`,
|
||||||
|
'GET'
|
||||||
|
) as ApiResponse<{
|
||||||
|
feedbacks: Feedback[];
|
||||||
|
stats: FeedbackStats;
|
||||||
|
distribution: RatingDistribution[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
feedbacks.value = response.data?.data.feedbacks;
|
||||||
|
currentUserStats.value = response.data?.data.stats;
|
||||||
|
ratingDistribution.value = response.data?.data.distribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero dei feedback';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni statistiche feedback utente
|
||||||
|
*/
|
||||||
|
const fetchUserStats = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/feedback/user/${userId}/stats`,
|
||||||
|
'GET'
|
||||||
|
) as ApiResponse<{
|
||||||
|
stats: FeedbackStats;
|
||||||
|
distribution: { asDriver: RatingDistribution[]; asPassenger: RatingDistribution[] };
|
||||||
|
commonTags: { _id: FeedbackTag; count: number }[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
currentUserStats.value = response.data?.data.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero delle statistiche';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni feedback per un viaggio
|
||||||
|
*/
|
||||||
|
const fetchRideFeedback = async (rideId: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/feedback/ride/${rideId}`,
|
||||||
|
'GET'
|
||||||
|
) as ApiResponse<Feedback[]>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
feedbacks.value = response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero dei feedback';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se posso lasciare feedback
|
||||||
|
*/
|
||||||
|
const canLeaveFeedback = async (rideId: string, toUserId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/feedback/can-leave/${rideId}/${toUserId}`,
|
||||||
|
'GET'
|
||||||
|
) as ApiResponse<{ canLeave: boolean; reason?: string }>;
|
||||||
|
|
||||||
|
return response.data?.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Errore verifica feedback:', err);
|
||||||
|
return { canLeave: false, reason: 'Errore nella verifica' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni i miei feedback ricevuti
|
||||||
|
*/
|
||||||
|
const fetchMyReceivedFeedback = async (role?: FeedbackRole) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (role) queryParams.append('role', role);
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/feedback/my/received?${queryParams.toString()}`,
|
||||||
|
'GET'
|
||||||
|
) as ApiResponse<{ feedbacks: Feedback[]; stats: FeedbackStats }>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
myReceivedFeedback.value = response.data?.data.feedbacks;
|
||||||
|
currentUserStats.value = response.data?.data.stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero dei feedback';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni i miei feedback dati
|
||||||
|
*/
|
||||||
|
const fetchMyGivenFeedback = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
'/api/trasporti/feedback/my/given',
|
||||||
|
'GET'
|
||||||
|
) as PaginatedResponse<Feedback>;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
myGivenFeedback.value = response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero dei feedback';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rispondi a un feedback
|
||||||
|
*/
|
||||||
|
const respondToFeedback = async (feedbackId: string, text: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/feedback/${feedbackId}/response`,
|
||||||
|
'POST',
|
||||||
|
{ text }
|
||||||
|
) as ApiResponse<Feedback>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
// Aggiorna nella lista
|
||||||
|
const index = myReceivedFeedback.value.findIndex(f => f._id === feedbackId);
|
||||||
|
if (index !== -1) {
|
||||||
|
myReceivedFeedback.value[index] = response.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nella risposta';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segnala feedback
|
||||||
|
*/
|
||||||
|
const reportFeedback = async (feedbackId: string, reason: string) => {
|
||||||
|
try {
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/feedback/${feedbackId}/report`,
|
||||||
|
'POST',
|
||||||
|
{ reason }
|
||||||
|
) as ApiResponse<void>;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nella segnalazione';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segna come utile
|
||||||
|
*/
|
||||||
|
const markAsHelpful = async (feedbackId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/feedback/${feedbackId}/helpful`,
|
||||||
|
'POST'
|
||||||
|
) as ApiResponse<{ helpfulCount: number }>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
const feedback = feedbacks.value.find(f => f._id === feedbackId);
|
||||||
|
if (feedback && feedback.helpful) {
|
||||||
|
feedback.helpful.count = response.data?.data.helpfulCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Errore mark helpful:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// UTILITIES
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatta rating in stelle
|
||||||
|
*/
|
||||||
|
const formatRatingStars = (rating: number): string => {
|
||||||
|
const fullStars = Math.floor(rating);
|
||||||
|
const halfStar = rating % 1 >= 0.5;
|
||||||
|
const emptyStars = 5 - fullStars - (halfStar ? 1 : 0);
|
||||||
|
|
||||||
|
return '★'.repeat(fullStars) +
|
||||||
|
(halfStar ? '½' : '') +
|
||||||
|
'☆'.repeat(emptyStars);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni colore per rating
|
||||||
|
*/
|
||||||
|
const getRatingColor = (rating: number): string => {
|
||||||
|
if (rating >= 4.5) return 'positive';
|
||||||
|
if (rating >= 4) return 'light-green';
|
||||||
|
if (rating >= 3) return 'warning';
|
||||||
|
if (rating >= 2) return 'orange';
|
||||||
|
return 'negative';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni label per rating
|
||||||
|
*/
|
||||||
|
const getRatingLabel = (rating: number): string => {
|
||||||
|
if (rating >= 4.5) return 'Eccellente';
|
||||||
|
if (rating >= 4) return 'Ottimo';
|
||||||
|
if (rating >= 3.5) return 'Molto buono';
|
||||||
|
if (rating >= 3) return 'Buono';
|
||||||
|
if (rating >= 2) return 'Sufficiente';
|
||||||
|
return 'Da migliorare';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcola percentuale per distribuzione
|
||||||
|
*/
|
||||||
|
const calculateDistributionPercentage = (count: number): number => {
|
||||||
|
const total = ratingDistribution.value.reduce((sum, d) => sum + d.count, 0);
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return Math.round((count / total) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtra tag positivi
|
||||||
|
*/
|
||||||
|
const getPositiveTags = (tags?: FeedbackTag[]): FeedbackTag[] => {
|
||||||
|
if (!tags) return [];
|
||||||
|
const positiveTags: FeedbackTag[] = [
|
||||||
|
'puntuale', 'gentile', 'auto_pulita', 'guida_sicura',
|
||||||
|
'buona_conversazione', 'silenzioso', 'flessibile',
|
||||||
|
'rispettoso', 'affidabile', 'consigliato'
|
||||||
|
];
|
||||||
|
return tags.filter(t => positiveTags.includes(t));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtra tag negativi
|
||||||
|
*/
|
||||||
|
const getNegativeTags = (tags?: FeedbackTag[]): FeedbackTag[] => {
|
||||||
|
if (!tags) return [];
|
||||||
|
const negativeTags: FeedbackTag[] = [
|
||||||
|
'in_ritardo', 'scortese', 'guida_pericolosa',
|
||||||
|
'auto_sporca', 'non_rispettoso'
|
||||||
|
];
|
||||||
|
return tags.filter(t => negativeTags.includes(t));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni icona per tag
|
||||||
|
*/
|
||||||
|
const getTagIcon = (tag: FeedbackTag): string => {
|
||||||
|
const icons: Record<FeedbackTag, string> = {
|
||||||
|
puntuale: '⏰',
|
||||||
|
gentile: '😊',
|
||||||
|
auto_pulita: '✨',
|
||||||
|
guida_sicura: '🛡️',
|
||||||
|
buona_conversazione: '💬',
|
||||||
|
silenzioso: '🤫',
|
||||||
|
flessibile: '🤸',
|
||||||
|
rispettoso: '🙏',
|
||||||
|
affidabile: '💯',
|
||||||
|
consigliato: '👍',
|
||||||
|
in_ritardo: '⏳',
|
||||||
|
scortese: '😤',
|
||||||
|
guida_pericolosa: '⚠️',
|
||||||
|
auto_sporca: '🗑️',
|
||||||
|
non_rispettoso: '👎'
|
||||||
|
};
|
||||||
|
return icons[tag] || '📝';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni label per tag
|
||||||
|
*/
|
||||||
|
const getTagLabel = (tag: FeedbackTag): string => {
|
||||||
|
const labels: Record<FeedbackTag, string> = {
|
||||||
|
puntuale: 'Puntuale',
|
||||||
|
gentile: 'Gentile',
|
||||||
|
auto_pulita: 'Auto pulita',
|
||||||
|
guida_sicura: 'Guida sicura',
|
||||||
|
buona_conversazione: 'Buona conversazione',
|
||||||
|
silenzioso: 'Rispetta il silenzio',
|
||||||
|
flessibile: 'Flessibile',
|
||||||
|
rispettoso: 'Rispettoso',
|
||||||
|
affidabile: 'Affidabile',
|
||||||
|
consigliato: 'Consigliato',
|
||||||
|
in_ritardo: 'In ritardo',
|
||||||
|
scortese: 'Scortese',
|
||||||
|
guida_pericolosa: 'Guida pericolosa',
|
||||||
|
auto_sporca: 'Auto sporca',
|
||||||
|
non_rispettoso: 'Non rispettoso'
|
||||||
|
};
|
||||||
|
return labels[tag] || tag;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulisci stato
|
||||||
|
*/
|
||||||
|
const clearState = () => {
|
||||||
|
feedbacks.value = [];
|
||||||
|
myReceivedFeedback.value = [];
|
||||||
|
myGivenFeedback.value = [];
|
||||||
|
currentUserStats.value = null;
|
||||||
|
ratingDistribution.value = [];
|
||||||
|
error.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// RETURN
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
feedbacks,
|
||||||
|
myReceivedFeedback,
|
||||||
|
myGivenFeedback,
|
||||||
|
currentUserStats,
|
||||||
|
ratingDistribution,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
averageRating,
|
||||||
|
totalFeedbacks,
|
||||||
|
hasGoodRating,
|
||||||
|
|
||||||
|
// API Methods
|
||||||
|
createFeedback,
|
||||||
|
fetchUserFeedback,
|
||||||
|
fetchUserStats,
|
||||||
|
fetchRideFeedback,
|
||||||
|
canLeaveFeedback,
|
||||||
|
fetchMyReceivedFeedback,
|
||||||
|
fetchMyGivenFeedback,
|
||||||
|
respondToFeedback,
|
||||||
|
reportFeedback,
|
||||||
|
markAsHelpful,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
formatRatingStars,
|
||||||
|
getRatingColor,
|
||||||
|
getRatingLabel,
|
||||||
|
calculateDistributionPercentage,
|
||||||
|
getPositiveTags,
|
||||||
|
getNegativeTags,
|
||||||
|
getTagIcon,
|
||||||
|
getTagLabel,
|
||||||
|
clearState
|
||||||
|
};
|
||||||
|
}
|
||||||
195
src/modules/trasporti/composables/useGeocoding.ts
Normal file
195
src/modules/trasporti/composables/useGeocoding.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
// useGeocoding.ts
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import type { Location, Coordinates } from '../types';
|
||||||
|
|
||||||
|
export function useGeocoding() {
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search cities by name
|
||||||
|
*/
|
||||||
|
const searchCities = async (query: string): Promise<Location[]> => {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TODO: Implementare chiamata API reale
|
||||||
|
// Esempio con OpenStreetMap Nominatim (gratuito)
|
||||||
|
const response = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/search?` +
|
||||||
|
`q=${encodeURIComponent(query)}&` +
|
||||||
|
`format=json&` +
|
||||||
|
`addressdetails=1&` +
|
||||||
|
`limit=5&` +
|
||||||
|
`accept-language=it`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Errore nella ricerca città');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data.map((item: any) => ({
|
||||||
|
city: item.address?.city ||
|
||||||
|
item.address?.town ||
|
||||||
|
item.address?.village ||
|
||||||
|
item.display_name.split(',')[0],
|
||||||
|
region: item.address?.state || item.address?.region,
|
||||||
|
country: item.address?.country || 'Italia',
|
||||||
|
coordinates: {
|
||||||
|
lat: parseFloat(item.lat),
|
||||||
|
lng: parseFloat(item.lon)
|
||||||
|
},
|
||||||
|
address: item.display_name
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching cities:', error);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get address from coordinates (reverse geocoding)
|
||||||
|
*/
|
||||||
|
const getAddressFromCoordinates = async (
|
||||||
|
coordinates: Coordinates
|
||||||
|
): Promise<Location | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?` +
|
||||||
|
`lat=${coordinates.lat}&` +
|
||||||
|
`lon=${coordinates.lng}&` +
|
||||||
|
`format=json&` +
|
||||||
|
`accept-language=it`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Errore nel reverse geocoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
city: data.address?.city ||
|
||||||
|
data.address?.town ||
|
||||||
|
data.address?.village ||
|
||||||
|
'Sconosciuto',
|
||||||
|
region: data.address?.state || data.address?.region,
|
||||||
|
country: data.address?.country || 'Italia',
|
||||||
|
coordinates,
|
||||||
|
address: data.display_name
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in reverse geocoding:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate route between two points
|
||||||
|
*/
|
||||||
|
const calculateRoute = async (
|
||||||
|
start: Coordinates,
|
||||||
|
end: Coordinates,
|
||||||
|
waypoints?: Coordinates[]
|
||||||
|
): Promise<any> => {
|
||||||
|
try {
|
||||||
|
// Costruisci coordinate per OSRM
|
||||||
|
let coords = `${start.lng},${start.lat}`;
|
||||||
|
|
||||||
|
if (waypoints && waypoints.length > 0) {
|
||||||
|
waypoints.forEach(wp => {
|
||||||
|
coords += `;${wp.lng},${wp.lat}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
coords += `;${end.lng},${end.lat}`;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://router.project-osrm.org/route/v1/driving/${coords}?` +
|
||||||
|
`overview=full&` +
|
||||||
|
`geometries=geojson&` +
|
||||||
|
`steps=true`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Errore nel calcolo del percorso');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
|
||||||
|
throw new Error('Nessun percorso trovato');
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = data.routes[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
distance: route.distance, // in metri
|
||||||
|
duration: route.duration, // in secondi
|
||||||
|
geometry: route.geometry,
|
||||||
|
legs: route.legs
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calculating route:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggest waypoints between departure and destination
|
||||||
|
*/
|
||||||
|
const suggestWaypoints = async (
|
||||||
|
start: Coordinates,
|
||||||
|
end: Coordinates
|
||||||
|
): Promise<Location[]> => {
|
||||||
|
try {
|
||||||
|
// Calcola punti intermedi sulla rotta
|
||||||
|
const midLat = (start.lat + end.lat) / 2;
|
||||||
|
const midLng = (start.lng + end.lng) / 2;
|
||||||
|
|
||||||
|
// Cerca città vicine al punto medio
|
||||||
|
const response = await fetch(
|
||||||
|
`https://nominatim.openstreetmap.org/reverse?` +
|
||||||
|
`lat=${midLat}&` +
|
||||||
|
`lon=${midLng}&` +
|
||||||
|
`format=json&` +
|
||||||
|
`accept-language=it`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.address?.city || data.address?.town) {
|
||||||
|
return [{
|
||||||
|
city: data.address.city || data.address.town,
|
||||||
|
region: data.address.state || data.address.region,
|
||||||
|
country: data.address.country || 'Italia',
|
||||||
|
coordinates: {
|
||||||
|
lat: midLat,
|
||||||
|
lng: midLng
|
||||||
|
},
|
||||||
|
address: data.display_name
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error suggesting waypoints:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
searchCities,
|
||||||
|
getAddressFromCoordinates,
|
||||||
|
calculateRoute,
|
||||||
|
suggestWaypoints
|
||||||
|
};
|
||||||
|
}
|
||||||
254
src/modules/trasporti/composables/useRealtimeChat.ts
Normal file
254
src/modules/trasporti/composables/useRealtimeChat.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
// useRealtimeChat.ts
|
||||||
|
import { ref, onUnmounted } from 'vue';
|
||||||
|
import { useChat } from './useChat';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STATE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const isPolling = ref(false);
|
||||||
|
const activeChatId = ref<string | null>(null);
|
||||||
|
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const typingTimeout = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Simulated online users (in una implementazione reale verrebbero dal server)
|
||||||
|
const simulatedOnlineUsers = ref<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Typing users per chat
|
||||||
|
const typingUsersMap = ref<Map<string, Set<string>>>(new Map());
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// COMPOSABLE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useRealtimeChat() {
|
||||||
|
const {
|
||||||
|
onlineUsers,
|
||||||
|
typingUsers,
|
||||||
|
fetchMessages,
|
||||||
|
messages,
|
||||||
|
currentChat
|
||||||
|
} = useChat();
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// POLLING CONFIGURATION
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 5000; // 5 secondi
|
||||||
|
const TYPING_TIMEOUT = 3000; // 3 secondi
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// METHODS
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inizia polling per nuovi messaggi
|
||||||
|
*/
|
||||||
|
const startPolling = (chatId: string) => {
|
||||||
|
if (isPolling.value && activeChatId.value === chatId) {
|
||||||
|
return; // Già in polling per questa chat
|
||||||
|
}
|
||||||
|
|
||||||
|
stopPolling(); // Stop precedente polling
|
||||||
|
|
||||||
|
activeChatId.value = chatId;
|
||||||
|
isPolling.value = true;
|
||||||
|
|
||||||
|
pollingInterval.value = setInterval(async () => {
|
||||||
|
if (messages.value.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prendi l'ultimo messaggio e cerca nuovi messaggi dopo di esso
|
||||||
|
const lastMessage = messages.value[messages.value.length - 1];
|
||||||
|
|
||||||
|
await fetchMessages(chatId, {
|
||||||
|
after: lastMessage.createdAt,
|
||||||
|
limit: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simula typing cleanup (in una implementazione reale verrebbe dal server)
|
||||||
|
cleanupTypingUsers(chatId);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Polling error:', error);
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ferma polling
|
||||||
|
*/
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollingInterval.value) {
|
||||||
|
clearInterval(pollingInterval.value);
|
||||||
|
pollingInterval.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPolling.value = false;
|
||||||
|
activeChatId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to chat (avvia polling)
|
||||||
|
*/
|
||||||
|
const subscribeToChat = (chatId: string) => {
|
||||||
|
startPolling(chatId);
|
||||||
|
|
||||||
|
// Simula utenti online (in produzione questi dati verrebbero dal server)
|
||||||
|
if (currentChat.value?.participants) {
|
||||||
|
currentChat.value.participants.forEach(participant => {
|
||||||
|
if (participant._id) {
|
||||||
|
simulateUserOnline(participant._id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from chat (ferma polling)
|
||||||
|
*/
|
||||||
|
const unsubscribeFromChat = (chatId: string) => {
|
||||||
|
if (activeChatId.value === chatId) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulisci typing users per questa chat
|
||||||
|
typingUsersMap.value.delete(chatId);
|
||||||
|
updateTypingUsersArray();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invia evento typing
|
||||||
|
*/
|
||||||
|
const sendTyping = (chatId: string) => {
|
||||||
|
// In una implementazione reale, qui invieresti una richiesta al server
|
||||||
|
// Per ora, simuliamo localmente
|
||||||
|
|
||||||
|
console.log(`Typing in chat: ${chatId}`);
|
||||||
|
|
||||||
|
// In produzione:
|
||||||
|
// await Api.SendReq(`/api/trasporti/chats/${chatId}/typing`, 'POST');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simula utente online (per testing)
|
||||||
|
*/
|
||||||
|
const simulateUserOnline = (userId: string) => {
|
||||||
|
simulatedOnlineUsers.value.add(userId);
|
||||||
|
onlineUsers.value = Array.from(simulatedOnlineUsers.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simula utente offline (per testing)
|
||||||
|
*/
|
||||||
|
const simulateUserOffline = (userId: string) => {
|
||||||
|
simulatedOnlineUsers.value.delete(userId);
|
||||||
|
onlineUsers.value = Array.from(simulatedOnlineUsers.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiungi utente che sta scrivendo
|
||||||
|
*/
|
||||||
|
const addTypingUser = (chatId: string, userId: string) => {
|
||||||
|
if (!typingUsersMap.value.has(chatId)) {
|
||||||
|
typingUsersMap.value.set(chatId, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
typingUsersMap.value.get(chatId)!.add(userId);
|
||||||
|
updateTypingUsersArray();
|
||||||
|
|
||||||
|
// Auto-remove dopo timeout
|
||||||
|
if (typingTimeout.value) {
|
||||||
|
clearTimeout(typingTimeout.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
typingTimeout.value = setTimeout(() => {
|
||||||
|
removeTypingUser(chatId, userId);
|
||||||
|
}, TYPING_TIMEOUT);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rimuovi utente che sta scrivendo
|
||||||
|
*/
|
||||||
|
const removeTypingUser = (chatId: string, userId: string) => {
|
||||||
|
if (typingUsersMap.value.has(chatId)) {
|
||||||
|
typingUsersMap.value.get(chatId)!.delete(userId);
|
||||||
|
updateTypingUsersArray();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulisci typing users per una chat
|
||||||
|
*/
|
||||||
|
const cleanupTypingUsers = (chatId: string) => {
|
||||||
|
typingUsersMap.value.delete(chatId);
|
||||||
|
updateTypingUsersArray();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna array typing users (per la chat corrente)
|
||||||
|
*/
|
||||||
|
const updateTypingUsersArray = () => {
|
||||||
|
if (activeChatId.value && typingUsersMap.value.has(activeChatId.value)) {
|
||||||
|
typingUsers.value = Array.from(typingUsersMap.value.get(activeChatId.value)!);
|
||||||
|
} else {
|
||||||
|
typingUsers.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check connessione (simula heartbeat)
|
||||||
|
*/
|
||||||
|
const checkConnection = () => {
|
||||||
|
// In una implementazione reale con WebSocket, qui controlleresti la connessione
|
||||||
|
return isPolling.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Riconnetti (riavvia polling)
|
||||||
|
*/
|
||||||
|
const reconnect = () => {
|
||||||
|
if (activeChatId.value) {
|
||||||
|
stopPolling();
|
||||||
|
startPolling(activeChatId.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// LIFECYCLE
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopPolling();
|
||||||
|
if (typingTimeout.value) {
|
||||||
|
clearTimeout(typingTimeout.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// RETURN
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isPolling,
|
||||||
|
activeChatId,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
subscribeToChat,
|
||||||
|
unsubscribeFromChat,
|
||||||
|
sendTyping,
|
||||||
|
startPolling,
|
||||||
|
stopPolling,
|
||||||
|
|
||||||
|
// Simulate (for testing)
|
||||||
|
simulateUserOnline,
|
||||||
|
simulateUserOffline,
|
||||||
|
addTypingUser,
|
||||||
|
removeTypingUser,
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
checkConnection,
|
||||||
|
reconnect
|
||||||
|
};
|
||||||
|
}
|
||||||
165
src/modules/trasporti/composables/useRecentCities.ts
Normal file
165
src/modules/trasporti/composables/useRecentCities.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// src/composables/useRecentCities.ts
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
interface RecentCity {
|
||||||
|
city: string;
|
||||||
|
region?: string;
|
||||||
|
country?: string;
|
||||||
|
lat?: number;
|
||||||
|
lng?: number;
|
||||||
|
timestamp: number;
|
||||||
|
type: 'search' | 'trip';
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'trasporti_recent_cities';
|
||||||
|
const MAX_RECENT = 10;
|
||||||
|
|
||||||
|
export function useRecentCities() {
|
||||||
|
const recentCities = ref<RecentCity[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load recent cities from localStorage
|
||||||
|
*/
|
||||||
|
const loadRecent = (): void => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
recentCities.value = JSON.parse(stored);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent cities:', error);
|
||||||
|
recentCities.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save recent cities to localStorage
|
||||||
|
*/
|
||||||
|
const saveRecent = (): void => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(recentCities.value));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving recent cities:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a city to recent searches
|
||||||
|
*/
|
||||||
|
const addRecentSearch = (city: any): void => {
|
||||||
|
if (!city?.city) return;
|
||||||
|
|
||||||
|
const newRecent: RecentCity = {
|
||||||
|
city: city.city,
|
||||||
|
region: city.region,
|
||||||
|
country: city.country,
|
||||||
|
lat: city.lat,
|
||||||
|
lng: city.lng,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'search'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove if already exists
|
||||||
|
recentCities.value = recentCities.value.filter(
|
||||||
|
r => !(r.city === newRecent.city && r.region === newRecent.region)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to beginning
|
||||||
|
recentCities.value.unshift(newRecent);
|
||||||
|
|
||||||
|
// Keep only MAX_RECENT
|
||||||
|
if (recentCities.value.length > MAX_RECENT) {
|
||||||
|
recentCities.value = recentCities.value.slice(0, MAX_RECENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRecent();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a city from a completed trip
|
||||||
|
*/
|
||||||
|
const addRecentTrip = (city: any): void => {
|
||||||
|
if (!city?.city) return;
|
||||||
|
|
||||||
|
const newRecent: RecentCity = {
|
||||||
|
city: city.city,
|
||||||
|
region: city.region,
|
||||||
|
country: city.country,
|
||||||
|
lat: city.lat,
|
||||||
|
lng: city.lng,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
type: 'trip'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove if already exists
|
||||||
|
recentCities.value = recentCities.value.filter(
|
||||||
|
r => !(r.city === newRecent.city && r.region === newRecent.region)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to beginning
|
||||||
|
recentCities.value.unshift(newRecent);
|
||||||
|
|
||||||
|
// Keep only MAX_RECENT
|
||||||
|
if (recentCities.value.length > MAX_RECENT) {
|
||||||
|
recentCities.value = recentCities.value.slice(0, MAX_RECENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRecent();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent searches (max 2)
|
||||||
|
*/
|
||||||
|
const getRecentSearches = computed(() => {
|
||||||
|
return recentCities.value
|
||||||
|
.filter(c => c.type === 'search')
|
||||||
|
.slice(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent trips (max 2)
|
||||||
|
*/
|
||||||
|
const getRecentTrips = computed(() => {
|
||||||
|
return recentCities.value
|
||||||
|
.filter(c => c.type === 'trip')
|
||||||
|
.slice(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all recent cities for a specific type
|
||||||
|
*/
|
||||||
|
const getRecentByType = (type: 'search' | 'trip') => {
|
||||||
|
return recentCities.value.filter(c => c.type === type);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all recent cities
|
||||||
|
*/
|
||||||
|
const clearRecent = (): void => {
|
||||||
|
recentCities.value = [];
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear recent cities by type
|
||||||
|
*/
|
||||||
|
const clearRecentByType = (type: 'search' | 'trip'): void => {
|
||||||
|
recentCities.value = recentCities.value.filter(c => c.type !== type);
|
||||||
|
saveRecent();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
loadRecent();
|
||||||
|
|
||||||
|
return {
|
||||||
|
recentCities,
|
||||||
|
getRecentSearches,
|
||||||
|
getRecentTrips,
|
||||||
|
addRecentSearch,
|
||||||
|
addRecentTrip,
|
||||||
|
getRecentByType,
|
||||||
|
clearRecent,
|
||||||
|
clearRecentByType,
|
||||||
|
loadRecent
|
||||||
|
};
|
||||||
|
}
|
||||||
408
src/modules/trasporti/composables/useRideRequests.ts
Normal file
408
src/modules/trasporti/composables/useRideRequests.ts
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
import { ref, reactive, computed } from 'vue';
|
||||||
|
import { Api } from '@api';
|
||||||
|
import type {
|
||||||
|
RideRequest,
|
||||||
|
RideRequestFormData,
|
||||||
|
RideRequestStatus,
|
||||||
|
ApiResponse,
|
||||||
|
RequestsReceivedResponse,
|
||||||
|
PaginatedResponse,
|
||||||
|
RideRequestCounts
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STATE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const receivedRequests = ref<RideRequest[]>([]);
|
||||||
|
const sentRequests = ref<RideRequest[]>([]);
|
||||||
|
const currentRequest = ref<RideRequest | null>(null);
|
||||||
|
const requestCounts = ref<RideRequestCounts>({
|
||||||
|
pending: 0,
|
||||||
|
accepted: 0,
|
||||||
|
rejected: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const pagination = reactive({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
total: 0,
|
||||||
|
pages: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// COMPOSABLE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useRideRequests() {
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// COMPUTED
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const pendingReceivedCount = computed(() =>
|
||||||
|
receivedRequests.value.filter(r => r.status === 'pending').length
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasPendingRequests = computed(() => pendingReceivedCount.value > 0);
|
||||||
|
|
||||||
|
const pendingSentCount = computed(() =>
|
||||||
|
sentRequests.value.filter(r => r.status === 'pending').length
|
||||||
|
);
|
||||||
|
|
||||||
|
const acceptedRequests = computed(() =>
|
||||||
|
sentRequests.value.filter(r => r.status === 'accepted')
|
||||||
|
);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// API CALLS
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea richiesta passaggio
|
||||||
|
*/
|
||||||
|
const createRequest = async (requestData: Partial<RideRequestFormData>) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
'/api/trasporti/requests',
|
||||||
|
'POST',
|
||||||
|
requestData,
|
||||||
|
false, true
|
||||||
|
) as ApiResponse<{ request: RideRequest; chatId: string }>;
|
||||||
|
|
||||||
|
if (response.success && response.data.data) {
|
||||||
|
sentRequests.value.unshift(response.data.data.request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nella creazione della richiesta';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni richieste ricevute (sono conducente)
|
||||||
|
*/
|
||||||
|
const fetchReceivedRequests = async (status?: RideRequestStatus) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (status) queryParams.append('status', status);
|
||||||
|
queryParams.append('page', pagination.page.toString());
|
||||||
|
queryParams.append('limit', pagination.limit.toString());
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/requests/received?${queryParams.toString()}`,
|
||||||
|
'GET'
|
||||||
|
) as RequestsReceivedResponse;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
receivedRequests.value = response.data.data;
|
||||||
|
requestCounts.value = response.data?.data.counts;
|
||||||
|
Object.assign(pagination, response.data?.data.pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero delle richieste';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni richieste inviate (sono passeggero)
|
||||||
|
*/
|
||||||
|
const fetchSentRequests = async (status?: RideRequestStatus) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (status) queryParams.append('status', status);
|
||||||
|
queryParams.append('page', pagination.page.toString());
|
||||||
|
queryParams.append('limit', pagination.limit.toString());
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/requests/sent?${queryParams.toString()}`,
|
||||||
|
'GET'
|
||||||
|
) as PaginatedResponse<RideRequest>;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
sentRequests.value = response.data.data;
|
||||||
|
Object.assign(pagination, response?.data.pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero delle richieste';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni richieste per un viaggio specifico
|
||||||
|
*/
|
||||||
|
const fetchRequestsForRide = async (rideId: string, status?: RideRequestStatus) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (status) queryParams.append('status', status);
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/requests/ride/${rideId}?${queryParams.toString()}`,
|
||||||
|
'GET'
|
||||||
|
) as ApiResponse<RideRequest[]>;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero delle richieste';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni singola richiesta
|
||||||
|
*/
|
||||||
|
const fetchRequest = async (requestId: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/requests/${requestId}`,
|
||||||
|
'GET'
|
||||||
|
) as ApiResponse<RideRequest>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
currentRequest.value = response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero della richiesta';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accetta richiesta
|
||||||
|
*/
|
||||||
|
const acceptRequest = async (requestId: string, responseMessage?: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/requests/${requestId}/accept`,
|
||||||
|
'POST',
|
||||||
|
{ responseMessage }
|
||||||
|
) as ApiResponse<RideRequest>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
// Aggiorna nella lista
|
||||||
|
updateRequestInList(requestId, response.data?.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nell\'accettazione della richiesta';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rifiuta richiesta
|
||||||
|
*/
|
||||||
|
const rejectRequest = async (requestId: string, responseMessage?: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/requests/${requestId}/reject`,
|
||||||
|
'POST',
|
||||||
|
{ responseMessage }
|
||||||
|
) as ApiResponse<RideRequest>;
|
||||||
|
|
||||||
|
if (response.success && response.dat?.data) {
|
||||||
|
updateRequestInList(requestId, response.data?.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel rifiuto della richiesta';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancella richiesta
|
||||||
|
*/
|
||||||
|
const cancelRequest = async (requestId: string, reason?: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = await Api.SendReq(
|
||||||
|
`/api/trasporti/requests/${requestId}/cancel`,
|
||||||
|
'POST',
|
||||||
|
{ reason }
|
||||||
|
) as ApiResponse<RideRequest>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
updateRequestInList(requestId, response.data?.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nella cancellazione';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// UTILITIES
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna richiesta nelle liste
|
||||||
|
*/
|
||||||
|
const updateRequestInList = (requestId: string, updatedRequest: RideRequest) => {
|
||||||
|
// Aggiorna in received
|
||||||
|
const receivedIndex = receivedRequests.value.findIndex(r => r._id === requestId);
|
||||||
|
if (receivedIndex !== -1) {
|
||||||
|
receivedRequests.value[receivedIndex] = updatedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiorna in sent
|
||||||
|
const sentIndex = sentRequests.value.findIndex(r => r._id === requestId);
|
||||||
|
if (sentIndex !== -1) {
|
||||||
|
sentRequests.value[sentIndex] = updatedRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggiorna current
|
||||||
|
if (currentRequest.value?._id === requestId) {
|
||||||
|
currentRequest.value = updatedRequest;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni colore status
|
||||||
|
*/
|
||||||
|
const getRequestStatusColor = (status: RideRequestStatus): string => {
|
||||||
|
const colors: Record<RideRequestStatus, string> = {
|
||||||
|
pending: 'warning',
|
||||||
|
accepted: 'positive',
|
||||||
|
rejected: 'negative',
|
||||||
|
cancelled: 'grey',
|
||||||
|
expired: 'grey',
|
||||||
|
completed: 'info'
|
||||||
|
};
|
||||||
|
return colors[status] || 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni label status
|
||||||
|
*/
|
||||||
|
const getRequestStatusLabel = (status: RideRequestStatus): string => {
|
||||||
|
const labels: Record<RideRequestStatus, string> = {
|
||||||
|
pending: 'In attesa',
|
||||||
|
accepted: 'Accettata',
|
||||||
|
rejected: 'Rifiutata',
|
||||||
|
cancelled: 'Cancellata',
|
||||||
|
expired: 'Scaduta',
|
||||||
|
completed: 'Completata'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni icona status
|
||||||
|
*/
|
||||||
|
const getRequestStatusIcon = (status: RideRequestStatus): string => {
|
||||||
|
const icons: Record<RideRequestStatus, string> = {
|
||||||
|
pending: 'hourglass_empty',
|
||||||
|
accepted: 'check_circle',
|
||||||
|
rejected: 'cancel',
|
||||||
|
cancelled: 'block',
|
||||||
|
expired: 'schedule',
|
||||||
|
completed: 'verified'
|
||||||
|
};
|
||||||
|
return icons[status] || 'help';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulisci stato
|
||||||
|
*/
|
||||||
|
const clearState = () => {
|
||||||
|
receivedRequests.value = [];
|
||||||
|
sentRequests.value = [];
|
||||||
|
currentRequest.value = null;
|
||||||
|
error.value = null;
|
||||||
|
pagination.page = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// RETURN
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
receivedRequests,
|
||||||
|
sentRequests,
|
||||||
|
currentRequest,
|
||||||
|
requestCounts,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
pagination,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
pendingReceivedCount,
|
||||||
|
hasPendingRequests,
|
||||||
|
pendingSentCount,
|
||||||
|
acceptedRequests,
|
||||||
|
|
||||||
|
// API Methods
|
||||||
|
createRequest,
|
||||||
|
fetchReceivedRequests,
|
||||||
|
fetchSentRequests,
|
||||||
|
fetchRequestsForRide,
|
||||||
|
fetchRequest,
|
||||||
|
acceptRequest,
|
||||||
|
rejectRequest,
|
||||||
|
cancelRequest,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
getRequestStatusColor,
|
||||||
|
getRequestStatusLabel,
|
||||||
|
getRequestStatusIcon,
|
||||||
|
clearState
|
||||||
|
};
|
||||||
|
}
|
||||||
586
src/modules/trasporti/composables/useRides.ts
Normal file
586
src/modules/trasporti/composables/useRides.ts
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
|
import { Api } from '@api'; // Adatta al tuo path
|
||||||
|
import type {
|
||||||
|
Ride,
|
||||||
|
RideFormData,
|
||||||
|
RideSearchFilters,
|
||||||
|
RideType,
|
||||||
|
RideStatus,
|
||||||
|
ApiResponse,
|
||||||
|
PaginatedResponse,
|
||||||
|
MyRidesResponse,
|
||||||
|
RidesStatsResponse,
|
||||||
|
Waypoint,
|
||||||
|
Location,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STATE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const rides = ref<Ride[]>([]);
|
||||||
|
const currentRide = ref<Ride | null>(null);
|
||||||
|
const myRides = reactive<{
|
||||||
|
all: Ride[];
|
||||||
|
upcoming: Ride[];
|
||||||
|
past: Ride[];
|
||||||
|
}>({
|
||||||
|
all: [],
|
||||||
|
upcoming: [],
|
||||||
|
past: [],
|
||||||
|
});
|
||||||
|
const stats = ref<RidesStatsResponse | null>(null);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
const pagination = reactive({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
total: 0,
|
||||||
|
pages: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filters = reactive<RideSearchFilters>({
|
||||||
|
type: undefined,
|
||||||
|
from: '',
|
||||||
|
to: '',
|
||||||
|
date: '',
|
||||||
|
seats: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// COMPOSABLE
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function useRides() {
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// COMPUTED
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
const hasRides = computed(() => rides.value.length > 0);
|
||||||
|
const hasMorePages = computed(() => pagination.page < pagination.pages);
|
||||||
|
|
||||||
|
const offersCount = computed(() =>
|
||||||
|
Array.isArray(rides.value) ? rides.value.filter((r) => r.type === 'offer').length : 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const requestsCount = computed(() =>
|
||||||
|
Array.isArray(rides.value)
|
||||||
|
? rides.value.filter((r) => r.type === 'request').length
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// ✅ Fixed: was returning array instead of count, and added .length
|
||||||
|
const activeRides = computed(() =>
|
||||||
|
Array.isArray(rides.value)
|
||||||
|
? rides.value.filter((r) => ['active', 'full'].includes(r.status)).length
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// API CALLS
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni lista viaggi con filtri
|
||||||
|
*/
|
||||||
|
const fetchRides = async (
|
||||||
|
options: {
|
||||||
|
reset?: boolean;
|
||||||
|
filters?: RideSearchFilters;
|
||||||
|
} = {}
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
if (options.reset) {
|
||||||
|
pagination.page = 1;
|
||||||
|
rides.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.filters) {
|
||||||
|
Object.assign(filters, options.filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
queryParams.append('page', pagination.page.toString());
|
||||||
|
queryParams.append('limit', pagination.limit.toString());
|
||||||
|
|
||||||
|
if (filters.type) queryParams.append('type', filters.type);
|
||||||
|
if (filters.from) queryParams.append('departureCity', filters.from);
|
||||||
|
if (filters.to) queryParams.append('destinationCity', filters.to);
|
||||||
|
if (filters.date) queryParams.append('date', filters.date);
|
||||||
|
if (filters.seats) queryParams.append('minSeats', filters.seats.toString());
|
||||||
|
if (filters.passingThrough)
|
||||||
|
queryParams.append('passingThrough', filters.passingThrough);
|
||||||
|
|
||||||
|
const response = (await Api.SendReq(
|
||||||
|
`/api/trasporti/rides?${queryParams.toString()}`,
|
||||||
|
'GET'
|
||||||
|
)) as PaginatedResponse<Ride>;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// ✅ Ensure response.data is always an array
|
||||||
|
const newRides = Array.isArray(response.data.data) ? response.data.data : [];
|
||||||
|
|
||||||
|
if (options.reset) {
|
||||||
|
rides.value = newRides;
|
||||||
|
} else {
|
||||||
|
rides.value = [...rides.value, ...newRides];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response?.data.pagination) {
|
||||||
|
Object.assign(pagination, response?.data.pagination);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Errore nel recupero dei viaggi');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero dei viaggi';
|
||||||
|
rides.value = []; // ✅ Reset to empty array on error
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ricerca viaggi avanzata
|
||||||
|
*/
|
||||||
|
const searchRides = async (searchFilters: RideSearchFilters) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (searchFilters.from) queryParams.append('from', searchFilters.from);
|
||||||
|
if (searchFilters.to) queryParams.append('to', searchFilters.to);
|
||||||
|
if (searchFilters.date) queryParams.append('date', searchFilters.date);
|
||||||
|
if (searchFilters.seats)
|
||||||
|
queryParams.append('seats', searchFilters.seats.toString());
|
||||||
|
if (searchFilters.type) queryParams.append('type', searchFilters.type);
|
||||||
|
queryParams.append('page', pagination.page.toString());
|
||||||
|
queryParams.append('limit', pagination.limit.toString());
|
||||||
|
|
||||||
|
const response = (await Api.SendReq(
|
||||||
|
`/api/trasporti/rides/search?${queryParams.toString()}`,
|
||||||
|
'GET'
|
||||||
|
)) as PaginatedResponse<Ride>;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// ✅ Ensure response.data is always an array
|
||||||
|
rides.value = Array.isArray(response.data.data) ? response.data.data : [];
|
||||||
|
|
||||||
|
if (response?.data.pagination) {
|
||||||
|
Object.assign(pagination, response?.data.pagination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nella ricerca';
|
||||||
|
rides.value = []; // ✅ Reset to empty array on error
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni singolo viaggio
|
||||||
|
*/
|
||||||
|
const fetchRide = async (rideId: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = (await Api.SendReq(
|
||||||
|
`/api/trasporti/rides/${rideId}`,
|
||||||
|
'GET'
|
||||||
|
)) as ApiResponse<Ride>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
currentRide.value = response.data.data;
|
||||||
|
} else {
|
||||||
|
throw new Error('Viaggio non trovato');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero del viaggio';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea nuovo viaggio
|
||||||
|
*/
|
||||||
|
const createRide = async (rideData: Partial<RideFormData>) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = (await Api.SendReq(
|
||||||
|
'/api/trasporti/rides',
|
||||||
|
'POST',
|
||||||
|
rideData
|
||||||
|
)) as ApiResponse<Ride>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
// Aggiungi in testa alla lista
|
||||||
|
rides.value.unshift(response.data?.data);
|
||||||
|
currentRide.value = response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nella creazione del viaggio';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggiorna viaggio
|
||||||
|
*/
|
||||||
|
const updateRide = async (rideId: string, updateData: Partial<RideFormData>) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = (await Api.SendReq(
|
||||||
|
`/api/trasporti/rides/${rideId}`,
|
||||||
|
'PUT',
|
||||||
|
updateData
|
||||||
|
)) as ApiResponse<Ride>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
// Aggiorna nella lista
|
||||||
|
const index = rides.value.findIndex((r) => r._id === rideId);
|
||||||
|
if (index !== -1) {
|
||||||
|
rides.value[index] = response.data.data;
|
||||||
|
}
|
||||||
|
if (currentRide.value?._id === rideId) {
|
||||||
|
currentRide.value = response.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||"Errore nell'aggiornamento del viaggio";
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancella viaggio
|
||||||
|
*/
|
||||||
|
const deleteRide = async (rideId: string, reason?: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = (await Api.SendReq(`/api/trasporti/rides/${rideId}`, 'DELETE', {
|
||||||
|
reason,
|
||||||
|
})) as ApiResponse<void>;
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// Rimuovi dalla lista
|
||||||
|
rides.value = rides.value.filter((r) => r._id !== rideId);
|
||||||
|
if (currentRide.value?._id === rideId) {
|
||||||
|
currentRide.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nella cancellazione del viaggio';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completa viaggio
|
||||||
|
*/
|
||||||
|
const completeRide = async (rideId: string) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const response = (await Api.SendReq(
|
||||||
|
`/api/trasporti/rides/${rideId}/complete`,
|
||||||
|
'POST'
|
||||||
|
)) as ApiResponse<Ride>;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
const index = rides.value.findIndex((r) => r._id === rideId);
|
||||||
|
if (index !== -1) {
|
||||||
|
rides.value[index] = response.data.data;
|
||||||
|
}
|
||||||
|
if (currentRide.value?._id === rideId) {
|
||||||
|
currentRide.value = response.data.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel completamento del viaggio';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni i miei viaggi
|
||||||
|
*/
|
||||||
|
const fetchMyRides = async (options?: {
|
||||||
|
type?: RideType;
|
||||||
|
role?: 'driver' | 'passenger';
|
||||||
|
status?: RideStatus;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (options?.type) queryParams.append('type', options.type);
|
||||||
|
if (options?.role) queryParams.append('role', options.role);
|
||||||
|
if (options?.status) queryParams.append('status', options.status);
|
||||||
|
queryParams.append('page', pagination.page.toString());
|
||||||
|
queryParams.append('limit', pagination.limit.toString());
|
||||||
|
|
||||||
|
const response = (await Api.SendReq(
|
||||||
|
`/api/trasporti/rides/my?${queryParams.toString()}`,
|
||||||
|
'GET'
|
||||||
|
)) as MyRidesResponse;
|
||||||
|
|
||||||
|
if (response.success && response.data?.data) {
|
||||||
|
myRides.all = response.data.data.all;
|
||||||
|
myRides.upcoming = response.data.data.upcoming;
|
||||||
|
myRides.past = response.data.data.past;
|
||||||
|
Object.assign(pagination, response?.data.pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.data?.message || err.message ||'Errore nel recupero dei tuoi viaggi';
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni statistiche per widget
|
||||||
|
*/
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = (await Api.SendReq(
|
||||||
|
'/api/trasporti/rides/stats',
|
||||||
|
'GET'
|
||||||
|
)) as ApiResponse<RidesStatsResponse>;
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
stats.value = response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Errore recupero statistiche:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// UTILITIES
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carica pagina successiva
|
||||||
|
*/
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (hasMorePages.value && !loading.value) {
|
||||||
|
pagination.page++;
|
||||||
|
await fetchRides({ reset: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset filtri
|
||||||
|
*/
|
||||||
|
const resetFilters = () => {
|
||||||
|
filters.type = undefined;
|
||||||
|
filters.from = '';
|
||||||
|
filters.to = '';
|
||||||
|
filters.date = '';
|
||||||
|
filters.seats = 1;
|
||||||
|
filters.passingThrough = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulisci stato
|
||||||
|
*/
|
||||||
|
const clearState = () => {
|
||||||
|
rides.value = [];
|
||||||
|
currentRide.value = null;
|
||||||
|
myRides.all = [];
|
||||||
|
myRides.upcoming = [];
|
||||||
|
myRides.past = [];
|
||||||
|
error.value = null;
|
||||||
|
pagination.page = 1;
|
||||||
|
pagination.total = 0;
|
||||||
|
pagination.pages = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatta data viaggio
|
||||||
|
*/
|
||||||
|
const formatRideDate = (date: Date | string) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
const today = new Date();
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
if (d.toDateString() === today.toDateString()) {
|
||||||
|
return `Oggi, ${d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
} else if (d.toDateString() === tomorrow.toDateString()) {
|
||||||
|
return `Domani, ${d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
|
} else {
|
||||||
|
return d.toLocaleDateString('it-IT', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatta durata viaggio
|
||||||
|
*/
|
||||||
|
const formatDuration = (minutes: number) => {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const mins = minutes % 60;
|
||||||
|
if (hours === 0) return `${mins} min`;
|
||||||
|
if (mins === 0) return `${hours} h`;
|
||||||
|
return `${hours} h ${mins} min`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatta distanza
|
||||||
|
*/
|
||||||
|
const formatDistance = (km: number) => {
|
||||||
|
if (km < 1) return `${Math.round(km * 1000)} m`;
|
||||||
|
return `${km.toFixed(1)} km`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni colore status
|
||||||
|
*/
|
||||||
|
const getStatusColor = (status: RideStatus): string => {
|
||||||
|
const colors: Record<RideStatus, string> = {
|
||||||
|
draft: 'grey',
|
||||||
|
active: 'positive',
|
||||||
|
full: 'warning',
|
||||||
|
in_progress: 'info',
|
||||||
|
completed: 'positive',
|
||||||
|
cancelled: 'negative',
|
||||||
|
expired: 'grey',
|
||||||
|
};
|
||||||
|
return colors[status] || 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ottieni label status
|
||||||
|
*/
|
||||||
|
const getStatusLabel = (status: RideStatus): string => {
|
||||||
|
const labels: Record<RideStatus, string> = {
|
||||||
|
draft: 'Bozza',
|
||||||
|
active: 'Attivo',
|
||||||
|
full: 'Completo',
|
||||||
|
in_progress: 'In corso',
|
||||||
|
completed: 'Completato',
|
||||||
|
cancelled: 'Cancellato',
|
||||||
|
expired: 'Scaduto',
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se il viaggio è nel passato
|
||||||
|
*/
|
||||||
|
const isPastRide = (ride: Ride) => {
|
||||||
|
return new Date(ride.dateTime) < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se l'utente può prenotare
|
||||||
|
*/
|
||||||
|
const canBook = (ride: Ride, userId: string) => {
|
||||||
|
if (ride.type !== 'offer') return false;
|
||||||
|
if (ride.status !== 'active') return false;
|
||||||
|
if (ride.passengers && ride.passengers.available <= 0) return false;
|
||||||
|
if (typeof ride.userId === 'string' && ride.userId === userId) return false;
|
||||||
|
if (typeof ride.userId === 'object' && ride.userId._id === userId) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// RETURN
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
rides,
|
||||||
|
currentRide,
|
||||||
|
myRides,
|
||||||
|
stats,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
pagination,
|
||||||
|
filters,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
hasRides,
|
||||||
|
hasMorePages,
|
||||||
|
offersCount,
|
||||||
|
requestsCount,
|
||||||
|
activeRides,
|
||||||
|
|
||||||
|
// API Methods
|
||||||
|
fetchRides,
|
||||||
|
searchRides,
|
||||||
|
fetchRide,
|
||||||
|
createRide,
|
||||||
|
updateRide,
|
||||||
|
deleteRide,
|
||||||
|
completeRide,
|
||||||
|
fetchMyRides,
|
||||||
|
fetchStats,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
loadMore,
|
||||||
|
resetFilters,
|
||||||
|
clearState,
|
||||||
|
formatRideDate,
|
||||||
|
formatDuration,
|
||||||
|
formatDistance,
|
||||||
|
getStatusColor,
|
||||||
|
getStatusLabel,
|
||||||
|
isPastRide,
|
||||||
|
canBook,
|
||||||
|
};
|
||||||
|
}
|
||||||
299
src/modules/trasporti/pages/ChatListPage.scss
Normal file
299
src/modules/trasporti/pages/ChatListPage.scss
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
// ChatListPage.scss
|
||||||
|
.chat-list-page {
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
&__header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-radius: 0 0 24px 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 40px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
:deep(.q-field__control) {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.q-field__native),
|
||||||
|
:deep(.q-field__prefix),
|
||||||
|
:deep(.q-field__suffix) {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.q-field__native::placeholder) {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
&__tabs {
|
||||||
|
background: white;
|
||||||
|
margin: 0 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
:deep(.q-tab) {
|
||||||
|
text-transform: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tab-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
&__content {
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 24px;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 20px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List
|
||||||
|
&__list {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--unread {
|
||||||
|
border-left: 4px solid var(--q-primary);
|
||||||
|
background: linear-gradient(90deg, rgba(102, 126, 234, 0.05) 0%, white 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.q-item) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__slide-action {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 24px;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.q-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--archive {
|
||||||
|
background: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--delete {
|
||||||
|
background: #f44336;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
&__avatar-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__online-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: #4caf50;
|
||||||
|
border: 3px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-badge {
|
||||||
|
padding: 3px;
|
||||||
|
min-height: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
&__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__last-message {
|
||||||
|
color: #666;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&--unread {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__unread-badge {
|
||||||
|
font-weight: 600;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load more
|
||||||
|
&__load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search dialog
|
||||||
|
&__search-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-top: 60px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.chat-list-page {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
|
||||||
|
&__tabs {
|
||||||
|
background: #1e1e30;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
background: #1e1e30;
|
||||||
|
|
||||||
|
&--unread {
|
||||||
|
background: linear-gradient(90deg, rgba(102, 126, 234, 0.1) 0%, #1e1e30 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__last-message--unread {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
310
src/modules/trasporti/pages/ChatListPage.ts
Normal file
310
src/modules/trasporti/pages/ChatListPage.ts
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
// ChatListPage.ts
|
||||||
|
import { defineComponent, ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useChat } from '../composables/useChat';
|
||||||
|
import { useAuth } from '@/composables/useAuth'; // Il tuo composable auth esistente
|
||||||
|
import type { Chat, User, Message } from '../types/trasporti.types';
|
||||||
|
import { debounce } from 'quasar';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'ChatListPage',
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const {
|
||||||
|
chats,
|
||||||
|
loading,
|
||||||
|
loadChats,
|
||||||
|
archiveChat,
|
||||||
|
deleteChat,
|
||||||
|
createChat,
|
||||||
|
searchUsers: searchUsersApi,
|
||||||
|
onlineUsers
|
||||||
|
} = useChat();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const activeTab = ref('all');
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const hasMore = ref(true);
|
||||||
|
const page = ref(1);
|
||||||
|
const showUserSearch = ref(false);
|
||||||
|
const showGroupCreate = ref(false);
|
||||||
|
const userSearchQuery = ref('');
|
||||||
|
const searchedUsers = ref<User[]>([]);
|
||||||
|
const searchingUsers = ref(false);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const currentUserId = computed(() => currentUser.value?._id);
|
||||||
|
|
||||||
|
const unreadCount = computed(() => {
|
||||||
|
return chats.value.reduce((total, chat) => total + (chat.unreadCount || 0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredChats = computed(() => {
|
||||||
|
let result = [...chats.value];
|
||||||
|
|
||||||
|
// Filter by tab
|
||||||
|
switch (activeTab.value) {
|
||||||
|
case 'unread':
|
||||||
|
result = result.filter(chat => chat.unreadCount > 0);
|
||||||
|
break;
|
||||||
|
case 'rides':
|
||||||
|
result = result.filter(chat => chat.rideId);
|
||||||
|
break;
|
||||||
|
case 'archived':
|
||||||
|
result = result.filter(chat => chat.archived);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result = result.filter(chat => !chat.archived);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase();
|
||||||
|
result = result.filter(chat => {
|
||||||
|
const otherUser = getOtherParticipant(chat);
|
||||||
|
const fullName = `${otherUser?.name || ''} ${otherUser?.surname || ''}`.toLowerCase();
|
||||||
|
const username = otherUser?.username?.toLowerCase() || '';
|
||||||
|
const rideInfo = chat.rideInfo
|
||||||
|
? `${chat.rideInfo.departure} ${chat.rideInfo.destination}`.toLowerCase()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return fullName.includes(query) ||
|
||||||
|
username.includes(query) ||
|
||||||
|
rideInfo.includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: pinned first, then by last message date
|
||||||
|
result.sort((a, b) => {
|
||||||
|
if (a.pinned && !b.pinned) return -1;
|
||||||
|
if (!a.pinned && b.pinned) return 1;
|
||||||
|
|
||||||
|
const dateA = new Date(a.lastMessage?.createdAt || a.updatedAt).getTime();
|
||||||
|
const dateB = new Date(b.lastMessage?.createdAt || b.updatedAt).getTime();
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyStateIcon = computed(() => {
|
||||||
|
if (searchQuery.value) return 'search_off';
|
||||||
|
switch (activeTab.value) {
|
||||||
|
case 'unread': return 'mark_email_read';
|
||||||
|
case 'rides': return 'no_transfer';
|
||||||
|
case 'archived': return 'inventory_2';
|
||||||
|
default: return 'forum';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyStateTitle = computed(() => {
|
||||||
|
if (searchQuery.value) return 'Nessun risultato';
|
||||||
|
switch (activeTab.value) {
|
||||||
|
case 'unread': return 'Tutto letto!';
|
||||||
|
case 'rides': return 'Nessuna chat viaggio';
|
||||||
|
case 'archived': return 'Nessuna chat archiviata';
|
||||||
|
default: return 'Nessuna conversazione';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyStateMessage = computed(() => {
|
||||||
|
if (searchQuery.value) return 'Prova con altri termini di ricerca';
|
||||||
|
switch (activeTab.value) {
|
||||||
|
case 'unread': return 'Non hai messaggi da leggere';
|
||||||
|
case 'rides': return 'Le chat relative ai viaggi appariranno qui';
|
||||||
|
case 'archived': return 'Le chat archiviate appariranno qui';
|
||||||
|
default: return 'Inizia a cercare viaggi per connetterti con altri utenti';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const getOtherParticipant = (chat: Chat): User | undefined => {
|
||||||
|
return chat.participants?.find(p => p._id !== currentUserId.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (user?: User): string => {
|
||||||
|
if (!user) return '?';
|
||||||
|
const name = user.name || '';
|
||||||
|
const surname = user.surname || '';
|
||||||
|
return `${name.charAt(0)}${surname.charAt(0)}`.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOnline = (userId?: string): boolean => {
|
||||||
|
if (!userId) return false;
|
||||||
|
return onlineUsers.value.includes(userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date?: string | Date): string => {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
const messageDate = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - messageDate.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
return messageDate.toLocaleTimeString('it-IT', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
} else if (days === 1) {
|
||||||
|
return 'Ieri';
|
||||||
|
} else if (days < 7) {
|
||||||
|
return messageDate.toLocaleDateString('it-IT', { weekday: 'short' });
|
||||||
|
} else {
|
||||||
|
return messageDate.toLocaleDateString('it-IT', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessagePreview = (message?: Message): string => {
|
||||||
|
if (!message) return 'Nessun messaggio';
|
||||||
|
|
||||||
|
if (message.type === 'image') return '📷 Foto';
|
||||||
|
if (message.type === 'location') return '📍 Posizione';
|
||||||
|
if (message.type === 'ride_request') return '🚗 Richiesta passaggio';
|
||||||
|
if (message.type === 'ride_accepted') return '✅ Passaggio accettato';
|
||||||
|
if (message.type === 'ride_rejected') return '❌ Passaggio rifiutato';
|
||||||
|
|
||||||
|
return message.content || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageStatusIcon = (message?: Message): string => {
|
||||||
|
if (!message) return '';
|
||||||
|
if (message.read) return 'done_all';
|
||||||
|
if (message.delivered) return 'done_all';
|
||||||
|
return 'done';
|
||||||
|
};
|
||||||
|
|
||||||
|
const openChat = (chat: Chat) => {
|
||||||
|
router.push(`/trasporti/chat/${chat._id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onArchiveChat = async (chat: Chat) => {
|
||||||
|
try {
|
||||||
|
await archiveChat(chat._id, !chat.archived);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: chat.archived ? 'Chat ripristinata' : 'Chat archiviata',
|
||||||
|
icon: 'archive'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore durante l\'archiviazione'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteChat = async (chat: Chat) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Elimina conversazione',
|
||||||
|
message: 'Sei sicuro di voler eliminare questa conversazione? L\'azione non è reversibile.',
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
await deleteChat(chat._id);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Conversazione eliminata',
|
||||||
|
icon: 'delete'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore durante l\'eliminazione'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
loadingMore.value = true;
|
||||||
|
try {
|
||||||
|
page.value++;
|
||||||
|
const newChats = await loadChats({ page: page.value, limit: 20 });
|
||||||
|
if (newChats.length < 20) {
|
||||||
|
hasMore.value = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchUsers = debounce(async (query: string) => {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
searchedUsers.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
searchingUsers.value = true;
|
||||||
|
try {
|
||||||
|
searchedUsers.value = await searchUsersApi(query);
|
||||||
|
} finally {
|
||||||
|
searchingUsers.value = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const startChatWith = async (user: User) => {
|
||||||
|
try {
|
||||||
|
const chat = await createChat([user._id]);
|
||||||
|
showUserSearch.value = false;
|
||||||
|
router.push(`/trasporti/chat/${chat._id}`);
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore nella creazione della chat'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadChats({ page: 1, limit: 20 });
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
searchQuery,
|
||||||
|
activeTab,
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
hasMore,
|
||||||
|
showUserSearch,
|
||||||
|
showGroupCreate,
|
||||||
|
userSearchQuery,
|
||||||
|
searchedUsers,
|
||||||
|
searchingUsers,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
currentUserId,
|
||||||
|
unreadCount,
|
||||||
|
filteredChats,
|
||||||
|
emptyStateIcon,
|
||||||
|
emptyStateTitle,
|
||||||
|
emptyStateMessage,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
getOtherParticipant,
|
||||||
|
getInitials,
|
||||||
|
isOnline,
|
||||||
|
formatTime,
|
||||||
|
getMessagePreview,
|
||||||
|
getMessageStatusIcon,
|
||||||
|
openChat,
|
||||||
|
onArchiveChat,
|
||||||
|
onDeleteChat,
|
||||||
|
loadMore,
|
||||||
|
searchUsers,
|
||||||
|
startChatWith
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
275
src/modules/trasporti/pages/ChatListPage.vue
Normal file
275
src/modules/trasporti/pages/ChatListPage.vue
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<!-- ChatListPage.vue -->
|
||||||
|
<template>
|
||||||
|
<q-page class="chat-list-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="chat-list-page__header">
|
||||||
|
<div class="chat-list-page__title-section">
|
||||||
|
<q-icon name="forum" class="chat-list-page__icon" />
|
||||||
|
<div>
|
||||||
|
<h1 class="chat-list-page__title">Messaggi</h1>
|
||||||
|
<p class="chat-list-page__subtitle">Le tue conversazioni</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<q-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="Cerca conversazione..."
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
class="chat-list-page__search"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</template>
|
||||||
|
<template v-if="searchQuery" #append>
|
||||||
|
<q-icon name="close" class="cursor-pointer" @click="searchQuery = ''" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<q-tabs
|
||||||
|
v-model="activeTab"
|
||||||
|
class="chat-list-page__tabs"
|
||||||
|
active-color="primary"
|
||||||
|
indicator-color="primary"
|
||||||
|
align="justify"
|
||||||
|
>
|
||||||
|
<q-tab name="all" label="Tutte" icon="inbox" />
|
||||||
|
<q-tab name="unread" icon="mark_email_unread">
|
||||||
|
<template #default>
|
||||||
|
<div class="chat-list-page__tab-content">
|
||||||
|
<span>Non lette</span>
|
||||||
|
<q-badge v-if="unreadCount > 0" color="negative" :label="unreadCount" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-tab>
|
||||||
|
<q-tab name="rides" label="Viaggi" icon="directions_car" />
|
||||||
|
<q-tab name="archived" label="Archiviate" icon="archive" />
|
||||||
|
</q-tabs>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="chat-list-page__content">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="chat-list-page__loading">
|
||||||
|
<q-spinner-dots size="50px" color="primary" />
|
||||||
|
<p>Caricamento conversazioni...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="filteredChats.length === 0" class="chat-list-page__empty">
|
||||||
|
<q-icon :name="emptyStateIcon" size="80px" color="grey-4" />
|
||||||
|
<h3>{{ emptyStateTitle }}</h3>
|
||||||
|
<p>{{ emptyStateMessage }}</p>
|
||||||
|
<q-btn
|
||||||
|
v-if="activeTab === 'all' && !searchQuery"
|
||||||
|
color="primary"
|
||||||
|
icon="explore"
|
||||||
|
label="Esplora viaggi"
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
to="/trasporti"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat List -->
|
||||||
|
<q-list v-else class="chat-list-page__list" separator>
|
||||||
|
<q-slide-item
|
||||||
|
v-for="chat in filteredChats"
|
||||||
|
:key="chat._id"
|
||||||
|
class="chat-list-page__item"
|
||||||
|
:class="{ 'chat-list-page__item--unread': chat.unreadCount > 0 }"
|
||||||
|
@left="onArchiveChat(chat)"
|
||||||
|
@right="onDeleteChat(chat)"
|
||||||
|
>
|
||||||
|
<template #left>
|
||||||
|
<div class="chat-list-page__slide-action chat-list-page__slide-action--archive">
|
||||||
|
<q-icon name="archive" />
|
||||||
|
<span>Archivia</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #right>
|
||||||
|
<div class="chat-list-page__slide-action chat-list-page__slide-action--delete">
|
||||||
|
<q-icon name="delete" />
|
||||||
|
<span>Elimina</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<q-item clickable @click="openChat(chat)">
|
||||||
|
<!-- Avatar -->
|
||||||
|
<q-item-section avatar>
|
||||||
|
<div class="chat-list-page__avatar-wrapper">
|
||||||
|
<q-avatar size="56px">
|
||||||
|
<img
|
||||||
|
v-if="getOtherParticipant(chat)?.profile?.img"
|
||||||
|
:src="getOtherParticipant(chat).profile.img"
|
||||||
|
:alt="getOtherParticipant(chat).name"
|
||||||
|
/>
|
||||||
|
<div v-else class="chat-list-page__avatar-placeholder">
|
||||||
|
{{ getInitials(getOtherParticipant(chat)) }}
|
||||||
|
</div>
|
||||||
|
</q-avatar>
|
||||||
|
|
||||||
|
<!-- Online indicator -->
|
||||||
|
<div
|
||||||
|
v-if="isOnline(getOtherParticipant(chat)?._id)"
|
||||||
|
class="chat-list-page__online-dot"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Ride type badge -->
|
||||||
|
<q-badge
|
||||||
|
v-if="chat.rideInfo"
|
||||||
|
:color="chat.rideInfo.type === 'offer' ? 'positive' : 'negative'"
|
||||||
|
floating
|
||||||
|
rounded
|
||||||
|
class="chat-list-page__ride-badge"
|
||||||
|
>
|
||||||
|
<q-icon :name="chat.rideInfo.type === 'offer' ? 'directions_car' : 'hail'" size="12px" />
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="chat-list-page__name">
|
||||||
|
{{ getOtherParticipant(chat)?.name }} {{ getOtherParticipant(chat)?.surname }}
|
||||||
|
</q-item-label>
|
||||||
|
|
||||||
|
<!-- Ride info -->
|
||||||
|
<q-item-label v-if="chat.rideInfo" caption class="chat-list-page__ride-info">
|
||||||
|
<q-icon name="place" size="14px" />
|
||||||
|
{{ chat.rideInfo.departure }} → {{ chat.rideInfo.destination }}
|
||||||
|
</q-item-label>
|
||||||
|
|
||||||
|
<!-- Last message -->
|
||||||
|
<q-item-label
|
||||||
|
caption
|
||||||
|
lines="1"
|
||||||
|
class="chat-list-page__last-message"
|
||||||
|
:class="{ 'chat-list-page__last-message--unread': chat.unreadCount > 0 }"
|
||||||
|
>
|
||||||
|
<q-icon
|
||||||
|
v-if="chat.lastMessage?.senderId === currentUserId"
|
||||||
|
:name="getMessageStatusIcon(chat.lastMessage)"
|
||||||
|
size="14px"
|
||||||
|
:color="chat.lastMessage?.read ? 'primary' : 'grey'"
|
||||||
|
/>
|
||||||
|
{{ getMessagePreview(chat.lastMessage) }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
|
||||||
|
<!-- Right side -->
|
||||||
|
<q-item-section side>
|
||||||
|
<div class="chat-list-page__meta">
|
||||||
|
<q-item-label caption class="chat-list-page__time">
|
||||||
|
{{ formatTime(chat.lastMessage?.createdAt || chat.updatedAt) }}
|
||||||
|
</q-item-label>
|
||||||
|
|
||||||
|
<q-badge
|
||||||
|
v-if="chat.unreadCount > 0"
|
||||||
|
color="primary"
|
||||||
|
:label="chat.unreadCount > 99 ? '99+' : chat.unreadCount"
|
||||||
|
rounded
|
||||||
|
class="chat-list-page__unread-badge"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-icon
|
||||||
|
v-else-if="chat.pinned"
|
||||||
|
name="push_pin"
|
||||||
|
size="18px"
|
||||||
|
color="grey"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-slide-item>
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<!-- Load More -->
|
||||||
|
<div v-if="hasMore && !loading" class="chat-list-page__load-more">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="primary"
|
||||||
|
label="Carica altre conversazioni"
|
||||||
|
:loading="loadingMore"
|
||||||
|
@click="loadMore"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAB for new message -->
|
||||||
|
<q-page-sticky position="bottom-right" :offset="[18, 18]">
|
||||||
|
<q-fab
|
||||||
|
icon="edit"
|
||||||
|
direction="up"
|
||||||
|
color="primary"
|
||||||
|
:disable="loading"
|
||||||
|
>
|
||||||
|
<q-fab-action
|
||||||
|
color="secondary"
|
||||||
|
icon="person_search"
|
||||||
|
label="Cerca utente"
|
||||||
|
@click="showUserSearch = true"
|
||||||
|
/>
|
||||||
|
<q-fab-action
|
||||||
|
color="accent"
|
||||||
|
icon="group"
|
||||||
|
label="Nuovo gruppo"
|
||||||
|
@click="showGroupCreate = true"
|
||||||
|
/>
|
||||||
|
</q-fab>
|
||||||
|
</q-page-sticky>
|
||||||
|
|
||||||
|
<!-- User Search Dialog -->
|
||||||
|
<q-dialog v-model="showUserSearch" position="top">
|
||||||
|
<q-card class="chat-list-page__search-dialog">
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">Nuova conversazione</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-input
|
||||||
|
v-model="userSearchQuery"
|
||||||
|
placeholder="Cerca per nome o username..."
|
||||||
|
outlined
|
||||||
|
autofocus
|
||||||
|
@update:model-value="searchUsers"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-list v-if="searchedUsers.length > 0" class="q-mt-md">
|
||||||
|
<q-item
|
||||||
|
v-for="user in searchedUsers"
|
||||||
|
:key="user._id"
|
||||||
|
clickable
|
||||||
|
@click="startChatWith(user)"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-avatar>
|
||||||
|
<img v-if="user.profile?.img" :src="user.profile.img" />
|
||||||
|
<span v-else>{{ getInitials(user) }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>{{ user.name }} {{ user.surname }}</q-item-label>
|
||||||
|
<q-item-label caption>@{{ user.username }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<div v-else-if="userSearchQuery && !searchingUsers" class="text-center q-pa-md text-grey">
|
||||||
|
Nessun utente trovato
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./ChatListPage.ts" />
|
||||||
|
<style lang="scss" src="./ChatListPage.scss" />
|
||||||
413
src/modules/trasporti/pages/ChatPage.scss
Normal file
413
src/modules/trasporti/pages/ChatPage.scss
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
// ChatPage.scss
|
||||||
|
.chat-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background: #f0f2f5;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
&__header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
|
||||||
|
:deep(.q-toolbar) {
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__typing {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__typing-dots {
|
||||||
|
animation: blink 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ride Banner
|
||||||
|
&__ride-banner {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-banner-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-banner-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-banner-route {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-banner-date {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
&__messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
position: relative;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23667eea' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
&__load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date-separator {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 16px 0;
|
||||||
|
|
||||||
|
span {
|
||||||
|
background: rgba(102, 126, 234, 0.1);
|
||||||
|
color: #667eea;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typing indicator
|
||||||
|
&__typing-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__typing-bubble {
|
||||||
|
background: white;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__typing-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #bbb;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: typingBounce 1.4s infinite ease-in-out;
|
||||||
|
|
||||||
|
&:nth-child(1) { animation-delay: 0s; }
|
||||||
|
&:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
&:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typingBounce {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); }
|
||||||
|
30% { transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll button
|
||||||
|
&__scroll-btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 90px;
|
||||||
|
right: 20px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply preview
|
||||||
|
&__reply-preview {
|
||||||
|
background: white;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-author {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
display: block;
|
||||||
|
max-width: 250px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input area
|
||||||
|
&__input-area {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
:deep(.q-field__control) {
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
min-height: 44px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.q-field__native) {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emoji picker
|
||||||
|
&__emoji-picker {
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__emoji {
|
||||||
|
font-size: 24px;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach menu
|
||||||
|
&__attach-menu {
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__attach-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__attach-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-avatar {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile card
|
||||||
|
&__profile-card {
|
||||||
|
width: 320px;
|
||||||
|
max-width: 90vw;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__profile-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.q-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.q-avatar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 4px solid rgba(255, 255, 255, 0.3);
|
||||||
|
|
||||||
|
span {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__profile-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
|
||||||
|
.q-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up-enter-from,
|
||||||
|
.slide-up-leave-to {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.chat-page {
|
||||||
|
background: #121212;
|
||||||
|
|
||||||
|
&__messages {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date-separator span {
|
||||||
|
background: rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__typing-bubble {
|
||||||
|
background: #2d2d44;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input-area {
|
||||||
|
background: #1e1e30;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
:deep(.q-field__control) {
|
||||||
|
background: #2d2d44;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__reply-preview {
|
||||||
|
background: #1e1e30;
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
466
src/modules/trasporti/pages/ChatPage.ts
Normal file
466
src/modules/trasporti/pages/ChatPage.ts
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
// ChatPage.ts
|
||||||
|
import { defineComponent, ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useChat } from '../composables/useChat';
|
||||||
|
import { useRealtimeChat } from '../composables/useRealtimeChat';
|
||||||
|
import { useAuth } from '@/composables/useAuth';
|
||||||
|
import MessageBubble from '../components/chat/MessageBubble.vue';
|
||||||
|
import type { Message, User, RideInfo } from '../types/trasporti.types';
|
||||||
|
import { debounce } from 'quasar';
|
||||||
|
|
||||||
|
interface MessageGroup {
|
||||||
|
date: string;
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'ChatPage',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
MessageBubble
|
||||||
|
},
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentChat,
|
||||||
|
messages,
|
||||||
|
loading,
|
||||||
|
loadChat,
|
||||||
|
loadMessages,
|
||||||
|
sendMessage: sendMessageApi,
|
||||||
|
markAsRead,
|
||||||
|
deleteMessage: deleteMsg,
|
||||||
|
onlineUsers,
|
||||||
|
typingUsers,
|
||||||
|
toggleMuteChat
|
||||||
|
} = useChat();
|
||||||
|
|
||||||
|
const {
|
||||||
|
subscribeToChat,
|
||||||
|
unsubscribeFromChat,
|
||||||
|
sendTyping
|
||||||
|
} = useRealtimeChat();
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const messagesContainer = ref<HTMLElement>();
|
||||||
|
const messageInput = ref();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const messageText = ref('');
|
||||||
|
const sending = ref(false);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const hasMoreMessages = ref(true);
|
||||||
|
const showScrollButton = ref(false);
|
||||||
|
const newMessagesCount = ref(0);
|
||||||
|
const replyTo = ref<Message | null>(null);
|
||||||
|
const showEmoji = ref(false);
|
||||||
|
const showAttachMenu = ref(false);
|
||||||
|
const showUserProfile = ref(false);
|
||||||
|
const searchInChat = ref(false);
|
||||||
|
const isMuted = ref(false);
|
||||||
|
const lastSeen = ref<Date | null>(null);
|
||||||
|
|
||||||
|
const commonEmojis = ['😊', '😂', '❤️', '👍', '🙏', '😍', '🎉', '🚗', '📍', '✅', '❌', '⏰'];
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const chatId = computed(() => route.params.id as string);
|
||||||
|
const currentUserId = computed(() => currentUser.value?._id);
|
||||||
|
|
||||||
|
const otherUser = computed((): User | undefined => {
|
||||||
|
if (!currentChat.value?.participants) return undefined;
|
||||||
|
return currentChat.value.participants.find(p => p._id !== currentUserId.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rideInfo = computed((): RideInfo | undefined => {
|
||||||
|
return currentChat.value?.rideInfo;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOnline = computed(() => {
|
||||||
|
return otherUser.value ? onlineUsers.value.includes(otherUser.value._id) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isTyping = computed(() => {
|
||||||
|
return otherUser.value ? typingUsers.value.includes(otherUser.value._id) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedMessages = computed((): MessageGroup[] => {
|
||||||
|
const groups: MessageGroup[] = [];
|
||||||
|
let currentDate = '';
|
||||||
|
|
||||||
|
messages.value.forEach(message => {
|
||||||
|
const messageDate = formatDateHeader(new Date(message.createdAt));
|
||||||
|
|
||||||
|
if (messageDate !== currentDate) {
|
||||||
|
currentDate = messageDate;
|
||||||
|
groups.push({
|
||||||
|
date: messageDate,
|
||||||
|
messages: [message]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
groups[groups.length - 1].messages.push(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const goBack = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (user?: User): string => {
|
||||||
|
if (!user) return '?';
|
||||||
|
return `${user.name?.charAt(0) || ''}${user.surname?.charAt(0) || ''}`.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateHeader = (date: Date): string => {
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return 'Oggi';
|
||||||
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return 'Ieri';
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString('it-IT', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLastSeen = (date: Date): string => {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return 'adesso';
|
||||||
|
if (minutes < 60) return `${minutes} min fa`;
|
||||||
|
if (hours < 24) return `${hours} ore fa`;
|
||||||
|
if (days === 1) return 'ieri';
|
||||||
|
return `${days} giorni fa`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRideDate = (date: string): string => {
|
||||||
|
return new Date(date).toLocaleDateString('it-IT', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldShowAvatar = (message: Message, allMessages: Message[]): boolean => {
|
||||||
|
const index = allMessages.indexOf(message);
|
||||||
|
if (index === allMessages.length - 1) return true;
|
||||||
|
return allMessages[index + 1].senderId !== message.senderId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToBottom = (smooth = true) => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTo({
|
||||||
|
top: messagesContainer.value.scrollHeight,
|
||||||
|
behavior: smooth ? 'smooth' : 'auto'
|
||||||
|
});
|
||||||
|
newMessagesCount.value = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!messagesContainer.value) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value;
|
||||||
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||||
|
|
||||||
|
showScrollButton.value = distanceFromBottom > 200;
|
||||||
|
|
||||||
|
// Load more when scrolled to top
|
||||||
|
if (scrollTop < 100 && hasMoreMessages.value && !loadingMore.value) {
|
||||||
|
loadMoreMessages();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMoreMessages = async () => {
|
||||||
|
if (loadingMore.value || !hasMoreMessages.value) return;
|
||||||
|
|
||||||
|
loadingMore.value = true;
|
||||||
|
const oldHeight = messagesContainer.value?.scrollHeight || 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const olderMessages = await loadMessages(chatId.value, {
|
||||||
|
before: messages.value[0]?.createdAt,
|
||||||
|
limit: 30
|
||||||
|
});
|
||||||
|
|
||||||
|
if (olderMessages.length < 30) {
|
||||||
|
hasMoreMessages.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintain scroll position
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
const newHeight = messagesContainer.value.scrollHeight;
|
||||||
|
messagesContainer.value.scrollTop = newHeight - oldHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
const content = messageText.value.trim();
|
||||||
|
if (!content || sending.value) return;
|
||||||
|
|
||||||
|
sending.value = true;
|
||||||
|
const replyToId = replyTo.value?._id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendMessageApi(chatId.value, {
|
||||||
|
content,
|
||||||
|
type: 'text',
|
||||||
|
replyTo: replyToId
|
||||||
|
});
|
||||||
|
|
||||||
|
messageText.value = '';
|
||||||
|
replyTo.value = null;
|
||||||
|
scrollToBottom();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore nell\'invio del messaggio'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
sending.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTyping = debounce(() => {
|
||||||
|
sendTyping(chatId.value);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const addEmoji = (emoji: string) => {
|
||||||
|
messageText.value += emoji;
|
||||||
|
showEmoji.value = false;
|
||||||
|
messageInput.value?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReact = async (data: { messageId: string; emoji: string }) => {
|
||||||
|
// TODO: Implementa reazione
|
||||||
|
console.log('React:', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteMessage = async (messageId: string) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Elimina messaggio',
|
||||||
|
message: 'Eliminare questo messaggio?',
|
||||||
|
cancel: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
await deleteMsg(chatId.value, messageId);
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore nell\'eliminazione'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const callUser = () => {
|
||||||
|
if (otherUser.value?.profile?.cell) {
|
||||||
|
window.open(`tel:${otherUser.value.profile.cell}`);
|
||||||
|
} else {
|
||||||
|
$q.notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Numero di telefono non disponibile'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewRide = () => {
|
||||||
|
if (rideInfo.value?.rideId) {
|
||||||
|
router.push(`/trasporti/viaggio/${rideInfo.value.rideId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewDriverProfile = () => {
|
||||||
|
if (otherUser.value) {
|
||||||
|
router.push(`/trasporti/profilo/${otherUser.value._id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = async () => {
|
||||||
|
try {
|
||||||
|
await toggleMuteChat(chatId.value, !isMuted.value);
|
||||||
|
isMuted.value = !isMuted.value;
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
type: 'info',
|
||||||
|
message: isMuted.value ? 'Notifiche silenziate' : 'Notifiche attivate',
|
||||||
|
icon: isMuted.value ? 'notifications_off' : 'notifications'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteConversation = () => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Elimina conversazione',
|
||||||
|
message: 'Sei sicuro? Questa azione non è reversibile.',
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
// TODO: Implementa eliminazione chat
|
||||||
|
router.push('/trasporti/chat');
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore nell\'eliminazione'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockUser = () => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Blocca utente',
|
||||||
|
message: `Bloccare ${otherUser.value?.name}? Non potrete più scambiarvi messaggi.`,
|
||||||
|
cancel: true
|
||||||
|
}).onOk(() => {
|
||||||
|
// TODO: Implementa blocco
|
||||||
|
showUserProfile.value = false;
|
||||||
|
router.push('/trasporti/chat');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachImage = () => {
|
||||||
|
showAttachMenu.value = false;
|
||||||
|
// TODO: Implementa upload immagine
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachDocument = () => {
|
||||||
|
showAttachMenu.value = false;
|
||||||
|
// TODO: Implementa upload documento
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareLocation = () => {
|
||||||
|
showAttachMenu.value = false;
|
||||||
|
// TODO: Implementa condivisione posizione
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendRideRequest = () => {
|
||||||
|
showAttachMenu.value = false;
|
||||||
|
// TODO: Implementa richiesta passaggio
|
||||||
|
};
|
||||||
|
|
||||||
|
const startVoiceMessage = () => {
|
||||||
|
// TODO: Implementa messaggio vocale
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for new messages
|
||||||
|
watch(() => messages.value.length, (newLen, oldLen) => {
|
||||||
|
if (newLen > oldLen) {
|
||||||
|
const lastMessage = messages.value[messages.value.length - 1];
|
||||||
|
|
||||||
|
if (lastMessage.senderId === currentUserId.value) {
|
||||||
|
scrollToBottom();
|
||||||
|
} else if (showScrollButton.value) {
|
||||||
|
newMessagesCount.value++;
|
||||||
|
} else {
|
||||||
|
scrollToBottom();
|
||||||
|
markAsRead(chatId.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadChat(chatId.value);
|
||||||
|
await loadMessages(chatId.value, { limit: 50 });
|
||||||
|
scrollToBottom(false);
|
||||||
|
markAsRead(chatId.value);
|
||||||
|
subscribeToChat(chatId.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unsubscribeFromChat(chatId.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Refs
|
||||||
|
messagesContainer,
|
||||||
|
messageInput,
|
||||||
|
|
||||||
|
// State
|
||||||
|
messageText,
|
||||||
|
sending,
|
||||||
|
loadingMore,
|
||||||
|
hasMoreMessages,
|
||||||
|
showScrollButton,
|
||||||
|
newMessagesCount,
|
||||||
|
replyTo,
|
||||||
|
showEmoji,
|
||||||
|
showAttachMenu,
|
||||||
|
showUserProfile,
|
||||||
|
searchInChat,
|
||||||
|
isMuted,
|
||||||
|
lastSeen,
|
||||||
|
commonEmojis,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
currentUserId,
|
||||||
|
currentUser,
|
||||||
|
otherUser,
|
||||||
|
rideInfo,
|
||||||
|
isOnline,
|
||||||
|
isTyping,
|
||||||
|
groupedMessages,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
goBack,
|
||||||
|
getInitials,
|
||||||
|
formatLastSeen,
|
||||||
|
formatRideDate,
|
||||||
|
shouldShowAvatar,
|
||||||
|
scrollToBottom,
|
||||||
|
onScroll,
|
||||||
|
loadMoreMessages,
|
||||||
|
sendMessage,
|
||||||
|
onTyping,
|
||||||
|
addEmoji,
|
||||||
|
onReact,
|
||||||
|
onDeleteMessage,
|
||||||
|
callUser,
|
||||||
|
viewRide,
|
||||||
|
viewDriverProfile,
|
||||||
|
toggleMute,
|
||||||
|
deleteConversation,
|
||||||
|
blockUser,
|
||||||
|
attachImage,
|
||||||
|
attachDocument,
|
||||||
|
shareLocation,
|
||||||
|
sendRideRequest,
|
||||||
|
startVoiceMessage
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
345
src/modules/trasporti/pages/ChatPage.vue
Normal file
345
src/modules/trasporti/pages/ChatPage.vue
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
<!-- ChatPage.vue -->
|
||||||
|
<template>
|
||||||
|
<q-page class="chat-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<q-header class="chat-page__header" elevated>
|
||||||
|
<q-toolbar>
|
||||||
|
<q-btn flat round icon="arrow_back" @click="goBack" />
|
||||||
|
|
||||||
|
<q-avatar size="42px" class="q-ml-sm" @click="showUserProfile = true">
|
||||||
|
<img
|
||||||
|
v-if="otherUser?.profile?.img"
|
||||||
|
:src="otherUser.profile.img"
|
||||||
|
:alt="otherUser.name"
|
||||||
|
/>
|
||||||
|
<div v-else class="chat-page__avatar-placeholder">
|
||||||
|
{{ getInitials(otherUser) }}
|
||||||
|
</div>
|
||||||
|
</q-avatar>
|
||||||
|
|
||||||
|
<q-toolbar-title class="q-ml-sm">
|
||||||
|
<div class="chat-page__user-name" @click="showUserProfile = true">
|
||||||
|
{{ otherUser?.name }} {{ otherUser?.surname }}
|
||||||
|
</div>
|
||||||
|
<div class="chat-page__user-status">
|
||||||
|
<template v-if="isTyping">
|
||||||
|
<span class="chat-page__typing">sta scrivendo</span>
|
||||||
|
<span class="chat-page__typing-dots">...</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isOnline">
|
||||||
|
<q-icon name="circle" size="8px" color="positive" />
|
||||||
|
<span>Online</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="lastSeen">
|
||||||
|
<span>Ultimo accesso {{ formatLastSeen(lastSeen) }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</q-toolbar-title>
|
||||||
|
|
||||||
|
<q-btn flat round icon="phone" @click="callUser" />
|
||||||
|
<q-btn flat round icon="more_vert">
|
||||||
|
<q-menu>
|
||||||
|
<q-list style="min-width: 200px">
|
||||||
|
<q-item clickable v-close-popup @click="showUserProfile = true">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="person" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Profilo utente</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item v-if="rideInfo" clickable v-close-popup @click="viewRide">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="directions_car" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Dettagli viaggio</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-item clickable v-close-popup @click="searchInChat = true">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Cerca</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item clickable v-close-popup @click="toggleMute">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="isMuted ? 'notifications' : 'notifications_off'" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>{{ isMuted ? 'Attiva notifiche' : 'Silenzia' }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-item clickable v-close-popup class="text-negative" @click="deleteConversation">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="delete" color="negative" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>Elimina conversazione</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</q-toolbar>
|
||||||
|
|
||||||
|
<!-- Ride Info Banner -->
|
||||||
|
<div v-if="rideInfo" class="chat-page__ride-banner" @click="viewRide">
|
||||||
|
<div class="chat-page__ride-banner-content">
|
||||||
|
<q-icon
|
||||||
|
:name="rideInfo.type === 'offer' ? 'directions_car' : 'hail'"
|
||||||
|
:color="rideInfo.type === 'offer' ? 'positive' : 'negative'"
|
||||||
|
size="24px"
|
||||||
|
/>
|
||||||
|
<div class="chat-page__ride-banner-text">
|
||||||
|
<span class="chat-page__ride-banner-route">
|
||||||
|
{{ rideInfo.departure }} → {{ rideInfo.destination }}
|
||||||
|
</span>
|
||||||
|
<span class="chat-page__ride-banner-date">
|
||||||
|
{{ formatRideDate(rideInfo.departureDate) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-icon name="chevron_right" />
|
||||||
|
</div>
|
||||||
|
</q-header>
|
||||||
|
|
||||||
|
<!-- Messages Area -->
|
||||||
|
<div
|
||||||
|
ref="messagesContainer"
|
||||||
|
class="chat-page__messages"
|
||||||
|
@scroll="onScroll"
|
||||||
|
>
|
||||||
|
<!-- Load More -->
|
||||||
|
<div v-if="hasMoreMessages" class="chat-page__load-more">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
icon="expand_less"
|
||||||
|
:loading="loadingMore"
|
||||||
|
@click="loadMoreMessages"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date separators and messages -->
|
||||||
|
<template v-for="(group, index) in groupedMessages" :key="index">
|
||||||
|
<div class="chat-page__date-separator">
|
||||||
|
<span>{{ group.date }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MessageBubble
|
||||||
|
v-for="message in group.messages"
|
||||||
|
:key="message._id"
|
||||||
|
:message="message"
|
||||||
|
:is-mine="message.senderId === currentUserId"
|
||||||
|
:show-avatar="shouldShowAvatar(message, group.messages)"
|
||||||
|
:sender="message.senderId === currentUserId ? currentUser : otherUser"
|
||||||
|
@reply="replyTo = message"
|
||||||
|
@react="onReact"
|
||||||
|
@delete="onDeleteMessage"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Typing indicator -->
|
||||||
|
<div v-if="isTyping" class="chat-page__typing-indicator">
|
||||||
|
<q-avatar size="32px">
|
||||||
|
<img v-if="otherUser?.profile?.img" :src="otherUser.profile.img" />
|
||||||
|
<span v-else>{{ getInitials(otherUser) }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
<div class="chat-page__typing-bubble">
|
||||||
|
<span class="chat-page__typing-dot"></span>
|
||||||
|
<span class="chat-page__typing-dot"></span>
|
||||||
|
<span class="chat-page__typing-dot"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scroll to bottom button -->
|
||||||
|
<transition name="fade">
|
||||||
|
<q-btn
|
||||||
|
v-if="showScrollButton"
|
||||||
|
class="chat-page__scroll-btn"
|
||||||
|
round
|
||||||
|
color="white"
|
||||||
|
text-color="primary"
|
||||||
|
icon="keyboard_arrow_down"
|
||||||
|
size="md"
|
||||||
|
@click="scrollToBottom"
|
||||||
|
>
|
||||||
|
<q-badge v-if="newMessagesCount > 0" color="primary" floating>
|
||||||
|
{{ newMessagesCount }}
|
||||||
|
</q-badge>
|
||||||
|
</q-btn>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reply Preview -->
|
||||||
|
<transition name="slide-up">
|
||||||
|
<div v-if="replyTo" class="chat-page__reply-preview">
|
||||||
|
<div class="chat-page__reply-content">
|
||||||
|
<q-icon name="reply" size="20px" />
|
||||||
|
<div>
|
||||||
|
<span class="chat-page__reply-author">
|
||||||
|
{{ replyTo.senderId === currentUserId ? 'Tu' : otherUser?.name }}
|
||||||
|
</span>
|
||||||
|
<span class="chat-page__reply-text">{{ replyTo.content }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-btn flat round size="sm" icon="close" @click="replyTo = null" />
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Input Area -->
|
||||||
|
<div class="chat-page__input-area">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
icon="attach_file"
|
||||||
|
color="grey-7"
|
||||||
|
@click="showAttachMenu = true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="chat-page__input-wrapper">
|
||||||
|
<q-input
|
||||||
|
ref="messageInput"
|
||||||
|
v-model="messageText"
|
||||||
|
placeholder="Scrivi un messaggio..."
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
autogrow
|
||||||
|
class="chat-page__input"
|
||||||
|
@keydown.enter.prevent="sendMessage"
|
||||||
|
@update:model-value="onTyping"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<q-btn flat round dense icon="mood" @click="showEmoji = !showEmoji">
|
||||||
|
<q-popup-proxy
|
||||||
|
v-model="showEmoji"
|
||||||
|
:offset="[0, 10]"
|
||||||
|
anchor="top right"
|
||||||
|
self="bottom right"
|
||||||
|
>
|
||||||
|
<div class="chat-page__emoji-picker">
|
||||||
|
<span
|
||||||
|
v-for="emoji in commonEmojis"
|
||||||
|
:key="emoji"
|
||||||
|
class="chat-page__emoji"
|
||||||
|
@click="addEmoji(emoji)"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</q-popup-proxy>
|
||||||
|
</q-btn>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
round
|
||||||
|
:color="messageText.trim() ? 'primary' : 'grey-4'"
|
||||||
|
:icon="messageText.trim() ? 'send' : 'mic'"
|
||||||
|
:disable="sending"
|
||||||
|
@click="messageText.trim() ? sendMessage() : startVoiceMessage()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Attachment Menu -->
|
||||||
|
<q-dialog v-model="showAttachMenu" position="bottom">
|
||||||
|
<q-card class="chat-page__attach-menu">
|
||||||
|
<div class="chat-page__attach-grid">
|
||||||
|
<div class="chat-page__attach-item" @click="attachImage">
|
||||||
|
<q-avatar color="purple" text-color="white" icon="image" />
|
||||||
|
<span>Foto</span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-page__attach-item" @click="attachDocument">
|
||||||
|
<q-avatar color="blue" text-color="white" icon="description" />
|
||||||
|
<span>Documento</span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-page__attach-item" @click="shareLocation">
|
||||||
|
<q-avatar color="green" text-color="white" icon="location_on" />
|
||||||
|
<span>Posizione</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="rideInfo" class="chat-page__attach-item" @click="sendRideRequest">
|
||||||
|
<q-avatar color="orange" text-color="white" icon="directions_car" />
|
||||||
|
<span>Richiedi passaggio</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- User Profile Dialog -->
|
||||||
|
<q-dialog v-model="showUserProfile" position="right" full-height>
|
||||||
|
<q-card class="chat-page__profile-card">
|
||||||
|
<q-card-section class="chat-page__profile-header">
|
||||||
|
<q-btn flat round icon="close" @click="showUserProfile = false" />
|
||||||
|
<q-avatar size="100px">
|
||||||
|
<img v-if="otherUser?.profile?.img" :src="otherUser.profile.img" />
|
||||||
|
<span v-else class="text-h4">{{ getInitials(otherUser) }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
<h4>{{ otherUser?.name }} {{ otherUser?.surname }}</h4>
|
||||||
|
<p>@{{ otherUser?.username }}</p>
|
||||||
|
|
||||||
|
<div class="chat-page__profile-actions">
|
||||||
|
<q-btn round color="primary" icon="directions_car" @click="viewDriverProfile">
|
||||||
|
<q-tooltip>Profilo guida</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
<q-btn round color="secondary" icon="phone" @click="callUser">
|
||||||
|
<q-tooltip>Chiama</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-list>
|
||||||
|
<q-item v-if="otherUser?.profile?.cell">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="phone" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption>Telefono</q-item-label>
|
||||||
|
<q-item-label>{{ otherUser.profile.cell }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item v-if="otherUser?.profile?.Biografia">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="info" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption>Bio</q-item-label>
|
||||||
|
<q-item-label>{{ otherUser.profile.Biografia }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
|
||||||
|
<q-item v-if="otherUser?.profile?.driverProfile">
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon name="star" color="amber" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption>Valutazione guida</q-item-label>
|
||||||
|
<q-item-label>
|
||||||
|
{{ otherUser.profile.driverProfile.rating?.toFixed(1) || 'N/D' }}
|
||||||
|
<span class="text-grey"> · {{ otherUser.profile.driverProfile.totalRides || 0 }} viaggi</span>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<q-btn
|
||||||
|
color="negative"
|
||||||
|
outline
|
||||||
|
full-width
|
||||||
|
icon="block"
|
||||||
|
label="Blocca utente"
|
||||||
|
@click="blockUser"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./ChatPage.ts" />
|
||||||
|
<style lang="scss" src="./ChatPage.scss" />
|
||||||
405
src/modules/trasporti/pages/DriverProfilePage.scss
Normal file
405
src/modules/trasporti/pages/DriverProfilePage.scss
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
.driver-profile-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
|
||||||
|
&__loading,
|
||||||
|
&__error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 16px 0 24px;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cover
|
||||||
|
&__cover {
|
||||||
|
position: relative;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cover-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, var(--q-primary), var(--q-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar-container {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -60px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
background: white;
|
||||||
|
color: var(--q-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 36px;
|
||||||
|
border: 4px solid white;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__verified-badge {
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container
|
||||||
|
&__container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
&__header {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 72px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__member-since,
|
||||||
|
&__languages {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-stars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-count {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick stats
|
||||||
|
&__quick-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quick-stat {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quick-stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--q-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quick-stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cards
|
||||||
|
&__card {
|
||||||
|
border-radius: 16px !important;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bio {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--q-grey-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vehicles
|
||||||
|
&__vehicles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle-details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badges
|
||||||
|
&__badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--q-grey-7);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
&__stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-box {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-rating {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-categories {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-category {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr 32px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-bar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-value {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sections
|
||||||
|
&__section {
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rides {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 48px;
|
||||||
|
color: var(--q-grey-5);
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.driver-profile-page {
|
||||||
|
background: #121212;
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-color: #2d2d2d;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__vehicle,
|
||||||
|
&__stat-box {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__badge {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bio {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.driver-profile-page {
|
||||||
|
&__cover {
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar-container {
|
||||||
|
bottom: -50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
width: 100px !important;
|
||||||
|
height: 100px !important;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quick-stats {
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quick-stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.q-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rides {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/modules/trasporti/pages/DriverProfilePage.ts
Normal file
172
src/modules/trasporti/pages/DriverProfilePage.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { ref, computed, onMounted, defineComponent } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useDriverProfile } from '../composables/useDriverProfile';
|
||||||
|
import { useChat } from '../composables/useChat';
|
||||||
|
import RideCard from '../components/ride/RideCard.vue';
|
||||||
|
import FeedbackList from '../components/feedback/FeedbackList.vue';
|
||||||
|
import type { DriverPublicProfile } from '../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'DriverProfilePage',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RideCard,
|
||||||
|
FeedbackList
|
||||||
|
},
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const {
|
||||||
|
driverProfile: profile,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchDriverProfile,
|
||||||
|
formatMemberSince,
|
||||||
|
getBadgeIcon
|
||||||
|
} = useDriverProfile();
|
||||||
|
|
||||||
|
const { getOrCreateDirectChat } = useChat();
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const ridesSection = ref<HTMLElement | null>(null);
|
||||||
|
const currentUserId = ref(''); // TODO: Get from auth
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const userId = computed(() => route.params.id as string);
|
||||||
|
|
||||||
|
const isOwnProfile = computed(() => userId.value === currentUserId.value);
|
||||||
|
|
||||||
|
const userName = computed(() => {
|
||||||
|
if (!profile.value?.user) return 'Utente';
|
||||||
|
const user = profile.value.user;
|
||||||
|
if (user.name) {
|
||||||
|
return `${user.name} ${user.surname || ''}`.trim();
|
||||||
|
}
|
||||||
|
return user.username || 'Utente';
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
return userName.value
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberSince = computed(() => {
|
||||||
|
if (!profile.value?.user.driverProfile?.memberSince) return '';
|
||||||
|
return formatMemberSince(profile.value.user.driverProfile.memberSince);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const goBack = () => router.back();
|
||||||
|
|
||||||
|
const goToProfile = (profileUserId: string) => {
|
||||||
|
if (profileUserId !== userId.value) {
|
||||||
|
router.push(`/trasporti/profilo/${profileUserId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToRide = (rideId: string) => {
|
||||||
|
router.push(`/trasporti/viaggio/${rideId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const contactUser = async () => {
|
||||||
|
try {
|
||||||
|
const response = await getOrCreateDirectChat(userId.value);
|
||||||
|
if (response?.data?.data) {
|
||||||
|
router.push(`/trasporti/chat/${response.data.data._id}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({ type: 'negative', message: error.data?.message || error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToRides = () => {
|
||||||
|
ridesSection.value?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const editProfile = () => {
|
||||||
|
router.push('/trasporti/profilo/modifica');
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewAllReviews = () => {
|
||||||
|
router.push(`/trasporti/recensioni/${userId.value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStarIcon = (star: number, rating: number): string => {
|
||||||
|
if (star <= Math.floor(rating)) return 'star';
|
||||||
|
if (star === Math.ceil(rating) && rating % 1 >= 0.5) return 'star_half';
|
||||||
|
return 'star_outline';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVehicleIcon = (type?: string): string => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
auto: '🚗',
|
||||||
|
moto: '🏍️',
|
||||||
|
furgone: '🚐',
|
||||||
|
minibus: '🚌',
|
||||||
|
altro: '🚙'
|
||||||
|
};
|
||||||
|
return icons[type || 'auto'] || '🚗';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatBadgeName = (name: string): string => {
|
||||||
|
return name
|
||||||
|
.split('_')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryLabel = (key: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
punctuality: 'Puntualità',
|
||||||
|
cleanliness: 'Pulizia',
|
||||||
|
communication: 'Comunicazione',
|
||||||
|
driving: 'Guida',
|
||||||
|
respect: 'Rispetto',
|
||||||
|
reliability: 'Affidabilità'
|
||||||
|
};
|
||||||
|
return labels[key] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchDriverProfile(userId.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
profile,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
ridesSection,
|
||||||
|
currentUserId,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
isOwnProfile,
|
||||||
|
userName,
|
||||||
|
userInitials,
|
||||||
|
memberSince,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
goBack,
|
||||||
|
goToProfile,
|
||||||
|
goToRide,
|
||||||
|
contactUser,
|
||||||
|
scrollToRides,
|
||||||
|
editProfile,
|
||||||
|
viewAllReviews,
|
||||||
|
getStarIcon,
|
||||||
|
getVehicleIcon,
|
||||||
|
getBadgeIcon,
|
||||||
|
formatBadgeName,
|
||||||
|
getCategoryLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
327
src/modules/trasporti/pages/DriverProfilePage.vue
Normal file
327
src/modules/trasporti/pages/DriverProfilePage.vue
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
<template>
|
||||||
|
<q-page class="driver-profile-page">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="driver-profile-page__loading">
|
||||||
|
<q-spinner color="primary" size="48px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="driver-profile-page__error">
|
||||||
|
<q-icon name="person_off" size="64px" color="grey-4" />
|
||||||
|
<h3>Profilo non trovato</h3>
|
||||||
|
<q-btn color="primary" label="Torna indietro" @click="goBack" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<template v-else-if="profile">
|
||||||
|
<!-- Cover & Avatar -->
|
||||||
|
<div class="driver-profile-page__cover">
|
||||||
|
<div class="driver-profile-page__cover-bg"></div>
|
||||||
|
<div class="driver-profile-page__avatar-container">
|
||||||
|
<q-avatar size="120px" class="driver-profile-page__avatar">
|
||||||
|
<img v-if="profile.user.img" :src="profile.user.img" />
|
||||||
|
<span v-else>{{ userInitials }}</span>
|
||||||
|
</q-avatar>
|
||||||
|
|
||||||
|
<!-- Verified badge -->
|
||||||
|
<q-badge
|
||||||
|
v-if="profile.user.driverProfile?.verifiedDriver"
|
||||||
|
color="positive"
|
||||||
|
floating
|
||||||
|
class="driver-profile-page__verified-badge"
|
||||||
|
>
|
||||||
|
<q-icon name="verified" size="16px" />
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="driver-profile-page__container">
|
||||||
|
<!-- Header Info -->
|
||||||
|
<div class="driver-profile-page__header">
|
||||||
|
<h1 class="driver-profile-page__name">{{ userName }}</h1>
|
||||||
|
|
||||||
|
<div class="driver-profile-page__meta">
|
||||||
|
<span v-if="profile.user.driverProfile?.memberSince" class="driver-profile-page__member-since">
|
||||||
|
<q-icon name="calendar_today" size="14px" />
|
||||||
|
Membro da {{ memberSince }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-if="profile.user.languages?.length" class="driver-profile-page__languages">
|
||||||
|
<q-icon name="translate" size="14px" />
|
||||||
|
{{ profile.user.languages.join(', ') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rating -->
|
||||||
|
<div class="driver-profile-page__rating">
|
||||||
|
<div class="driver-profile-page__rating-stars">
|
||||||
|
<q-icon
|
||||||
|
v-for="star in 5"
|
||||||
|
:key="star"
|
||||||
|
:name="getStarIcon(star, profile.stats.overall.averageRating)"
|
||||||
|
color="amber"
|
||||||
|
size="24px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="driver-profile-page__rating-value">
|
||||||
|
{{ profile.stats.overall.averageRating.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
<span class="driver-profile-page__rating-count">
|
||||||
|
({{ profile.stats.overall.totalFeedbacks }} recensioni)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="driver-profile-page__quick-stats">
|
||||||
|
<div class="driver-profile-page__quick-stat">
|
||||||
|
<span class="driver-profile-page__quick-stat-value">{{ profile.stats.ridesAsDriver }}</span>
|
||||||
|
<span class="driver-profile-page__quick-stat-label">Viaggi offerti</span>
|
||||||
|
</div>
|
||||||
|
<div class="driver-profile-page__quick-stat">
|
||||||
|
<span class="driver-profile-page__quick-stat-value">{{ profile.stats.ridesAsPassenger }}</span>
|
||||||
|
<span class="driver-profile-page__quick-stat-label">Come passeggero</span>
|
||||||
|
</div>
|
||||||
|
<div class="driver-profile-page__quick-stat">
|
||||||
|
<span class="driver-profile-page__quick-stat-value">{{ profile.stats.completedRides }}</span>
|
||||||
|
<span class="driver-profile-page__quick-stat-label">Completati</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div v-if="!isOwnProfile" class="driver-profile-page__actions">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="chat"
|
||||||
|
label="Contatta"
|
||||||
|
unelevated
|
||||||
|
@click="contactUser"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
icon="directions_car"
|
||||||
|
label="Vedi viaggi"
|
||||||
|
@click="scrollToRides"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="driver-profile-page__actions">
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="edit"
|
||||||
|
label="Modifica profilo"
|
||||||
|
outline
|
||||||
|
@click="editProfile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bio -->
|
||||||
|
<q-card v-if="profile.user.bio" class="driver-profile-page__card" flat bordered>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="driver-profile-page__section-title">
|
||||||
|
<q-icon name="person" color="primary" />
|
||||||
|
<span>Chi sono</span>
|
||||||
|
</div>
|
||||||
|
<p class="driver-profile-page__bio">{{ profile.user.bio }}</p>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Vehicles -->
|
||||||
|
<q-card
|
||||||
|
v-if="profile.user.driverProfile?.vehicles?.length"
|
||||||
|
class="driver-profile-page__card"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="driver-profile-page__section-title">
|
||||||
|
<q-icon name="directions_car" color="primary" />
|
||||||
|
<span>Veicoli</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="driver-profile-page__vehicles">
|
||||||
|
<div
|
||||||
|
v-for="(vehicle, index) in profile.user.driverProfile.vehicles"
|
||||||
|
:key="index"
|
||||||
|
class="driver-profile-page__vehicle"
|
||||||
|
>
|
||||||
|
<span class="driver-profile-page__vehicle-icon">
|
||||||
|
{{ getVehicleIcon(vehicle.type) }}
|
||||||
|
</span>
|
||||||
|
<div class="driver-profile-page__vehicle-info">
|
||||||
|
<span class="driver-profile-page__vehicle-name">
|
||||||
|
{{ vehicle.brand }} {{ vehicle.model }}
|
||||||
|
</span>
|
||||||
|
<span class="driver-profile-page__vehicle-details">
|
||||||
|
<span
|
||||||
|
class="driver-profile-page__vehicle-color"
|
||||||
|
:style="{ backgroundColor: vehicle.colorHex }"
|
||||||
|
></span>
|
||||||
|
{{ vehicle.color }} • {{ vehicle.seats }} posti
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<q-badge v-if="vehicle.isDefault" color="primary" outline label="Predefinito" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
<q-card
|
||||||
|
v-if="profile.user.driverProfile?.badges?.length"
|
||||||
|
class="driver-profile-page__card"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="driver-profile-page__section-title">
|
||||||
|
<q-icon name="emoji_events" color="primary" />
|
||||||
|
<span>Badge</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="driver-profile-page__badges">
|
||||||
|
<div
|
||||||
|
v-for="(badge, index) in profile.user.driverProfile.badges"
|
||||||
|
:key="index"
|
||||||
|
class="driver-profile-page__badge"
|
||||||
|
>
|
||||||
|
<span class="driver-profile-page__badge-icon">{{ getBadgeIcon(badge.name) }}</span>
|
||||||
|
<span class="driver-profile-page__badge-name">{{ formatBadgeName(badge.name) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Stats Details -->
|
||||||
|
<q-card class="driver-profile-page__card" flat bordered>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="driver-profile-page__section-title">
|
||||||
|
<q-icon name="insights" color="primary" />
|
||||||
|
<span>Statistiche</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="driver-profile-page__stats-grid">
|
||||||
|
<!-- As Driver -->
|
||||||
|
<div v-if="profile.stats.asDriver" class="driver-profile-page__stat-box">
|
||||||
|
<div class="driver-profile-page__stat-header">
|
||||||
|
<span>🚗 Come Conducente</span>
|
||||||
|
<div class="driver-profile-page__stat-rating">
|
||||||
|
<q-icon name="star" color="amber" size="18px" />
|
||||||
|
{{ profile.stats.asDriver.averageRating.toFixed(1) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="driver-profile-page__stat-categories">
|
||||||
|
<div
|
||||||
|
v-for="(value, key) in profile.stats.asDriver.categories"
|
||||||
|
:key="key"
|
||||||
|
v-show="value"
|
||||||
|
class="driver-profile-page__stat-category"
|
||||||
|
>
|
||||||
|
<span>{{ getCategoryLabel(key) }}</span>
|
||||||
|
<q-linear-progress
|
||||||
|
:value="value / 5"
|
||||||
|
color="amber"
|
||||||
|
track-color="grey-3"
|
||||||
|
rounded
|
||||||
|
size="8px"
|
||||||
|
class="driver-profile-page__stat-bar"
|
||||||
|
/>
|
||||||
|
<span class="driver-profile-page__stat-value">{{ value?.toFixed(1) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- As Passenger -->
|
||||||
|
<div v-if="profile.stats.asPassenger" class="driver-profile-page__stat-box">
|
||||||
|
<div class="driver-profile-page__stat-header">
|
||||||
|
<span>👤 Come Passeggero</span>
|
||||||
|
<div class="driver-profile-page__stat-rating">
|
||||||
|
<q-icon name="star" color="amber" size="18px" />
|
||||||
|
{{ profile.stats.asPassenger.averageRating.toFixed(1) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="driver-profile-page__stat-categories">
|
||||||
|
<div
|
||||||
|
v-for="(value, key) in profile.stats.asPassenger.categories"
|
||||||
|
:key="key"
|
||||||
|
v-show="value"
|
||||||
|
class="driver-profile-page__stat-category"
|
||||||
|
>
|
||||||
|
<span>{{ getCategoryLabel(key) }}</span>
|
||||||
|
<q-linear-progress
|
||||||
|
:value="value / 5"
|
||||||
|
color="amber"
|
||||||
|
track-color="grey-3"
|
||||||
|
rounded
|
||||||
|
size="8px"
|
||||||
|
class="driver-profile-page__stat-bar"
|
||||||
|
/>
|
||||||
|
<span class="driver-profile-page__stat-value">{{ value?.toFixed(1) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- Recent Rides -->
|
||||||
|
<div ref="ridesSection" class="driver-profile-page__section">
|
||||||
|
<div class="driver-profile-page__section-header">
|
||||||
|
<h2 class="driver-profile-page__section-heading">
|
||||||
|
<q-icon name="history" color="primary" />
|
||||||
|
Viaggi recenti
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="profile.recentRides?.length" class="driver-profile-page__rides">
|
||||||
|
<RideCard
|
||||||
|
v-for="ride in profile.recentRides"
|
||||||
|
:key="ride._id"
|
||||||
|
:ride="ride"
|
||||||
|
:compact="true"
|
||||||
|
:show-book-button="false"
|
||||||
|
@click="goToRide(ride._id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="driver-profile-page__empty">
|
||||||
|
<q-icon name="directions_car" size="48px" color="grey-4" />
|
||||||
|
<span>Nessun viaggio recente</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reviews -->
|
||||||
|
<div class="driver-profile-page__section">
|
||||||
|
<div class="driver-profile-page__section-header">
|
||||||
|
<h2 class="driver-profile-page__section-heading">
|
||||||
|
<q-icon name="rate_review" color="primary" />
|
||||||
|
Recensioni
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FeedbackList
|
||||||
|
:feedbacks="profile.recentFeedback"
|
||||||
|
:stats="profile.stats"
|
||||||
|
:show-stats="false"
|
||||||
|
:show-filters="false"
|
||||||
|
:has-more="false"
|
||||||
|
:current-user-id="currentUserId"
|
||||||
|
@user-click="goToProfile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-if="profile.stats.overall.totalFeedbacks > 3"
|
||||||
|
flat
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
label="Vedi tutte le recensioni"
|
||||||
|
class="full-width q-mt-md"
|
||||||
|
@click="viewAllReviews"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./DriverProfilePage.ts" />
|
||||||
|
<style lang="scss" src="./DriverProfilePage.scss" />
|
||||||
463
src/modules/trasporti/pages/Helppage.vue
Normal file
463
src/modules/trasporti/pages/Helppage.vue
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
<!-- HelpPage.vue -->
|
||||||
|
<template>
|
||||||
|
<q-page class="help-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="help-page__header">
|
||||||
|
<q-btn flat round icon="arrow_back" color="white" @click="goBack" />
|
||||||
|
<div>
|
||||||
|
<h1>Come Funziona</h1>
|
||||||
|
<p>Guida ai Trasporti Solidali</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<div class="help-page__hero">
|
||||||
|
<div class="help-page__hero-icon">
|
||||||
|
<q-icon name="directions_car" size="48px" color="white" />
|
||||||
|
</div>
|
||||||
|
<h2>Viaggia insieme, risparmia insieme</h2>
|
||||||
|
<p>Condividi i tuoi viaggi e aiuta la community a muoversi in modo sostenibile</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="help-page__quick-actions">
|
||||||
|
<div class="help-page__action" @click="router.push('/trasporti/offri')">
|
||||||
|
<div class="help-page__action-icon help-page__action-icon--offer">
|
||||||
|
<q-icon name="directions_car" size="28px" />
|
||||||
|
</div>
|
||||||
|
<span>Offri passaggio</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__action" @click="router.push('/trasporti/cerca')">
|
||||||
|
<div class="help-page__action-icon help-page__action-icon--search">
|
||||||
|
<q-icon name="search" size="28px" />
|
||||||
|
</div>
|
||||||
|
<span>Cerca passaggio</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__action" @click="router.push('/trasporti/richiedi')">
|
||||||
|
<div class="help-page__action-icon help-page__action-icon--request">
|
||||||
|
<q-icon name="hail" size="28px" />
|
||||||
|
</div>
|
||||||
|
<span>Richiedi passaggio</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="help-page__content">
|
||||||
|
<!-- How It Works Section -->
|
||||||
|
<div class="help-page__section">
|
||||||
|
<h3 class="help-page__section-title">
|
||||||
|
<q-icon name="play_circle" />
|
||||||
|
Come iniziare
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="help-page__steps">
|
||||||
|
<div class="help-page__step">
|
||||||
|
<div class="help-page__step-number">1</div>
|
||||||
|
<div class="help-page__step-content">
|
||||||
|
<h4>Completa il tuo profilo</h4>
|
||||||
|
<p>Aggiungi una foto, una descrizione e le tue preferenze di viaggio.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__step">
|
||||||
|
<div class="help-page__step-number">2</div>
|
||||||
|
<div class="help-page__step-content">
|
||||||
|
<h4>Registra i tuoi veicoli</h4>
|
||||||
|
<p>Se vuoi offrire passaggi, aggiungi i dati del tuo veicolo.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__step">
|
||||||
|
<div class="help-page__step-number">3</div>
|
||||||
|
<div class="help-page__step-content">
|
||||||
|
<h4>Pubblica o cerca</h4>
|
||||||
|
<p>Pubblica un viaggio o cerca tra quelli disponibili.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__step">
|
||||||
|
<div class="help-page__step-number">4</div>
|
||||||
|
<div class="help-page__step-content">
|
||||||
|
<h4>Contatta e parti!</h4>
|
||||||
|
<p>Mettiti d'accordo via chat, conferma e parti insieme!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- For Drivers Section -->
|
||||||
|
<div class="help-page__section">
|
||||||
|
<h3 class="help-page__section-title">
|
||||||
|
<q-icon name="directions_car" />
|
||||||
|
Per chi offre passaggi
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<q-expansion-item v-for="(item, index) in driverFAQs" :key="`driver-${index}`" :label="item.question" expand-separator class="help-page__faq-item">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>{{ item.answer }}</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- For Passengers Section -->
|
||||||
|
<div class="help-page__section">
|
||||||
|
<h3 class="help-page__section-title">
|
||||||
|
<q-icon name="hail" />
|
||||||
|
Per chi cerca passaggi
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<q-expansion-item v-for="(item, index) in passengerFAQs" :key="`passenger-${index}`" :label="item.question" expand-separator class="help-page__faq-item">
|
||||||
|
<q-card>
|
||||||
|
<q-card-section>{{ item.answer }}</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-expansion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Safety Section -->
|
||||||
|
<div class="help-page__section help-page__section--safety">
|
||||||
|
<h3 class="help-page__section-title">
|
||||||
|
<q-icon name="verified_user" />
|
||||||
|
Sicurezza e fiducia
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="help-page__safety-grid">
|
||||||
|
<div class="help-page__safety-item">
|
||||||
|
<q-icon name="star" size="32px" color="amber" />
|
||||||
|
<h4>Sistema di recensioni</h4>
|
||||||
|
<p>Ogni utente può lasciare e ricevere feedback dopo i viaggi</p>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__safety-item">
|
||||||
|
<q-icon name="verified" size="32px" color="info" />
|
||||||
|
<h4>Profili verificati</h4>
|
||||||
|
<p>Gli utenti possono verificare email, telefono e documenti</p>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__safety-item">
|
||||||
|
<q-icon name="chat" size="32px" color="positive" />
|
||||||
|
<h4>Chat integrata</h4>
|
||||||
|
<p>Comunica direttamente con gli altri utenti in sicurezza</p>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__safety-item">
|
||||||
|
<q-icon name="groups" size="32px" color="primary" />
|
||||||
|
<h4>Community solidale</h4>
|
||||||
|
<p>Una rete di persone che condividono valori e risorse</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tips Section -->
|
||||||
|
<div class="help-page__section">
|
||||||
|
<h3 class="help-page__section-title">
|
||||||
|
<q-icon name="lightbulb" />
|
||||||
|
Consigli utili
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="help-page__tips">
|
||||||
|
<div class="help-page__tip">
|
||||||
|
<q-icon name="photo_camera" color="primary" />
|
||||||
|
<span>Aggiungi una foto profilo chiara e riconoscibile</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__tip">
|
||||||
|
<q-icon name="schedule" color="primary" />
|
||||||
|
<span>Sii puntuale agli appuntamenti</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__tip">
|
||||||
|
<q-icon name="message" color="primary" />
|
||||||
|
<span>Rispondi rapidamente ai messaggi</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__tip">
|
||||||
|
<q-icon name="location_on" color="primary" />
|
||||||
|
<span>Indica punti di incontro facili da trovare</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__tip">
|
||||||
|
<q-icon name="star_rate" color="primary" />
|
||||||
|
<span>Lascia sempre un feedback dopo il viaggio</span>
|
||||||
|
</div>
|
||||||
|
<div class="help-page__tip">
|
||||||
|
<q-icon name="eco" color="primary" />
|
||||||
|
<span>Rispetta l'ambiente: meno auto, meno emissioni!</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Section -->
|
||||||
|
<div class="help-page__section help-page__section--contact">
|
||||||
|
<h3 class="help-page__section-title">
|
||||||
|
<q-icon name="support_agent" />
|
||||||
|
Hai bisogno di aiuto?
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p>Se hai domande o problemi, il nostro team è sempre pronto ad aiutarti.</p>
|
||||||
|
|
||||||
|
<div class="help-page__contact-actions">
|
||||||
|
<q-btn color="primary" icon="email" label="Scrivi al supporto" unelevated @click="contactSupport" />
|
||||||
|
<q-btn color="grey-7" icon="forum" label="Vai al forum" outline @click="openForum" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'HelpPage',
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const driverFAQs = ref([
|
||||||
|
{
|
||||||
|
question: 'Come pubblico un viaggio?',
|
||||||
|
answer: 'Dalla homepage, clicca su "Offri passaggio". Inserisci partenza, destinazione, data e ora, seleziona il veicolo e il numero di posti disponibili. Aggiungi eventuali note e pubblica!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Posso modificare un viaggio pubblicato?',
|
||||||
|
answer: 'Sì, puoi modificare i dettagli del viaggio finché non ci sono prenotazioni confermate. Vai nei tuoi viaggi e clicca su "Modifica".'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Come gestisco le richieste di passaggio?',
|
||||||
|
answer: 'Riceverai una notifica per ogni richiesta. Puoi accettare o rifiutare dalla sezione "Richieste". Ti consigliamo di rispondere entro 24 ore.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Cosa succede se devo cancellare un viaggio?',
|
||||||
|
answer: 'Puoi cancellare un viaggio dalla sezione "I miei viaggi". I passeggeri verranno avvisati automaticamente. Cancellazioni frequenti potrebbero influire sulla tua reputazione.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Devo registrare il mio veicolo?',
|
||||||
|
answer: 'Sì, per offrire passaggi devi registrare almeno un veicolo con i dati richiesti (marca, modello, targa, posti). Questo aiuta i passeggeri a riconoscerti.'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const passengerFAQs = ref([
|
||||||
|
{
|
||||||
|
question: 'Come cerco un passaggio?',
|
||||||
|
answer: 'Dalla homepage, clicca su "Cerca passaggio". Inserisci partenza, destinazione e data. Puoi filtrare i risultati per orario, prezzo e preferenze.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Come prenoto un passaggio?',
|
||||||
|
answer: 'Trova un viaggio che ti interessa, clicca su "Richiedi passaggio" e invia un messaggio al conducente. Attendi la conferma prima di considerare il viaggio prenotato.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Come pago il viaggio?',
|
||||||
|
answer: 'Il contributo spese viene concordato direttamente tra conducente e passeggero. Solitamente si paga in contanti all\'arrivo o come concordato in chat.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Cosa faccio se il conducente non si presenta?',
|
||||||
|
answer: 'Prova prima a contattarlo via chat o telefono. Se non riesci a raggiungerlo, segnala il problema al supporto e lascia un feedback negativo.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Posso cancellare una prenotazione?',
|
||||||
|
answer: 'Sì, puoi cancellare dalla sezione "Le mie richieste". Ti chiediamo di avvisare il prima possibile per permettere al conducente di trovare altri passeggeri.'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const goBack = () => router.back();
|
||||||
|
|
||||||
|
const contactSupport = () => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Contatta il supporto',
|
||||||
|
message: 'Invia una email a supporto@tuosito.it oppure usa il modulo di contatto.',
|
||||||
|
ok: 'OK'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openForum = () => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Forum Community',
|
||||||
|
message: 'Il forum sarà presto disponibile!',
|
||||||
|
ok: 'OK'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
router,
|
||||||
|
driverFAQs,
|
||||||
|
passengerFAQs,
|
||||||
|
goBack,
|
||||||
|
contactSupport,
|
||||||
|
openForum
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.help-page {
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
h1 { font-size: 20px; font-weight: 600; margin: 0; }
|
||||||
|
p { margin: 4px 0 0; opacity: 0.85; font-size: 14px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hero {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h2 { font-size: 22px; font-weight: 600; margin: 16px 0 8px; }
|
||||||
|
p { opacity: 0.9; margin: 0; font-size: 14px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__hero-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quick-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
span { font-size: 12px; color: #555; font-weight: 500; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:hover { transform: translateY(-2px); }
|
||||||
|
|
||||||
|
&--offer { background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%); }
|
||||||
|
&--search { background: linear-gradient(135deg, #1e88e5 0%, #1565c0 100%); }
|
||||||
|
&--request { background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content { padding: 16px; }
|
||||||
|
|
||||||
|
&__section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
&--safety { background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); }
|
||||||
|
&--contact { text-align: center; p { color: #666; margin-bottom: 20px; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
.q-icon { color: #667eea; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__steps { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
&__step {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__step-number {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__step-content {
|
||||||
|
h4 { margin: 0 0 4px; font-size: 15px; color: #333; }
|
||||||
|
p { margin: 0; font-size: 13px; color: #666; line-height: 1.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__faq-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
:deep(.q-expansion-item__container) { border-radius: 8px; overflow: hidden; }
|
||||||
|
:deep(.q-item__label) { font-weight: 500; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__safety-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__safety-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
h4 { margin: 12px 0 4px; font-size: 14px; color: #333; }
|
||||||
|
p { margin: 0; font-size: 12px; color: #666; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tips { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
&__tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
span { font-size: 13px; color: #555; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__contact-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.body--dark {
|
||||||
|
.help-page {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16162a 100%);
|
||||||
|
&__section { background: #1e1e30; &--safety { background: rgba(76, 175, 80, 0.1); } }
|
||||||
|
&__section-title { color: #fff; }
|
||||||
|
&__step-content { h4 { color: #fff; } p { color: #aaa; } }
|
||||||
|
&__safety-item { background: rgba(255, 255, 255, 0.05); h4 { color: #fff; } }
|
||||||
|
&__tip { background: rgba(255, 255, 255, 0.05); span { color: #ccc; } }
|
||||||
|
&__action span { color: #ccc; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
146
src/modules/trasporti/pages/MyRidesPage.scss
Normal file
146
src/modules/trasporti/pages/MyRidesPage.scss
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
.my-rides-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f5f5;
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.q-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tabs {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub-tabs {
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__panels {
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
.q-tab-panel {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 64px 24px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&--small {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: var(--q-grey-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__requests-list {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__requests-dialog {
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List transition
|
||||||
|
.list-enter-active,
|
||||||
|
.list-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.my-rides-page {
|
||||||
|
background: #121212;
|
||||||
|
|
||||||
|
&__tabs {
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__sub-tabs {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__requests-list {
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
298
src/modules/trasporti/pages/MyRidesPage.ts
Normal file
298
src/modules/trasporti/pages/MyRidesPage.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { ref, computed, onMounted, watch, defineComponent } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useRides } from '../composables/useRides';
|
||||||
|
import { useRideRequests } from '../composables/useRideRequests';
|
||||||
|
import MyRideCard from '../components/ride/MyRideCard.vue';
|
||||||
|
import RequestCard from '../components/ride/RequestCard.vue';
|
||||||
|
import type { Ride, RideRequest } from '../types';
|
||||||
|
import { useUserStore } from 'app/src/store';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'MyRidesPage',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
MyRideCard,
|
||||||
|
RequestCard
|
||||||
|
},
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const {
|
||||||
|
myRides,
|
||||||
|
loading,
|
||||||
|
fetchMyRides,
|
||||||
|
deleteRide,
|
||||||
|
completeRide: completeRideApi
|
||||||
|
} = useRides();
|
||||||
|
|
||||||
|
const {
|
||||||
|
receivedRequests,
|
||||||
|
sentRequests,
|
||||||
|
loading: loadingRequests,
|
||||||
|
requestCounts,
|
||||||
|
fetchReceivedRequests,
|
||||||
|
fetchSentRequests,
|
||||||
|
acceptRequest: acceptRequestApi,
|
||||||
|
rejectRequest: rejectRequestApi,
|
||||||
|
cancelRequest: cancelRequestApi
|
||||||
|
} = useRideRequests();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const activeTab = ref('upcoming');
|
||||||
|
const requestsSubTab = ref('received');
|
||||||
|
const roleFilter = ref<'all' | 'driver' | 'passenger'>('all');
|
||||||
|
const showRequestsDialog = ref(false);
|
||||||
|
const selectedRide = ref<Ride | null>(null);
|
||||||
|
const selectedRideRequests = ref<RideRequest[]>([]);
|
||||||
|
const currentUserId = ref<string>(userStore.my._id);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const roleFilters = [
|
||||||
|
{ label: 'Tutti', value: 'all' },
|
||||||
|
{ label: '🚗 Come conducente', value: 'driver' },
|
||||||
|
{ label: '👤 Come passeggero', value: 'passenger' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const upcomingCount = computed(() => myRides.upcoming.length);
|
||||||
|
const pendingRequestsCount = computed(() => requestCounts.value.pending);
|
||||||
|
|
||||||
|
const filteredUpcoming = computed(() => {
|
||||||
|
if (roleFilter.value === 'all') return myRides.upcoming;
|
||||||
|
if (roleFilter.value === 'driver') {
|
||||||
|
return myRides.upcoming.filter(r => isDriver(r));
|
||||||
|
}
|
||||||
|
return myRides.upcoming.filter(r => !isDriver(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredPast = computed(() => {
|
||||||
|
if (roleFilter.value === 'all') return myRides.past;
|
||||||
|
if (roleFilter.value === 'driver') {
|
||||||
|
return myRides.past.filter(r => isDriver(r));
|
||||||
|
}
|
||||||
|
return myRides.past.filter(r => !isDriver(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const isDriver = (ride: Ride): boolean => {
|
||||||
|
const userId = typeof ride.userId === 'string' ? ride.userId : ride.userId._id;
|
||||||
|
return userId === currentUserId.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPendingRequests = (rideId: string): number => {
|
||||||
|
return receivedRequests.value.filter(r => {
|
||||||
|
const reqRideId = typeof r.rideId === 'string' ? r.rideId : r.rideId._id;
|
||||||
|
return reqRideId === rideId && r.status === 'pending';
|
||||||
|
}).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canLeaveFeedback = (ride: Ride): boolean => {
|
||||||
|
// TODO: Implement logic to check if user can leave feedback
|
||||||
|
return ride.status === 'completed';
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToCreate = () => {
|
||||||
|
router.push({ path: '/trasporti/richiedi' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToRide = (rideId: string) => {
|
||||||
|
router.push(`/trasporti/viaggio/${rideId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToProfile = (userId: string) => {
|
||||||
|
router.push(`/trasporti/profilo/${userId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editRide = (rideId: string) => {
|
||||||
|
router.push(`/trasporti/viaggio/${rideId}/modifica`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRide = async (ride: Ride) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Cancella Viaggio',
|
||||||
|
message: 'Sei sicuro di voler cancellare questo viaggio?',
|
||||||
|
prompt: {
|
||||||
|
model: '',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Motivo (opzionale)'
|
||||||
|
},
|
||||||
|
cancel: true
|
||||||
|
}).onOk(async (reason: string) => {
|
||||||
|
try {
|
||||||
|
await deleteRide(ride._id, reason);
|
||||||
|
$q.notify({ type: 'positive', message: 'Viaggio cancellato' });
|
||||||
|
await fetchMyRides();
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({ type: 'negative', message: error.data?.message || error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const completeRide = async (ride: Ride) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Completa Viaggio',
|
||||||
|
message: 'Confermi che il viaggio è stato completato?',
|
||||||
|
cancel: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
await completeRideApi(ride._id);
|
||||||
|
$q.notify({ type: 'positive', message: 'Viaggio completato!' });
|
||||||
|
await fetchMyRides();
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({ type: 'negative', message: error.data?.message || error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRequestsDialog = async (ride: Ride) => {
|
||||||
|
selectedRide.value = ride;
|
||||||
|
selectedRideRequests.value = receivedRequests.value.filter(r => {
|
||||||
|
const reqRideId = typeof r.rideId === 'string' ? r.rideId : r.rideId._id;
|
||||||
|
return reqRideId === ride._id;
|
||||||
|
});
|
||||||
|
showRequestsDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openFeedbackDialog = (ride: Ride) => {
|
||||||
|
router.push({
|
||||||
|
name: 'leave-feedback',
|
||||||
|
params: { rideId: ride._id }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptRequest = async (request: RideRequest) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Accetta Richiesta',
|
||||||
|
message: `Vuoi accettare la richiesta di ${getUserName(request.passengerId)}?`,
|
||||||
|
prompt: {
|
||||||
|
model: '',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Messaggio (opzionale)'
|
||||||
|
},
|
||||||
|
cancel: true
|
||||||
|
}).onOk(async (message: string) => {
|
||||||
|
try {
|
||||||
|
await acceptRequestApi(request._id, message);
|
||||||
|
$q.notify({ type: 'positive', message: 'Richiesta accettata!' });
|
||||||
|
await fetchReceivedRequests();
|
||||||
|
await fetchMyRides();
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({ type: 'negative', message: error.data?.message || error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectRequest = async (request: RideRequest) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Rifiuta Richiesta',
|
||||||
|
message: 'Vuoi rifiutare questa richiesta?',
|
||||||
|
prompt: {
|
||||||
|
model: '',
|
||||||
|
type: 'text',
|
||||||
|
label: 'Motivo (opzionale)'
|
||||||
|
},
|
||||||
|
cancel: true
|
||||||
|
}).onOk(async (message: string) => {
|
||||||
|
try {
|
||||||
|
await rejectRequestApi(request._id, message);
|
||||||
|
$q.notify({ type: 'info', message: 'Richiesta rifiutata' });
|
||||||
|
await fetchReceivedRequests();
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({ type: 'negative', message: error.data?.message || error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRequest = async (request: RideRequest) => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'Annulla Richiesta',
|
||||||
|
message: 'Vuoi annullare questa richiesta?',
|
||||||
|
cancel: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
await cancelRequestApi(request._id);
|
||||||
|
$q.notify({ type: 'info', message: 'Richiesta annullata' });
|
||||||
|
await fetchSentRequests();
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({ type: 'negative', message: error.data?.message || error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserName = (user: any): string => {
|
||||||
|
if (typeof user === 'string') return 'Utente';
|
||||||
|
if (user.name) return `${user.name} ${user.surname || ''}`.trim();
|
||||||
|
return user.username || 'Utente';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch tab changes
|
||||||
|
watch(activeTab, async (tab) => {
|
||||||
|
if (tab === 'requests') {
|
||||||
|
if (requestsSubTab.value === 'received') {
|
||||||
|
await fetchReceivedRequests();
|
||||||
|
} else {
|
||||||
|
await fetchSentRequests();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(requestsSubTab, async (subTab) => {
|
||||||
|
if (subTab === 'received') {
|
||||||
|
await fetchReceivedRequests();
|
||||||
|
} else {
|
||||||
|
await fetchSentRequests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchMyRides();
|
||||||
|
await fetchReceivedRequests();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
activeTab,
|
||||||
|
requestsSubTab,
|
||||||
|
roleFilter,
|
||||||
|
showRequestsDialog,
|
||||||
|
selectedRideRequests,
|
||||||
|
loading,
|
||||||
|
loadingRequests,
|
||||||
|
myRides,
|
||||||
|
receivedRequests,
|
||||||
|
sentRequests,
|
||||||
|
currentUserId,
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
roleFilters,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
upcomingCount,
|
||||||
|
pendingRequestsCount,
|
||||||
|
filteredUpcoming,
|
||||||
|
filteredPast,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
isDriver,
|
||||||
|
getPendingRequests,
|
||||||
|
canLeaveFeedback,
|
||||||
|
goToCreate,
|
||||||
|
goToRide,
|
||||||
|
goToProfile,
|
||||||
|
editRide,
|
||||||
|
cancelRide,
|
||||||
|
completeRide,
|
||||||
|
openRequestsDialog,
|
||||||
|
openFeedbackDialog,
|
||||||
|
acceptRequest,
|
||||||
|
rejectRequest,
|
||||||
|
cancelRequest
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
221
src/modules/trasporti/pages/MyRidesPage.vue
Normal file
221
src/modules/trasporti/pages/MyRidesPage.vue
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<q-page class="my-rides-page">
|
||||||
|
<div class="my-rides-page__container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="my-rides-page__header">
|
||||||
|
<h1 class="my-rides-page__title">I Miei Viaggi</h1>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="add"
|
||||||
|
label="Nuovo Viaggio"
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
@click="goToCreate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<q-tabs
|
||||||
|
v-model="activeTab"
|
||||||
|
class="my-rides-page__tabs"
|
||||||
|
active-color="primary"
|
||||||
|
indicator-color="primary"
|
||||||
|
align="left"
|
||||||
|
narrow-indicator
|
||||||
|
no-caps
|
||||||
|
>
|
||||||
|
<q-tab name="upcoming" icon="event">
|
||||||
|
<span class="q-ml-sm">In arrivo</span>
|
||||||
|
<q-badge v-if="upcomingCount > 0" color="primary" floating>
|
||||||
|
{{ upcomingCount }}
|
||||||
|
</q-badge>
|
||||||
|
</q-tab>
|
||||||
|
<q-tab name="past" icon="history">
|
||||||
|
<span class="q-ml-sm">Passati</span>
|
||||||
|
</q-tab>
|
||||||
|
<q-tab name="requests" icon="inbox">
|
||||||
|
<span class="q-ml-sm">Richieste</span>
|
||||||
|
<q-badge v-if="pendingRequestsCount > 0" color="negative" floating>
|
||||||
|
{{ pendingRequestsCount }}
|
||||||
|
</q-badge>
|
||||||
|
</q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
|
||||||
|
<!-- Filter Pills -->
|
||||||
|
<div class="my-rides-page__filters">
|
||||||
|
<q-chip
|
||||||
|
v-for="filter in roleFilters"
|
||||||
|
:key="filter.value"
|
||||||
|
:selected="roleFilter === filter.value"
|
||||||
|
:color="roleFilter === filter.value ? 'primary' : undefined"
|
||||||
|
:text-color="roleFilter === filter.value ? 'white' : undefined"
|
||||||
|
clickable
|
||||||
|
@click="roleFilter = filter.value"
|
||||||
|
>
|
||||||
|
{{ filter.label }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Panels -->
|
||||||
|
<q-tab-panels v-model="activeTab" animated class="my-rides-page__panels">
|
||||||
|
<!-- Upcoming Rides -->
|
||||||
|
<q-tab-panel name="upcoming" class="q-pa-none">
|
||||||
|
<div v-if="loading" class="my-rides-page__loading">
|
||||||
|
<q-skeleton v-for="i in 3" :key="i" type="rect" height="180px" class="q-mb-md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredUpcoming.length === 0" class="my-rides-page__empty">
|
||||||
|
<div class="my-rides-page__empty-icon">📅</div>
|
||||||
|
<h3>Nessun viaggio in programma</h3>
|
||||||
|
<p>I tuoi prossimi viaggi appariranno qui</p>
|
||||||
|
<q-btn color="primary" label="Crea un viaggio" @click="goToCreate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="my-rides-page__list">
|
||||||
|
<TransitionGroup name="list">
|
||||||
|
<MyRideCard
|
||||||
|
v-for="ride in filteredUpcoming"
|
||||||
|
:key="ride._id"
|
||||||
|
:ride="ride"
|
||||||
|
:is-driver="isDriver(ride)"
|
||||||
|
:pending-requests="getPendingRequests(ride._id)"
|
||||||
|
@click="goToRide(ride._id)"
|
||||||
|
@manage-requests="openRequestsDialog(ride)"
|
||||||
|
@cancel="cancelRide(ride)"
|
||||||
|
@complete="completeRide(ride)"
|
||||||
|
@edit="editRide(ride._id)"
|
||||||
|
/>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- Past Rides -->
|
||||||
|
<q-tab-panel name="past" class="q-pa-none">
|
||||||
|
<div v-if="loading" class="my-rides-page__loading">
|
||||||
|
<q-skeleton v-for="i in 3" :key="i" type="rect" height="180px" class="q-mb-md" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="filteredPast.length === 0" class="my-rides-page__empty">
|
||||||
|
<div class="my-rides-page__empty-icon">🛣️</div>
|
||||||
|
<h3>Nessun viaggio passato</h3>
|
||||||
|
<p>I viaggi completati appariranno qui</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="my-rides-page__list">
|
||||||
|
<MyRideCard
|
||||||
|
v-for="ride in filteredPast"
|
||||||
|
:key="ride._id"
|
||||||
|
:ride="ride"
|
||||||
|
:is-driver="isDriver(ride)"
|
||||||
|
:show-feedback-prompt="canLeaveFeedback(ride)"
|
||||||
|
@click="goToRide(ride._id)"
|
||||||
|
@leave-feedback="openFeedbackDialog(ride)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- Requests -->
|
||||||
|
<q-tab-panel name="requests" class="q-pa-none">
|
||||||
|
<q-tabs
|
||||||
|
v-model="requestsSubTab"
|
||||||
|
class="my-rides-page__sub-tabs"
|
||||||
|
active-color="primary"
|
||||||
|
indicator-color="primary"
|
||||||
|
align="left"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
<q-tab name="received" label="Ricevute" />
|
||||||
|
<q-tab name="sent" label="Inviate" />
|
||||||
|
</q-tabs>
|
||||||
|
|
||||||
|
<!-- Received Requests -->
|
||||||
|
<div v-if="requestsSubTab === 'received'">
|
||||||
|
<div v-if="loadingRequests" class="my-rides-page__loading">
|
||||||
|
<q-skeleton v-for="i in 3" :key="i" type="QItem" class="q-mb-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="receivedRequests.length === 0" class="my-rides-page__empty my-rides-page__empty--small">
|
||||||
|
<q-icon name="inbox" size="48px" color="grey-4" />
|
||||||
|
<span>Nessuna richiesta ricevuta</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-list v-else class="my-rides-page__requests-list">
|
||||||
|
<RequestCard
|
||||||
|
v-for="request in receivedRequests"
|
||||||
|
:key="request._id"
|
||||||
|
:request="request"
|
||||||
|
mode="received"
|
||||||
|
@accept="acceptRequest(request)"
|
||||||
|
@reject="rejectRequest(request)"
|
||||||
|
@view-ride="goToRide(request.rideId._id || request.rideId)"
|
||||||
|
@view-user="goToProfile(request.passengerId._id || request.passengerId)"
|
||||||
|
/>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sent Requests -->
|
||||||
|
<div v-if="requestsSubTab === 'sent'">
|
||||||
|
<div v-if="loadingRequests" class="my-rides-page__loading">
|
||||||
|
<q-skeleton v-for="i in 3" :key="i" type="QItem" class="q-mb-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="sentRequests.length === 0" class="my-rides-page__empty my-rides-page__empty--small">
|
||||||
|
<q-icon name="send" size="48px" color="grey-4" />
|
||||||
|
<span>Nessuna richiesta inviata</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-list v-else class="my-rides-page__requests-list">
|
||||||
|
<RequestCard
|
||||||
|
v-for="request in sentRequests"
|
||||||
|
:key="request._id"
|
||||||
|
:request="request"
|
||||||
|
mode="sent"
|
||||||
|
@cancel="cancelRequest(request)"
|
||||||
|
@view-ride="goToRide(request.rideId._id || request.rideId)"
|
||||||
|
@view-user="goToProfile(request.driverId._id || request.driverId)"
|
||||||
|
/>
|
||||||
|
</q-list>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Requests Dialog -->
|
||||||
|
<q-dialog v-model="showRequestsDialog" position="bottom" full-width>
|
||||||
|
<q-card class="my-rides-page__requests-dialog">
|
||||||
|
<q-card-section class="row items-center">
|
||||||
|
<div class="text-h6">Richieste per questo viaggio</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat round icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-card-section class="q-pa-none" style="max-height: 60vh; overflow-y: auto">
|
||||||
|
<q-list v-if="selectedRideRequests.length > 0">
|
||||||
|
<RequestCard
|
||||||
|
v-for="request in selectedRideRequests"
|
||||||
|
:key="request._id"
|
||||||
|
:request="request"
|
||||||
|
mode="received"
|
||||||
|
@accept="acceptRequest(request); showRequestsDialog = false"
|
||||||
|
@reject="rejectRequest(request)"
|
||||||
|
@view-user="goToProfile(request.passengerId._id || request.passengerId)"
|
||||||
|
/>
|
||||||
|
</q-list>
|
||||||
|
<div v-else class="text-center q-pa-lg text-grey">
|
||||||
|
Nessuna richiesta pendente
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<!-- FAB Mobile -->
|
||||||
|
<q-page-sticky position="bottom-right" :offset="[18, 18]" class="lt-md">
|
||||||
|
<q-btn fab color="primary" icon="add" @click="goToCreate" />
|
||||||
|
</q-page-sticky>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./MyRidesPage.ts" />
|
||||||
|
<style lang="scss" src="./MyRidesPage.scss" />
|
||||||
790
src/modules/trasporti/pages/Myfeedbackpage.vue
Normal file
790
src/modules/trasporti/pages/Myfeedbackpage.vue
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
<!-- MyFeedbackPage.vue -->
|
||||||
|
<template>
|
||||||
|
<q-page class="my-feedback-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="my-feedback-page__header">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
icon="arrow_back"
|
||||||
|
color="white"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h1>I Miei Feedback</h1>
|
||||||
|
<p>Le valutazioni che hai ricevuto</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Summary -->
|
||||||
|
<div class="my-feedback-page__stats" v-if="!loading && stats">
|
||||||
|
<div class="my-feedback-page__rating-card">
|
||||||
|
<div class="my-feedback-page__rating-value">
|
||||||
|
{{ stats.averageRating?.toFixed(1) || '–' }}
|
||||||
|
</div>
|
||||||
|
<div class="my-feedback-page__rating-stars">
|
||||||
|
<q-icon
|
||||||
|
v-for="n in 5"
|
||||||
|
:key="n"
|
||||||
|
:name="n <= Math.round(stats.averageRating || 0) ? 'star' : 'star_border'"
|
||||||
|
:color="n <= Math.round(stats.averageRating || 0) ? 'amber' : 'grey-4'"
|
||||||
|
size="20px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="my-feedback-page__rating-count">
|
||||||
|
{{ stats.totalCount || 0 }} valutazioni
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-feedback-page__stats-grid">
|
||||||
|
<div class="my-feedback-page__stat-item">
|
||||||
|
<q-icon name="directions_car" color="positive" size="24px" />
|
||||||
|
<span class="my-feedback-page__stat-value">{{ stats.asDriver || 0 }}</span>
|
||||||
|
<span class="my-feedback-page__stat-label">Come conducente</span>
|
||||||
|
</div>
|
||||||
|
<div class="my-feedback-page__stat-item">
|
||||||
|
<q-icon name="hail" color="info" size="24px" />
|
||||||
|
<span class="my-feedback-page__stat-value">{{ stats.asPassenger || 0 }}</span>
|
||||||
|
<span class="my-feedback-page__stat-label">Come passeggero</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<q-tabs
|
||||||
|
v-model="activeTab"
|
||||||
|
class="my-feedback-page__tabs"
|
||||||
|
active-color="primary"
|
||||||
|
indicator-color="primary"
|
||||||
|
align="justify"
|
||||||
|
>
|
||||||
|
<q-tab name="received" label="Ricevuti" icon="inbox" />
|
||||||
|
<q-tab name="given" label="Dati" icon="send" />
|
||||||
|
</q-tabs>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="my-feedback-page__content">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="my-feedback-page__loading">
|
||||||
|
<q-spinner-dots size="50px" color="primary" />
|
||||||
|
<p>Caricamento feedback...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="filteredFeedbacks.length === 0" class="my-feedback-page__empty">
|
||||||
|
<q-icon
|
||||||
|
:name="activeTab === 'received' ? 'star_border' : 'rate_review'"
|
||||||
|
size="80px"
|
||||||
|
color="grey-4"
|
||||||
|
/>
|
||||||
|
<h3>{{ activeTab === 'received' ? 'Nessun feedback ricevuto' : 'Nessun feedback dato' }}</h3>
|
||||||
|
<p>
|
||||||
|
{{ activeTab === 'received'
|
||||||
|
? 'Completa i tuoi primi viaggi per ricevere valutazioni'
|
||||||
|
: 'Non hai ancora lasciato feedback ad altri utenti'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
icon="explore"
|
||||||
|
label="Esplora viaggi"
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
to="/trasporti"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feedback List -->
|
||||||
|
<div v-else class="my-feedback-page__list">
|
||||||
|
<TransitionGroup name="list">
|
||||||
|
<div
|
||||||
|
v-for="feedback in filteredFeedbacks"
|
||||||
|
:key="feedback._id"
|
||||||
|
class="my-feedback-page__card"
|
||||||
|
>
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div class="my-feedback-page__card-header">
|
||||||
|
<div class="my-feedback-page__user" @click="viewProfile(feedback)">
|
||||||
|
<q-avatar size="44px">
|
||||||
|
<img
|
||||||
|
v-if="getOtherUser(feedback)?.profile?.img"
|
||||||
|
:src="getOtherUser(feedback).profile.img"
|
||||||
|
/>
|
||||||
|
<div v-else class="my-feedback-page__avatar-placeholder">
|
||||||
|
{{ getInitials(getOtherUser(feedback)) }}
|
||||||
|
</div>
|
||||||
|
</q-avatar>
|
||||||
|
<div class="my-feedback-page__user-info">
|
||||||
|
<span class="my-feedback-page__user-name">
|
||||||
|
{{ getOtherUser(feedback)?.name }} {{ getOtherUser(feedback)?.surname }}
|
||||||
|
</span>
|
||||||
|
<span class="my-feedback-page__user-role">
|
||||||
|
<q-icon
|
||||||
|
:name="feedback.toUserRole === 'driver' ? 'directions_car' : 'hail'"
|
||||||
|
size="14px"
|
||||||
|
/>
|
||||||
|
{{ feedback.toUserRole === 'driver' ? 'Come conducente' : 'Come passeggero' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-feedback-page__rating">
|
||||||
|
<div class="my-feedback-page__rating-number">{{ feedback.rating }}</div>
|
||||||
|
<div class="my-feedback-page__rating-mini-stars">
|
||||||
|
<q-icon
|
||||||
|
v-for="n in 5"
|
||||||
|
:key="n"
|
||||||
|
:name="n <= feedback.rating ? 'star' : 'star_border'"
|
||||||
|
:color="n <= feedback.rating ? 'amber' : 'grey-4'"
|
||||||
|
size="12px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ride Info -->
|
||||||
|
<div
|
||||||
|
class="my-feedback-page__ride-info"
|
||||||
|
v-if="feedback.rideInfo"
|
||||||
|
@click="viewRide(feedback.rideId)"
|
||||||
|
>
|
||||||
|
<q-icon name="route" size="16px" color="grey-6" />
|
||||||
|
<span>{{ feedback.rideInfo.departure }} → {{ feedback.rideInfo.destination }}</span>
|
||||||
|
<span class="my-feedback-page__ride-date">
|
||||||
|
{{ formatDate(feedback.rideInfo.departureDate) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories -->
|
||||||
|
<div class="my-feedback-page__categories" v-if="feedback.categories?.length">
|
||||||
|
<div
|
||||||
|
v-for="cat in feedback.categories"
|
||||||
|
:key="cat.key"
|
||||||
|
class="my-feedback-page__category"
|
||||||
|
>
|
||||||
|
<span class="my-feedback-page__category-label">{{ cat.label }}</span>
|
||||||
|
<div class="my-feedback-page__category-stars">
|
||||||
|
<q-icon
|
||||||
|
v-for="n in 5"
|
||||||
|
:key="n"
|
||||||
|
:name="n <= cat.value ? 'star' : 'star_border'"
|
||||||
|
:color="n <= cat.value ? 'primary' : 'grey-4'"
|
||||||
|
size="14px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="my-feedback-page__tags" v-if="feedback.tags?.length">
|
||||||
|
<q-chip
|
||||||
|
v-for="tag in feedback.tags"
|
||||||
|
:key="tag"
|
||||||
|
:color="getTagColor(tag)"
|
||||||
|
text-color="white"
|
||||||
|
size="sm"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment -->
|
||||||
|
<div class="my-feedback-page__comment" v-if="feedback.comment">
|
||||||
|
<q-icon name="format_quote" size="16px" color="grey-5" />
|
||||||
|
<p>{{ feedback.comment }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="my-feedback-page__card-footer">
|
||||||
|
<span class="my-feedback-page__date">
|
||||||
|
{{ formatTimeAgo(feedback.createdAt) }}
|
||||||
|
</span>
|
||||||
|
<q-chip
|
||||||
|
v-if="feedback.isPublic"
|
||||||
|
color="green-1"
|
||||||
|
text-color="green-8"
|
||||||
|
size="sm"
|
||||||
|
icon="visibility"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
Pubblico
|
||||||
|
</q-chip>
|
||||||
|
<q-chip
|
||||||
|
v-else
|
||||||
|
color="grey-3"
|
||||||
|
text-color="grey-7"
|
||||||
|
size="sm"
|
||||||
|
icon="visibility_off"
|
||||||
|
dense
|
||||||
|
>
|
||||||
|
Privato
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<!-- Load More -->
|
||||||
|
<div v-if="hasMore" class="my-feedback-page__load-more">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
color="primary"
|
||||||
|
:loading="loadingMore"
|
||||||
|
@click="loadMore"
|
||||||
|
>
|
||||||
|
Carica altri
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useQuasar, date as qdate } from 'quasar';
|
||||||
|
import { Api } from '@api';
|
||||||
|
|
||||||
|
interface FeedbackStats {
|
||||||
|
averageRating: number;
|
||||||
|
totalCount: number;
|
||||||
|
asDriver: number;
|
||||||
|
asPassenger: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedbackCategory {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedbackItem {
|
||||||
|
_id: string;
|
||||||
|
rideId: string;
|
||||||
|
fromUserId: string;
|
||||||
|
toUserId: string;
|
||||||
|
fromUser?: any;
|
||||||
|
toUser?: any;
|
||||||
|
toUserRole: 'driver' | 'passenger';
|
||||||
|
rating: number;
|
||||||
|
categories?: FeedbackCategory[];
|
||||||
|
tags?: string[];
|
||||||
|
comment?: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
rideInfo?: {
|
||||||
|
departure: string;
|
||||||
|
destination: string;
|
||||||
|
departureDate: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'MyFeedbackPage',
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const activeTab = ref<'received' | 'given'>('received');
|
||||||
|
const stats = ref<FeedbackStats | null>(null);
|
||||||
|
const receivedFeedbacks = ref<FeedbackItem[]>([]);
|
||||||
|
const givenFeedbacks = ref<FeedbackItem[]>([]);
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const hasMore = ref(false);
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const filteredFeedbacks = computed(() => {
|
||||||
|
return activeTab.value === 'received'
|
||||||
|
? receivedFeedbacks.value
|
||||||
|
: givenFeedbacks.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const goBack = () => {
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInitials = (user: any) => {
|
||||||
|
if (!user) return '?';
|
||||||
|
const name = user.name || '';
|
||||||
|
const surname = user.surname || '';
|
||||||
|
return `${name.charAt(0)}${surname.charAt(0)}`.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOtherUser = (feedback: FeedbackItem) => {
|
||||||
|
return activeTab.value === 'received' ? feedback.fromUser : feedback.toUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return qdate.formatDate(dateStr, 'DD MMM YYYY');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimeAgo = (dateStr: string) => {
|
||||||
|
const now = new Date();
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) return 'Oggi';
|
||||||
|
if (diffDays === 1) return 'Ieri';
|
||||||
|
if (diffDays < 7) return `${diffDays} giorni fa`;
|
||||||
|
if (diffDays < 30) return `${Math.floor(diffDays / 7)} settimane fa`;
|
||||||
|
return formatDate(dateStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTagColor = (tag: string): string => {
|
||||||
|
const positiveWords = ['puntuale', 'gentile', 'sicuro', 'pulito', 'preciso'];
|
||||||
|
const isPositive = positiveWords.some(w => tag.toLowerCase().includes(w));
|
||||||
|
return isPositive ? 'positive' : 'primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewProfile = (feedback: FeedbackItem) => {
|
||||||
|
const user = getOtherUser(feedback);
|
||||||
|
if (user?._id) {
|
||||||
|
router.push(`/trasporti/profilo/${user._id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewRide = (rideId: string) => {
|
||||||
|
router.push(`/trasporti/ride/${rideId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFeedbacks = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [statsRes, receivedRes, givenRes] = await Promise.all([
|
||||||
|
Api.SendReq('/api/trasporti/feedback/stats', 'GET'),
|
||||||
|
Api.SendReq('/api/trasporti/feedback/received', 'GET'),
|
||||||
|
Api.SendReq('/api/trasporti/feedback/given', 'GET')
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (statsRes.success) {
|
||||||
|
stats.value = statsRes.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receivedRes.success) {
|
||||||
|
receivedFeedbacks.value = receivedRes.data.feedbacks || [];
|
||||||
|
hasMore.value = receivedRes.data.hasMore || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (givenRes.success) {
|
||||||
|
givenFeedbacks.value = givenRes.data.feedbacks || [];
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: error.data?.message || error.message || 'Errore nel caricamento dei feedback'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
loadingMore.value = true;
|
||||||
|
currentPage.value++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = activeTab.value === 'received'
|
||||||
|
? '/api/trasporti/feedback/received'
|
||||||
|
: '/api/trasporti/feedback/given';
|
||||||
|
|
||||||
|
const response = await Api.SendReq(`${endpoint}?page=${currentPage.value}`, 'GET');
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
const newFeedbacks = response.data.feedbacks || [];
|
||||||
|
|
||||||
|
if (activeTab.value === 'received') {
|
||||||
|
receivedFeedbacks.value.push(...newFeedbacks);
|
||||||
|
} else {
|
||||||
|
givenFeedbacks.value.push(...newFeedbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore.value = response.data.hasMore || false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore nel caricamento'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadFeedbacks();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
loadingMore,
|
||||||
|
activeTab,
|
||||||
|
stats,
|
||||||
|
filteredFeedbacks,
|
||||||
|
hasMore,
|
||||||
|
goBack,
|
||||||
|
getInitials,
|
||||||
|
getOtherUser,
|
||||||
|
formatDate,
|
||||||
|
formatTimeAgo,
|
||||||
|
getTagColor,
|
||||||
|
viewProfile,
|
||||||
|
viewRide,
|
||||||
|
loadMore
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.my-feedback-page {
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
|
||||||
|
// Header
|
||||||
|
&__header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
&__stats {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-value {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-stars {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-count {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-item {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
&__tabs {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content
|
||||||
|
&__content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading,
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 20px 0 8px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #888;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card
|
||||||
|
&__card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-role {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffc107;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-mini-stars {
|
||||||
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ride Info
|
||||||
|
&__ride-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-date {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
&__categories {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__category-stars {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
&__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment
|
||||||
|
&__comment {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
&__card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load More
|
||||||
|
&__load-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transitions
|
||||||
|
.list-enter-active,
|
||||||
|
.list-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-enter-from,
|
||||||
|
.list-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark Mode
|
||||||
|
.body--dark {
|
||||||
|
.my-feedback-page {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16162a 100%);
|
||||||
|
|
||||||
|
&__rating-card,
|
||||||
|
&__stat-item,
|
||||||
|
&__card {
|
||||||
|
background: #1e1e30;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rating-value,
|
||||||
|
&__stat-value,
|
||||||
|
&__user-name {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-info {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__comment {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-footer {
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
649
src/modules/trasporti/pages/Requestspage.vue
Normal file
649
src/modules/trasporti/pages/Requestspage.vue
Normal file
@@ -0,0 +1,649 @@
|
|||||||
|
<!-- RequestsPage.vue -->
|
||||||
|
<template>
|
||||||
|
<q-page class="requests-page">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="requests-page__header">
|
||||||
|
<q-btn flat round icon="arrow_back" color="white" @click="goBack" />
|
||||||
|
<div>
|
||||||
|
<h1>Richieste</h1>
|
||||||
|
<p>Gestisci le richieste di passaggio</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="requests-page__stats" v-if="!loading">
|
||||||
|
<div class="requests-page__stat">
|
||||||
|
<q-icon name="inbox" color="warning" size="24px" />
|
||||||
|
<div class="requests-page__stat-info">
|
||||||
|
<span class="requests-page__stat-value">{{ stats.pending }}</span>
|
||||||
|
<span class="requests-page__stat-label">In attesa</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="requests-page__stat">
|
||||||
|
<q-icon name="check_circle" color="positive" size="24px" />
|
||||||
|
<div class="requests-page__stat-info">
|
||||||
|
<span class="requests-page__stat-value">{{ stats.accepted }}</span>
|
||||||
|
<span class="requests-page__stat-label">Accettate</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="requests-page__stat">
|
||||||
|
<q-icon name="cancel" color="negative" size="24px" />
|
||||||
|
<div class="requests-page__stat-info">
|
||||||
|
<span class="requests-page__stat-value">{{ stats.rejected }}</span>
|
||||||
|
<span class="requests-page__stat-label">Rifiutate</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<q-tabs v-model="activeTab" class="requests-page__tabs" active-color="primary" indicator-color="primary" align="justify">
|
||||||
|
<q-tab name="received" icon="move_to_inbox">
|
||||||
|
<template #default>
|
||||||
|
<div class="requests-page__tab-content">
|
||||||
|
<span>Ricevute</span>
|
||||||
|
<q-badge v-if="pendingReceivedCount > 0" color="negative" :label="pendingReceivedCount" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-tab>
|
||||||
|
<q-tab name="sent" icon="send">
|
||||||
|
<template #default>
|
||||||
|
<div class="requests-page__tab-content">
|
||||||
|
<span>Inviate</span>
|
||||||
|
<q-badge v-if="pendingSentCount > 0" color="info" :label="pendingSentCount" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</q-tab>
|
||||||
|
</q-tabs>
|
||||||
|
|
||||||
|
<!-- Filter Chips -->
|
||||||
|
<div class="requests-page__filters">
|
||||||
|
<q-chip
|
||||||
|
v-for="filter in statusFilters"
|
||||||
|
:key="filter.value"
|
||||||
|
:color="activeFilter === filter.value ? 'primary' : 'grey-3'"
|
||||||
|
:text-color="activeFilter === filter.value ? 'white' : 'grey-8'"
|
||||||
|
clickable
|
||||||
|
@click="activeFilter = filter.value"
|
||||||
|
>
|
||||||
|
<q-icon :name="filter.icon" size="16px" class="q-mr-xs" />
|
||||||
|
{{ filter.label }}
|
||||||
|
</q-chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="requests-page__content">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="requests-page__loading">
|
||||||
|
<q-spinner-dots size="50px" color="primary" />
|
||||||
|
<p>Caricamento richieste...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div v-else-if="filteredRequests.length === 0" class="requests-page__empty">
|
||||||
|
<q-icon :name="activeTab === 'received' ? 'move_to_inbox' : 'send'" size="80px" color="grey-4" />
|
||||||
|
<h3>{{ emptyTitle }}</h3>
|
||||||
|
<p>{{ emptyMessage }}</p>
|
||||||
|
<q-btn v-if="activeTab === 'sent'" color="primary" icon="search" label="Cerca un passaggio" rounded unelevated to="/trasporti/cerca" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Requests List -->
|
||||||
|
<div v-else class="requests-page__list">
|
||||||
|
<TransitionGroup name="list">
|
||||||
|
<div v-for="request in filteredRequests" :key="request._id" class="requests-page__card" :class="`requests-page__card--${request.status}`">
|
||||||
|
<!-- Status Badge -->
|
||||||
|
<div class="requests-page__status-badge" :class="`requests-page__status-badge--${request.status}`">
|
||||||
|
<q-icon :name="getStatusIcon(request.status)" size="14px" />
|
||||||
|
{{ getStatusLabel(request.status) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div class="requests-page__card-header">
|
||||||
|
<div class="requests-page__user" @click="viewProfile(request)">
|
||||||
|
<q-avatar size="48px">
|
||||||
|
<img v-if="getOtherUser(request)?.profile?.img" :src="getOtherUser(request).profile.img" />
|
||||||
|
<div v-else class="requests-page__avatar-placeholder">
|
||||||
|
{{ getInitials(getOtherUser(request)) }}
|
||||||
|
</div>
|
||||||
|
</q-avatar>
|
||||||
|
<div class="requests-page__user-info">
|
||||||
|
<span class="requests-page__user-name">
|
||||||
|
{{ getOtherUser(request)?.name }} {{ getOtherUser(request)?.surname }}
|
||||||
|
</span>
|
||||||
|
<div class="requests-page__user-rating" v-if="getOtherUser(request)?.rating">
|
||||||
|
<q-icon name="star" color="amber" size="14px" />
|
||||||
|
<span>{{ getOtherUser(request).rating.toFixed(1) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="requests-page__date">{{ formatTimeAgo(request.createdAt) }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ride Info -->
|
||||||
|
<div class="requests-page__ride" @click="viewRide(request.rideId)">
|
||||||
|
<div class="requests-page__ride-type">
|
||||||
|
<q-icon
|
||||||
|
:name="request.rideInfo?.type === 'offer' ? 'directions_car' : 'hail'"
|
||||||
|
:color="request.rideInfo?.type === 'offer' ? 'positive' : 'info'"
|
||||||
|
size="20px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="requests-page__ride-details">
|
||||||
|
<div class="requests-page__ride-route">
|
||||||
|
{{ request.rideInfo?.departure }} → {{ request.rideInfo?.destination }}
|
||||||
|
</div>
|
||||||
|
<div class="requests-page__ride-datetime">
|
||||||
|
<q-icon name="event" size="14px" />
|
||||||
|
{{ formatDate(request.rideInfo?.departureDate) }}
|
||||||
|
<q-icon name="schedule" size="14px" class="q-ml-sm" />
|
||||||
|
{{ request.rideInfo?.departureTime }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-icon name="chevron_right" color="grey-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Details -->
|
||||||
|
<div class="requests-page__details" v-if="request.seats || request.pickupPoint">
|
||||||
|
<div class="requests-page__detail" v-if="request.seats">
|
||||||
|
<q-icon name="event_seat" size="16px" color="grey-6" />
|
||||||
|
<span>{{ request.seats }} {{ request.seats === 1 ? 'posto' : 'posti' }} richiesti</span>
|
||||||
|
</div>
|
||||||
|
<div class="requests-page__detail" v-if="request.pickupPoint">
|
||||||
|
<q-icon name="location_on" size="16px" color="grey-6" />
|
||||||
|
<span>{{ request.pickupPoint }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<div class="requests-page__message" v-if="request.message">
|
||||||
|
<q-icon name="chat_bubble_outline" size="16px" color="grey-5" />
|
||||||
|
<p>{{ request.message }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Response -->
|
||||||
|
<div class="requests-page__response" v-if="request.response && request.status !== 'pending'">
|
||||||
|
<q-icon name="reply" size="16px" color="grey-5" />
|
||||||
|
<p>{{ request.response }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions for received pending -->
|
||||||
|
<div class="requests-page__actions" v-if="activeTab === 'received' && request.status === 'pending'">
|
||||||
|
<q-btn flat color="negative" icon="close" label="Rifiuta" size="sm" @click="rejectRequest(request)" />
|
||||||
|
<q-btn unelevated color="positive" icon="check" label="Accetta" size="sm" @click="acceptRequest(request)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions for sent pending -->
|
||||||
|
<div class="requests-page__actions" v-if="activeTab === 'sent' && request.status === 'pending'">
|
||||||
|
<q-btn flat color="grey" icon="delete_outline" label="Annulla" size="sm" @click="cancelRequest(request)" />
|
||||||
|
<q-btn flat color="primary" icon="chat" label="Scrivi" size="sm" @click="openChat(request)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions for accepted -->
|
||||||
|
<div class="requests-page__actions" v-if="request.status === 'accepted'">
|
||||||
|
<q-btn flat color="primary" icon="chat" label="Scrivi" size="sm" @click="openChat(request)" />
|
||||||
|
<q-btn flat color="info" icon="info" label="Dettagli" size="sm" @click="viewRide(request.rideId)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
|
||||||
|
<div v-if="hasMore" class="requests-page__load-more">
|
||||||
|
<q-btn flat color="primary" :loading="loadingMore" @click="loadMore">Carica altre</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Response Dialog -->
|
||||||
|
<q-dialog v-model="showResponseDialog" persistent>
|
||||||
|
<q-card class="requests-page__dialog">
|
||||||
|
<q-card-section class="text-center">
|
||||||
|
<q-icon :name="responseAction === 'accept' ? 'check_circle' : 'cancel'" :color="responseAction === 'accept' ? 'positive' : 'negative'" size="60px" />
|
||||||
|
<h3>{{ responseAction === 'accept' ? 'Accetta richiesta' : 'Rifiuta richiesta' }}</h3>
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-section>
|
||||||
|
<q-input v-model="responseMessage" type="textarea" outlined autogrow :label="responseAction === 'accept' ? 'Messaggio (opzionale)' : 'Motivo (opzionale)'" :maxlength="300" counter />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="center" class="q-pb-md">
|
||||||
|
<q-btn flat label="Annulla" color="grey" v-close-popup />
|
||||||
|
<q-btn unelevated :label="responseAction === 'accept' ? 'Conferma' : 'Rifiuta'" :color="responseAction === 'accept' ? 'positive' : 'negative'" :loading="responding" @click="submitResponse" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useQuasar, date as qdate } from 'quasar';
|
||||||
|
import { Api } from '@api';
|
||||||
|
|
||||||
|
interface RequestStats {
|
||||||
|
pending: number;
|
||||||
|
accepted: number;
|
||||||
|
rejected: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RideRequest {
|
||||||
|
_id: string;
|
||||||
|
rideId: string;
|
||||||
|
fromUserId: string;
|
||||||
|
toUserId: string;
|
||||||
|
fromUser?: any;
|
||||||
|
toUser?: any;
|
||||||
|
seats: number;
|
||||||
|
pickupPoint?: string;
|
||||||
|
message?: string;
|
||||||
|
response?: string;
|
||||||
|
status: 'pending' | 'accepted' | 'rejected' | 'cancelled';
|
||||||
|
createdAt: string;
|
||||||
|
rideInfo?: {
|
||||||
|
departure: string;
|
||||||
|
destination: string;
|
||||||
|
departureDate: string;
|
||||||
|
departureTime: string;
|
||||||
|
type: 'offer' | 'request';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'RequestsPage',
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const responding = ref(false);
|
||||||
|
const activeTab = ref<'received' | 'sent'>('received');
|
||||||
|
const activeFilter = ref('all');
|
||||||
|
const stats = ref<RequestStats>({ pending: 0, accepted: 0, rejected: 0 });
|
||||||
|
const receivedRequests = ref<RideRequest[]>([]);
|
||||||
|
const sentRequests = ref<RideRequest[]>([]);
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const hasMore = ref(false);
|
||||||
|
const showResponseDialog = ref(false);
|
||||||
|
const selectedRequest = ref<RideRequest | null>(null);
|
||||||
|
const responseAction = ref<'accept' | 'reject'>('accept');
|
||||||
|
const responseMessage = ref('');
|
||||||
|
|
||||||
|
const statusFilters = [
|
||||||
|
{ value: 'all', label: 'Tutte', icon: 'list' },
|
||||||
|
{ value: 'pending', label: 'In attesa', icon: 'hourglass_empty' },
|
||||||
|
{ value: 'accepted', label: 'Accettate', icon: 'check_circle' },
|
||||||
|
{ value: 'rejected', label: 'Rifiutate', icon: 'cancel' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const pendingReceivedCount = computed(() => receivedRequests.value.filter(r => r.status === 'pending').length);
|
||||||
|
const pendingSentCount = computed(() => sentRequests.value.filter(r => r.status === 'pending').length);
|
||||||
|
|
||||||
|
const filteredRequests = computed(() => {
|
||||||
|
const requests = activeTab.value === 'received' ? receivedRequests.value : sentRequests.value;
|
||||||
|
if (activeFilter.value === 'all') return requests;
|
||||||
|
return requests.filter(r => r.status === activeFilter.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyTitle = computed(() => {
|
||||||
|
if (activeFilter.value !== 'all') {
|
||||||
|
return `Nessuna richiesta ${statusFilters.find(f => f.value === activeFilter.value)?.label.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
return activeTab.value === 'received' ? 'Nessuna richiesta ricevuta' : 'Nessuna richiesta inviata';
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyMessage = computed(() => {
|
||||||
|
if (activeFilter.value !== 'all') return 'Prova a cambiare i filtri';
|
||||||
|
return activeTab.value === 'received'
|
||||||
|
? 'Quando qualcuno richiederà un passaggio sui tuoi viaggi, lo vedrai qui'
|
||||||
|
: 'Cerca un passaggio e invia la tua prima richiesta';
|
||||||
|
});
|
||||||
|
|
||||||
|
const goBack = () => router.back();
|
||||||
|
|
||||||
|
const getInitials = (user: any) => {
|
||||||
|
if (!user) return '?';
|
||||||
|
return `${(user.name || '').charAt(0)}${(user.surname || '').charAt(0)}`.toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOtherUser = (request: RideRequest) => {
|
||||||
|
return activeTab.value === 'received' ? request.fromUser : request.toUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string): string => {
|
||||||
|
const icons: Record<string, string> = { pending: 'hourglass_empty', accepted: 'check_circle', rejected: 'cancel', cancelled: 'block' };
|
||||||
|
return icons[status] || 'help';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string): string => {
|
||||||
|
const labels: Record<string, string> = { pending: 'In attesa', accepted: 'Accettata', rejected: 'Rifiutata', cancelled: 'Annullata' };
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr?: string) => dateStr ? qdate.formatDate(dateStr, 'DD MMM YYYY') : '';
|
||||||
|
|
||||||
|
const formatTimeAgo = (dateStr: string) => {
|
||||||
|
const diffMs = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
if (diffMins < 1) return 'Adesso';
|
||||||
|
if (diffMins < 60) return `${diffMins} min fa`;
|
||||||
|
if (diffHours < 24) return `${diffHours} ore fa`;
|
||||||
|
if (diffDays === 1) return 'Ieri';
|
||||||
|
if (diffDays < 7) return `${diffDays} giorni fa`;
|
||||||
|
return formatDate(dateStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewProfile = (request: RideRequest) => {
|
||||||
|
const user = getOtherUser(request);
|
||||||
|
if (user?._id) router.push(`/trasporti/profilo/${user._id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewRide = (rideId?: string) => {
|
||||||
|
if (rideId) router.push(`/trasporti/ride/${rideId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openChat = (request: RideRequest) => {
|
||||||
|
const user = getOtherUser(request);
|
||||||
|
if (user?._id) router.push(`/trasporti/chat?userId=${user._id}&rideId=${request.rideId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const acceptRequest = (request: RideRequest) => {
|
||||||
|
selectedRequest.value = request;
|
||||||
|
responseAction.value = 'accept';
|
||||||
|
responseMessage.value = '';
|
||||||
|
showResponseDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectRequest = (request: RideRequest) => {
|
||||||
|
selectedRequest.value = request;
|
||||||
|
responseAction.value = 'reject';
|
||||||
|
responseMessage.value = '';
|
||||||
|
showResponseDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitResponse = async () => {
|
||||||
|
if (!selectedRequest.value) return;
|
||||||
|
responding.value = true;
|
||||||
|
try {
|
||||||
|
const endpoint = responseAction.value === 'accept'
|
||||||
|
? `/api/trasporti/richieste/${selectedRequest.value._id}/accept`
|
||||||
|
: `/api/trasporti/richieste/${selectedRequest.value._id}/reject`;
|
||||||
|
const response = await Api.SendReq(endpoint, 'PUT', { message: responseMessage.value });
|
||||||
|
if (response.success) {
|
||||||
|
const index = receivedRequests.value.findIndex(r => r._id === selectedRequest.value?._id);
|
||||||
|
if (index !== -1) {
|
||||||
|
receivedRequests.value[index].status = responseAction.value === 'accept' ? 'accepted' : 'rejected';
|
||||||
|
receivedRequests.value[index].response = responseMessage.value;
|
||||||
|
}
|
||||||
|
stats.value.pending = Math.max(0, stats.value.pending - 1);
|
||||||
|
if (responseAction.value === 'accept') stats.value.accepted++;
|
||||||
|
else stats.value.rejected++;
|
||||||
|
$q.notify({ type: 'positive', message: responseAction.value === 'accept' ? 'Richiesta accettata!' : 'Richiesta rifiutata' });
|
||||||
|
showResponseDialog.value = false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({ type: 'negative', message: error.data?.message || error.message || 'Errore' });
|
||||||
|
} finally {
|
||||||
|
responding.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelRequest = async (request: RideRequest) => {
|
||||||
|
$q.dialog({ title: 'Annulla richiesta', message: 'Sei sicuro?', cancel: true }).onOk(async () => {
|
||||||
|
try {
|
||||||
|
const response = await Api.SendReq(`/api/trasporti/richieste/${request._id}/cancel`, 'PUT');
|
||||||
|
if (response.success) {
|
||||||
|
const index = sentRequests.value.findIndex(r => r._id === request._id);
|
||||||
|
if (index !== -1) sentRequests.value[index].status = 'cancelled';
|
||||||
|
$q.notify({ type: 'positive', message: 'Richiesta annullata' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({ type: 'negative', message: error.data?.message || error.message || 'Errore' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRequests = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [statsRes, receivedRes, sentRes] = await Promise.all([
|
||||||
|
Api.SendReq('/api/trasporti/richieste/stats', 'GET'),
|
||||||
|
Api.SendReq('/api/trasporti/richieste/received', 'GET'),
|
||||||
|
Api.SendReq('/api/trasporti/richieste/sent', 'GET')
|
||||||
|
]);
|
||||||
|
if (statsRes.success) stats.value = statsRes.data;
|
||||||
|
if (receivedRes.success) {
|
||||||
|
receivedRequests.value = receivedRes.data.requests || [];
|
||||||
|
hasMore.value = receivedRes.data.hasMore || false;
|
||||||
|
}
|
||||||
|
if (sentRes.success) sentRequests.value = sentRes.data.requests || [];
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({ type: 'negative', message: error.data?.message || error.message || 'Errore nel caricamento' });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
loadingMore.value = true;
|
||||||
|
currentPage.value++;
|
||||||
|
try {
|
||||||
|
const endpoint = activeTab.value === 'received' ? '/api/trasporti/richieste/received' : '/api/trasporti/richieste/sent';
|
||||||
|
const response = await Api.SendReq(`${endpoint}?page=${currentPage.value}`, 'GET');
|
||||||
|
if (response.success) {
|
||||||
|
const newRequests = response.data.requests || [];
|
||||||
|
if (activeTab.value === 'received') receivedRequests.value.push(...newRequests);
|
||||||
|
else sentRequests.value.push(...newRequests);
|
||||||
|
hasMore.value = response.data.hasMore || false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({ type: 'negative', message: 'Errore' });
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(activeTab, () => { currentPage.value = 1; activeFilter.value = 'all'; });
|
||||||
|
onMounted(() => loadRequests());
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading, loadingMore, responding, activeTab, activeFilter, stats, statusFilters,
|
||||||
|
filteredRequests, hasMore, pendingReceivedCount, pendingSentCount, emptyTitle, emptyMessage,
|
||||||
|
showResponseDialog, selectedRequest, responseAction, responseMessage,
|
||||||
|
goBack, getInitials, getOtherUser, getStatusIcon, getStatusLabel, formatDate, formatTimeAgo,
|
||||||
|
viewProfile, viewRide, openChat, acceptRequest, rejectRequest, submitResponse, cancelRequest, loadMore
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.requests-page {
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
h1 { font-size: 20px; font-weight: 600; margin: 0; }
|
||||||
|
p { margin: 4px 0 0; opacity: 0.85; font-size: 14px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stat-info { display: flex; flex-direction: column; }
|
||||||
|
&__stat-value { font-size: 20px; font-weight: 700; color: #333; }
|
||||||
|
&__stat-label { font-size: 11px; color: #888; }
|
||||||
|
|
||||||
|
&__tabs { background: white; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); }
|
||||||
|
&__tab-content { display: flex; align-items: center; gap: 6px; }
|
||||||
|
|
||||||
|
&__filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content { padding: 16px; }
|
||||||
|
|
||||||
|
&__loading, &__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
h3 { margin: 20px 0 8px; color: #333; font-size: 18px; }
|
||||||
|
p { color: #888; margin: 0 0 20px; max-width: 280px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
&__card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
position: relative;
|
||||||
|
&--pending { border-left: 4px solid #ff9800; }
|
||||||
|
&--accepted { border-left: 4px solid #4caf50; }
|
||||||
|
&--rejected { border-left: 4px solid #f44336; }
|
||||||
|
&--cancelled { border-left: 4px solid #9e9e9e; opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
&--pending { background: #fff3e0; color: #e65100; }
|
||||||
|
&--accepted { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
&--rejected { background: #ffebee; color: #c62828; }
|
||||||
|
&--cancelled { background: #f5f5f5; color: #616161; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-right: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user { display: flex; align-items: center; gap: 12px; cursor: pointer; }
|
||||||
|
|
||||||
|
&__avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__user-info { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
&__user-name { font-weight: 600; color: #333; }
|
||||||
|
&__user-rating { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #666; }
|
||||||
|
&__date { font-size: 11px; color: #999; }
|
||||||
|
|
||||||
|
&__ride {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover { background: #e9ecef; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-type {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__ride-details { flex: 1; }
|
||||||
|
&__ride-route { font-weight: 600; color: #333; margin-bottom: 4px; }
|
||||||
|
&__ride-datetime { display: flex; align-items: center; gap: 4px; font-size: 12px; color: #666; }
|
||||||
|
|
||||||
|
&__details { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; }
|
||||||
|
&__detail { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #555; }
|
||||||
|
|
||||||
|
&__message, &__response {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
p { margin: 0; font-size: 13px; color: #555; line-height: 1.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&__response { background: #e8f5e9; p { color: #2e7d32; } }
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__load-more { display: flex; justify-content: center; padding: 20px; }
|
||||||
|
|
||||||
|
&__dialog {
|
||||||
|
border-radius: 16px;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 400px;
|
||||||
|
h3 { margin: 16px 0 8px; font-size: 18px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
|
||||||
|
.list-enter-from, .list-leave-to { opacity: 0; transform: translateY(20px); }
|
||||||
|
|
||||||
|
.body--dark {
|
||||||
|
.requests-page {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16162a 100%);
|
||||||
|
&__stats, &__card { background: #1e1e30; }
|
||||||
|
&__stat-value, &__user-name, &__ride-route { color: #fff; }
|
||||||
|
&__ride { background: rgba(255, 255, 255, 0.05); &:hover { background: rgba(255, 255, 255, 0.1); } }
|
||||||
|
&__ride-type { background: rgba(255, 255, 255, 0.1); }
|
||||||
|
&__message { background: rgba(255, 255, 255, 0.03); }
|
||||||
|
&__actions { border-color: rgba(255, 255, 255, 0.1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
181
src/modules/trasporti/pages/RideCreatePage.scss
Normal file
181
src/modules/trasporti/pages/RideCreatePage.scss
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
.ride-create-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(180deg, rgba(var(--q-primary-rgb), 0.02) 0%, transparent 50%);
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stepper {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
:deep(.q-stepper__header) {
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.q-stepper__step-inner) {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__step-content {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__step-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__type-toggle {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-inputs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(var(--q-primary-rgb), 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__flexibility {
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--q-grey-8);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__seats {
|
||||||
|
.q-btn-toggle {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
.q-btn {
|
||||||
|
border-radius: 8px !important;
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__seats-toggle {
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand animation
|
||||||
|
.expand-enter-active,
|
||||||
|
.expand-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-enter-from,
|
||||||
|
.expand-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
.body--dark {
|
||||||
|
.ride-create-page {
|
||||||
|
background: linear-gradient(180deg, rgba(var(--q-primary-rgb), 0.05) 0%, transparent 50%);
|
||||||
|
|
||||||
|
&__stepper {
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__flexibility,
|
||||||
|
&__route-info {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nav {
|
||||||
|
border-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__seats .q-btn-toggle {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.ride-create-page {
|
||||||
|
&__container {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__stepper {
|
||||||
|
:deep(.q-stepper__step-inner) {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__step-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__route-info {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
369
src/modules/trasporti/pages/RideCreatePage.ts
Normal file
369
src/modules/trasporti/pages/RideCreatePage.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import { ref, reactive, computed, onMounted, defineComponent } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { useRides } from '../composables/useRides';
|
||||||
|
import { useGeocoding } from '../composables/useGeocoding';
|
||||||
|
import { useDriverProfile } from '../composables/useDriverProfile';
|
||||||
|
import RideTypeToggle from '../components/ride/RideTypeToggle.vue';
|
||||||
|
import CityAutocomplete from '../components/ride/CityAutocomplete.vue';
|
||||||
|
import WaypointsEditor from '../components/ride/WaypointsEditor.vue';
|
||||||
|
import RideMap from '../components/ride/RideMap.vue';
|
||||||
|
import RecurrenceSelector from '../components/ride/RecurrenceSelector.vue';
|
||||||
|
import VehicleSelector from '../components/ride/VehicleSelector.vue';
|
||||||
|
import PreferencesSelector from '../components/ride/PreferencesSelector.vue';
|
||||||
|
import ContribTypeSelector from '../components/ride/ContribTypeSelector.vue';
|
||||||
|
import RideCard from '../components/ride/RideCard.vue';
|
||||||
|
import type {
|
||||||
|
RideFormData,
|
||||||
|
Location,
|
||||||
|
Waypoint,
|
||||||
|
Vehicle,
|
||||||
|
RouteResult,
|
||||||
|
Ride,
|
||||||
|
RideType,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'RideCreatePage',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RideTypeToggle,
|
||||||
|
CityAutocomplete,
|
||||||
|
WaypointsEditor,
|
||||||
|
RideMap,
|
||||||
|
RecurrenceSelector,
|
||||||
|
VehicleSelector,
|
||||||
|
PreferencesSelector,
|
||||||
|
ContribTypeSelector,
|
||||||
|
RideCard,
|
||||||
|
},
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const $q = useQuasar();
|
||||||
|
|
||||||
|
const { createRide, updateRide, fetchRide, formatDuration } = useRides();
|
||||||
|
const { suggestWaypoints } = useGeocoding();
|
||||||
|
const { myVehicles, addVehicle } = useDriverProfile();
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const stepperRef = ref<any>(null);
|
||||||
|
const currentStep = ref(1);
|
||||||
|
const submitting = ref(false);
|
||||||
|
const routeInfo = ref<RouteResult | null>(null);
|
||||||
|
const suggestedWaypoints = ref<any[]>([]);
|
||||||
|
|
||||||
|
// Check if editing
|
||||||
|
const rideId = computed(() => route.params.id as string | undefined);
|
||||||
|
const isEditing = computed(() => !!rideId.value);
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const formData = reactive<RideFormData & { date: string; time: string }>({
|
||||||
|
type: (route.query.type as RideType) || 'offer',
|
||||||
|
departure: undefined as any,
|
||||||
|
destination: undefined as any,
|
||||||
|
waypoints: [],
|
||||||
|
date: '',
|
||||||
|
time: '',
|
||||||
|
dateTime: '',
|
||||||
|
flexibleTime: false,
|
||||||
|
flexibleMinutes: 30,
|
||||||
|
recurrence: { type: 'once' },
|
||||||
|
passengers: { max: 3, available: 3 },
|
||||||
|
seatsNeeded: 1,
|
||||||
|
vehicle: {} as Vehicle,
|
||||||
|
preferences: {},
|
||||||
|
contribution: { contribTypes: [] },
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Options
|
||||||
|
const seatsOptions = [
|
||||||
|
{ label: '1', value: 1 },
|
||||||
|
{ label: '2', value: 2 },
|
||||||
|
{ label: '3', value: 3 },
|
||||||
|
{ label: '4', value: 4 },
|
||||||
|
{ label: '5', value: 5 },
|
||||||
|
{ label: '6+', value: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const seatsNeededOptions = [
|
||||||
|
{ label: '1 posto', value: 1 },
|
||||||
|
{ label: '2 posti', value: 2 },
|
||||||
|
{ label: '3 posti', value: 3 },
|
||||||
|
{ label: '4+ posti', value: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const totalSteps = computed(() => 7);
|
||||||
|
|
||||||
|
const savedVehicles = computed((): Vehicle[] => {
|
||||||
|
const vehicles = myVehicles.value;
|
||||||
|
|
||||||
|
// If it's already an array, return it
|
||||||
|
if (Array.isArray(vehicles)) {
|
||||||
|
return vehicles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a response object with data array, extract it
|
||||||
|
if (vehicles && typeof vehicles === 'object' && 'data' in vehicles) {
|
||||||
|
const data = (vehicles as any).data;
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to empty array
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedDuration = computed(() => {
|
||||||
|
if (!routeInfo.value) return '';
|
||||||
|
return formatDuration(routeInfo.value.duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canProceed = computed(() => {
|
||||||
|
switch (currentStep.value) {
|
||||||
|
case 1:
|
||||||
|
return !!formData.type;
|
||||||
|
case 2:
|
||||||
|
return !!formData.departure?.city && !!formData.destination?.city;
|
||||||
|
case 3:
|
||||||
|
return !!formData.date && !!formData.time;
|
||||||
|
case 4:
|
||||||
|
if (formData.type === 'offer') {
|
||||||
|
return formData.passengers.max > 0;
|
||||||
|
}
|
||||||
|
return formData.seatsNeeded > 0;
|
||||||
|
case 5:
|
||||||
|
return true; // Preferenze opzionali
|
||||||
|
case 6:
|
||||||
|
return true; // Contributo opzionale
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preview ride per il riepilogo
|
||||||
|
const previewRide = computed((): Partial<Ride> => {
|
||||||
|
const dateTime =
|
||||||
|
formData.date && formData.time
|
||||||
|
? new Date(`${formData.date}T${formData.time}`)
|
||||||
|
: new Date();
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: 'preview',
|
||||||
|
type: formData.type,
|
||||||
|
departure: formData.departure || { city: '', coordinates: { lat: 0, lng: 0 } },
|
||||||
|
destination: formData.destination || {
|
||||||
|
city: '',
|
||||||
|
coordinates: { lat: 0, lng: 0 },
|
||||||
|
},
|
||||||
|
waypoints: formData.waypoints || [],
|
||||||
|
dateTime: dateTime.toISOString(),
|
||||||
|
passengers: formData.passengers,
|
||||||
|
seatsNeeded: formData.seatsNeeded,
|
||||||
|
vehicle: formData.vehicle,
|
||||||
|
preferences: formData.preferences,
|
||||||
|
contribution: formData.contribution,
|
||||||
|
status: 'active',
|
||||||
|
estimatedDistance: routeInfo.value?.distance,
|
||||||
|
estimatedDuration: routeInfo.value?.duration,
|
||||||
|
notes: formData.notes,
|
||||||
|
userId: {
|
||||||
|
_id: 'current',
|
||||||
|
username: 'Tu',
|
||||||
|
name: 'Tu',
|
||||||
|
} as any,
|
||||||
|
} as Partial<Ride>;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const goBack = () => {
|
||||||
|
if (currentStep.value > 1) {
|
||||||
|
stepperRef.value?.previous();
|
||||||
|
} else {
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDepartureSelect = async (location: Location) => {
|
||||||
|
formData.departure = location;
|
||||||
|
await updateSuggestedWaypoints();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDestinationSelect = async (location: Location) => {
|
||||||
|
formData.destination = location;
|
||||||
|
await updateSuggestedWaypoints();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSuggestedWaypoints = async () => {
|
||||||
|
if (formData.departure?.coordinates && formData.destination?.coordinates) {
|
||||||
|
try {
|
||||||
|
const suggestions = await suggestWaypoints(
|
||||||
|
formData.departure.coordinates,
|
||||||
|
formData.destination.coordinates
|
||||||
|
);
|
||||||
|
suggestedWaypoints.value = suggestions || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore suggerimento waypoints:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRouteCalculated = (route: RouteResult) => {
|
||||||
|
routeInfo.value = route;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSaveVehicle = async (vehicle: Vehicle) => {
|
||||||
|
try {
|
||||||
|
await addVehicle(vehicle);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Veicolo salvato nel profilo',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Errore salvataggio veicolo:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRide = async () => {
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Combina data e ora
|
||||||
|
const dateTime = new Date(`${formData.date}T${formData.time}`);
|
||||||
|
|
||||||
|
const rideData: Partial<RideFormData> = {
|
||||||
|
type: formData.type,
|
||||||
|
departure: formData.departure,
|
||||||
|
destination: formData.destination,
|
||||||
|
waypoints: formData.waypoints,
|
||||||
|
dateTime: dateTime.toISOString(),
|
||||||
|
flexibleTime: formData.flexibleTime,
|
||||||
|
flexibleMinutes: formData.flexibleMinutes,
|
||||||
|
recurrence: formData.recurrence,
|
||||||
|
preferences: formData.preferences,
|
||||||
|
contribution: formData.contribution,
|
||||||
|
notes: formData.notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formData.type === 'offer') {
|
||||||
|
rideData.passengers = formData.passengers;
|
||||||
|
rideData.vehicle = formData.vehicle;
|
||||||
|
} else {
|
||||||
|
rideData.seatsNeeded = formData.seatsNeeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
if (isEditing.value) {
|
||||||
|
response = await updateRide(rideId.value!, rideData);
|
||||||
|
} else {
|
||||||
|
response = await createRide(rideData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: isEditing.value ? 'Viaggio aggiornato!' : 'Viaggio pubblicato!',
|
||||||
|
caption:
|
||||||
|
formData.type === 'offer'
|
||||||
|
? 'I passeggeri potranno ora prenotare'
|
||||||
|
: 'I conducenti potranno contattarti',
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
name: 'ride-detail',
|
||||||
|
params: { id: response.data?._id || rideId.value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore durante la pubblicazione',
|
||||||
|
caption: error.data?.message || error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load existing ride if editing
|
||||||
|
onMounted(async () => {
|
||||||
|
if (isEditing.value) {
|
||||||
|
try {
|
||||||
|
const response = await fetchRide(rideId.value!);
|
||||||
|
if (response?.data?.data) {
|
||||||
|
const ride = response.data.data;
|
||||||
|
|
||||||
|
formData.type = ride.type;
|
||||||
|
formData.departure = ride.departure;
|
||||||
|
formData.destination = ride.destination;
|
||||||
|
formData.waypoints = ride.waypoints || [];
|
||||||
|
|
||||||
|
const dt = new Date(ride.dateTime);
|
||||||
|
formData.date = dt.toISOString().split('T')[0];
|
||||||
|
formData.time = dt.toTimeString().slice(0, 5);
|
||||||
|
|
||||||
|
formData.flexibleTime = ride.flexibleTime || false;
|
||||||
|
formData.flexibleMinutes = ride.flexibleMinutes || 30;
|
||||||
|
formData.recurrence = ride.recurrence || { type: 'once' };
|
||||||
|
formData.passengers = ride.passengers || { max: 3, available: 3 };
|
||||||
|
formData.seatsNeeded = ride.seatsNeeded || 1;
|
||||||
|
formData.vehicle = ride.vehicle || {};
|
||||||
|
formData.preferences = ride.preferences || {};
|
||||||
|
formData.contribution = ride.contribution || { contribTypes: [] };
|
||||||
|
formData.notes = ride.notes || '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Errore caricamento viaggio',
|
||||||
|
});
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create safe navigation methods
|
||||||
|
const goNext = () => {
|
||||||
|
stepperRef.value?.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goPrevious = () => {
|
||||||
|
stepperRef.value?.previous();
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
// Refs
|
||||||
|
stepperRef,
|
||||||
|
currentStep,
|
||||||
|
submitting,
|
||||||
|
routeInfo,
|
||||||
|
suggestedWaypoints,
|
||||||
|
|
||||||
|
// State
|
||||||
|
formData,
|
||||||
|
isEditing,
|
||||||
|
|
||||||
|
// Options
|
||||||
|
seatsOptions,
|
||||||
|
seatsNeededOptions,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
totalSteps,
|
||||||
|
savedVehicles,
|
||||||
|
formattedDuration,
|
||||||
|
canProceed,
|
||||||
|
previewRide,
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
goBack,
|
||||||
|
onDepartureSelect,
|
||||||
|
onDestinationSelect,
|
||||||
|
onRouteCalculated,
|
||||||
|
onSaveVehicle,
|
||||||
|
submitRide,
|
||||||
|
goPrevious,
|
||||||
|
goNext,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
349
src/modules/trasporti/pages/RideCreatePage.vue
Normal file
349
src/modules/trasporti/pages/RideCreatePage.vue
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<template>
|
||||||
|
<q-page class="ride-create-page">
|
||||||
|
<div class="ride-create-page__container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="ride-create-page__header">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
icon="arrow_back"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<h1 class="ride-create-page__title">
|
||||||
|
{{ isEditing ? 'Modifica Viaggio' : 'Nuovo Viaggio' }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stepper -->
|
||||||
|
<q-stepper
|
||||||
|
v-model="currentStep"
|
||||||
|
ref="stepperRef"
|
||||||
|
color="primary"
|
||||||
|
animated
|
||||||
|
flat
|
||||||
|
class="ride-create-page__stepper"
|
||||||
|
>
|
||||||
|
<!-- Step 1: Tipo -->
|
||||||
|
<q-step
|
||||||
|
:name="1"
|
||||||
|
title="Tipo"
|
||||||
|
icon="swap_horiz"
|
||||||
|
:done="currentStep > 1"
|
||||||
|
>
|
||||||
|
<div class="ride-create-page__step-content">
|
||||||
|
<h3 class="ride-create-page__step-title">
|
||||||
|
Cosa vuoi fare?
|
||||||
|
</h3>
|
||||||
|
<RideTypeToggle
|
||||||
|
v-model="formData.type"
|
||||||
|
:card-mode="true"
|
||||||
|
class="ride-create-page__type-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<!-- Step 2: Percorso -->
|
||||||
|
<q-step
|
||||||
|
:name="2"
|
||||||
|
title="Percorso"
|
||||||
|
icon="route"
|
||||||
|
:done="currentStep > 2"
|
||||||
|
>
|
||||||
|
<div class="ride-create-page__step-content">
|
||||||
|
<h3 class="ride-create-page__step-title">
|
||||||
|
Definisci il tuo percorso
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="ride-create-page__route-inputs">
|
||||||
|
<!-- Partenza -->
|
||||||
|
<CityAutocomplete
|
||||||
|
v-model="formData.departure"
|
||||||
|
label="Città di partenza"
|
||||||
|
placeholder="Da dove parti?"
|
||||||
|
prepend-icon="trip_origin"
|
||||||
|
icon-color="positive"
|
||||||
|
:rules="[(val: any) => !!val || 'Partenza richiesta']"
|
||||||
|
@select="onDepartureSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Destinazione -->
|
||||||
|
<CityAutocomplete
|
||||||
|
v-model="formData.destination"
|
||||||
|
label="Città di destinazione"
|
||||||
|
placeholder="Dove vai?"
|
||||||
|
prepend-icon="place"
|
||||||
|
icon-color="negative"
|
||||||
|
:rules="[(val: any) => !!val || 'Destinazione richiesta']"
|
||||||
|
@select="onDestinationSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Waypoints -->
|
||||||
|
<WaypointsEditor
|
||||||
|
v-model="formData.waypoints"
|
||||||
|
:departure-city="formData.departure?.city"
|
||||||
|
:destination-city="formData.destination?.city"
|
||||||
|
:suggested-waypoints="suggestedWaypoints"
|
||||||
|
:show-suggestions="!!formData.departure && !!formData.destination"
|
||||||
|
class="q-mt-lg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Mappa Preview -->
|
||||||
|
<RideMap
|
||||||
|
v-if="formData.departure && formData.destination"
|
||||||
|
:departure="formData.departure"
|
||||||
|
:destination="formData.destination"
|
||||||
|
:waypoints="formData.waypoints"
|
||||||
|
:show-route="true"
|
||||||
|
map-height="300px"
|
||||||
|
class="q-mt-lg"
|
||||||
|
@route-calculated="onRouteCalculated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Info percorso -->
|
||||||
|
<div v-if="routeInfo" class="ride-create-page__route-info">
|
||||||
|
<div class="ride-create-page__route-info-item">
|
||||||
|
<q-icon name="straighten" size="20px" />
|
||||||
|
<span>{{ routeInfo.distance }} km</span>
|
||||||
|
</div>
|
||||||
|
<div class="ride-create-page__route-info-item">
|
||||||
|
<q-icon name="schedule" size="20px" />
|
||||||
|
<span>{{ formattedDuration }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<!-- Step 3: Data e Ora -->
|
||||||
|
<q-step
|
||||||
|
:name="3"
|
||||||
|
title="Quando"
|
||||||
|
icon="event"
|
||||||
|
:done="currentStep > 3"
|
||||||
|
>
|
||||||
|
<div class="ride-create-page__step-content">
|
||||||
|
<h3 class="ride-create-page__step-title">
|
||||||
|
Quando vuoi partire?
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="row q-gutter-md">
|
||||||
|
<q-input
|
||||||
|
v-model="formData.date"
|
||||||
|
type="date"
|
||||||
|
label="Data partenza"
|
||||||
|
outlined
|
||||||
|
:rules="[val => !!val || 'Data richiesta']"
|
||||||
|
class="col-12 col-sm-6"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="event" color="primary" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="formData.time"
|
||||||
|
type="time"
|
||||||
|
label="Ora partenza"
|
||||||
|
outlined
|
||||||
|
:rules="[val => !!val || 'Ora richiesta']"
|
||||||
|
class="col-12 col-sm-6"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="schedule" color="primary" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flessibilità orario -->
|
||||||
|
<div class="ride-create-page__flexibility q-mt-md">
|
||||||
|
<q-toggle
|
||||||
|
v-model="formData.flexibleTime"
|
||||||
|
label="Orario flessibile"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<transition name="expand">
|
||||||
|
<q-slider
|
||||||
|
v-if="formData.flexibleTime"
|
||||||
|
v-model="formData.flexibleMinutes"
|
||||||
|
:min="15"
|
||||||
|
:max="120"
|
||||||
|
:step="15"
|
||||||
|
label
|
||||||
|
:label-value="`± ${formData.flexibleMinutes} min`"
|
||||||
|
color="primary"
|
||||||
|
class="q-mt-sm"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ricorrenza -->
|
||||||
|
<RecurrenceSelector
|
||||||
|
v-model="formData.recurrence"
|
||||||
|
class="q-mt-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<!-- Step 4: Dettagli (solo per offerte) -->
|
||||||
|
<q-step
|
||||||
|
v-if="formData.type === 'offer'"
|
||||||
|
:name="4"
|
||||||
|
title="Veicolo"
|
||||||
|
icon="directions_car"
|
||||||
|
:done="currentStep > 4"
|
||||||
|
>
|
||||||
|
<div class="ride-create-page__step-content">
|
||||||
|
<h3 class="ride-create-page__step-title">
|
||||||
|
Il tuo veicolo
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<VehicleSelector
|
||||||
|
v-model="formData.vehicle"
|
||||||
|
:saved-vehicles="savedVehicles"
|
||||||
|
@save-vehicle="onSaveVehicle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Posti disponibili -->
|
||||||
|
<div class="ride-create-page__seats q-mt-lg">
|
||||||
|
<div class="ride-create-page__label">
|
||||||
|
Posti disponibili per i passeggeri
|
||||||
|
</div>
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="formData.passengers.max"
|
||||||
|
:options="seatsOptions"
|
||||||
|
spread
|
||||||
|
no-caps
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
toggle-color="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<!-- Step 4b: Posti necessari (solo per richieste) -->
|
||||||
|
<q-step
|
||||||
|
v-if="formData.type === 'request'"
|
||||||
|
:name="4"
|
||||||
|
title="Posti"
|
||||||
|
icon="airline_seat_recline_normal"
|
||||||
|
:done="currentStep > 4"
|
||||||
|
>
|
||||||
|
<div class="ride-create-page__step-content">
|
||||||
|
<h3 class="ride-create-page__step-title">
|
||||||
|
Di quanti posti hai bisogno?
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="formData.seatsNeeded"
|
||||||
|
:options="seatsNeededOptions"
|
||||||
|
spread
|
||||||
|
no-caps
|
||||||
|
rounded
|
||||||
|
unelevated
|
||||||
|
toggle-color="primary"
|
||||||
|
class="ride-create-page__seats-toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<!-- Step 5: Preferenze -->
|
||||||
|
<q-step
|
||||||
|
:name="5"
|
||||||
|
title="Preferenze"
|
||||||
|
icon="tune"
|
||||||
|
:done="currentStep > 5"
|
||||||
|
>
|
||||||
|
<div class="ride-create-page__step-content">
|
||||||
|
<PreferencesSelector
|
||||||
|
v-model="formData.preferences"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<!-- Step 6: Contributo -->
|
||||||
|
<q-step
|
||||||
|
:name="6"
|
||||||
|
title="Contributo"
|
||||||
|
icon="payments"
|
||||||
|
:done="currentStep > 6"
|
||||||
|
>
|
||||||
|
<div class="ride-create-page__step-content">
|
||||||
|
<h3 class="ride-create-page__step-title">
|
||||||
|
{{ formData.type === 'offer' ? 'Cosa chiedi in cambio?' : 'Cosa offri in cambio?' }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ContribTypeSelector
|
||||||
|
v-model="formData.contribution"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<!-- Step 7: Riepilogo -->
|
||||||
|
<q-step
|
||||||
|
:name="7"
|
||||||
|
title="Conferma"
|
||||||
|
icon="check_circle"
|
||||||
|
>
|
||||||
|
<div class="ride-create-page__step-content">
|
||||||
|
<h3 class="ride-create-page__step-title">
|
||||||
|
Riepilogo del viaggio
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<RideCard
|
||||||
|
:ride="previewRide"
|
||||||
|
:compact="false"
|
||||||
|
:show-book-button="false"
|
||||||
|
:clickable="false"
|
||||||
|
class="ride-create-page__preview"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Note aggiuntive -->
|
||||||
|
<q-input
|
||||||
|
v-model="formData.notes"
|
||||||
|
type="textarea"
|
||||||
|
label="Note aggiuntive (opzionale)"
|
||||||
|
placeholder="Aggiungi informazioni utili per i passeggeri..."
|
||||||
|
outlined
|
||||||
|
autogrow
|
||||||
|
:maxlength="1000"
|
||||||
|
counter
|
||||||
|
class="q-mt-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-step>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<template v-slot:navigation>
|
||||||
|
<q-stepper-navigation class="ride-create-page__nav">
|
||||||
|
<q-btn
|
||||||
|
v-if="currentStep > 1"
|
||||||
|
flat
|
||||||
|
color="primary"
|
||||||
|
label="Indietro"
|
||||||
|
@click="goPrevious"
|
||||||
|
/>
|
||||||
|
<q-space />
|
||||||
|
<q-btn
|
||||||
|
v-if="currentStep < totalSteps"
|
||||||
|
color="primary"
|
||||||
|
label="Avanti"
|
||||||
|
:disable="!canProceed"
|
||||||
|
@click="goNext"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
v-else
|
||||||
|
color="primary"
|
||||||
|
:label="isEditing ? 'Salva Modifiche' : 'Pubblica Viaggio'"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="submitRide"
|
||||||
|
/>
|
||||||
|
</q-stepper-navigation>
|
||||||
|
</template>
|
||||||
|
</q-stepper>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" src="./RideCreatePage.ts" />
|
||||||
|
<style lang="scss" src="./RideCreatePage.scss" />
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user