Эх сурвалжийг харах

Merge branch 'feature/DIARIA-gus-bloqueio-logins' of Softpar/sfp_front_vue_diarista_cliente into development

zntt 1 сар өмнө
parent
commit
38c02aff0b

+ 104 - 5
package-lock.json

@@ -1,15 +1,17 @@
 {
-  "name": "quasar-skeleton",
+  "name": "diarista-cliente-app",
   "version": "0.0.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "quasar-skeleton",
+      "name": "diarista-cliente-app",
       "version": "0.0.1",
       "dependencies": {
         "@bufbuild/protobuf": "^2.5.1",
         "@capacitor/device": "^7.0.1",
+        "@capacitor/geolocation": "^8.2.0",
+        "@capacitor/google-maps": "^8.0.1",
         "@quasar/cli": "^2.5.0",
         "@quasar/extras": "^1.17.0",
         "axios": "^1.9.0",
@@ -311,9 +313,9 @@
       }
     },
     "node_modules/@capacitor/core": {
-      "version": "7.4.2",
-      "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-7.4.2.tgz",
-      "integrity": "sha512-akCf9A1FUR8AWTtmgGjHEq6LmGsjA2U7igaJ9PxiCBfyxKqlDbuGHrlNdpvHEjV5tUPH3KYtkze6gtFcNKPU9A==",
+      "version": "8.3.1",
+      "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.1.tgz",
+      "integrity": "sha512-UF8ItlHguU1Z6GXfPTeT2gakf+ctNI8pAS1kwSBQlsJMlfD4OPoto/SmKnOxKCQvnF4WRcdWeg6C0zREUNaAQg==",
       "license": "MIT",
       "peer": true,
       "dependencies": {
@@ -329,6 +331,32 @@
         "@capacitor/core": ">=7.0.0"
       }
     },
+    "node_modules/@capacitor/geolocation": {
+      "version": "8.2.0",
+      "resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.0.tgz",
+      "integrity": "sha512-N29QcoIPmme0xSxRkm7+3hjoHp6mBAOarxecvtCCZKyOBeKiJsFUq981cezg2XWBa6fhCXJMCCjQPngKK/dIag==",
+      "license": "MIT",
+      "dependencies": {
+        "@capacitor/synapse": "^1.0.4"
+      },
+      "peerDependencies": {
+        "@capacitor/core": ">=8.0.0"
+      }
+    },
+    "node_modules/@capacitor/google-maps": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/@capacitor/google-maps/-/google-maps-8.0.1.tgz",
+      "integrity": "sha512-XSOyanbtOeO5KrSfoJOpbcSW4EXvfdxp+SUy6umdIGFeR2WJKNnPC6isXn+zjJgJH8kblT2X4fF0dxhZV8wLBg==",
+      "license": "MIT",
+      "dependencies": {
+        "@googlemaps/js-api-loader": "^2.0.2",
+        "@googlemaps/markerclusterer": "^2.6.2",
+        "@types/google.maps": "^3.58.1"
+      },
+      "peerDependencies": {
+        "@capacitor/core": ">=8.0.0"
+      }
+    },
     "node_modules/@capacitor/keyboard": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-7.0.1.tgz",
@@ -359,6 +387,12 @@
         "@capacitor/core": ">=7.0.0"
       }
     },
+    "node_modules/@capacitor/synapse": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
+      "integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
+      "license": "ISC"
+    },
     "node_modules/@esbuild/aix-ppc64": {
       "version": "0.25.6",
       "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
@@ -990,6 +1024,26 @@
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
       }
     },
+    "node_modules/@googlemaps/js-api-loader": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-2.0.2.tgz",
+      "integrity": "sha512-bKVuTqatS8Jven5aFqVB7rCHF1VFEzpzyi0ruzO0GUR+A7m9oMqMgtnmpANj7kMYEvvhty8Fk7TnJ1MKjWHu+Q==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@types/google.maps": "^3.53.1"
+      }
+    },
+    "node_modules/@googlemaps/markerclusterer": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/@googlemaps/markerclusterer/-/markerclusterer-2.6.2.tgz",
+      "integrity": "sha512-U6uVhq8iWhiIckA89sgRu8OK35mjd6/3CuoZKWakKEf0QmRRWpatlsPb3kqXkoWSmbcZkopRiI4dnW6DQSd7bQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@types/supercluster": "^7.1.3",
+        "fast-equals": "^5.2.2",
+        "supercluster": "^8.0.1"
+      }
+    },
     "node_modules/@humanfs/core": {
       "version": "0.19.1",
       "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -2400,6 +2454,18 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/geojson": {
+      "version": "7946.0.16",
+      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+      "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/google.maps": {
+      "version": "3.64.0",
+      "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.0.tgz",
+      "integrity": "sha512-dN0H6tB4lgLQLovcbPXFYYOEV41TpyyJghzb5jrzjB96FZmjeOghevVdC+BMGd6YqyCqXaggyEtqRXLRjzCBZA==",
+      "license": "MIT"
+    },
     "node_modules/@types/har-format": {
       "version": "1.2.16",
       "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
@@ -2498,6 +2564,15 @@
         "@types/send": "*"
       }
     },
+    "node_modules/@types/supercluster": {
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
+      "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/geojson": "*"
+      }
+    },
     "node_modules/@typescript-eslint/project-service": {
       "version": "8.37.0",
       "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz",
@@ -5180,6 +5255,15 @@
       "dev": true,
       "license": "Apache-2.0"
     },
+    "node_modules/fast-equals": {
+      "version": "5.4.0",
+      "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
+      "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/fast-fifo": {
       "version": "1.3.2",
       "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@@ -6374,6 +6458,12 @@
         "graceful-fs": "^4.1.6"
       }
     },
+    "node_modules/kdbush": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
+      "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
+      "license": "ISC"
+    },
     "node_modules/keyv": {
       "version": "4.5.4",
       "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -8846,6 +8936,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/supercluster": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
+      "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
+      "license": "ISC",
+      "dependencies": {
+        "kdbush": "^4.0.2"
+      }
+    },
     "node_modules/superjson": {
       "version": "2.2.2",
       "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz",

+ 2 - 0
package.json

@@ -20,6 +20,8 @@
   "dependencies": {
     "@bufbuild/protobuf": "^2.5.1",
     "@capacitor/device": "^7.0.1",
+    "@capacitor/geolocation": "^8.2.0",
+    "@capacitor/google-maps": "^8.0.1",
     "@quasar/cli": "^2.5.0",
     "@quasar/extras": "^1.17.0",
     "axios": "^1.9.0",

+ 4 - 0
quasar.config.js

@@ -5,6 +5,9 @@
 
 import { defineConfig } from "#q-app/wrappers";
 import { fileURLToPath } from "node:url";
+import { config as loadEnv } from "dotenv";
+
+loadEnv();
 
 export default defineConfig((ctx) => {
   return {
@@ -67,6 +70,7 @@ export default defineConfig((ctx) => {
         WEBSOCKET_ROOM: ctx.dev ? "LARAVEL" : "LARAVEL",
         WEBSOCKET_API_KEY:
           "7wArC/kl0nTbt4zBu0agw.NXLyjA96I6x1XmBcuokwPqfo3/CIxzqYw.PTthh5eqa08Uf4ubFlOqatpShoz1CRRID9pZReEFvBk3il6E9u",
+        GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY ?? "",
       },
       // rawDefine: {}
       // ignorePublicFolder: true,

+ 6 - 0
src-capacitor/android/app/src/main/AndroidManifest.xml

@@ -9,6 +9,10 @@
         android:supportsRtl="true"
         android:theme="@style/AppTheme">
 
+        <meta-data
+            android:name="com.google.android.geo.API_KEY"
+            android:value="@string/maps_api_key"/>
+
         <activity
             android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
             android:name=".MainActivity"
@@ -39,4 +43,6 @@
     <!-- Permissions -->
 
     <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
 </manifest>

+ 1 - 0
src-capacitor/android/app/src/main/res/values/strings.xml

@@ -4,4 +4,5 @@
     <string name="title_activity_main">Quasar App</string>
     <string name="package_name">inf.br.softpar.skeleton</string>
     <string name="custom_url_scheme">inf.br.softpar.skeleton</string>
+    <string name="maps_api_key">google_maps_api_key</string>
 </resources>

+ 3 - 3
src/api/user.js

@@ -30,13 +30,13 @@ export const userTypes = async () => {
   return data.payload;
 };
 
-export const sendCode = async (email, phone, type = 'CLIENT') => {
-  const data = await api.post("/user-send-code", { email, phone, type });
+export const sendCode = async (email, phone) => {
+  const data = await api.post("/client-send-code", { email, phone });
   return data;
 }
 
 export const validateCode = async (email, phone, code, isLogin) => {
-  const data = await api.post("/user-validate-code", { email, phone, code, isLogin });
+  const data = await api.post("/validate-code-client", { email, phone, code, isLogin });
   return data;
 }
 

+ 41 - 21
src/components/login/LoginStepFourPanel.vue

@@ -15,9 +15,8 @@
           rounded
           class="bg-surface q-mb-md"
           input-class="text-text"
-          placeholder="Digite seu CEP"
+          :placeholder="$t('common.terms.cep')"
           hide-bottom-space
-          :rules="[inputRules.requiredHideMessage, inputRules.cep]"
           lazy-rules
           mask="#####-###"
           :loading="loadingCep"
@@ -35,11 +34,12 @@
           rounded
           padding="6px 16px"
           class="full-width"
+          :loading="loadingLocation"
           @click="useLocation"
         />
-
       </div>
     </q-card>
+
     <div class="text-center q-pt-sm">
       <q-btn
         flat
@@ -55,36 +55,56 @@
 
 <script setup>
 import { ref } from 'vue';
-import { useInputRules } from 'src/composables/useInputRules';
-import axios from 'axios';
+import { useQuasar } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import { useGeocodingApi } from 'src/composables/useGeocodingApi';
+import { useGeolocation } from 'src/composables/useGeolocation';
+
+const emit = defineEmits(['back', 'cep-resolved', 'device-location']);
 
-const emit = defineEmits(['back']);
-const cep = defineModel('cep', { type: String, default: '' });
+const $q = useQuasar();
+const { t } = useI18n();
+const { geocodeCep } = useGeocodingApi();
+const { requestPermission, getCurrentPosition } = useGeolocation();
 
-const { inputRules } = useInputRules();
+const cep = ref('');
 const loadingCep = ref(false);
+const loadingLocation = ref(false);
 
-const fetchCep = async (rawCep) => {
-  const cleaned = rawCep.replace(/\D/g, '');
+const onCepChange = async (val) => {
+  const cleaned = val?.replace(/\D/g, '') ?? '';
   if (cleaned.length !== 8) return;
+
   loadingCep.value = true;
   try {
-    const { data } = await axios.get(`https://viacep.com.br/ws/${cleaned}/json/`);
-    if (data.erro) cep.value = '';
-  } catch (error) {
-    console.log(error)
+    const result = await geocodeCep(cleaned);
+    if (!result) {
+      $q.notify({ type: 'negative', message: t('auth.cep_not_found') });
+      return;
+    }
+    emit('cep-resolved', result);
+  } catch {
+    $q.notify({ type: 'negative', message: t('auth.cep_not_found') });
   } finally {
     loadingCep.value = false;
   }
 };
 
-const onCepChange = (val) => {
-  const cleaned = val?.replace(/\D/g, '') ?? '';
-  if (cleaned.length === 8) fetchCep(val);
-};
-
-const useLocation = () => {
-  // getlocationfromdevice
+const useLocation = async () => {
+  loadingLocation.value = true;
+  try {
+    const granted = await requestPermission();
+    if (!granted) {
+      $q.notify({ type: 'warning', message: t('auth.location_permission_denied') });
+      return;
+    }
+    const position = await getCurrentPosition();
+    emit('device-location', position);
+  } catch {
+    $q.notify({ type: 'negative', message: t('auth.location_permission_denied') });
+  } finally {
+    loadingLocation.value = false;
+  }
 };
 </script>
 

+ 55 - 0
src/composables/useGeocodingApi.js

@@ -0,0 +1,55 @@
+import axios from 'axios';
+
+const GEOCODING_URL = 'https://maps.googleapis.com/maps/api/geocode/json';
+
+const parseAddressComponents = (components) => {
+  const get = (type, nameType = 'long_name') =>
+    components.find((c) => c.types.includes(type))?.[nameType] ?? '';
+
+  return {
+    address: `${get('route')}`.trim(),
+    number: get('street_number'),
+    district: get('sublocality_level_1') || get('sublocality') || get('neighborhood'),
+    city: get('administrative_area_level_2'),
+    state: get('administrative_area_level_1', 'short_name'),
+    zip_code: get('postal_code').replace(/\D/g, ''),
+  };
+};
+
+export const useGeocodingApi = () => {
+  const geocodeCep = async (cep) => {
+    const cleaned = cep.replace(/\D/g, '');
+    const { data } = await axios.get(GEOCODING_URL, {
+      params: {
+        address: `${cleaned}, Brazil`,
+        key: process.env.GOOGLE_MAPS_API_KEY,
+      },
+    });
+
+    if (data.status !== 'OK' || !data.results.length) return null;
+
+    const result = data.results[0];
+    const { lat, lng } = result.geometry.location;
+    const parsed = parseAddressComponents(result.address_components);
+
+    return { lat, lng, ...parsed };
+  };
+
+  const reverseGeocode = async (lat, lng) => {
+    const { data } = await axios.get(GEOCODING_URL, {
+      params: {
+        latlng: `${lat},${lng}`,
+        key: process.env.GOOGLE_MAPS_API_KEY,
+      },
+    });
+
+    if (data.status !== 'OK' || !data.results.length) return null;
+
+    const result = data.results[0];
+    const parsed = parseAddressComponents(result.address_components);
+
+    return { lat, lng, ...parsed };
+  };
+
+  return { geocodeCep, reverseGeocode };
+};

+ 21 - 0
src/composables/useGeolocation.js

@@ -0,0 +1,21 @@
+import { Geolocation } from '@capacitor/geolocation';
+
+export const useGeolocation = () => {
+  const requestPermission = async () => {
+    const status = await Geolocation.requestPermissions();
+    return status.location === 'granted' || status.location === 'limited';
+  };
+
+  const getCurrentPosition = async () => {
+    const position = await Geolocation.getCurrentPosition({
+      enableHighAccuracy: true,
+      timeout: 10000,
+    });
+    return {
+      lat: position.coords.latitude,
+      lng: position.coords.longitude,
+    };
+  };
+
+  return { requestPermission, getCurrentPosition };
+};

+ 9 - 1
src/i18n/locales/en.json

@@ -150,7 +150,15 @@
     "address_type_other": "Other",
     "step4_title": "Add your ZIP code and find the nearest cleaners",
     "use_location": "use my location",
-    "back_to_register": "Back to registration"
+    "back_to_register": "Back to registration",
+    "cep_not_found": "ZIP code not found. Please check and try again.",
+    "location_permission_denied": "Location permission denied. Please enable it in device settings.",
+    "confirm_location": "confirm location",
+    "confirm_address": "confirm address",
+    "complement": "Complement",
+    "complement_placeholder": "Ex: Apartment, Suite, House.",
+    "address_nickname_placeholder": "Ex: Apartment in Condominium",
+    "register_error": "Error completing registration. Please try again."
   },
   "business": {
     "advertise": "Advertise",

+ 9 - 1
src/i18n/locales/es.json

@@ -150,7 +150,15 @@
     "address_type_other": "Otro",
     "step4_title": "Agrega tu código postal y encuentra los limpiadores más cercanos",
     "use_location": "usar mi ubicación",
-    "back_to_register": "Volver al registro"
+    "back_to_register": "Volver al registro",
+    "cep_not_found": "Código postal no encontrado. Verifique e intente nuevamente.",
+    "location_permission_denied": "Permiso de ubicación denegado. Habilítelo en la configuración del dispositivo.",
+    "confirm_location": "confirmar ubicación",
+    "confirm_address": "confirmar dirección",
+    "complement": "Complemento",
+    "complement_placeholder": "Ej: Apartamento, Conjunto, Casa.",
+    "address_nickname_placeholder": "Ej: Apartamento en el Condominio",
+    "register_error": "Error al finalizar el registro. Inténtelo de nuevo."
   },
   "business": {
     "advertise": "Anunciar",

+ 9 - 1
src/i18n/locales/pt.json

@@ -150,7 +150,15 @@
     "address_type_other": "Outro",
     "step4_title": "Adicione seu CEP e veja os diaristas mais próximos",
     "use_location": "usar minha localização",
-    "back_to_register": "Voltar para o cadastro"
+    "back_to_register": "Voltar para o cadastro",
+    "cep_not_found": "CEP não encontrado. Verifique e tente novamente.",
+    "location_permission_denied": "Permissão de localização negada. Habilite nas configurações do dispositivo.",
+    "confirm_location": "confirmar localização",
+    "confirm_address": "confirmar endereço",
+    "complement": "Complemento",
+    "complement_placeholder": "Ex: Apartamento, Conjunto, Casa.",
+    "address_nickname_placeholder": "Ex: Apartamento no Condomínio",
+    "register_error": "Erro ao finalizar cadastro. Tente novamente."
   },
   "business": {
     "advertise": "Anunciar",

+ 21 - 2
src/pages/LoginPage.vue

@@ -13,7 +13,11 @@
         <img :src="FotoDiarista" class="splash-layer splash-layer--photo" />
         <img :src="LogoLogin" class="splash-layer splash-layer--logo-small" />
         <div class="step4-card-wrapper">
-          <LoginStepFourPanel v-model:cep="stepFourCep" @back="steps = 3" />
+          <LoginStepFourPanel
+            @back="steps = 3"
+            @cep-resolved="onCepResolved"
+            @device-location="onDeviceLocation"
+          />
         </div>
       </div>
 
@@ -79,6 +83,7 @@ import { ref } from 'vue';
 import { createUserAndClient, sendCode, validateCode } from 'src/api/user';
 import { useAuth } from 'src/composables/useAuth';
 import { useRouter } from 'vue-router';
+import { useRegistrationFlowStore } from 'src/stores/registrationFlow';
 
 import BackgroundLogin from 'src/assets/background-login.svg';
 import FotoDiarista from 'src/assets/foto_diarista_login.svg';
@@ -92,11 +97,11 @@ import LoginStepFourPanel from 'src/components/login/LoginStepFourPanel.vue';
 
 const router = useRouter();
 const { setAuthDataFromPayload } = useAuth();
+const flowStore = useRegistrationFlowStore();
 
 const email = ref('');
 const phone = ref('');
 const code = ref('');
-const stepFourCep = ref('');
 
 const stepThreeForm = ref({
   name: '',
@@ -161,6 +166,20 @@ const registerUserAndClient = async () => {
   }
 };
 
+const navigateToMap = (lat, lng) => {
+  flowStore.setCredentials(email.value, phone.value, code.value);
+  flowStore.setInitialLocation(lat, lng);
+  router.push({ name: 'LocationMapPage' });
+};
+
+const onCepResolved = (geoData) => {
+  navigateToMap(geoData.lat, geoData.lng);
+};
+
+const onDeviceLocation = ({ lat, lng }) => {
+  navigateToMap(lat, lng);
+};
+
 const onSubmit = async () => {
   switch (steps.value) {
     case 1: 

+ 223 - 0
src/pages/location/AddressCompletionPage.vue

@@ -0,0 +1,223 @@
+<template>
+  <q-page class="address-completion-page bg-surface-dark">
+    <div class="address-completion-inner">
+      <q-btn
+        flat
+        dense
+        round
+        color="white"
+        icon="mdi-arrow-left"
+        class="address-completion-back"
+        @click="router.back()"
+      />
+
+      <div class="address-completion-content">
+        <div class="address-completion-field-label">{{ $t('common.terms.address') }}</div>
+        <q-input
+          :model-value="addressDisplay"
+          outlined
+          readonly
+          dense
+          bg-color="white"
+          class="q-mb-lg"
+          input-class="text-text"
+        />
+
+        <q-checkbox
+          v-model="form.no_complement"
+          :label="$t('auth.no_complement')"
+          color="primary"
+          class="q-mb-sm"
+        />
+
+        <q-input
+          v-if="!form.no_complement"
+          v-model="form.complement"
+          outlined
+          dense
+          bg-color="white"
+          :label="$t('auth.complement') + '*'"
+          :placeholder="$t('auth.complement_placeholder')"
+          class="q-mb-md"
+          input-class="text-text"
+        />
+
+        <div class="address-completion-field-label">{{ $t('auth.address_nickname') }}</div>
+        <q-input
+          v-model="form.nickname"
+          outlined
+          dense
+          bg-color="white"
+          :placeholder="$t('auth.address_nickname_placeholder')"
+          class="q-mb-md"
+          input-class="text-text"
+        />
+
+        <div class="address-completion-field-label">{{ $t('auth.address_instructions') }}</div>
+        <q-input
+          v-model="form.instructions"
+          outlined
+          type="textarea"
+          bg-color="white"
+          class="q-mb-lg"
+          input-class="text-text"
+          rows="3"
+          autogrow
+        />
+
+        <div class="address-type-row q-mb-xl">
+          <q-btn
+            v-for="type in addressTypes"
+            :key="type.value"
+            rounded
+            :outline="form.address_type !== type.value"
+            :color="form.address_type === type.value ? 'primary-button' : 'grey-4'"
+            :text-color="form.address_type === type.value ? 'white' : 'grey-8'"
+            :icon="type.icon"
+            :label="type.label"
+            size="sm"
+            padding="8px 14px"
+            @click="form.address_type = type.value"
+          />
+        </div>
+      </div>
+
+      <div class="address-completion-footer">
+        <q-btn
+          color="primary-button"
+          :label="$t('auth.confirm_address')"
+          rounded
+          padding="14px 16px"
+          class="full-width"
+          :loading="submitting"
+          @click="handleConfirm"
+        />
+      </div>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue';
+import { useRouter } from 'vue-router';
+import { useQuasar } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import { useRegistrationFlowStore } from 'src/stores/registrationFlow';
+import { useAuth } from 'src/composables/useAuth';
+import { createUserAndClient } from 'src/api/user';
+
+const router = useRouter();
+const $q = useQuasar();
+const { t } = useI18n();
+const flowStore = useRegistrationFlowStore();
+const { setAuthDataFromPayload } = useAuth();
+
+const submitting = ref(false);
+
+const form = ref({
+  no_complement: false,
+  complement: '',
+  nickname: '',
+  instructions: '',
+  address_type: 'home',
+});
+
+const addressDisplay = computed(() => {
+  const parts = [
+    flowStore.confirmedAddress,
+    flowStore.confirmedNumber,
+    flowStore.confirmedDistrict,
+  ].filter(Boolean);
+  return parts.join(', ');
+});
+
+const addressTypes = computed(() => [
+  { value: 'home', label: t('auth.address_type_home'), icon: 'mdi-home-outline' },
+  { value: 'commercial', label: t('auth.address_type_commercial'), icon: 'mdi-office-building-outline' },
+  { value: 'other', label: t('auth.address_type_other'), icon: 'mdi-map-marker-outline' },
+]);
+
+const handleConfirm = async () => {
+  if (!flowStore.hasConfirmedLocation() || !flowStore.hasCredentials()) {
+    router.replace({ name: 'LoginPage' });
+    return;
+  }
+
+  submitting.value = true;
+  try {
+    const payload = {
+      email: flowStore.email || undefined,
+      phone: flowStore.phone || undefined,
+      code: flowStore.code,
+      zip_code: flowStore.confirmedZipCode || undefined,
+      address: flowStore.confirmedAddress || undefined,
+      number: flowStore.confirmedNumber || undefined,
+      district: flowStore.confirmedDistrict || undefined,
+      city: flowStore.confirmedCity || undefined,
+      state: flowStore.confirmedState || undefined,
+      latitude: flowStore.confirmedLat,
+      longitude: flowStore.confirmedLng,
+      has_complement: !form.value.no_complement,
+      complement: form.value.no_complement ? null : (form.value.complement || null),
+      nickname: form.value.nickname || null,
+      instructions: form.value.instructions || null,
+      address_type: form.value.address_type,
+    };
+
+    const response = await createUserAndClient(payload);
+    if (response.status === 200) {
+      await setAuthDataFromPayload(response.data.payload);
+      flowStore.clear();
+      router.push({ name: 'DashboardPage' });
+    }
+  } catch {
+    $q.notify({ type: 'negative', message: t('auth.register_error') });
+  } finally {
+    submitting.value = false;
+  }
+};
+</script>
+
+<style lang="scss" scoped>
+.address-completion-page {
+  min-height: 100dvh;
+  display: flex;
+  justify-content: center;
+}
+
+.address-completion-inner {
+  width: 100%;
+  max-width: 500px;
+  min-height: 100dvh;
+  display: flex;
+  flex-direction: column;
+  padding: 16px 20px;
+}
+
+.address-completion-back {
+  align-self: flex-start;
+  margin-bottom: 12px;
+  margin-top: env(safe-area-inset-top);
+}
+
+.address-completion-content {
+  flex: 1;
+}
+
+.address-completion-field-label {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--q-text);
+  margin-bottom: 6px;
+}
+
+.address-type-row {
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.address-completion-footer {
+  padding: 16px 0 calc(12px + env(safe-area-inset-bottom));
+}
+</style>

+ 178 - 0
src/pages/location/LocationMapPage.vue

@@ -0,0 +1,178 @@
+<template>
+  <q-page class="location-map-page">
+    <div ref="mapRef" class="location-map-container" />
+
+    <q-btn
+      flat
+      round
+      color="white"
+      icon="mdi-arrow-left"
+      class="location-map-back-btn"
+      @click="handleBack"
+    />
+
+    <div class="location-map-bottom-card">
+      <p class="location-map-address-label">{{ $t('common.terms.address') }}</p>
+      <q-input
+        :model-value="currentAddress"
+        outlined
+        dense
+        readonly
+        class="location-map-address-input q-mb-md"
+        bg-color="white"
+        :loading="reversing"
+      />
+      <q-btn
+        color="primary-button"
+        :label="$t('auth.confirm_location')"
+        rounded
+        padding="14px 16px"
+        class="full-width"
+        :loading="reversing"
+        @click="handleConfirm"
+      />
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted } from 'vue';
+import { useRouter } from 'vue-router';
+import { GoogleMap } from '@capacitor/google-maps';
+import { useRegistrationFlowStore } from 'src/stores/registrationFlow';
+import { useGeocodingApi } from 'src/composables/useGeocodingApi';
+
+const router = useRouter();
+const flowStore = useRegistrationFlowStore();
+const { reverseGeocode } = useGeocodingApi();
+
+const mapRef = ref(null);
+const reversing = ref(false);
+const currentAddress = ref('');
+const currentLat = ref(null);
+const currentLng = ref(null);
+const currentGeoData = ref(null);
+
+let googleMap = null;
+const markerId = ref(null);
+
+const handleBack = async () => {
+  await destroyMap();
+  router.back();
+};
+
+const handleConfirm = async () => {
+  if (!currentGeoData.value) return;
+  flowStore.setConfirmedLocation(currentGeoData.value);
+  await destroyMap();
+  router.push({ name: 'AddressCompletionPage' });
+};
+
+const destroyMap = async () => {
+  if (googleMap) {
+    await googleMap.destroy();
+    googleMap = null;
+  }
+};
+
+const updateMarkerPosition = async (lat, lng) => {
+  reversing.value = true;
+  try {
+    const geoData = await reverseGeocode(lat, lng);
+    if (geoData) {
+      currentGeoData.value = { ...geoData, lat, lng };
+      const parts = [geoData.address, geoData.number, geoData.district].filter(Boolean);
+      currentAddress.value = parts.join(', ');
+    }
+  } finally {
+    reversing.value = false;
+  }
+};
+
+onMounted(async () => {
+  if (!flowStore.hasInitialLocation()) {
+    router.replace({ name: 'LoginPage' });
+    return;
+  }
+
+  const lat = flowStore.initialLat;
+  const lng = flowStore.initialLng;
+  currentLat.value = lat;
+  currentLng.value = lng;
+
+  await updateMarkerPosition(lat, lng);
+
+  googleMap = await GoogleMap.create({
+    id: 'location-map',
+    element: mapRef.value,
+    apiKey: process.env.GOOGLE_MAPS_API_KEY,
+    config: {
+      center: { lat, lng },
+      zoom: 17,
+    },
+  });
+
+  markerId.value = await googleMap.addMarker({
+    coordinate: { lat, lng },
+    draggable: true,
+  });
+
+  await googleMap.setOnMarkerDragEndListener(async (event) => {
+    const { latitude, longitude } = event;
+    currentLat.value = latitude;
+    currentLng.value = longitude;
+    await updateMarkerPosition(latitude, longitude);
+  });
+});
+
+onUnmounted(async () => {
+  await destroyMap();
+});
+</script>
+
+<style lang="scss" scoped>
+.location-map-page {
+  position: relative;
+  width: 100vw;
+  height: 100dvh;
+  overflow: hidden;
+  background: #e5e3df;
+}
+
+.location-map-container {
+  width: 100%;
+  height: 100%;
+}
+
+.location-map-back-btn {
+  position: absolute;
+  top: calc(16px + env(safe-area-inset-top));
+  left: 16px;
+  z-index: 10;
+  background: rgba(0, 0, 0, 0.4);
+  border-radius: 50%;
+}
+
+.location-map-bottom-card {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 10;
+  background: white;
+  border-radius: 24px 24px 0 0;
+  padding: 20px 20px calc(20px + env(safe-area-inset-bottom));
+  box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.12);
+}
+
+.location-map-address-label {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--q-text);
+  margin: 0 0 8px;
+}
+
+.location-map-address-input {
+  border-radius: 8px;
+}
+</style>

+ 18 - 0
src/router/routes.js

@@ -45,6 +45,24 @@ const routes = [
       },
     ],
   },
+  {
+    path: "/location",
+    component: () => import("layouts/LoginLayout.vue"),
+    children: [
+      {
+        path: "map",
+        name: "LocationMapPage",
+        component: () => import("pages/location/LocationMapPage.vue"),
+        meta: { title: "Confirmar localização" },
+      },
+      {
+        path: "address",
+        name: "AddressCompletionPage",
+        component: () => import("pages/location/AddressCompletionPage.vue"),
+        meta: { title: "Confirmar endereço" },
+      },
+    ],
+  },
 
   // Always leave this as last one,
   // but you can also remove it

+ 77 - 0
src/stores/registrationFlow.js

@@ -0,0 +1,77 @@
+import { defineStore } from 'pinia';
+import { ref } from 'vue';
+
+export const useRegistrationFlowStore = defineStore('registrationFlow', () => {
+  const email = ref('');
+  const phone = ref('');
+  const code = ref('');
+
+  const initialLat = ref(null);
+  const initialLng = ref(null);
+
+  const confirmedLat = ref(null);
+  const confirmedLng = ref(null);
+  const confirmedAddress = ref('');
+  const confirmedNumber = ref('');
+  const confirmedDistrict = ref('');
+  const confirmedCity = ref('');
+  const confirmedState = ref('');
+  const confirmedZipCode = ref('');
+
+  const setCredentials = (e, p, c) => {
+    email.value = e;
+    phone.value = p;
+    code.value = c;
+  };
+
+  const setInitialLocation = (lat, lng) => {
+    initialLat.value = lat;
+    initialLng.value = lng;
+  };
+
+  const setConfirmedLocation = (data) => {
+    confirmedLat.value = data.lat;
+    confirmedLng.value = data.lng;
+    confirmedAddress.value = data.address ?? '';
+    confirmedNumber.value = data.number ?? '';
+    confirmedDistrict.value = data.district ?? '';
+    confirmedCity.value = data.city ?? '';
+    confirmedState.value = data.state ?? '';
+    confirmedZipCode.value = data.zip_code ?? '';
+  };
+
+  const clear = () => {
+    email.value = '';
+    phone.value = '';
+    code.value = '';
+    initialLat.value = null;
+    initialLng.value = null;
+    confirmedLat.value = null;
+    confirmedLng.value = null;
+    confirmedAddress.value = '';
+    confirmedNumber.value = '';
+    confirmedDistrict.value = '';
+    confirmedCity.value = '';
+    confirmedState.value = '';
+    confirmedZipCode.value = '';
+  };
+
+  const hasCredentials = () => !!(email.value || phone.value) && !!code.value;
+  const hasInitialLocation = () => initialLat.value !== null && initialLng.value !== null;
+  const hasConfirmedLocation = () => confirmedLat.value !== null && confirmedLng.value !== null;
+
+  return {
+    email, phone, code,
+    initialLat, initialLng,
+    confirmedLat, confirmedLng,
+    confirmedAddress, confirmedNumber, confirmedDistrict,
+    confirmedCity, confirmedState, confirmedZipCode,
+    setCredentials,
+    setInitialLocation,
+    setConfirmedLocation,
+    clear,
+    hasCredentials,
+    hasInitialLocation,
+    hasConfirmedLocation,
+  };
+});