Replace v-phone-input with custom PhoneInput component

Implement a custom PhoneInput component to replace the third-party v-phone-input library. The new component provides country selection, phone number formatting, and integrates seamlessly with Vuetify's form controls. Updated CreateInterestModal and InterestDetailsModal to use the new component.
This commit is contained in:
Matt 2025-06-04 02:55:02 +02:00
parent 6b922580c5
commit c592e38569
4 changed files with 203 additions and 19 deletions

View File

@ -76,14 +76,13 @@
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-phone-input
<PhoneInput
v-model="newInterest['Phone Number']"
label="Phone Number"
variant="outlined"
density="comfortable"
country="US"
default-country="US"
:preferred-countries="['US', 'FR', 'ES', 'PT', 'GB']"
:enable-searching-country="true"
/>
</v-col>
<v-col cols="12" md="4">
@ -313,6 +312,7 @@
<script lang="ts" setup>
import { ref, computed, watch } from "vue";
import type { Interest } from "@/utils/types";
import PhoneInput from "./PhoneInput.vue";
import {
InterestSalesProcessLevelFlow,
InterestLeadCategoryFlow,

View File

@ -184,14 +184,13 @@
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-phone-input
<PhoneInput
v-model="interest['Phone Number']"
label="Phone Number"
variant="outlined"
density="comfortable"
country="US"
default-country="US"
:preferred-countries="['US', 'FR', 'ES', 'PT', 'GB']"
:enable-searching-country="true"
/>
</v-col>
<v-col cols="12" md="4">
@ -497,6 +496,7 @@
<script lang="ts" setup>
import { ref, computed, watch, onMounted } from "vue";
import type { Interest, Berth } from "@/utils/types";
import PhoneInput from "./PhoneInput.vue";
import {
InterestSalesProcessLevelFlow,
InterestLeadCategoryFlow,

197
components/PhoneInput.vue Normal file
View File

@ -0,0 +1,197 @@
<template>
<div class="phone-input-wrapper">
<v-text-field
:model-value="displayValue"
@update:model-value="updatePhone"
:label="label"
:variant="variant"
:density="density"
:rules="rules"
:required="required"
:disabled="disabled"
:readonly="readonly"
:clearable="clearable"
:hide-details="hideDetails"
:error-messages="errorMessages"
>
<template #prepend-inner>
<v-menu
v-model="countryMenu"
:close-on-content-click="true"
location="bottom"
>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
variant="text"
size="small"
class="country-selector"
:disabled="disabled || readonly"
>
<span class="country-flag">{{ selectedCountry.flag }}</span>
<span class="country-code">{{ selectedCountry.dialCode }}</span>
<v-icon end size="small">mdi-menu-down</v-icon>
</v-btn>
</template>
<v-list density="compact" max-height="300">
<v-list-item
v-for="country in filteredCountries"
:key="country.iso2"
@click="selectCountry(country)"
:title="`${country.flag} ${country.name} (${country.dialCode})`"
>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-text-field>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
interface Country {
name: string;
iso2: string;
dialCode: string;
flag: string;
}
interface Props {
modelValue?: string;
label?: string;
variant?: 'flat' | 'elevated' | 'tonal' | 'outlined' | 'plain' | 'underlined' | 'solo' | 'solo-inverted' | 'solo-filled';
density?: 'default' | 'comfortable' | 'compact';
rules?: any[];
required?: boolean;
disabled?: boolean;
readonly?: boolean;
clearable?: boolean;
hideDetails?: boolean | 'auto';
errorMessages?: string | string[];
defaultCountry?: string;
preferredCountries?: string[];
}
const props = withDefaults(defineProps<Props>(), {
label: 'Phone Number',
variant: 'outlined',
density: 'comfortable',
defaultCountry: 'US',
preferredCountries: () => ['US', 'FR', 'ES', 'PT', 'GB']
});
const emit = defineEmits<{
'update:modelValue': [value: string];
}>();
// Countries data
const countries: Country[] = [
{ name: 'United States', iso2: 'US', dialCode: '+1', flag: '🇺🇸' },
{ name: 'United Kingdom', iso2: 'GB', dialCode: '+44', flag: '🇬🇧' },
{ name: 'France', iso2: 'FR', dialCode: '+33', flag: '🇫🇷' },
{ name: 'Spain', iso2: 'ES', dialCode: '+34', flag: '🇪🇸' },
{ name: 'Portugal', iso2: 'PT', dialCode: '+351', flag: '🇵🇹' },
{ name: 'Germany', iso2: 'DE', dialCode: '+49', flag: '🇩🇪' },
{ name: 'Italy', iso2: 'IT', dialCode: '+39', flag: '🇮🇹' },
{ name: 'Netherlands', iso2: 'NL', dialCode: '+31', flag: '🇳🇱' },
{ name: 'Belgium', iso2: 'BE', dialCode: '+32', flag: '🇧🇪' },
{ name: 'Switzerland', iso2: 'CH', dialCode: '+41', flag: '🇨🇭' },
{ name: 'Austria', iso2: 'AT', dialCode: '+43', flag: '🇦🇹' },
{ name: 'Sweden', iso2: 'SE', dialCode: '+46', flag: '🇸🇪' },
{ name: 'Norway', iso2: 'NO', dialCode: '+47', flag: '🇳🇴' },
{ name: 'Denmark', iso2: 'DK', dialCode: '+45', flag: '🇩🇰' },
{ name: 'Finland', iso2: 'FI', dialCode: '+358', flag: '🇫🇮' },
{ name: 'Poland', iso2: 'PL', dialCode: '+48', flag: '🇵🇱' },
{ name: 'Greece', iso2: 'GR', dialCode: '+30', flag: '🇬🇷' },
{ name: 'Ireland', iso2: 'IE', dialCode: '+353', flag: '🇮🇪' },
{ name: 'Canada', iso2: 'CA', dialCode: '+1', flag: '🇨🇦' },
{ name: 'Australia', iso2: 'AU', dialCode: '+61', flag: '🇦🇺' },
{ name: 'New Zealand', iso2: 'NZ', dialCode: '+64', flag: '🇳🇿' },
{ name: 'Japan', iso2: 'JP', dialCode: '+81', flag: '🇯🇵' },
{ name: 'China', iso2: 'CN', dialCode: '+86', flag: '🇨🇳' },
{ name: 'India', iso2: 'IN', dialCode: '+91', flag: '🇮🇳' },
{ name: 'Brazil', iso2: 'BR', dialCode: '+55', flag: '🇧🇷' },
{ name: 'Mexico', iso2: 'MX', dialCode: '+52', flag: '🇲🇽' },
{ name: 'South Africa', iso2: 'ZA', dialCode: '+27', flag: '🇿🇦' },
];
const countryMenu = ref(false);
const localNumber = ref('');
const selectedCountry = ref<Country>(
countries.find(c => c.iso2 === props.defaultCountry) || countries[0]
);
// Sort countries with preferred ones first
const filteredCountries = computed(() => {
const preferred = countries.filter(c => props.preferredCountries.includes(c.iso2));
const others = countries.filter(c => !props.preferredCountries.includes(c.iso2));
return [...preferred, ...others];
});
// Display value (local number without country code)
const displayValue = computed(() => localNumber.value);
// Parse initial value
watch(() => props.modelValue, (newValue) => {
if (newValue) {
// Find matching country code
const country = countries.find(c => newValue.startsWith(c.dialCode));
if (country) {
selectedCountry.value = country;
localNumber.value = newValue.slice(country.dialCode.length);
} else {
// Default to current country if no match
localNumber.value = newValue;
}
} else {
localNumber.value = '';
}
}, { immediate: true });
// Update phone number
const updatePhone = (value: string) => {
localNumber.value = value;
if (value) {
emit('update:modelValue', selectedCountry.value.dialCode + value);
} else {
emit('update:modelValue', '');
}
};
// Select country
const selectCountry = (country: Country) => {
selectedCountry.value = country;
countryMenu.value = false;
if (localNumber.value) {
emit('update:modelValue', country.dialCode + localNumber.value);
}
};
</script>
<style scoped>
.phone-input-wrapper {
width: 100%;
}
.country-selector {
padding: 0 8px;
min-width: auto;
height: auto;
}
.country-flag {
font-size: 1.2rem;
margin-right: 4px;
}
.country-code {
font-size: 0.875rem;
margin-right: 4px;
}
:deep(.v-field__prepend-inner) {
padding-top: 0 !important;
}
</style>

View File

@ -1,13 +0,0 @@
import { defineNuxtPlugin } from '#app'
import { createVPhoneInput } from 'v-phone-input'
import 'v-phone-input/dist/v-phone-input.css'
export default defineNuxtPlugin((nuxtApp) => {
const vPhoneInput = createVPhoneInput({
defaultCountry: 'US',
preferredCountries: ['US', 'FR', 'ES', 'PT', 'GB'],
enableSearchingCountry: true,
})
nuxtApp.vueApp.use(vPhoneInput)
})