Add global branding and implement member ID system
Build And Push Image / docker (push) Successful in 3m2s
Details
Build And Push Image / docker (push) Successful in 3m2s
Details
- Add MonacoUSA logo component with global header placement - Implement member ID generation and migration system - Create profile page and improve dashboard navigation - Add member ID as payment reference in dues banner - Enable support contact functionality with pre-filled email
This commit is contained in:
parent
e33f32e15a
commit
ecae3795ee
75
app.vue
75
app.vue
|
|
@ -1,7 +1,19 @@
|
|||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<div class="app-container">
|
||||
<!-- Global MonacoUSA Logo - appears on all pages -->
|
||||
<div class="global-logo">
|
||||
<MonacoUSALogo
|
||||
size="small"
|
||||
variant="white"
|
||||
clickable
|
||||
@click="handleLogoClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
@ -11,4 +23,61 @@ useHead({
|
|||
return titleChunk ? `${titleChunk} • MonacoUSA Portal` : 'MonacoUSA Portal';
|
||||
},
|
||||
});
|
||||
|
||||
// Handle logo click - navigate to home/dashboard
|
||||
const handleLogoClick = () => {
|
||||
navigateTo('/');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.app-container {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.global-logo {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 2000; /* Higher than dashboard drawer and other elements */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Adjust positioning on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.global-logo {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure logo doesn't interfere with dashboard layout */
|
||||
@media (min-width: 1280px) {
|
||||
.global-logo {
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide logo on very small screens if needed */
|
||||
@media (max-width: 320px) {
|
||||
.global-logo {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles - hide logo when printing */
|
||||
@media print {
|
||||
.global-logo {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Accessibility - ensure logo doesn't block important content */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.global-logo {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,21 @@
|
|||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<v-row dense>
|
||||
<v-col cols="12">
|
||||
<div class="text-caption font-weight-bold">Payment Reference:</div>
|
||||
<div class="text-body-2 font-family-monospace" style="background-color: rgba(163, 21, 21, 0.1); padding: 8px; border-radius: 4px; border-left: 4px solid #a31515;">
|
||||
{{ memberData?.member_id || 'Member ID pending' }}
|
||||
</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
<v-icon size="small" class="mr-1">mdi-information-outline</v-icon>
|
||||
Please include your member ID in the wire transfer reference for identification
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-divider class="my-2" />
|
||||
|
||||
<div class="text-caption d-flex align-center">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
<template>
|
||||
<div class="monaco-logo" :class="sizeClass">
|
||||
<v-img
|
||||
:src="logoSrc"
|
||||
:width="logoWidth"
|
||||
:height="logoHeight"
|
||||
class="logo-img"
|
||||
alt="MonacoUSA Logo"
|
||||
:style="logoStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
variant?: 'default' | 'white' | 'dark'
|
||||
clickable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'medium',
|
||||
variant: 'default',
|
||||
clickable: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>();
|
||||
|
||||
// Computed properties for responsive sizing
|
||||
const sizeClass = computed(() => `monaco-logo--${props.size}`);
|
||||
|
||||
const logoSrc = computed(() => {
|
||||
// Use the high-res Monaco flag image
|
||||
return '/MONACOUSA-Flags_376x376.png';
|
||||
});
|
||||
|
||||
const logoWidth = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'small': return 32;
|
||||
case 'medium': return 48;
|
||||
case 'large': return 80;
|
||||
default: return 48;
|
||||
}
|
||||
});
|
||||
|
||||
const logoHeight = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'small': return 32;
|
||||
case 'medium': return 48;
|
||||
case 'large': return 80;
|
||||
default: return 48;
|
||||
}
|
||||
});
|
||||
|
||||
const logoStyle = computed(() => {
|
||||
const baseStyle: Record<string, string> = {
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
|
||||
};
|
||||
|
||||
if (props.clickable) {
|
||||
baseStyle.cursor = 'pointer';
|
||||
baseStyle.transition = 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out';
|
||||
}
|
||||
|
||||
if (props.variant === 'white') {
|
||||
baseStyle.backgroundColor = 'white';
|
||||
baseStyle.padding = '4px';
|
||||
} else if (props.variant === 'dark') {
|
||||
baseStyle.backgroundColor = 'rgba(0, 0, 0, 0.1)';
|
||||
baseStyle.padding = '4px';
|
||||
}
|
||||
|
||||
return baseStyle;
|
||||
});
|
||||
|
||||
// Handle click events
|
||||
const handleClick = () => {
|
||||
if (props.clickable) {
|
||||
emit('click');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monaco-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.monaco-logo--small {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.monaco-logo--medium {
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.monaco-logo--large {
|
||||
min-width: 80px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.monaco-logo:hover .logo-img {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 16px rgba(163, 21, 21, 0.2);
|
||||
}
|
||||
|
||||
/* Ensure the logo maintains aspect ratio */
|
||||
.v-img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Animation for clickable logos */
|
||||
.monaco-logo[style*="cursor: pointer"]:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.monaco-logo[style*="cursor: pointer"]:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Accessibility improvements */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.logo-img,
|
||||
.monaco-logo {
|
||||
transition: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.logo-img {
|
||||
border: 2px solid currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.monaco-logo {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,477 @@
|
|||
<template>
|
||||
<v-container>
|
||||
<!-- 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 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>
|
||||
</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 memberData = ref<Member | null>(null);
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
});
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadMemberData = async () => {
|
||||
if (!user.value?.email) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
const response = await $fetch('/api/members') as any;
|
||||
const members = response?.data || response?.list || [];
|
||||
|
||||
// Find member by email
|
||||
const member = members.find((m: any) => m.email === user.value?.email);
|
||||
if (member) {
|
||||
memberData.value = member;
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
};
|
||||
|
||||
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 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>
|
||||
|
|
@ -162,8 +162,7 @@ const { firstName, user, userTier } = useAuth();
|
|||
|
||||
// Navigation methods (placeholder implementations)
|
||||
const navigateToProfile = () => {
|
||||
// TODO: Implement profile navigation
|
||||
console.log('Navigate to profile');
|
||||
navigateTo('/dashboard/profile');
|
||||
};
|
||||
|
||||
const navigateToEvents = () => {
|
||||
|
|
@ -177,8 +176,19 @@ const navigateToResources = () => {
|
|||
};
|
||||
|
||||
const contactSupport = () => {
|
||||
// TODO: Implement support contact
|
||||
console.log('Contact support');
|
||||
const subject = encodeURIComponent('MonacoUSA Portal Support Request');
|
||||
const body = encodeURIComponent(`Hello,
|
||||
|
||||
I need assistance with:
|
||||
|
||||
[Please describe your issue]
|
||||
|
||||
Member: ${user.value?.name || 'Not provided'}
|
||||
Email: ${user.value?.email || 'Not provided'}
|
||||
|
||||
Thank you!`);
|
||||
|
||||
window.open(`mailto:support@monacousa.org?subject=${subject}&body=${body}`, '_self');
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import { createSessionManager } from '~/server/utils/session';
|
||||
import { findMembersWithoutMemberID, generateMemberID } from '~/server/utils/member-id';
|
||||
import { updateMember, handleNocoDbError } from '~/server/utils/nocodb';
|
||||
import type { Member } from '~/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[api/admin/migrate-member-ids.post] =========================');
|
||||
console.log('[api/admin/migrate-member-ids.post] POST /api/admin/migrate-member-ids - Migrate existing member IDs');
|
||||
console.log('[api/admin/migrate-member-ids.post] Request from:', getClientIP(event));
|
||||
|
||||
try {
|
||||
// Validate session and require Admin privileges
|
||||
const sessionManager = createSessionManager();
|
||||
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
|
||||
const session = sessionManager.getSession(cookieHeader);
|
||||
|
||||
if (!session?.user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
const userTier = session.user.tier;
|
||||
if (userTier !== 'admin') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Administrator privileges required for member ID migration'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[api/admin/migrate-member-ids.post] Authorized admin user:', session.user.email);
|
||||
|
||||
// Find all members without member IDs
|
||||
console.log('[api/admin/migrate-member-ids.post] Finding members without member IDs...');
|
||||
const membersWithoutId = await findMembersWithoutMemberID();
|
||||
|
||||
if (membersWithoutId.length === 0) {
|
||||
console.log('[api/admin/migrate-member-ids.post] No members found without member IDs');
|
||||
return {
|
||||
success: true,
|
||||
message: 'All members already have member IDs assigned',
|
||||
migrated: 0,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[api/admin/migrate-member-ids.post] Found ${membersWithoutId.length} members without member IDs`);
|
||||
|
||||
const migrationResults = [];
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Migrate each member
|
||||
for (let i = 0; i < membersWithoutId.length; i++) {
|
||||
const member = membersWithoutId[i];
|
||||
console.log(`[api/admin/migrate-member-ids.post] Migrating member ${i + 1}/${membersWithoutId.length}: ${member.first_name} ${member.last_name} (ID: ${member.Id})`);
|
||||
|
||||
try {
|
||||
// Generate unique member ID
|
||||
const memberID = await generateMemberID();
|
||||
console.log(`[api/admin/migrate-member-ids.post] Generated ID ${memberID} for member ${member.Id}`);
|
||||
|
||||
// Update member with new member ID
|
||||
await updateMember(member.Id, { member_id: memberID });
|
||||
|
||||
migrationResults.push({
|
||||
memberId: member.Id,
|
||||
memberName: `${member.first_name} ${member.last_name}`,
|
||||
generatedId: memberID,
|
||||
success: true
|
||||
});
|
||||
|
||||
successCount++;
|
||||
console.log(`[api/admin/migrate-member-ids.post] ✅ Successfully migrated member ${member.Id} with ID ${memberID}`);
|
||||
|
||||
// Add a small delay to avoid overwhelming the database
|
||||
if (i < membersWithoutId.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[api/admin/migrate-member-ids.post] ❌ Failed to migrate member ${member.Id}:`, error);
|
||||
|
||||
migrationResults.push({
|
||||
memberId: member.Id,
|
||||
memberName: `${member.first_name} ${member.last_name}`,
|
||||
generatedId: null,
|
||||
success: false,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[api/admin/migrate-member-ids.post] =========================');
|
||||
console.log('[api/admin/migrate-member-ids.post] Migration completed');
|
||||
console.log('[api/admin/migrate-member-ids.post] Total members processed:', membersWithoutId.length);
|
||||
console.log('[api/admin/migrate-member-ids.post] Successful migrations:', successCount);
|
||||
console.log('[api/admin/migrate-member-ids.post] Failed migrations:', errorCount);
|
||||
console.log('[api/admin/migrate-member-ids.post] =========================');
|
||||
|
||||
return {
|
||||
success: errorCount === 0,
|
||||
message: `Migration completed. ${successCount} members successfully migrated, ${errorCount} errors.`,
|
||||
migrated: successCount,
|
||||
errors: errorCount,
|
||||
total: membersWithoutId.length,
|
||||
results: migrationResults
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[api/admin/migrate-member-ids.post] ❌ Migration failed:', error);
|
||||
handleNocoDbError(error, 'migrateMemberIDs', 'Member ID Migration');
|
||||
}
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { createMember, handleNocoDbError } from '~/server/utils/nocodb';
|
||||
import { createSessionManager } from '~/server/utils/session';
|
||||
import { generateMemberID } from '~/server/utils/member-id';
|
||||
import type { Member, MembershipStatus } from '~/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
|
@ -50,7 +51,7 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
// Sanitize and prepare data
|
||||
const memberData = sanitizeMemberData(normalizedBody);
|
||||
const memberData = await sanitizeMemberData(normalizedBody);
|
||||
console.log('[api/members.post] Sanitized data fields:', Object.keys(memberData));
|
||||
|
||||
// Create member in NocoDB
|
||||
|
|
@ -109,9 +110,14 @@ function validateMemberData(data: any): string[] {
|
|||
return errors;
|
||||
}
|
||||
|
||||
function sanitizeMemberData(data: any): Partial<Member> {
|
||||
async function sanitizeMemberData(data: any): Promise<Partial<Member>> {
|
||||
const sanitized: any = {};
|
||||
|
||||
// Generate unique member ID
|
||||
console.log('[api/members.post] Generating member ID for new member...');
|
||||
sanitized.member_id = await generateMemberID();
|
||||
console.log('[api/members.post] Generated member ID:', sanitized.member_id);
|
||||
|
||||
// Required fields
|
||||
sanitized.first_name = data.first_name.trim();
|
||||
sanitized.last_name = data.last_name.trim();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { getMembers, updateMember } from './nocodb';
|
||||
import type { Member } from '~/utils/types';
|
||||
|
||||
/**
|
||||
* Generates a unique member ID in the format MUSA-{unique 6-digit number}
|
||||
* Checks against existing member IDs to ensure uniqueness
|
||||
* @returns Promise<string> - The unique member ID
|
||||
*/
|
||||
export async function generateMemberID(): Promise<string> {
|
||||
console.log('[member-id] Generating new member ID...');
|
||||
|
||||
let memberID: string;
|
||||
let isUnique = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100; // Prevent infinite loops
|
||||
|
||||
while (!isUnique && attempts < maxAttempts) {
|
||||
attempts++;
|
||||
|
||||
// Generate a 6-digit number (100000 to 999999)
|
||||
const uniqueNumber = Math.floor(Math.random() * 900000) + 100000;
|
||||
memberID = `MUSA-${uniqueNumber}`;
|
||||
|
||||
console.log(`[member-id] Attempt ${attempts}: Checking uniqueness of ${memberID}`);
|
||||
|
||||
// Check if ID already exists in database
|
||||
const existingMember = await checkMemberIDExists(memberID);
|
||||
isUnique = !existingMember;
|
||||
|
||||
if (!isUnique) {
|
||||
console.log(`[member-id] ID ${memberID} already exists, generating new one...`);
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
console.error('[member-id] Failed to generate unique member ID after maximum attempts');
|
||||
throw new Error('Failed to generate unique member ID after maximum attempts');
|
||||
}
|
||||
|
||||
console.log(`[member-id] ✅ Generated unique member ID: ${memberID!} (attempts: ${attempts})`);
|
||||
return memberID!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a member ID already exists in the database
|
||||
* @param memberID - The member ID to check
|
||||
* @returns Promise<boolean> - True if the member ID exists, false otherwise
|
||||
*/
|
||||
export async function checkMemberIDExists(memberID: string): Promise<boolean> {
|
||||
try {
|
||||
console.log(`[member-id] Checking if member ID exists: ${memberID}`);
|
||||
|
||||
// Get all members and check for duplicate member_id
|
||||
const members = await getMembers();
|
||||
const memberList = Array.isArray(members) ? members : members?.list || [];
|
||||
|
||||
const existingMember = memberList.find((member: Member) => member.member_id === memberID);
|
||||
const exists = !!existingMember;
|
||||
|
||||
console.log(`[member-id] Member ID ${memberID} exists: ${exists}`);
|
||||
return exists;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[member-id] Error checking member ID existence:', error);
|
||||
// In case of error, assume it doesn't exist to allow generation to continue
|
||||
// The actual creation will fail if there's a real database issue
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds all members without a member_id field
|
||||
* Used for migration purposes
|
||||
* @returns Promise<Member[]> - Array of members without member IDs
|
||||
*/
|
||||
export async function findMembersWithoutMemberID(): Promise<Member[]> {
|
||||
try {
|
||||
console.log('[member-id] Finding members without member IDs for migration...');
|
||||
|
||||
const members = await getMembers();
|
||||
const memberList = Array.isArray(members) ? members : members?.list || [];
|
||||
|
||||
const membersWithoutId = memberList.filter((member: Member) =>
|
||||
!member.member_id || member.member_id.trim() === ''
|
||||
);
|
||||
|
||||
console.log(`[member-id] Found ${membersWithoutId.length} members without member IDs`);
|
||||
return membersWithoutId;
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[member-id] Error finding members without member IDs:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a member ID format
|
||||
* @param memberID - The member ID to validate
|
||||
* @returns boolean - True if valid format, false otherwise
|
||||
*/
|
||||
export function isValidMemberIDFormat(memberID: string): boolean {
|
||||
if (!memberID || typeof memberID !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check format: MUSA-{6 digits}
|
||||
const memberIDRegex = /^MUSA-\d{6}$/;
|
||||
return memberIDRegex.test(memberID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the numeric part from a member ID
|
||||
* @param memberID - The member ID (e.g., "MUSA-123456")
|
||||
* @returns number - The numeric part or null if invalid
|
||||
*/
|
||||
export function extractMemberIDNumber(memberID: string): number | null {
|
||||
if (!isValidMemberIDFormat(memberID)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numericPart = memberID.replace('MUSA-', '');
|
||||
return parseInt(numericPart, 10);
|
||||
}
|
||||
|
|
@ -118,6 +118,7 @@ export enum MembershipStatus {
|
|||
|
||||
export interface Member {
|
||||
Id: string;
|
||||
member_id?: string; // MUSA-{unique number} - Member identification number
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue