- Parte 3 : Viaggi

- Chat
This commit is contained in:
Surya Paolo
2025-12-24 00:26:29 +01:00
parent 11e946bfc6
commit 11c17bdd8e
126 changed files with 3580 additions and 2259 deletions

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/viaggi.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.SendReqWithData('/api/viaggi/widget/data', 'GET', {});
if (response.success) {
const data: WidgetData = response.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.SendReqWithData('/api/viaggi/stats/summary', 'GET');
if (response.success) {
stats.value = response.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: '/viaggi/crea',
query: { type }
});
};
const goToList = () => {
router.push('/viaggi');
};
const goToRide = (rideId: string) => {
router.push(`/viaggi/ride/${rideId}`);
};
const goToMyRides = () => {
router.push('/viaggi/rides/my');
};
const goToSearch = () => {
router.push('/viaggi/cerca');
};
const goToMap = () => {
router.push('/viaggi/mappa');
};
const goToHistory = () => {
router.push('/viaggi/storico');
};
const goToChat = () => {
router.push('/viaggi/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">Viaggi</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" />