diff --git a/package.json b/package.json
index 64004f37..da3b8435 100644
--- a/package.json
+++ b/package.json
@@ -59,7 +59,7 @@
         "salix": "cd ../salix && gulp back",
         "db": "cd ../salix && gulp docker",
         "cy:open": "npm run db && cypress open",
-        "test:e2e": "npm run db && cypress run",
+        "test:e2e": "npm run db && cypress run --headed --config video=false",
         "test:unit": "vitest",
         "build": "rm -rf dist/ ; quasar build",
         "clean": "rm -rf dist/",
diff --git a/src/components/common/FormModel.vue b/src/components/common/FormModel.vue
index 7f0bfa2e..be7ae1c4 100644
--- a/src/components/common/FormModel.vue
+++ b/src/components/common/FormModel.vue
@@ -81,7 +81,7 @@ const { isHeaderMounted } = storeToRefs(appStore);
 
 const isLoading = ref(false);
 const formData = ref({});
-const addressFormRef = ref(null);
+const formModelRef = ref(null);
 const hasChanges = ref(!props.observeFormChanges);
 const isResetting = ref(false);
 const originalData = ref(null);
@@ -119,6 +119,7 @@ onMounted(async () => {
         );
     }
 });
+
 async function fetch() {
     try {
         let { data } = await api.get(props.url, {
@@ -133,9 +134,12 @@ async function fetch() {
 }
 
 async function submit() {
-    console.log('submit: ');
     if (props.observeFormChanges && !hasChanges.value)
-        return notify('globals.noChanges', 'negative');
+        return notify('noChanges', 'negative');
+
+    const isValid = await formModelRef.value.validate();
+    console.log('isValid', isValid);
+    if (!isValid) return;
 
     isLoading.value = true;
     try {
@@ -167,7 +171,7 @@ defineExpose({
     <QCard class="form-container" v-bind="$attrs">
         <QForm
             v-if="!isLoading"
-            ref="addressFormRef"
+            ref="formModelRef"
             class="form"
             :class="separationBetweenInputs"
         >
diff --git a/src/components/common/VnInput.vue b/src/components/common/VnInput.vue
index 508ce132..7dcf1164 100644
--- a/src/components/common/VnInput.vue
+++ b/src/components/common/VnInput.vue
@@ -1,15 +1,20 @@
 <script setup>
-import { computed, ref } from 'vue';
+import { computed, ref, useAttrs, nextTick } from 'vue';
 import { useI18n } from 'vue-i18n';
+import { useRequired } from 'src/composables/useRequired';
 
+const $attrs = useAttrs();
+const { isRequired, requiredFieldRule } = useRequired($attrs);
+const { t } = useI18n();
 const emit = defineEmits([
     'update:modelValue',
     'update:options',
     'keyup.enter',
-    'remove'
+    'remove',
+    'blur'
 ]);
 
-const props = defineProps({
+const $props = defineProps({
     modelValue: {
         type: [String, Number],
         default: null
@@ -25,24 +30,43 @@ const props = defineProps({
     clearable: {
         type: Boolean,
         default: true
+    },
+    emptyToNull: {
+        type: Boolean,
+        default: true
+    },
+    insertable: {
+        type: Boolean,
+        default: false
+    },
+    maxlength: {
+        type: Number,
+        default: null
+    },
+    uppercase: {
+        type: Boolean,
+        default: false
     }
 });
 
-const { t } = useI18n();
-const requiredFieldRule = val => !!val || t('globals.fieldRequired');
 const vnInputRef = ref(null);
 const value = computed({
     get() {
-        return props.modelValue;
+        return $props.modelValue;
     },
     set(value) {
+        if ($props.emptyToNull && value === '') value = null;
         emit('update:modelValue', value);
     }
 });
 const hover = ref(false);
 const styleAttrs = computed(() => {
-    return props.isOutlined
-        ? { dense: true, outlined: true, rounded: true }
+    return $props.isOutlined
+        ? {
+              dense: true,
+              outlined: true,
+              rounded: true
+          }
         : {};
 });
 
@@ -51,66 +75,119 @@ const focus = () => {
 };
 
 defineExpose({
-    focus
+    focus,
+    vnInputRef
 });
 
-const inputRules = [
+const mixinRules = [
+    requiredFieldRule,
+    ...($attrs.rules ?? []),
     val => {
+        const { maxlength } = vnInputRef.value;
+        if (maxlength && +val.length > maxlength)
+            return t(`maxLength`, { value: maxlength });
         const { min, max } = vnInputRef.value.$attrs;
-        if (min >= 0) {
+        if (!min) return null;
+        if (min >= 0)
             if (Math.floor(val) < min) return t('inputMin', { value: min });
-        }
+        if (!max) return null;
         if (max > 0) {
             if (Math.floor(val) > max) return t('inputMax', { value: max });
         }
     }
 ];
+
+const handleKeydown = e => {
+    if (e.key === 'Backspace') return;
+
+    if ($props.insertable && e.key.match(/[0-9]/)) {
+        handleInsertMode(e);
+    }
+};
+
+const handleInsertMode = e => {
+    e.preventDefault();
+    const input = e.target;
+    const cursorPos = input.selectionStart;
+    const { maxlength } = vnInputRef.value;
+    let currentValue = value.value;
+    if (!currentValue) currentValue = e.key;
+    const newValue = e.key;
+    if (newValue && !isNaN(newValue) && cursorPos < maxlength) {
+        value.value =
+            currentValue.substring(0, cursorPos) +
+            newValue +
+            currentValue.substring(cursorPos + 1);
+    }
+    nextTick(() => {
+        input.setSelectionRange(cursorPos + 1, cursorPos + 1);
+    });
+};
+
+const handleUppercase = () => {
+    value.value = value.value?.toUpperCase() || '';
+};
 </script>
 
 <template>
-    <div
-        :rules="$attrs.required ? [requiredFieldRule] : null"
-        @mouseover="hover = true"
-        @mouseleave="hover = false"
-    >
+    <div @mouseover="hover = true" @mouseleave="hover = false">
         <QInput
             ref="vnInputRef"
             v-model="value"
             v-bind="{ ...$attrs, ...styleAttrs }"
             :type="$attrs.type"
-            :class="{ required: $attrs.required }"
+            :class="{ required: isRequired }"
+            @keyup.enter="emit('keyup.enter')"
+            @blur="emit('blur')"
+            @keydown="handleKeydown"
             :clearable="false"
-            :rules="inputRules"
+            :rules="mixinRules"
             :lazy-rules="true"
             hide-bottom-space
-            @keyup.enter="emit('keyup.enter')"
+            :data-cy="$attrs.dataCy ?? $attrs.label + '_input'"
         >
-            <template
-                v-if="$slots.prepend"
-                #prepend
-            >
+            <template #prepend v-if="$slots.prepend">
                 <slot name="prepend" />
             </template>
             <template #append>
-                <slot
-                    v-if="$slots.append && !$attrs.disabled"
-                    name="append"
-                />
                 <QIcon
-                    v-if="hover && value && !$attrs.disabled && props.clearable"
                     name="close"
                     size="xs"
+                    :style="{
+                        visibility:
+                            hover &&
+                            value &&
+                            !$attrs.disabled &&
+                            !$attrs.readonly &&
+                            $props.clearable
+                                ? 'visible'
+                                : 'hidden'
+                    }"
                     @click="
                         () => {
                             value = null;
+                            vnInputRef.focus();
                             emit('remove');
                         }
                     "
-                />
+                ></QIcon>
+
                 <QIcon
-                    v-if="info"
-                    name="info"
+                    name="match_case"
+                    size="xs"
+                    v-if="
+                        !$attrs.disabled && !$attrs.readonly && $props.uppercase
+                    "
+                    @click="handleUppercase"
+                    class="uppercase-icon"
                 >
+                    <QTooltip>
+                        {{ t('Convert to uppercase') }}
+                    </QTooltip>
+                </QIcon>
+
+                <slot name="append" v-if="$slots.append && !$attrs.disabled" />
+                <QIcon v-if="info" name="info">
                     <QTooltip max-width="350px">
                         {{ info }}
                     </QTooltip>
@@ -120,20 +197,44 @@ const inputRules = [
     </div>
 </template>
 
+<style>
+.uppercase-icon {
+    transition:
+        color 0.3s,
+        transform 0.2s;
+    cursor: pointer;
+}
+
+.uppercase-icon:hover {
+    color: #ed9937;
+    transform: scale(1.2);
+}
+</style>
+
 <i18n lang="yaml">
 en-US:
     inputMin: Must be more than {value}
+    maxLength: The value exceeds {value} characters
     inputMax: Must be less than {value}
+    Convert to uppercase: Convert to uppercase
 es-ES:
     inputMin: Debe ser mayor a {value}
+    maxLength: El valor excede los {value} carácteres
     inputMax: Debe ser menor a {value}
+    Convert to uppercase: Convertir a mayúsculas
 ca-ES:
     inputMin: Ha de ser més gran que {value}
-    inputMax: Ha de ser menys que {value}
+    maxLength: El valor excedeix els {value} caràcters
+    inputMax: Ha de ser menor que {value}
+    Convert to uppercase: Convertir a majúscules
 fr-FR:
     inputMin: Doit être supérieur à {value}
-    inputMax: Doit être supérieur à {value}
+    maxLength: La valeur dépasse {value} caractères
+    inputMax: Doit être inférieur à {value}
+    Convert to uppercase: Convertir en majuscules
 pt-PT:
     inputMin: Deve ser maior que {value}
-    inputMax: Deve ser maior que {value}
+    maxLength: O valor excede {value} caracteres
+    inputMax: Deve ser menor que {value}
+    Convert to uppercase: Converter para maiúsculas
 </i18n>
diff --git a/src/components/common/VnSelect.vue b/src/components/common/VnSelect.vue
index c4a3e1df..65dbb2f2 100644
--- a/src/components/common/VnSelect.vue
+++ b/src/components/common/VnSelect.vue
@@ -67,7 +67,7 @@ const $props = defineProps({
 });
 
 const { t } = useI18n();
-const requiredFieldRule = val => val ?? t('globals.fieldRequired');
+const requiredFieldRule = val => val ?? t('fieldRequired');
 
 const { optionLabel, optionValue, options } = toRefs($props);
 const myOptions = ref([]);
diff --git a/src/composables/useRequired.js b/src/composables/useRequired.js
new file mode 100644
index 00000000..a77345b5
--- /dev/null
+++ b/src/composables/useRequired.js
@@ -0,0 +1,17 @@
+import { useI18n } from 'vue-i18n';
+
+export function useRequired($attrs) {
+    const { t } = useI18n();
+
+    const isRequired =
+        typeof $attrs['required'] === 'boolean'
+            ? $attrs['required']
+            : Object.keys($attrs).includes('required');
+    const requiredFieldRule = val =>
+        isRequired ? !!val || t('fieldRequired') : null;
+
+    return {
+        isRequired,
+        requiredFieldRule
+    };
+}
diff --git a/src/i18n/ca-ES/index.js b/src/i18n/ca-ES/index.js
index 456795a5..48d214e0 100644
--- a/src/i18n/ca-ES/index.js
+++ b/src/i18n/ca-ES/index.js
@@ -134,6 +134,8 @@ export default {
     introduceSearchTerm: 'Introdueix un terme de cerca',
     noOrdersFound: `No s'han trobat comandes`,
     send: 'Enviar',
+    fieldRequired: 'Aquest camp és obligatori',
+    noChanges: 'No s’han fet canvis',
     // Image related translations
     'Cant lock cache': 'No es pot bloquejar la memòria cau',
     'Bad file format': 'Format de fitxer no reconegut',
diff --git a/src/i18n/en-US/index.js b/src/i18n/en-US/index.js
index 3e21b66d..80107dd5 100644
--- a/src/i18n/en-US/index.js
+++ b/src/i18n/en-US/index.js
@@ -167,6 +167,8 @@ export default {
     introduceSearchTerm: 'Enter a search term',
     noOrdersFound: 'No orders found',
     send: 'Send',
+    fieldRequired: 'Field required',
+    noChanges: 'No changes',
     // Image related translations
     'Cant lock cache': 'The cache could not be blocked',
     'Bad file format': 'Unrecognized file format',
diff --git a/src/i18n/es-ES/index.js b/src/i18n/es-ES/index.js
index 5034ff70..15639c2d 100644
--- a/src/i18n/es-ES/index.js
+++ b/src/i18n/es-ES/index.js
@@ -166,6 +166,8 @@ export default {
     introduceSearchTerm: 'Introduce un término de búsqueda',
     noOrdersFound: 'No se encontrado pedidos',
     send: 'Enviar',
+    fieldRequired: 'Campo requerido',
+    noChanges: 'No se han hecho cambios',
     // Image related translations
     'Cant lock cache': 'La caché no pudo ser bloqueada',
     'Bad file format': 'Formato de archivo no reconocido',
diff --git a/src/i18n/fr-FR/index.js b/src/i18n/fr-FR/index.js
index d30dee52..2eea9a38 100644
--- a/src/i18n/fr-FR/index.js
+++ b/src/i18n/fr-FR/index.js
@@ -134,6 +134,8 @@ export default {
     introduceSearchTerm: 'Entrez un terme de recherche',
     noOrdersFound: 'Aucune commande trouvée',
     send: 'Envoyer',
+    fieldRequired: 'Champ obligatoire',
+    noChanges: 'Aucun changement',
     // Image related translations
     'Cant lock cache': "Le cache n'a pas pu être verrouillé",
     'Bad file format': 'Format de fichier non reconnu',
diff --git a/src/i18n/pt-PT/index.js b/src/i18n/pt-PT/index.js
index 39bbe5de..460335e0 100644
--- a/src/i18n/pt-PT/index.js
+++ b/src/i18n/pt-PT/index.js
@@ -133,6 +133,8 @@ export default {
     introduceSearchTerm: 'Digite um termo de pesquisa',
     noOrdersFound: 'Nenhum pedido encontrado',
     send: 'Enviar',
+    fieldRequired: 'Campo obrigatório',
+    noChanges: 'Sem alterações',
     // Image related translations
     'Cant lock cache': 'O cache não pôde ser bloqueado',
     'Bad file format': 'Formato de arquivo inválido',
diff --git a/src/pages/Account/AddressDetails.vue b/src/pages/Account/AddressDetails.vue
index 31a372d7..362bc95d 100644
--- a/src/pages/Account/AddressDetails.vue
+++ b/src/pages/Account/AddressDetails.vue
@@ -116,22 +116,26 @@ onMounted(async () => {
                     v-model="data.nickname"
                     :label="t('name')"
                     data-cy="addressFormNickname"
+                    :required="true"
                 />
                 <VnInput
                     v-model="data.street"
                     :label="t('address')"
                     data-cy="addressFormStreet"
+                    :required="true"
                 />
                 <VnInput
                     v-model="data.city"
                     :label="t('city')"
                     data-cy="addressFormCity"
+                    :required="true"
                 />
                 <VnInput
                     v-model="data.postalCode"
                     type="number"
                     :label="t('postalCode')"
                     data-cy="addressFormPostcode"
+                    :required="true"
                 />
                 <VnSelect
                     v-model="data.countryFk"
@@ -139,12 +143,14 @@ onMounted(async () => {
                     :options="countriesOptions"
                     @update:model-value="data.provinceFk = null"
                     data-cy="addressFormCountry"
+                    :required="true"
                 />
                 <VnSelect
                     v-model="data.provinceFk"
                     :label="t('province')"
                     :options="provincesOptions"
                     data-cy="addressFormProvince"
+                    :required="true"
                 />
             </template>
         </FormModel>