Răsfoiți Sursa

feat: :sparkles: feat (calculo-distancias) criado calculo de distancias aproximado

foi criado calculo de distancias aproximado usando metodo matematico + funcao de preencher endereco no perfil quando nao ha lat e long fornecidos

fase:dev | origin:escopo
Gustavo Zanatta 2 săptămâni în urmă
părinte
comite
6849afe9e7

+ 104 - 5
package-lock.json

@@ -1,15 +1,17 @@
 {
-  "name": "quasar-skeleton",
+  "name": "diarista-prestador-app",
   "version": "0.0.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "quasar-skeleton",
+      "name": "diarista-prestador-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.4",
+      "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
+      "integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
       "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.1",
+      "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.64.1.tgz",
+      "integrity": "sha512-nEBoa6iDNipICtxJ5VlrOgPNZQ6ixIg5nuv8iryFj0Z/1NLgxyg3pQCVegPuCzGCyTQwQI/N3uZvLUysqAzaaw==",
+      "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.1.0",
+      "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
+      "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
+      "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 dotenvConfig } from "dotenv";
+
+dotenvConfig();
 
 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,

+ 1 - 1
src/components/dashboard/DashboardNextSchedules.vue

@@ -37,7 +37,7 @@
               <div class="column items-end text-text">
                 <div class="text-price">{{ formatCurrency(item.total_amount) }}</div>
                 <div class="text-type">{{ t(labelsPeriodTypes.find(label => label.value == item.period_type)?.label) }}</div>
-                <div class="text-distance">{{ item.distance || 0 }}{{ $t('common.km') }}</div>
+                <div class="text-distance">{{ item.distance_km ?? 0 }}{{ $t('common.km') }}</div>
               </div>
             </div>
 

+ 5 - 4
src/components/dashboard/DashboardOpportunities.vue

@@ -41,7 +41,7 @@
                 <div class="text-price">{{ formatCurrency(item.total_amount) }}</div>
                 <div class="text-type">{{ $t(labelsPeriodTypes.find(label => label.value == item.period_type)?.label) }}</div>
                 <div class="text-region text-weight-bold">{{ item.address?.district || 'N/A' }}</div>
-                <div class="text-distance">{{ item.distance || 0 }}{{ $t('common.km') }}</div>
+                <div class="text-distance">{{ item.distance_km ?? 0 }}{{ $t('common.km') }}</div>
               </div>
             </div>
 
@@ -91,9 +91,10 @@ const router = useRouter();
 const openOpportunityDetails = (item) => {
   $q.dialog({
     component: OpportunityDetailsDialog,
-   componentProps: {
-  opportunityId: item.custom_schedule?.id
-}
+    componentProps: {
+      opportunityId: item.custom_schedule?.id,
+      distanceKm: item.distance_km ?? null,
+    }
   })
 }
 

+ 1 - 1
src/components/dashboard/DashboardSolicitations.vue

@@ -46,7 +46,7 @@
                 <div class="text-type">{{ t(labelsPeriodTypes.find(label => label.value == item.period_type)?.label) }}</div>
                 <div class="text-region text-weight-bold">{{ item.address?.district || 'N/A' }}</div>
                 <div class="text-distance">
-                  <span class="q-pr-xs">{{ item.distance || 0 }}</span>
+                  <span class="q-pr-xs">{{ item.distance_km ?? 0 }}</span>
                   {{ $t('common.km') }}
                 </div>
               </div>

+ 1 - 1
src/components/dashboard/SolicitationDetailsDialog.vue

@@ -151,7 +151,7 @@
           </div>
           <div class="text-caption text-grey-6 text-center q-mt-xs">
             {{ $t('provider.dashboard.solicitations.distance_prefix') }}
-            <strong>{{ (solicitation.distance || 0) + ' km' }}</strong>
+            <strong>{{ (solicitation.distance_km ?? 0) + ' km' }}</strong>
             {{ $t('provider.dashboard.solicitations.distance_suffix') }}
           </div>
           <div class="q-mt-xs">

+ 94 - 1
src/components/profile/ProfileAddressDialog.vue

@@ -80,6 +80,27 @@
               </div>
             </div>
 
+            <div v-if="missingCoords" class="q-mb-md">
+              <q-banner rounded dense class="bg-orange-1 text-orange-9 q-mb-sm">
+                <template #avatar>
+                  <q-icon name="mdi-map-marker-off" color="orange-7" />
+                </template>
+                {{ $t('profile.address.missing_coords') }}
+              </q-banner>
+              <q-btn
+                unelevated
+                rounded
+                no-caps
+                outline
+                color="primary"
+                icon="mdi-map-marker"
+                class="full-width q-mb-md"
+                :label="$t('profile.address.update_on_map')"
+                :loading="geocodingCep"
+                @click="openMapDialog"
+              />
+            </div>
+
             <q-btn
               unelevated
               rounded
@@ -102,11 +123,13 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from 'vue';
+import { ref, computed, onMounted } from 'vue';
 import { useDialogPluginComponent, useQuasar } from 'quasar';
 import { searchAddressByCEP, updateAddress, createAddress, getAddresses } from 'src/api/address';
 import { userStore } from 'src/stores/user';
 import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
+import { useGeocodingApi } from 'src/composables/useGeocodingApi';
+import LocationMapDialog from 'src/components/shared/LocationMapDialog.vue';
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -114,6 +137,7 @@ const { dialogRef, onDialogOK } = useDialogPluginComponent();
 const $q = useQuasar();
 const user = userStore();
 const providerId = user.user.provider.id;
+const { geocodeFullAddress } = useGeocodingApi();
 const {
   form,
   hasUpdatedFields,
@@ -132,12 +156,19 @@ const {
   source: 'provider',
   source_id: providerId,
   address_type: 'home',
+  latitude: null,
+  longitude: null,
 });
 
 const search = ref('');
 const loadingCep = ref(false);
 const saving = ref(false);
 const addressId = ref(null);
+const geocodingCep = ref(false);
+
+const missingCoords = computed(() =>
+  addressId.value !== null && form.latitude == null && form.longitude == null
+);
 
 const onCepChange = async (val) => {
   if (val?.length === 8) {
@@ -151,6 +182,8 @@ const onCepChange = async (val) => {
         form.state_id = data.state_id;
         form.city = data.city;
         form.state = data.state;
+        form.latitude = null;
+        form.longitude = null;
       } else {
         $q.notify({ type: 'negative', message: 'CEP não encontrado' });
       }
@@ -160,6 +193,64 @@ const onCepChange = async (val) => {
   }
 };
 
+const openMapDialog = async () => {
+  let initialLat = null;
+  let initialLng = null;
+
+  const hasAddress = form.address || form.zip_code;
+  if (hasAddress) {
+    geocodingCep.value = true;
+    try {
+      const geo = await geocodeFullAddress({
+        address:  form.address,
+        number:   form.number,
+        district: form.district,
+        zip_code: form.zip_code,
+        city:     form.city?.name,
+        state:    form.state?.name,
+      });
+      if (geo) {
+        initialLat = geo.lat;
+        initialLng = geo.lng;
+      }
+    } catch {
+      // fallback to default
+    } finally {
+      geocodingCep.value = false;
+    }
+  }
+
+  const dialogProps = initialLat !== null
+    ? { initialLat, initialLng }
+    : {};
+
+  $q.dialog({
+    component: LocationMapDialog,
+    componentProps: dialogProps,
+  }).onOk(async (geoData) => {
+    form.latitude = geoData.lat;
+    form.longitude = geoData.lng;
+    form.address = geoData.address || form.address;
+    form.number = geoData.number || form.number;
+    form.district = geoData.district || form.district;
+
+    if (geoData.zip_code) {
+      form.zip_code = geoData.zip_code.replace(/\D/g, '');
+      try {
+        const addressData = await searchAddressByCEP(form.zip_code);
+        if (addressData) {
+          form.city_id = addressData.city_id;
+          form.state_id = addressData.state_id;
+          form.city = addressData.city;
+          form.state = addressData.state;
+        }
+      } catch {
+        // mantém cidade/estado atual se lookup falhar
+      }
+    }
+  });
+};
+
 const save = async () => {
   saving.value = true;
   try {
@@ -199,6 +290,8 @@ onMounted(async () => {
       source: 'provider',
       source_id: providerId,
       address_type: current.address_type || 'home',
+      latitude: current.latitude ?? null,
+      longitude: current.longitude ?? null,
     };
 
     Object.assign(form, initialData);

+ 206 - 0
src/components/shared/LocationMapDialog.vue

@@ -0,0 +1,206 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized @show="initMap">
+    <div class="location-map-dialog">
+      <div ref="mapRef" class="location-map-container" />
+
+      <q-btn
+        flat
+        round
+        color="white"
+        icon="mdi-arrow-left"
+        class="location-map-back-btn"
+        @click="handleCancel"
+      />
+
+      <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"
+          input-class="text-text"
+          :loading="reversing"
+          :placeholder="reversing ? '' : $t('auth.geocoding_failed_short')"
+        />
+        <q-btn
+          color="primary"
+          :label="$t('auth.confirm_location')"
+          rounded
+          padding="14px 16px"
+          class="full-width"
+          :loading="reversing"
+          :disable="!currentGeoData"
+          @click="handleConfirm"
+        />
+      </div>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onUnmounted } from 'vue';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import { GoogleMap } from '@capacitor/google-maps';
+import { useGeocodingApi } from 'src/composables/useGeocodingApi';
+
+const props = defineProps({
+  initialLat: {
+    type: Number,
+    default: -15.7801,
+  },
+  initialLng: {
+    type: Number,
+    default: -47.9292,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+const { reverseGeocode } = useGeocodingApi();
+
+const mapRef = ref(null);
+const reversing = ref(false);
+const currentAddress = ref('');
+const currentGeoData = ref(null);
+
+let googleMap = null;
+const markerId = ref(null);
+
+const handleCancel = async () => {
+  await destroyMap();
+  onDialogCancel();
+};
+
+const handleConfirm = async () => {
+  if (!currentGeoData.value) return;
+  await destroyMap();
+  onDialogOK(currentGeoData.value);
+};
+
+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, geoData.city, geoData.state].filter(Boolean);
+      currentAddress.value = parts.join(', ');
+    } else {
+      currentGeoData.value = { lat, lng, address: '', number: '', district: '', city: '', state: '', zip_code: '' };
+      currentAddress.value = '';
+      $q.notify({ type: 'warning', message: t('auth.geocoding_failed') });
+    }
+  } catch {
+    currentGeoData.value = { lat, lng, address: '', number: '', district: '', city: '', state: '', zip_code: '' };
+    currentAddress.value = '';
+    $q.notify({ type: 'warning', message: t('auth.geocoding_failed') });
+  } finally {
+    reversing.value = false;
+  }
+};
+
+const moveMarkerTo = async (lat, lng) => {
+  if (markerId.value) {
+    await googleMap.removeMarker(markerId.value);
+  }
+  markerId.value = await googleMap.addMarker({
+    coordinate: { lat, lng },
+    draggable: true,
+  });
+};
+
+const initMap = async () => {
+  googleMap = await GoogleMap.create({
+    id: 'location-map-dialog',
+    element: mapRef.value,
+    apiKey: process.env.GOOGLE_MAPS_API_KEY,
+    config: {
+      center: { lat: props.initialLat, lng: props.initialLng },
+      zoom: 17,
+    },
+  });
+
+  markerId.value = await googleMap.addMarker({
+    coordinate: { lat: props.initialLat, lng: props.initialLng },
+    draggable: true,
+  });
+
+  await updateMarkerPosition(props.initialLat, props.initialLng);
+
+  await googleMap.setOnMarkerDragEndListener(async (event) => {
+    const { latitude, longitude } = event;
+    await updateMarkerPosition(latitude, longitude);
+  });
+
+  await googleMap.setOnMapClickListener(async (event) => {
+    const { latitude, longitude } = event;
+    await moveMarkerTo(latitude, longitude);
+    await updateMarkerPosition(latitude, longitude);
+  });
+};
+
+onUnmounted(async () => {
+  await destroyMap();
+});
+</script>
+
+<style lang="scss" scoped>
+.location-map-dialog {
+  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>

+ 90 - 0
src/composables/useGeocodingApi.js

@@ -0,0 +1,90 @@
+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, ''),
+  };
+};
+
+const geocodeViaGoogleMaps = async (request) => {
+  const geocoder = new window.google.maps.Geocoder();
+  const { results } = await geocoder.geocode(request);
+  return results ?? [];
+};
+
+export const useGeocodingApi = () => {
+  const geocodeCep = async (cep) => {
+    const cleaned = cep.replace(/\D/g, '');
+
+    if (window.google?.maps?.Geocoder) {
+      const results = await geocodeViaGoogleMaps({ address: `${cleaned}, Brazil` });
+      if (!results.length) return null;
+      const result = results[0];
+      const parsed = parseAddressComponents(result.address_components);
+      return {
+        lat: result.geometry.location.lat(),
+        lng: result.geometry.location.lng(),
+        ...parsed,
+      };
+    }
+
+    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;
+    return { lat, lng, ...parseAddressComponents(result.address_components) };
+  };
+
+  const reverseGeocode = async (lat, lng) => {
+    if (window.google?.maps?.Geocoder) {
+      const results = await geocodeViaGoogleMaps({ location: { lat, lng } });
+      if (!results.length) return null;
+      return { lat, lng, ...parseAddressComponents(results[0].address_components) };
+    }
+
+    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];
+    return { lat, lng, ...parseAddressComponents(result.address_components) };
+  };
+
+  const geocodeFullAddress = async ({ address, number, district, zip_code, city, state }) => {
+    const parts = [address, number, district, zip_code, city, state, 'Brazil'].filter(Boolean);
+    const query = parts.join(', ');
+
+    if (window.google?.maps?.Geocoder) {
+      const results = await geocodeViaGoogleMaps({ address: query });
+      if (!results.length) return null;
+      const result = results[0];
+      return {
+        lat: result.geometry.location.lat(),
+        lng: result.geometry.location.lng(),
+        ...parseAddressComponents(result.address_components),
+      };
+    }
+
+    const { data } = await axios.get(GEOCODING_URL, {
+      params: { address: query, 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;
+    return { lat, lng, ...parseAddressComponents(result.address_components) };
+  };
+
+  return { geocodeCep, reverseGeocode, geocodeFullAddress };
+};

+ 7 - 2
src/i18n/locales/en.json

@@ -142,7 +142,10 @@
     "register": "Register",
     "logout": "Logout",
     "forgot_password": "Forgot my password",
-    "confirm_password": "Confirm password"
+    "confirm_password": "Confirm password",
+    "geocoding_failed_short": "Location not found",
+    "geocoding_failed": "Could not get the address for this location.",
+    "confirm_location": "Confirm location"
   },
   "provider": {
     "login": {
@@ -547,7 +550,9 @@
       "city_label": "City",
       "state_label": "State",
       "update_address": "Change address",
-      "search_placeholder": "Search"
+      "search_placeholder": "Search",
+      "missing_coords": "Coordinates not found for this address.",
+      "update_on_map": "Update on map"
     },
     "help": {
       "title": "Help",

+ 7 - 2
src/i18n/locales/es.json

@@ -142,7 +142,10 @@
     "register": "Registrarse",
     "logout": "Cerrar sesión",
     "forgot_password": "Olvidé mi contraseña",
-    "confirm_password": "Confirmar contraseña"
+    "confirm_password": "Confirmar contraseña",
+    "geocoding_failed_short": "Ubicación no encontrada",
+    "geocoding_failed": "No se pudo obtener la dirección para esta ubicación.",
+    "confirm_location": "Confirmar ubicación"
   },
   "provider": {
     "login": {
@@ -545,7 +548,9 @@
       "city_label": "Ciudad",
       "state_label": "Estado/Provincia",
       "update_address": "Cambiar dirección",
-      "search_placeholder": "Buscar"
+      "search_placeholder": "Buscar",
+      "missing_coords": "Coordenadas no encontradas para esta dirección.",
+      "update_on_map": "Actualizar en el mapa"
     },
     "help": {
       "title": "Ayuda",

+ 7 - 2
src/i18n/locales/pt.json

@@ -142,7 +142,10 @@
     "register": "Cadastrar",
     "logout": "Sair",
     "forgot_password": "Esqueci minha senha",
-    "confirm_password": "Confirmar senha"
+    "confirm_password": "Confirmar senha",
+    "geocoding_failed_short": "Localização não encontrada",
+    "geocoding_failed": "Não foi possível obter o endereço para esta localização.",
+    "confirm_location": "Confirmar localização"
   },
   "provider": {
     "login": {
@@ -559,7 +562,9 @@
       "city_label": "Cidade",
       "state_label": "Estado",
       "update_address": "Alterar endereço",
-      "search_placeholder": "Buscar"
+      "search_placeholder": "Buscar",
+      "missing_coords": "Coordenadas não encontradas para este endereço.",
+      "update_on_map": "Atualizar no mapa"
     },
     "help": {
       "title": "Ajuda",

+ 3 - 3
src/pages/opportunities/OpportunitiesPage.vue

@@ -62,7 +62,7 @@
 
 
           <div class="distance">
-            {{ $t('provider.dashboard.opportunities.distance_km', { distance: item.distance }) }}
+            {{ $t('provider.dashboard.opportunities.distance_km', { distance: item.distance_km ?? 0 }) }}
           </div>
 
           
@@ -89,13 +89,13 @@ const opportunities = ref([])
 const loading = ref(false)
 
 const goToOpportunityDetails = (item) => {
-
   const id = item.custom_schedule?.id || item.id
 
   $q.dialog({
     component: OpportunityDetailsDialog,
     componentProps: {
-      opportunityId: id
+      opportunityId: id,
+      distanceKm: item.distance_km ?? null,
     }
   })
 }

+ 5 - 1
src/pages/opportunities/components/OpportunityDetailsDialog.vue

@@ -63,7 +63,7 @@
         {{
           $t(
             'provider.dashboard.opportunity_details.distance_text',
-            { distance: details.schedule?.distance }
+            { distance: distanceKm ?? 0 }
           )
         }}
       </div>
@@ -168,6 +168,10 @@ const props = defineProps({
   opportunityId: {
     type: Number,
     required: true
+  },
+  distanceKm: {
+    type: Number,
+    default: null
   }
 })