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>
|
<template>
|
||||||
<NuxtLayout>
|
<div class="app-container">
|
||||||
<NuxtPage />
|
<!-- Global MonacoUSA Logo - appears on all pages -->
|
||||||
</NuxtLayout>
|
<div class="global-logo">
|
||||||
|
<MonacoUSALogo
|
||||||
|
size="small"
|
||||||
|
variant="white"
|
||||||
|
clickable
|
||||||
|
@click="handleLogoClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -11,4 +23,61 @@ useHead({
|
||||||
return titleChunk ? `${titleChunk} • MonacoUSA Portal` : 'MonacoUSA Portal';
|
return titleChunk ? `${titleChunk} • MonacoUSA Portal` : 'MonacoUSA Portal';
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle logo click - navigate to home/dashboard
|
||||||
|
const handleLogoClick = () => {
|
||||||
|
navigateTo('/');
|
||||||
|
};
|
||||||
</script>
|
</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-col>
|
||||||
</v-row>
|
</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" />
|
<v-divider class="my-2" />
|
||||||
|
|
||||||
<div class="text-caption d-flex align-center">
|
<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)
|
// Navigation methods (placeholder implementations)
|
||||||
const navigateToProfile = () => {
|
const navigateToProfile = () => {
|
||||||
// TODO: Implement profile navigation
|
navigateTo('/dashboard/profile');
|
||||||
console.log('Navigate to profile');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigateToEvents = () => {
|
const navigateToEvents = () => {
|
||||||
|
|
@ -177,8 +176,19 @@ const navigateToResources = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const contactSupport = () => {
|
const contactSupport = () => {
|
||||||
// TODO: Implement support contact
|
const subject = encodeURIComponent('MonacoUSA Portal Support Request');
|
||||||
console.log('Contact support');
|
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>
|
</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 { createMember, handleNocoDbError } from '~/server/utils/nocodb';
|
||||||
import { createSessionManager } from '~/server/utils/session';
|
import { createSessionManager } from '~/server/utils/session';
|
||||||
|
import { generateMemberID } from '~/server/utils/member-id';
|
||||||
import type { Member, MembershipStatus } from '~/utils/types';
|
import type { Member, MembershipStatus } from '~/utils/types';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -50,7 +51,7 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize and prepare data
|
// Sanitize and prepare data
|
||||||
const memberData = sanitizeMemberData(normalizedBody);
|
const memberData = await sanitizeMemberData(normalizedBody);
|
||||||
console.log('[api/members.post] Sanitized data fields:', Object.keys(memberData));
|
console.log('[api/members.post] Sanitized data fields:', Object.keys(memberData));
|
||||||
|
|
||||||
// Create member in NocoDB
|
// Create member in NocoDB
|
||||||
|
|
@ -109,9 +110,14 @@ function validateMemberData(data: any): string[] {
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeMemberData(data: any): Partial<Member> {
|
async function sanitizeMemberData(data: any): Promise<Partial<Member>> {
|
||||||
const sanitized: any = {};
|
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
|
// Required fields
|
||||||
sanitized.first_name = data.first_name.trim();
|
sanitized.first_name = data.first_name.trim();
|
||||||
sanitized.last_name = data.last_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 {
|
export interface Member {
|
||||||
Id: string;
|
Id: string;
|
||||||
|
member_id?: string; // MUSA-{unique number} - Member identification number
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue