Add support for multiple nationalities display with flags
Build And Push Image / docker (push) Successful in 2m18s
Details
Build And Push Image / docker (push) Successful in 2m18s
Details
- Create MultipleCountryFlags component to display multiple country flags - Support comma-separated nationality values (e.g., 'FR,MC,US') - Update admin members page to use MultipleCountryFlags in both list and grid views - Update board members page to display nationalities with flags - Add nationality column to board members table - Update member forms to support multiple nationality selection - Display flags with slight overlap for space efficiency, expand on hover - Maintain backward compatibility with single nationality values 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d34d16fda1
commit
b67100df2a
|
|
@ -0,0 +1,175 @@
|
||||||
|
<template>
|
||||||
|
<span class="multiple-country-flags" :class="{ 'multiple-country-flags--small': size === 'small' }">
|
||||||
|
<ClientOnly>
|
||||||
|
<template v-if="countryCodes.length > 0">
|
||||||
|
<VueCountryFlag
|
||||||
|
v-for="(code, index) in countryCodes"
|
||||||
|
:key="`${code}-${index}`"
|
||||||
|
:country="code"
|
||||||
|
:size="flagSize"
|
||||||
|
:title="getCountryName(code)"
|
||||||
|
class="country-flag-item"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="no-nationality">{{ fallbackText }}</span>
|
||||||
|
</template>
|
||||||
|
<template #fallback>
|
||||||
|
<span class="flag-placeholder" :style="placeholderStyle">🏳️</span>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
<span v-if="showName && countryCodes.length > 0" class="country-names">
|
||||||
|
{{ countryNames }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import VueCountryFlag from 'vue-country-flag-next';
|
||||||
|
import { getCountryName, parseCountryInput } from '~/utils/countries';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nationality?: string; // Can be comma-separated like "FR,MC,US"
|
||||||
|
showName?: boolean;
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
fallbackText?: string;
|
||||||
|
separator?: string; // For display names
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
nationality: '',
|
||||||
|
showName: false,
|
||||||
|
size: 'medium',
|
||||||
|
fallbackText: 'Not specified',
|
||||||
|
separator: ', '
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse multiple nationalities
|
||||||
|
const countryCodes = computed(() => {
|
||||||
|
if (!props.nationality) return [];
|
||||||
|
|
||||||
|
// Split by comma and clean up
|
||||||
|
const codes = props.nationality
|
||||||
|
.split(',')
|
||||||
|
.map(code => code.trim())
|
||||||
|
.filter(code => code.length > 0)
|
||||||
|
.map(code => {
|
||||||
|
// If it's already a 2-letter code, use it
|
||||||
|
if (code.length === 2) {
|
||||||
|
return code.toUpperCase();
|
||||||
|
}
|
||||||
|
// Try to parse country name to get the code
|
||||||
|
return parseCountryInput(code) || '';
|
||||||
|
})
|
||||||
|
.filter(code => code.length === 2); // Only keep valid 2-letter codes
|
||||||
|
|
||||||
|
// Remove duplicates
|
||||||
|
return [...new Set(codes)];
|
||||||
|
});
|
||||||
|
|
||||||
|
const countryNames = computed(() => {
|
||||||
|
return countryCodes.value
|
||||||
|
.map(code => getCountryName(code))
|
||||||
|
.filter(name => name)
|
||||||
|
.join(props.separator);
|
||||||
|
});
|
||||||
|
|
||||||
|
const flagSize = computed(() => {
|
||||||
|
const sizeMap = {
|
||||||
|
small: 'sm',
|
||||||
|
medium: 'md',
|
||||||
|
large: 'lg'
|
||||||
|
};
|
||||||
|
|
||||||
|
return sizeMap[props.size];
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeholderStyle = computed(() => {
|
||||||
|
const sizeMap = {
|
||||||
|
small: '1rem',
|
||||||
|
medium: '1.5rem',
|
||||||
|
large: '2rem'
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: sizeMap[props.size],
|
||||||
|
height: `calc(${sizeMap[props.size]} * 0.75)`,
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '2px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
fontSize: '0.75rem'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.multiple-country-flags {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiple-country-flags--small {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-flag-item {
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add slight overlap for multiple flags to save space */
|
||||||
|
.country-flag-item:not(:first-child) {
|
||||||
|
margin-left: -0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiple-country-flags--small .country-flag-item:not(:first-child) {
|
||||||
|
margin-left: -0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.country-names {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiple-country-flags--small .country-names {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-nationality {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multiple-country-flags--small .no-nationality {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-placeholder {
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure proper flag display */
|
||||||
|
:deep(.vue-country-flag) {
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add hover effect to see all flags clearly */
|
||||||
|
.multiple-country-flags:hover .country-flag-item:not(:first-child) {
|
||||||
|
margin-left: 0.125rem;
|
||||||
|
transition: margin-left 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -190,14 +190,12 @@
|
||||||
|
|
||||||
<template v-slot:item.nationality="{ item }">
|
<template v-slot:item.nationality="{ item }">
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
<CountryFlag
|
<MultipleCountryFlags
|
||||||
v-if="item.nationality"
|
:nationality="item.nationality"
|
||||||
:country-code="item.nationality"
|
:show-name="true"
|
||||||
:show-name="false"
|
|
||||||
size="small"
|
size="small"
|
||||||
class="mr-2"
|
fallback-text="Not specified"
|
||||||
/>
|
/>
|
||||||
<span>{{ getCountryName(item.nationality) || 'Not specified' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -320,16 +318,13 @@
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="d-flex align-center justify-center mb-3">
|
<div class="d-flex align-center justify-center mb-3">
|
||||||
<CountryFlag
|
<MultipleCountryFlags
|
||||||
v-if="member.nationality"
|
:nationality="member.nationality"
|
||||||
:country-code="member.nationality"
|
:show-name="true"
|
||||||
:show-name="false"
|
|
||||||
size="small"
|
size="small"
|
||||||
class="mr-2"
|
fallback-text="No nationality"
|
||||||
|
class="text-body-2 text-medium-emphasis"
|
||||||
/>
|
/>
|
||||||
<span class="text-body-2 text-medium-emphasis">
|
|
||||||
{{ getCountryName(member.nationality) || 'No nationality' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email -->
|
<!-- Email -->
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,16 @@
|
||||||
<div class="text-body-2">{{ item.email }}</div>
|
<div class="text-body-2">{{ item.email }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Nationality -->
|
||||||
|
<template v-slot:item.nationality="{ item }">
|
||||||
|
<MultipleCountryFlags
|
||||||
|
:nationality="item.nationality"
|
||||||
|
:show-name="false"
|
||||||
|
size="small"
|
||||||
|
fallback-text="-"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<template v-slot:item.status="{ item }">
|
<template v-slot:item.status="{ item }">
|
||||||
<v-chip
|
<v-chip
|
||||||
|
|
@ -328,9 +338,14 @@
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
<v-select
|
<v-select
|
||||||
v-model="newMember.nationality"
|
v-model="newMember.nationality"
|
||||||
label="Nationality"
|
label="Nationality (can select multiple)"
|
||||||
:items="nationalities"
|
:items="nationalityOptions"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
multiple
|
||||||
|
chips
|
||||||
|
closable-chips
|
||||||
|
hint="Select all applicable nationalities"
|
||||||
|
persistent-hint
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="6">
|
||||||
|
|
@ -395,12 +410,35 @@ const stats = ref({
|
||||||
const statusOptions = ['Active', 'Inactive'];
|
const statusOptions = ['Active', 'Inactive'];
|
||||||
const duesOptions = ['Paid', 'Pending', 'Overdue'];
|
const duesOptions = ['Paid', 'Pending', 'Overdue'];
|
||||||
const memberTypeOptions = ['Regular', 'Premium', 'Honorary', 'Board', 'Admin'];
|
const memberTypeOptions = ['Regular', 'Premium', 'Honorary', 'Board', 'Admin'];
|
||||||
const nationalities = ['United States', 'Monaco', 'France', 'Italy', 'United Kingdom', 'Germany', 'Other'];
|
// Country options with codes for multiple selection
|
||||||
|
const nationalityOptions = [
|
||||||
|
{ title: 'United States', value: 'US' },
|
||||||
|
{ title: 'Monaco', value: 'MC' },
|
||||||
|
{ title: 'France', value: 'FR' },
|
||||||
|
{ title: 'Italy', value: 'IT' },
|
||||||
|
{ title: 'United Kingdom', value: 'GB' },
|
||||||
|
{ title: 'Germany', value: 'DE' },
|
||||||
|
{ title: 'Spain', value: 'ES' },
|
||||||
|
{ title: 'Sweden', value: 'SE' },
|
||||||
|
{ title: 'Norway', value: 'NO' },
|
||||||
|
{ title: 'Denmark', value: 'DK' },
|
||||||
|
{ title: 'Canada', value: 'CA' },
|
||||||
|
{ title: 'Australia', value: 'AU' },
|
||||||
|
{ title: 'Japan', value: 'JP' },
|
||||||
|
{ title: 'China', value: 'CN' },
|
||||||
|
{ title: 'India', value: 'IN' },
|
||||||
|
{ title: 'Brazil', value: 'BR' },
|
||||||
|
{ title: 'Mexico', value: 'MX' },
|
||||||
|
{ title: 'Russia', value: 'RU' },
|
||||||
|
{ title: 'South Africa', value: 'ZA' },
|
||||||
|
{ title: 'Other', value: 'XX' }
|
||||||
|
];
|
||||||
|
|
||||||
// Table headers
|
// Table headers
|
||||||
const headers = [
|
const headers = [
|
||||||
{ title: 'Member', key: 'name', sortable: true },
|
{ title: 'Member', key: 'name', sortable: true },
|
||||||
{ title: 'Email', key: 'email', sortable: true },
|
{ title: 'Email', key: 'email', sortable: true },
|
||||||
|
{ title: 'Nationality', key: 'nationality', sortable: true },
|
||||||
{ title: 'Status', key: 'status', sortable: true },
|
{ title: 'Status', key: 'status', sortable: true },
|
||||||
{ title: 'Dues', key: 'duesStatus', sortable: true },
|
{ title: 'Dues', key: 'duesStatus', sortable: true },
|
||||||
{ title: 'Type', key: 'memberType', sortable: true },
|
{ title: 'Type', key: 'memberType', sortable: true },
|
||||||
|
|
@ -418,7 +456,7 @@ const newMember = ref({
|
||||||
lastName: '',
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
nationality: '',
|
nationality: [] as string[], // Array for multiple nationalities
|
||||||
memberType: 'Regular',
|
memberType: 'Regular',
|
||||||
joinDate: new Date().toISOString().split('T')[0]
|
joinDate: new Date().toISOString().split('T')[0]
|
||||||
});
|
});
|
||||||
|
|
@ -511,7 +549,14 @@ const deleteMember = (member: any) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const addMember = () => {
|
const addMember = () => {
|
||||||
console.log('Adding new member:', newMember.value);
|
// Convert nationality array to comma-separated string for storage
|
||||||
|
const memberData = {
|
||||||
|
...newMember.value,
|
||||||
|
nationality: Array.isArray(newMember.value.nationality)
|
||||||
|
? newMember.value.nationality.join(',')
|
||||||
|
: newMember.value.nationality
|
||||||
|
};
|
||||||
|
console.log('Adding new member:', memberData);
|
||||||
showAddMemberDialog.value = false;
|
showAddMemberDialog.value = false;
|
||||||
// Reset form
|
// Reset form
|
||||||
newMember.value = {
|
newMember.value = {
|
||||||
|
|
@ -519,7 +564,7 @@ const addMember = () => {
|
||||||
lastName: '',
|
lastName: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
nationality: '',
|
nationality: [],
|
||||||
memberType: 'Regular',
|
memberType: 'Regular',
|
||||||
joinDate: new Date().toISOString().split('T')[0]
|
joinDate: new Date().toISOString().split('T')[0]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue