- Implementazione TRASPORTI ! Passo 1
This commit is contained in:
@@ -60,6 +60,7 @@ export default defineConfig((ctx) => {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@components': path.resolve(__dirname, 'src/components'),
|
||||
'@views': path.resolve(__dirname, 'src/views'),
|
||||
'@modules': path.resolve(__dirname, 'src/modules'),
|
||||
'@boot': path.resolve(__dirname, 'src/boot'),
|
||||
'@store': path.resolve(__dirname, 'src/store'),
|
||||
'@storemod': path.resolve(__dirname, 'src/store/Modules'),
|
||||
|
||||
@@ -390,7 +390,7 @@ const generateImage = async () => {
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'Errore durante la generazione',
|
||||
message: error.data?.message || error.message || 'Errore durante la generazione',
|
||||
icon: 'error'
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
"
|
||||
>
|
||||
<q-item-section>{{
|
||||
circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.Iscriviti')
|
||||
circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.iscriviti')
|
||||
}}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
@@ -436,7 +436,7 @@
|
||||
"
|
||||
icon="fas fa-user-plus"
|
||||
color="primary"
|
||||
:label="circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.Iscriviti')"
|
||||
:label="circuit.askManagerToEnter ? t('circuit.ask') : t('circuit.iscriviti')"
|
||||
rounded
|
||||
size="lg"
|
||||
@click="
|
||||
|
||||
@@ -241,7 +241,7 @@ export default defineComponent({
|
||||
console.error('Errore download QR:', error);
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'Errore durante il download',
|
||||
message: error.data?.message || error.message || 'Errore durante il download',
|
||||
position: 'top',
|
||||
});
|
||||
emit('error', error);
|
||||
|
||||
@@ -262,9 +262,6 @@
|
||||
ref="inputUsername"
|
||||
tabindex="1"
|
||||
v-model="signup.username"
|
||||
:readonly="
|
||||
tools.getAskToVerifyReg() && !site.confpages?.enableRegMultiChoice
|
||||
"
|
||||
filled
|
||||
class="modern-input"
|
||||
@blur="v$.username.$touch"
|
||||
|
||||
@@ -251,7 +251,7 @@ export function usePosterGenerator() {
|
||||
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'Errore durante la generazione',
|
||||
message: error.data?.message || error.message || 'Errore durante la generazione',
|
||||
icon: 'error'
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -390,7 +390,7 @@ const generateImage = async () => {
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'Errore durante la generazione',
|
||||
message: error.data?.message || error.message || 'Errore durante la generazione',
|
||||
icon: 'error'
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -168,7 +168,7 @@ const uploadAsset = async (type: 'backgroundImage' | 'mainImage', file: File) =>
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
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) {
|
||||
$q.notify({
|
||||
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) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'Errore durante il salvataggio',
|
||||
message: error.data?.message || error.message || 'Errore durante il salvataggio',
|
||||
icon: 'error'
|
||||
});
|
||||
} finally {
|
||||
@@ -448,7 +448,7 @@ export function useTemplateBuilder() {
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'Errore durante la pubblicazione'
|
||||
message: error.data?.message || error.message || 'Errore durante la pubblicazione'
|
||||
});
|
||||
} finally {
|
||||
isPublishing.value = false;
|
||||
|
||||
@@ -298,6 +298,7 @@ export interface IConfPages {
|
||||
enableEcommerce: boolean
|
||||
enableAI: boolean
|
||||
enablePoster: boolean
|
||||
enableTrasporti: boolean
|
||||
enableGroups: boolean
|
||||
enableCircuits: 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