2025-08-12 04:25:35 +02:00
|
|
|
<template>
|
2025-08-13 15:57:34 +02:00
|
|
|
<v-card
|
2025-08-12 04:25:35 +02:00
|
|
|
v-if="event"
|
2025-08-13 15:57:34 +02:00
|
|
|
elevation="3"
|
|
|
|
|
class="upcoming-event-banner ma-2"
|
|
|
|
|
:color="eventTypeColor"
|
|
|
|
|
theme="dark"
|
|
|
|
|
rounded="xl"
|
2025-08-12 04:25:35 +02:00
|
|
|
>
|
2025-08-13 15:57:34 +02:00
|
|
|
<v-card-text class="pa-4">
|
|
|
|
|
<!-- Mobile Layout -->
|
|
|
|
|
<div v-if="$vuetify.display.mobile" class="mobile-banner-layout">
|
|
|
|
|
<!-- Header -->
|
|
|
|
|
<div class="d-flex align-center mb-3">
|
|
|
|
|
<v-avatar :color="eventTypeColor" class="me-3" size="40">
|
|
|
|
|
<v-icon :icon="eventTypeIcon" size="20"></v-icon>
|
|
|
|
|
</v-avatar>
|
|
|
|
|
<div class="flex-grow-1">
|
|
|
|
|
<h3 class="text-h6 font-weight-bold text-truncate">{{ event.title }}</h3>
|
|
|
|
|
<div class="text-caption opacity-90">{{ eventTypeLabel }}</div>
|
2025-08-12 04:25:35 +02:00
|
|
|
</div>
|
2025-08-13 15:57:34 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Event Details -->
|
|
|
|
|
<div class="mb-3">
|
|
|
|
|
<div class="d-flex align-center mb-1">
|
|
|
|
|
<v-icon size="16" class="me-2">mdi-calendar-clock</v-icon>
|
|
|
|
|
<span class="text-body-2">{{ formatEventDate }}</span>
|
2025-08-12 04:25:35 +02:00
|
|
|
</div>
|
2025-08-13 15:57:34 +02:00
|
|
|
|
|
|
|
|
<div v-if="event.location" class="d-flex align-center mb-1">
|
|
|
|
|
<v-icon size="16" class="me-2">mdi-map-marker</v-icon>
|
|
|
|
|
<span class="text-body-2 text-truncate">{{ event.location }}</span>
|
2025-08-12 04:25:35 +02:00
|
|
|
</div>
|
2025-08-13 15:57:34 +02:00
|
|
|
|
|
|
|
|
<div class="d-flex align-center justify-space-between">
|
|
|
|
|
<div v-if="event.is_paid === 'true'" class="d-flex align-center">
|
|
|
|
|
<v-icon size="16" class="me-2">mdi-currency-eur</v-icon>
|
2025-08-14 10:46:12 +02:00
|
|
|
<span class="text-body-2">{{ priceDisplay }}</span>
|
2025-08-13 15:57:34 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="event.max_attendees" class="d-flex align-center">
|
|
|
|
|
<v-icon size="16" class="me-2">mdi-account-group</v-icon>
|
|
|
|
|
<span class="text-body-2">{{ event.current_attendees || 0 }}/{{ event.max_attendees }} attending</span>
|
|
|
|
|
</div>
|
2025-08-12 04:25:35 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-08-13 15:57:34 +02:00
|
|
|
|
2025-08-12 04:25:35 +02:00
|
|
|
<!-- Action Buttons -->
|
2025-08-13 15:57:34 +02:00
|
|
|
<div class="d-flex ga-2">
|
|
|
|
|
<v-btn
|
|
|
|
|
@click="handleQuickRSVP"
|
|
|
|
|
:color="userRSVP ? 'success' : 'white'"
|
|
|
|
|
:variant="userRSVP ? 'elevated' : 'outlined'"
|
|
|
|
|
size="small"
|
|
|
|
|
class="text-none flex-grow-1"
|
|
|
|
|
rounded="lg"
|
|
|
|
|
>
|
|
|
|
|
<v-icon start size="18">
|
|
|
|
|
{{ userRSVP ? 'mdi-check' : 'mdi-plus' }}
|
|
|
|
|
</v-icon>
|
|
|
|
|
{{ userRSVP ? 'Attending' : 'Quick RSVP' }}
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
|
|
|
|
<v-btn
|
|
|
|
|
@click="handleViewDetails"
|
|
|
|
|
color="white"
|
|
|
|
|
variant="outlined"
|
|
|
|
|
size="small"
|
|
|
|
|
class="text-none"
|
|
|
|
|
rounded="lg"
|
|
|
|
|
icon
|
|
|
|
|
>
|
|
|
|
|
<v-icon size="18">mdi-eye</v-icon>
|
|
|
|
|
</v-btn>
|
|
|
|
|
</div>
|
2025-08-12 04:25:35 +02:00
|
|
|
</div>
|
2025-08-13 15:57:34 +02:00
|
|
|
|
|
|
|
|
<!-- Desktop Layout -->
|
|
|
|
|
<v-row v-else align="center" no-gutters>
|
|
|
|
|
<v-col cols="12" md="8">
|
|
|
|
|
<div class="d-flex align-center mb-2">
|
|
|
|
|
<v-avatar :color="eventTypeColor" class="me-3" size="32">
|
|
|
|
|
<v-icon :icon="eventTypeIcon" size="16"></v-icon>
|
|
|
|
|
</v-avatar>
|
|
|
|
|
<div>
|
|
|
|
|
<h3 class="text-h6 font-weight-bold">{{ event.title }}</h3>
|
|
|
|
|
<div class="text-caption opacity-90">{{ eventTypeLabel }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="d-flex align-center flex-wrap ga-4">
|
|
|
|
|
<div class="d-flex align-center">
|
|
|
|
|
<v-icon size="small" class="me-1">mdi-calendar-clock</v-icon>
|
|
|
|
|
<span class="text-body-2">{{ formatEventDate }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="event.location" class="d-flex align-center">
|
|
|
|
|
<v-icon size="small" class="me-1">mdi-map-marker</v-icon>
|
|
|
|
|
<span class="text-body-2">{{ event.location }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="event.is_paid === 'true'" class="d-flex align-center">
|
|
|
|
|
<v-icon size="small" class="me-1">mdi-currency-eur</v-icon>
|
2025-08-14 10:46:12 +02:00
|
|
|
<span class="text-body-2">{{ priceDisplay }}</span>
|
2025-08-13 15:57:34 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="event.max_attendees" class="d-flex align-center">
|
|
|
|
|
<v-icon size="small" class="me-1">mdi-account-group</v-icon>
|
|
|
|
|
<span class="text-body-2">{{ event.current_attendees || 0 }}/{{ event.max_attendees }} attending</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</v-col>
|
|
|
|
|
|
|
|
|
|
<v-col cols="12" md="4" class="text-end">
|
|
|
|
|
<div class="d-flex ga-2 justify-end">
|
|
|
|
|
<v-btn
|
|
|
|
|
@click="handleQuickRSVP"
|
|
|
|
|
:color="userRSVP ? 'success' : 'white'"
|
|
|
|
|
:variant="userRSVP ? 'elevated' : 'outlined'"
|
|
|
|
|
size="small"
|
|
|
|
|
class="text-none"
|
|
|
|
|
rounded="lg"
|
|
|
|
|
>
|
|
|
|
|
<v-icon start size="small">
|
|
|
|
|
{{ userRSVP ? 'mdi-check' : 'mdi-plus' }}
|
|
|
|
|
</v-icon>
|
|
|
|
|
{{ userRSVP ? 'Attending' : 'Quick RSVP' }}
|
|
|
|
|
</v-btn>
|
|
|
|
|
|
|
|
|
|
<v-btn
|
|
|
|
|
@click="handleViewDetails"
|
|
|
|
|
color="white"
|
|
|
|
|
variant="outlined"
|
|
|
|
|
size="small"
|
|
|
|
|
class="text-none"
|
|
|
|
|
rounded="lg"
|
|
|
|
|
>
|
|
|
|
|
<v-icon start size="small">mdi-eye</v-icon>
|
|
|
|
|
View Details
|
|
|
|
|
</v-btn>
|
|
|
|
|
</div>
|
|
|
|
|
</v-col>
|
|
|
|
|
</v-row>
|
|
|
|
|
</v-card-text>
|
|
|
|
|
</v-card>
|
2025-08-12 04:25:35 +02:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import type { Event, EventRSVP } from '~/utils/types';
|
2025-08-14 15:08:40 +02:00
|
|
|
|
|
|
|
|
// Helper functions to replace date-fns
|
|
|
|
|
const formatDate = (date: Date, formatStr: string): string => {
|
|
|
|
|
const options: Intl.DateTimeFormatOptions = {};
|
|
|
|
|
|
|
|
|
|
if (formatStr === 'HH:mm') {
|
|
|
|
|
options.hour = '2-digit';
|
|
|
|
|
options.minute = '2-digit';
|
|
|
|
|
options.hour12 = false;
|
|
|
|
|
} else if (formatStr === 'EEE, MMM d • HH:mm') {
|
|
|
|
|
return date.toLocaleDateString('en-US', {
|
|
|
|
|
weekday: 'short',
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric'
|
|
|
|
|
}) + ' • ' + date.toLocaleTimeString('en-US', {
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
hour12: false
|
|
|
|
|
});
|
|
|
|
|
} else if (formatStr === 'MMM d') {
|
|
|
|
|
options.month = 'short';
|
|
|
|
|
options.day = 'numeric';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return date.toLocaleDateString('en-US', options);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const addDays = (date: Date, days: number): Date => {
|
|
|
|
|
const result = new Date(date);
|
|
|
|
|
result.setDate(result.getDate() + days);
|
|
|
|
|
return result;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isWithinInterval = (date: Date, interval: { start: Date; end: Date }): boolean => {
|
|
|
|
|
return date >= interval.start && date <= interval.end;
|
|
|
|
|
};
|
2025-08-12 04:25:35 +02:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
event: Event | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
'event-click': [event: Event];
|
|
|
|
|
'quick-rsvp': [event: Event];
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
// Computed properties
|
|
|
|
|
const userRSVP = computed((): EventRSVP | null => {
|
|
|
|
|
return props.event?.user_rsvp || null;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const canRSVP = computed(() => {
|
|
|
|
|
if (!props.event) return false;
|
|
|
|
|
const eventDate = new Date(props.event.start_datetime);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
return eventDate > now; // Can RSVP to future events
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-13 15:57:34 +02:00
|
|
|
const eventTypeIcon = computed(() => {
|
2025-08-12 04:25:35 +02:00
|
|
|
if (!props.event) return 'mdi-calendar';
|
|
|
|
|
|
|
|
|
|
const icons = {
|
|
|
|
|
'meeting': 'mdi-account-group',
|
|
|
|
|
'social': 'mdi-party-popper',
|
|
|
|
|
'fundraiser': 'mdi-heart',
|
|
|
|
|
'workshop': 'mdi-school',
|
|
|
|
|
'board-only': 'mdi-shield-account'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return icons[props.event.event_type as keyof typeof icons] || 'mdi-calendar';
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-13 15:57:34 +02:00
|
|
|
const eventTypeColor = computed(() => {
|
2025-08-12 04:25:35 +02:00
|
|
|
if (!props.event) return 'primary';
|
|
|
|
|
|
|
|
|
|
// Check if event is soon (within 24 hours)
|
|
|
|
|
const eventDate = new Date(props.event.start_datetime);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
const isSoon = isWithinInterval(eventDate, {
|
|
|
|
|
start: now,
|
|
|
|
|
end: addDays(now, 1)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (isSoon) return 'warning';
|
|
|
|
|
|
|
|
|
|
const colors = {
|
|
|
|
|
'meeting': 'blue',
|
|
|
|
|
'social': 'green',
|
|
|
|
|
'fundraiser': 'orange',
|
|
|
|
|
'workshop': 'purple',
|
|
|
|
|
'board-only': 'red'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return colors[props.event.event_type as keyof typeof colors] || 'primary';
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-13 15:57:34 +02:00
|
|
|
const eventTypeLabel = computed(() => {
|
|
|
|
|
if (!props.event) return '';
|
|
|
|
|
|
|
|
|
|
const labels = {
|
|
|
|
|
'meeting': 'Meeting',
|
|
|
|
|
'social': 'Social Event',
|
|
|
|
|
'fundraiser': 'Fundraiser',
|
|
|
|
|
'workshop': 'Workshop',
|
|
|
|
|
'board-only': 'Board Only'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return labels[props.event.event_type as keyof typeof labels] || 'Event';
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-12 04:25:35 +02:00
|
|
|
const iconColor = computed(() => {
|
|
|
|
|
// Use white for better contrast on colored backgrounds
|
|
|
|
|
return 'white';
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-14 10:46:12 +02:00
|
|
|
const memberPrice = computed(() => props.event?.cost_members || '');
|
2025-08-13 15:57:34 +02:00
|
|
|
const nonMemberPrice = computed(() => props.event?.cost_non_members || '');
|
|
|
|
|
|
2025-08-14 10:46:12 +02:00
|
|
|
const priceDisplay = computed(() => {
|
|
|
|
|
if (!props.event || props.event.is_paid !== 'true') return '';
|
|
|
|
|
|
|
|
|
|
const memberCost = props.event.cost_members;
|
|
|
|
|
const nonMemberCost = props.event.cost_non_members;
|
|
|
|
|
|
|
|
|
|
if (memberCost && nonMemberCost) {
|
|
|
|
|
// Show both prices
|
|
|
|
|
return `€${memberCost} (Members) | €${nonMemberCost} (Non-Members)`;
|
|
|
|
|
} else if (memberCost) {
|
|
|
|
|
// Only member price
|
|
|
|
|
return `€${memberCost} (Members)`;
|
|
|
|
|
} else if (nonMemberCost) {
|
|
|
|
|
// Only non-member price
|
|
|
|
|
return `€${nonMemberCost}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return '';
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-12 04:25:35 +02:00
|
|
|
const formatEventDate = computed(() => {
|
|
|
|
|
if (!props.event) return '';
|
|
|
|
|
|
|
|
|
|
const startDate = new Date(props.event.start_datetime);
|
|
|
|
|
const endDate = new Date(props.event.end_datetime);
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
|
|
// Different formats based on timing
|
|
|
|
|
if (startDate.toDateString() === now.toDateString()) {
|
2025-08-14 15:08:40 +02:00
|
|
|
return `Today at ${formatDate(startDate, 'HH:mm')}`;
|
2025-08-12 04:25:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (startDate.toDateString() === addDays(now, 1).toDateString()) {
|
2025-08-14 15:08:40 +02:00
|
|
|
return `Tomorrow at ${formatDate(startDate, 'HH:mm')}`;
|
2025-08-12 04:25:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (startDate.toDateString() === endDate.toDateString()) {
|
2025-08-14 15:08:40 +02:00
|
|
|
return formatDate(startDate, 'EEE, MMM d • HH:mm');
|
2025-08-12 04:25:35 +02:00
|
|
|
}
|
|
|
|
|
|
2025-08-14 15:08:40 +02:00
|
|
|
return `${formatDate(startDate, 'MMM d')} - ${formatDate(endDate, 'MMM d')}`;
|
2025-08-12 04:25:35 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const capacityInfo = computed(() => {
|
|
|
|
|
if (!props.event?.max_attendees) return '';
|
|
|
|
|
|
|
|
|
|
const current = props.event.current_attendees || 0;
|
|
|
|
|
const max = parseInt(props.event.max_attendees);
|
|
|
|
|
|
|
|
|
|
return `${current}/${max} attending`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rsvpStatusColor = computed(() => {
|
|
|
|
|
const status = userRSVP.value?.rsvp_status;
|
|
|
|
|
switch (status) {
|
|
|
|
|
case 'confirmed': return 'success';
|
|
|
|
|
case 'waitlist': return 'warning';
|
|
|
|
|
case 'declined': return 'error';
|
|
|
|
|
default: return 'info';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rsvpStatusIcon = computed(() => {
|
|
|
|
|
const status = userRSVP.value?.rsvp_status;
|
|
|
|
|
switch (status) {
|
|
|
|
|
case 'confirmed': return 'mdi-check';
|
|
|
|
|
case 'waitlist': return 'mdi-clock';
|
|
|
|
|
case 'declined': return 'mdi-close';
|
|
|
|
|
default: return 'mdi-help';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const rsvpStatusText = computed(() => {
|
|
|
|
|
const status = userRSVP.value?.rsvp_status;
|
|
|
|
|
switch (status) {
|
|
|
|
|
case 'confirmed': return 'Attending';
|
|
|
|
|
case 'waitlist': return 'Waitlisted';
|
|
|
|
|
case 'declined': return 'Declined';
|
|
|
|
|
default: return 'Unknown';
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const quickRSVPColor = computed(() => {
|
2025-08-13 15:57:34 +02:00
|
|
|
return eventTypeColor.value === 'warning' ? 'success' : 'white';
|
2025-08-12 04:25:35 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Methods
|
|
|
|
|
const handleViewEvent = () => {
|
|
|
|
|
if (props.event) {
|
|
|
|
|
emit('event-click', props.event);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-13 15:57:34 +02:00
|
|
|
const handleViewDetails = () => {
|
|
|
|
|
if (props.event) {
|
|
|
|
|
emit('event-click', props.event);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-12 04:25:35 +02:00
|
|
|
const handleQuickRSVP = () => {
|
|
|
|
|
if (props.event) {
|
|
|
|
|
emit('quick-rsvp', props.event);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.v-banner :deep(.v-banner__wrapper) {
|
|
|
|
|
padding: 16px 24px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.v-banner :deep(.v-banner__prepend) {
|
|
|
|
|
margin-inline-end: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.v-banner :deep(.v-banner__actions) {
|
|
|
|
|
margin-inline-start: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Mobile optimizations */
|
|
|
|
|
@media (max-width: 600px) {
|
|
|
|
|
.v-banner :deep(.v-banner__wrapper) {
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.v-banner :deep(.v-banner__prepend) {
|
|
|
|
|
margin-inline-end: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.v-banner :deep(.v-banner__actions) {
|
|
|
|
|
margin-inline-start: 0;
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.text-h6 {
|
|
|
|
|
font-size: 1.1rem !important;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Ensure proper spacing on different screen sizes */
|
|
|
|
|
.ga-4 {
|
|
|
|
|
gap: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.ga-2 {
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (max-width: 600px) {
|
|
|
|
|
.ga-4 {
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|