- Implementazione TRASPORTI ! Passo 1

This commit is contained in:
Surya Paolo
2025-12-22 01:19:23 +01:00
parent 83a0cf653c
commit c9fc1a83d0
123 changed files with 27433 additions and 28 deletions

View File

@@ -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'),

View File

@@ -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 {

View File

@@ -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="

View File

@@ -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);

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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'
});
}

View File

@@ -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;

View File

@@ -298,6 +298,7 @@ export interface IConfPages {
enableEcommerce: boolean
enableAI: boolean
enablePoster: boolean
enableTrasporti: boolean
enableGroups: boolean
enableCircuits: boolean
enableProj?: boolean

View 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);
}
}
}

View 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
};
}
});

View 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" />

View 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);
}
}
}

View 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
};
}
});

View 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" />

View 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;
}
}
}

View 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
};
}
});

View 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" />

View 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%;
}
}
}

View 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
};
}
});

View 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" />

View 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';

View 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);
}
}
}

View 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
};
}
});

View 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" />

View 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);
}
}
}

View 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
};
}
});

View 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" />

View 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%;
}
}
}

View 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
};
}
});

View 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" />

View 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>

View 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';

View 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>

View 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);
}
}
}

View 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
};
}
});

View 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" />

View 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>

View 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);
}
}
}

View 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
};
}
});

View 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" />

View 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);
}
}
}
}

View 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
};
}
});

View 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" />

View 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>

View 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%);
}
}
}

View 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
};
}
});

View 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" />

View 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);
}
}
}

View 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
};
}
});

View 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" />

View 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;
}
}
}

View 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:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
}).addTo(map);
// Layer per markers
markersLayer = L.layerGroup().addTo(map);
// Update markers iniziale
updateMarkers();
};
// Update markers on map
const updateMarkers = () => {
if (!map || !markersLayer) return;
markersLayer.clearLayers();
// Marker partenza
if (props.departure?.coordinates) {
const marker = L.marker(
[props.departure.coordinates.lat, props.departure.coordinates.lng],
{ icon: startIcon }
);
marker.bindPopup(`<strong>Partenza</strong><br>${props.departure.city}`);
marker.on('click', () => emit('marker-click', 'departure', props.departure));
markersLayer.addLayer(marker);
}
// Marker waypoints
props.waypoints.forEach((wp, index) => {
if (wp.location?.coordinates) {
const marker = L.marker(
[wp.location.coordinates.lat, wp.location.coordinates.lng],
{ icon: waypointIcon }
);
marker.bindPopup(`<strong>Tappa ${index + 1}</strong><br>${wp.location.city}`);
marker.on('click', () => emit('marker-click', 'waypoint', wp, index));
markersLayer.addLayer(marker);
}
});
// Marker destinazione
if (props.destination?.coordinates) {
const marker = L.marker(
[props.destination.coordinates.lat, props.destination.coordinates.lng],
{ icon: endIcon }
);
marker.bindPopup(`<strong>Arrivo</strong><br>${props.destination.city}`);
marker.on('click', () => emit('marker-click', 'destination', props.destination));
markersLayer.addLayer(marker);
}
// Calcola e mostra percorso
if (props.showRoute) {
calculateAndShowRoute();
} else if (props.autoFit) {
fitBounds();
}
};
// Calculate and show route
// Calculate and show route
const calculateAndShowRoute = async () => {
if (!map || !props.departure?.coordinates || !props.destination?.coordinates) {
return;
}
loading.value = true;
try {
const waypointCoords: Coordinates[] = props.waypoints
.filter((wp) => wp.location?.coordinates)
.sort((a, b) => a.order - b.order)
.map((wp) => wp.location!.coordinates);
const result = await calculateRoute(
props.departure.coordinates,
props.destination.coordinates,
waypointCoords
);
console.log('Route result:', result); // Debug
if (result) {
// ✅ Convert to proper format for routeInfo
routeInfo.value = {
distance: Math.round((result.distance / 1000) * 10) / 10, // meters to km
duration: Math.round(result.duration / 60), // seconds to minutes
polyline: null,
};
emit('route-calculated', routeInfo.value);
// Remove previous route
if (routeLayer) {
map.removeLayer(routeLayer);
}
// ✅ Handle GeoJSON geometry (what OSRM returns with geometries=geojson)
if (result.geometry) {
let coordinates: [number, number][];
if (result.geometry.type === 'LineString') {
// GeoJSON format: [lng, lat] -> need to swap to [lat, lng] for Leaflet
coordinates = result.geometry.coordinates.map(
(coord: [number, number]) => [coord[1], coord[0]] as [number, number]
);
} else if (Array.isArray(result.geometry.coordinates)) {
// Already an array of coordinates
coordinates = result.geometry.coordinates.map(
(coord: [number, number]) => [coord[1], coord[0]] as [number, number]
);
} else {
console.warn('Unknown geometry format:', result.geometry);
coordinates = [];
}
if (coordinates.length > 0) {
routeLayer = L.polyline(coordinates, {
color: '#1976D2',
weight: 5,
opacity: 0.8,
smoothFactor: 1,
}).addTo(map);
}
}
// ✅ Fallback: Handle encoded polyline (if format changes)
else if (result.polyline) {
const decodedPath = decodePolyline(result.polyline);
routeLayer = L.polyline(decodedPath, {
color: '#1976D2',
weight: 5,
opacity: 0.8,
smoothFactor: 1,
}).addTo(map);
}
// ✅ Last fallback: straight line
else {
console.warn('No route geometry, drawing straight line');
const points: [number, number][] = [
[props.departure.coordinates.lat, props.departure.coordinates.lng],
...waypointCoords.map((c) => [c.lat, c.lng] as [number, number]),
[props.destination.coordinates.lat, props.destination.coordinates.lng],
];
routeLayer = L.polyline(points, {
color: '#1976D2',
weight: 5,
opacity: 0.6,
dashArray: '10, 10',
}).addTo(map);
}
if (props.autoFit) {
fitBounds();
}
}
} catch (error) {
console.error('Errore calcolo percorso:', error);
} finally {
loading.value = false;
}
};
// Decode Google Polyline format
const decodePolyline = (encoded: string): [number, number][] => {
const points: [number, number][] = [];
let index = 0;
let lat = 0;
let lng = 0;
while (index < encoded.length) {
let shift = 0;
let result = 0;
let byte: number;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
const deltaLat = result & 1 ? ~(result >> 1) : result >> 1;
lat += deltaLat;
shift = 0;
result = 0;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
const deltaLng = result & 1 ? ~(result >> 1) : result >> 1;
lng += deltaLng;
points.push([lat / 1e5, lng / 1e5]);
}
return points;
};
// Fit map to show all markers
const fitBounds = () => {
if (!map) return;
const bounds: L.LatLngBoundsExpression = [];
if (props.departure?.coordinates) {
bounds.push([props.departure.coordinates.lat, props.departure.coordinates.lng]);
}
props.waypoints.forEach((wp) => {
if (wp.location?.coordinates) {
bounds.push([wp.location.coordinates.lat, wp.location.coordinates.lng]);
}
});
if (props.destination?.coordinates) {
bounds.push([
props.destination.coordinates.lat,
props.destination.coordinates.lng,
]);
}
if (bounds.length > 0) {
map.fitBounds(bounds as L.LatLngBoundsExpression, {
padding: [50, 50],
maxZoom: 14,
});
}
};
// Center on user location
const centerOnUser = () => {
if (!map) return;
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
map!.setView([position.coords.latitude, position.coords.longitude], 13);
},
(error) => {
console.error('Errore geolocalizzazione:', error);
}
);
}
};
// Toggle fullscreen
const toggleFullscreen = () => {
if (!mapContainer.value) return;
if (!document.fullscreenElement) {
mapContainer.value.parentElement?.requestFullscreen();
isFullscreen.value = true;
} else {
document.exitFullscreen();
isFullscreen.value = false;
}
// Resize map after fullscreen change
setTimeout(() => {
map?.invalidateSize();
}, 100);
};
// Watch for changes
watch(
[() => props.departure, () => props.destination, () => props.waypoints],
() => {
updateMarkers();
},
{ deep: true }
);
// Lifecycle
onMounted(() => {
initMap();
});
onBeforeUnmount(() => {
if (map) {
map.remove();
map = null;
}
});
return {
mapContainer,
loading,
isFullscreen,
routeInfo,
formattedDuration,
fitBounds,
centerOnUser,
toggleFullscreen,
};
},
});

View File

@@ -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" />

View 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;
}
}
}

View 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
};
}
});

View 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" />

View 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);
}
}
}

View 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
};
}
});

View 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" />

View 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);
}
}
}

View 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,
};
},
});

View 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" />

View 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);
}
}
}

View 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
};
}
});

View 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" />

View 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';

View 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;
}
}
}
}

View 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
};
}
});

View 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" />

View 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';

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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
};
}

View 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,
};
}

View 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;
}
}
}

View 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
};
}
});

View 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" />

View 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;
}
}
}

View 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
};
}
});

View 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" />

View 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;
}
}
}

View 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
};
}
});

View 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" />

View 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>

View 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;
}
}
}

View 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
};
}
});

View 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" />

View 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>

View 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>

View 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;
}
}
}

View 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,
};
},
});

View 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