Add support for multiple nationalities display with flags
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:
Matt 2025-09-04 19:08:53 +02:00
parent d34d16fda1
commit b67100df2a
3 changed files with 235 additions and 20 deletions

View File

@ -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>

View File

@ -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 -->

View File

@ -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]
}; };