656 lines
21 KiB
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>
|