monacousa-portal/pages/dashboard/profile.vue

656 lines
21 KiB
Vue

<template>
<v-container>
<!-- Dues Payment Banner -->
<DuesPaymentBanner />
<!-- Page Header -->
<v-row class="mb-6">
<v-col>
<div class="d-flex align-center">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.back()"
class="mr-3"
/>
<div>
<h1 class="text-h3 font-weight-bold" style="color: #a31515;">
My Profile
</h1>
<p class="text-h6 text-medium-emphasis">
View and manage your membership information
</p>
</div>
</div>
</v-col>
</v-row>
<!-- Loading State -->
<v-row v-if="loading" class="justify-center">
<v-col cols="12" class="text-center">
<v-progress-circular
indeterminate
color="primary"
size="64"
/>
<p class="mt-4">Loading your profile...</p>
</v-col>
</v-row>
<!-- Profile Content -->
<div v-else>
<!-- Member ID Card -->
<v-row class="mb-6">
<v-col cols="12">
<v-card elevation="2" class="member-id-card">
<v-card-text class="pa-6">
<div class="d-flex align-center">
<v-icon size="48" color="primary" class="mr-4">mdi-badge-account</v-icon>
<div>
<h2 class="text-h4 font-weight-bold" style="color: #a31515;">
{{ memberData?.member_id || 'Member ID Pending' }}
</h2>
<p class="text-body-1 text-medium-emphasis">
Your unique MonacoUSA member identifier
</p>
</div>
<v-spacer />
<v-btn
v-if="memberData?.member_id"
icon="mdi-content-copy"
variant="outlined"
@click="copyMemberID"
:title="`Copy member ID: ${memberData.member_id}`"
/>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Profile Photo -->
<v-row class="mb-6">
<v-col cols="12">
<v-card elevation="2">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-account-circle</v-icon>
Profile Photo
</v-card-title>
<v-card-text class="pa-4">
<div class="d-flex align-center flex-wrap">
<!-- Avatar Preview -->
<div class="mr-6 mb-4 text-center">
<ProfileAvatar
v-if="memberData"
:member-id="memberData.member_id"
:member-name="fullName"
:first-name="memberData.first_name"
:last-name="memberData.last_name"
size="large"
:key="avatarBustKey"
class="mb-2"
/>
<p class="text-body-2 text-medium-emphasis">Current Photo</p>
</div>
<!-- Upload Controls -->
<div class="flex-grow-1 mb-4">
<v-file-input
v-model="selectedFiles"
accept="image/jpeg,image/png,image/webp"
label="Choose new profile photo (uploads automatically)"
variant="outlined"
density="compact"
prepend-icon="mdi-camera"
show-size
:disabled="uploading || deleting"
:loading="uploading"
@update:model-value="onSelectImage"
class="mb-3"
/>
<div class="d-flex gap-2 flex-wrap">
<v-btn
color="error"
variant="outlined"
prepend-icon="mdi-delete"
:loading="deleting"
:disabled="uploading || !memberData?.member_id"
@click="confirmDelete = true"
>
Remove Photo
</v-btn>
</div>
<p class="text-body-2 text-medium-emphasis mt-2">
Supported formats: JPG, PNG, WEBP • Maximum size: 5MB
</p>
</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Profile Information -->
<v-row>
<!-- Personal Information -->
<v-col cols="12" md="6">
<v-card elevation="2" class="h-100">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-account-details</v-icon>
Personal Information
</v-card-title>
<v-card-text class="pa-4">
<v-list>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Full Name</v-list-item-title>
<v-list-item-subtitle>{{ fullName || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Email Address</v-list-item-title>
<v-list-item-subtitle>{{ memberData?.email || user?.email || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Phone Number</v-list-item-title>
<v-list-item-subtitle>{{ memberData?.phone || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Date of Birth</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(memberData?.date_of_birth) || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Nationality</v-list-item-title>
<v-list-item-subtitle>
<div v-if="memberData?.nationality">
<CountryFlag
v-for="country in parseNationalities(memberData.nationality)"
:key="country"
:country="country"
size="small"
class="mr-1"
/>
</div>
<span v-else>Not provided</span>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Address</v-list-item-title>
<v-list-item-subtitle>{{ memberData?.address || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<!-- Membership Details -->
<v-col cols="12" md="6">
<v-card elevation="2" class="h-100">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-card-membership</v-icon>
Membership Details
</v-card-title>
<v-card-text class="pa-4">
<v-list>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Member Since</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(memberData?.member_since) || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Membership Status</v-list-item-title>
<v-list-item-subtitle>
<v-chip
:color="getStatusColor(memberData?.membership_status)"
size="small"
variant="flat"
>
{{ memberData?.membership_status || 'Pending' }}
</v-chip>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Account Tier</v-list-item-title>
<v-list-item-subtitle>
<v-chip
:color="getTierColor(userTier)"
size="small"
variant="flat"
>
{{ userTier?.toUpperCase() || 'USER' }}
</v-chip>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Registration Date</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(memberData?.registration_date) || 'Not available' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Dues and Payment Information -->
<v-row class="mt-6">
<v-col cols="12">
<v-card elevation="2">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-credit-card-outline</v-icon>
Dues and Payment Information
</v-card-title>
<v-card-text class="pa-4">
<v-row>
<v-col cols="12" sm="6" md="3">
<div class="text-center">
<v-icon
:color="memberData?.current_year_dues_paid === 'true' ? 'success' : 'warning'"
size="48"
class="mb-2"
>
{{ memberData?.current_year_dues_paid === 'true' ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
<h4 class="font-weight-bold">Current Year Dues</h4>
<v-chip
:color="memberData?.current_year_dues_paid === 'true' ? 'success' : 'warning'"
size="small"
variant="flat"
>
{{ memberData?.current_year_dues_paid === 'true' ? 'PAID' : 'UNPAID' }}
</v-chip>
</div>
</v-col>
<v-col cols="12" sm="6" md="3">
<div>
<h4 class="font-weight-bold mb-2">Last Payment Date</h4>
<p>{{ formatDate(memberData?.membership_date_paid) || 'No payment recorded' }}</p>
</div>
</v-col>
<v-col cols="12" sm="6" md="3">
<div>
<h4 class="font-weight-bold mb-2">Payment Due Date</h4>
<p>{{ formatDate(memberData?.payment_due_date) || 'Not set' }}</p>
</div>
</v-col>
<v-col cols="12" sm="6" md="3">
<div>
<h4 class="font-weight-bold mb-2">Days Remaining</h4>
<p :class="getDaysRemainingColor(daysRemaining)">
{{ daysRemaining >= 0 ? `${daysRemaining} days` : `${Math.abs(daysRemaining)} days overdue` }}
</p>
</div>
</v-col>
</v-row>
<!-- Payment Reference Info -->
<v-divider class="my-4" />
<div v-if="memberData?.member_id">
<h4 class="font-weight-bold mb-2">Payment Reference for Wire Transfers</h4>
<div class="d-flex align-center">
<v-text-field
:value="memberData.member_id"
readonly
variant="outlined"
density="compact"
class="mr-2"
style="max-width: 200px;"
/>
<v-btn
icon="mdi-content-copy"
variant="outlined"
@click="copyMemberID"
title="Copy member ID"
/>
</div>
<p class="text-body-2 text-medium-emphasis mt-2">
Include this member ID in your wire transfer reference for payment identification.
</p>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
<!-- Snackbar for notifications -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
>
{{ snackbar.message }}
<template #actions>
<v-btn
variant="text"
@click="snackbar.show = false"
>
Close
</v-btn>
</template>
</v-snackbar>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="confirmDelete" max-width="400">
<v-card>
<v-card-title class="text-h5">
Remove Profile Photo?
</v-card-title>
<v-card-text>
Are you sure you want to remove your profile photo? This action cannot be undone.
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
text
@click="confirmDelete = false"
:disabled="deleting"
>
Cancel
</v-btn>
<v-btn
color="error"
:loading="deleting"
@click="confirmDeleteImage"
>
Remove Photo
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
definePageMeta({
layout: 'dashboard',
middleware: 'auth'
});
const { user, userTier } = useAuth();
// Reactive state
const loading = ref(true);
const snackbar = ref({
show: false,
message: '',
color: 'success'
});
// Fetch complete member data (same as user.vue)
const { data: sessionData, pending: sessionPending, error: sessionError, refresh: refreshSession } =
await useFetch<{ success: boolean; member: Member }>('/api/auth/session', { server: false });
const memberData = computed<Member | null>(() => sessionData.value?.member || null);
// Computed properties
const fullName = computed(() => {
if (memberData.value) {
return `${memberData.value.first_name || ''} ${memberData.value.last_name || ''}`.trim();
}
return user.value?.name || '';
});
const daysRemaining = computed(() => {
if (!memberData.value?.payment_due_date) return 0;
const dueDate = new Date(memberData.value.payment_due_date);
const today = new Date();
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
});
// Profile image state
const uploading = ref(false);
const deleting = ref(false);
const avatarBustKey = ref(0);
const selectedFiles = ref<File[]>([]);
const confirmDelete = ref(false);
// Methods
const loadMemberData = async () => {
try {
loading.value = true;
await refreshSession();
if (!sessionData.value?.member) {
throw new Error('Missing member in session');
}
} catch (error) {
console.error('Failed to load member data:', error);
snackbar.value = {
show: true,
message: 'Failed to load profile data. Please try refreshing the page.',
color: 'error'
};
} finally {
loading.value = false;
}
};
// Profile image helpers
const onSelectImage = async (files: File[] | File | null) => {
const fileList = Array.isArray(files) ? files : files ? [files] : [];
if (fileList.length === 0) return;
const file = fileList[0];
// Basic validation
const maxBytes = 5 * 1024 * 1024; // 5MB
const allowed = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowed.includes(file.type)) {
snackbar.value = { show: true, message: 'Only JPG, PNG or WEBP images are allowed.', color: 'error' };
return;
}
if (file.size > maxBytes) {
snackbar.value = { show: true, message: 'Image must be 5MB or smaller.', color: 'error' };
return;
}
// Check if we have member data
if (!memberData.value?.member_id) {
snackbar.value = { show: true, message: 'Unable to upload: member ID not found.', color: 'error' };
return;
}
try {
uploading.value = true;
const body = new FormData();
body.append('image', file); // Changed from 'file' to 'image' to match backend expectation
await $fetch('/api/profile/upload-image', {
method: 'POST',
query: {
memberId: memberData.value.member_id
},
body
});
avatarBustKey.value++;
selectedFiles.value = []; // Clear the file input
snackbar.value = { show: true, message: 'Profile image updated.', color: 'success' };
} catch (e: any) {
console.error('Upload error:', e);
const errorMessage = e?.data?.message || e?.message || 'Failed to upload image.';
snackbar.value = { show: true, message: errorMessage, color: 'error' };
} finally {
uploading.value = false;
}
};
const confirmDeleteImage = async () => {
confirmDelete.value = false;
await onDeleteImage();
};
const onDeleteImage = async () => {
if (!memberData.value?.member_id) return;
try {
deleting.value = true;
await $fetch(`/api/profile/image/${encodeURIComponent(memberData.value.member_id)}`, {
method: 'DELETE'
});
avatarBustKey.value++;
snackbar.value = { show: true, message: 'Profile image removed.', color: 'success' };
} catch (e) {
console.error(e);
snackbar.value = { show: true, message: 'Failed to delete image.', color: 'error' };
} finally {
deleting.value = false;
}
};
const copyMemberID = async () => {
if (!memberData.value?.member_id) return;
try {
await navigator.clipboard.writeText(memberData.value.member_id);
snackbar.value = {
show: true,
message: `Member ID ${memberData.value.member_id} copied to clipboard!`,
color: 'success'
};
} catch (error) {
console.error('Failed to copy to clipboard:', error);
snackbar.value = {
show: true,
message: 'Failed to copy member ID to clipboard.',
color: 'error'
};
}
};
const formatDate = (dateString: string | undefined): string => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch (error) {
return dateString;
}
};
const parseNationalities = (nationalityString: string): string[] => {
return nationalityString ? nationalityString.split(',').map(n => n.trim()).filter(n => n.length > 0) : [];
};
const getStatusColor = (status: string | undefined): string => {
switch (status?.toLowerCase()) {
case 'active': return 'success';
case 'inactive': return 'error';
case 'pending': return 'warning';
case 'expired': return 'error';
default: return 'grey';
}
};
const getTierColor = (tier: string | undefined): string => {
switch (tier?.toLowerCase()) {
case 'admin': return 'error';
case 'board': return 'primary';
case 'user': return 'info';
default: return 'grey';
}
};
const getDaysRemainingColor = (days: number): string => {
if (days < 0) return 'text-error font-weight-bold';
if (days < 30) return 'text-warning font-weight-bold';
return 'text-success';
};
// Initialize
onMounted(() => {
loadMemberData();
});
// Watch for session loading
watch(sessionPending, (isPending) => {
loading.value = isPending;
});
// Watch for user changes
watch(user, () => {
if (user.value) {
loadMemberData();
}
});
</script>
<style scoped>
.member-id-card {
background: linear-gradient(135deg, rgba(163, 21, 21, 0.05) 0%, rgba(163, 21, 21, 0.1) 100%);
border-left: 4px solid #a31515;
}
.v-card {
border-radius: 12px !important;
}
.v-card:hover {
transform: translateY(-2px);
transition: transform 0.2s ease-in-out;
}
.v-btn {
text-transform: none !important;
}
.v-icon {
color: #a31515 !important;
}
.h-100 {
height: 100%;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.member-id-card .d-flex {
flex-direction: column;
text-align: center;
}
.member-id-card .d-flex > * {
margin-bottom: 16px;
}
}
</style>