Procházet zdrojové kódy

bui
ld: altera quasar.config.js para alternar ambientes com facilidade

Gustavo Mantovani před 1 týdnem
rodič
revize
382e167243
38 změnil soubory, kde provedl 1399 přidání a 188 odebrání
  1. 112 3
      package-lock.json
  2. 3 0
      package.json
  3. 28 26
      quasar.config.js
  4. 2 2
      src-capacitor/android/app/build.gradle
  5. 48 0
      src-capacitor/android/app/google-services.json
  6. 1 1
      src-capacitor/android/app/src/main/java/br/com/diarista/provider/MainActivity.java
  7. 2 2
      src-capacitor/android/app/src/main/res/values/strings.xml
  8. 1 1
      src-capacitor/capacitor.config.json
  9. 2 2
      src-capacitor/ios/App/App.xcodeproj/project.pbxproj
  10. 30 0
      src-capacitor/ios/App/App/GoogleService-Info.plist
  11. 13 0
      src/api/deviceToken.js
  12. 16 1
      src/api/review.js
  13. 39 0
      src/boot/push-notifications.js
  14. 65 5
      src/components/dashboard/DashboardHeaderBar.vue
  15. 10 2
      src/components/dashboard/DashboardNextSchedules.vue
  16. 14 6
      src/components/dashboard/DashboardOpportunities.vue
  17. 10 2
      src/components/dashboard/DashboardSolicitations.vue
  18. 3 2
      src/components/dashboard/DashboardSummaryInfos.vue
  19. 26 18
      src/components/dashboard/DashboardTodayServices.vue
  20. 9 1
      src/components/dashboard/NextSchedulesDetailsDialog.vue
  21. 90 8
      src/components/dashboard/ScheduleRatingDialog.vue
  22. 12 3
      src/components/dashboard/SolicitationDetailsDialog.vue
  23. 32 17
      src/components/login/LoginStepFourPanel.vue
  24. 94 1
      src/components/profile/ProfileAddressDialog.vue
  25. 206 0
      src/components/shared/LocationMapDialog.vue
  26. 90 0
      src/composables/useGeocodingApi.js
  27. 21 3
      src/i18n/locales/en.json
  28. 21 3
      src/i18n/locales/es.json
  29. 13 2
      src/i18n/locales/pt.json
  30. 50 64
      src/pages/LoginPage.vue
  31. 10 3
      src/pages/calendar/CalendarPage.vue
  32. 6 1
      src/pages/dashboard/DashboardPage.vue
  33. 274 0
      src/pages/notifications/NotificationsPage.vue
  34. 20 5
      src/pages/opportunities/OpportunitiesPage.vue
  35. 5 1
      src/pages/opportunities/components/OpportunityDetailsDialog.vue
  36. 8 2
      src/pages/payments/PaymentsPage.vue
  37. 2 1
      src/pages/profile/ProfilePage.vue
  38. 11 0
      src/router/routes/notifications.route.js

+ 112 - 3
package-lock.json

@@ -10,6 +10,9 @@
       "dependencies": {
         "@bufbuild/protobuf": "^2.5.1",
         "@capacitor/device": "^7.0.1",
+        "@capacitor/geolocation": "^8.2.0",
+        "@capacitor/google-maps": "^8.0.1",
+        "@capacitor/push-notifications": "^8.1.1",
         "@quasar/cli": "^2.5.0",
         "@quasar/extras": "^1.17.0",
         "axios": "^1.9.0",
@@ -311,9 +314,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 +332,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",
@@ -349,6 +378,15 @@
         "@capacitor/core": ">=7.0.0"
       }
     },
+    "node_modules/@capacitor/push-notifications": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.1.1.tgz",
+      "integrity": "sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@capacitor/core": ">=8.0.0"
+      }
+    },
     "node_modules/@capacitor/status-bar": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-7.0.1.tgz",
@@ -359,6 +397,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 +1034,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 +2464,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 +2574,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 +5265,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 +6468,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 +8946,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",

+ 3 - 0
package.json

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

+ 28 - 26
quasar.config.js

@@ -9,9 +9,31 @@ import { fileURLToPath } from "node:url";
 import { resolve } from "node:path";
 
 const envFiles = {
-  dev: ".env.app.dev",
+  dev:     ".env.app.dev",
   staging: ".env.app.staging",
-  prod: ".env.app.prod",
+  prod:    ".env.app.prod",
+};
+
+const loadAppEnv = (ctx) => {
+  const appEnv = process.env.APP_ENV || (ctx.dev ? "dev" : "prod");
+
+  const envFile = envFiles[appEnv];
+
+  if (!envFile) {
+    throw new Error(`APP_ENV invalido: "${appEnv}". Use dev, staging ou prod.`);
+  }
+
+  const fileEnv = parseEnvFile(resolve(process.cwd(), envFile));
+
+  return {
+    APP_ENV:           appEnv,
+    API_URL:           fileEnv.API_URL,
+    PASSWORD:          fileEnv.PASSWORD,
+    WEBSOCKET_API:     fileEnv.WEBSOCKET_API,
+    WEBSOCKET_PATH:    fileEnv.WEBSOCKET_PATH,
+    WEBSOCKET_ROOM:    fileEnv.WEBSOCKET_ROOM,
+    WEBSOCKET_API_KEY: fileEnv.WEBSOCKET_API_KEY,
+  };
 };
 
 const parseEnvFile = (filePath) => {
@@ -34,8 +56,7 @@ const parseEnvFile = (filePath) => {
         return env;
       }
 
-      const key = trimmedLine.slice(0, separatorIndex).trim();
-
+      const key   = trimmedLine.slice(0, separatorIndex).trim();
       const value = trimmedLine.slice(separatorIndex + 1).trim();
 
       env[key] = value.replace(/^["']|["']$/g, "");
@@ -44,28 +65,6 @@ const parseEnvFile = (filePath) => {
     }, {});
 };
 
-const loadAppEnv = (ctx) => {
-  const appEnv = process.env.APP_ENV || (ctx.dev ? "dev" : "prod");
-
-  const envFile = envFiles[appEnv];
-
-  if (!envFile) {
-    throw new Error(`APP_ENV invalido: "${appEnv}". Use dev, staging ou prod.`);
-  }
-
-  const fileEnv = parseEnvFile(resolve(process.cwd(), envFile));
-
-  return {
-    APP_ENV: appEnv,
-    API_URL: fileEnv.API_URL,
-    PASSWORD: fileEnv.PASSWORD,
-    WEBSOCKET_API: fileEnv.WEBSOCKET_API,
-    WEBSOCKET_PATH: fileEnv.WEBSOCKET_PATH,
-    WEBSOCKET_ROOM: fileEnv.WEBSOCKET_ROOM,
-    WEBSOCKET_API_KEY: fileEnv.WEBSOCKET_API_KEY,
-  };
-};
-
 export default defineConfig((ctx) => {
   const appEnv = loadAppEnv(ctx);
 
@@ -83,6 +82,7 @@ export default defineConfig((ctx) => {
       "axios",
       "i18n",
       "defaultPropsComponents",
+      "push-notifications",
       // "socket.io",
     ],
 
@@ -119,7 +119,9 @@ export default defineConfig((ctx) => {
 
       // publicPath: '/',
       // analyze: true,
+
       env: appEnv,
+
       // rawDefine: {}
       // ignorePublicFolder: true,
       // minify: false,

+ 2 - 2
src-capacitor/android/app/build.gradle

@@ -1,10 +1,10 @@
 apply plugin: 'com.android.application'
 
 android {
-    namespace "inf.br.softpar.skeleton"
+    namespace "br.com.diarista.provider"
     compileSdk rootProject.ext.compileSdkVersion
     defaultConfig {
-        applicationId "inf.br.softpar.skeleton"
+        applicationId "br.com.diarista.provider"
         minSdkVersion rootProject.ext.minSdkVersion
         targetSdkVersion rootProject.ext.targetSdkVersion
         versionCode 1

+ 48 - 0
src-capacitor/android/app/google-services.json

@@ -0,0 +1,48 @@
+{
+  "project_info": {
+    "project_number": "616416880897",
+    "project_id": "diariaappsfp",
+    "storage_bucket": "diariaappsfp.firebasestorage.app"
+  },
+  "client": [
+    {
+      "client_info": {
+        "mobilesdk_app_id": "1:616416880897:android:dcd2c6113f59bf73185300",
+        "android_client_info": {
+          "package_name": "br.com.diarista.client"
+        }
+      },
+      "oauth_client": [],
+      "api_key": [
+        {
+          "current_key": "AIzaSyDOTWcQQe5iSzOM7ezrQoPBSpj8146uSIo"
+        }
+      ],
+      "services": {
+        "appinvite_service": {
+          "other_platform_oauth_client": []
+        }
+      }
+    },
+    {
+      "client_info": {
+        "mobilesdk_app_id": "1:616416880897:android:37c1d70a33d27018185300",
+        "android_client_info": {
+          "package_name": "br.com.diarista.provider"
+        }
+      },
+      "oauth_client": [],
+      "api_key": [
+        {
+          "current_key": "AIzaSyDOTWcQQe5iSzOM7ezrQoPBSpj8146uSIo"
+        }
+      ],
+      "services": {
+        "appinvite_service": {
+          "other_platform_oauth_client": []
+        }
+      }
+    }
+  ],
+  "configuration_version": "1"
+}

+ 1 - 1
src-capacitor/android/app/src/main/java/inf/br/softpar/skeleton/MainActivity.java → src-capacitor/android/app/src/main/java/br/com/diarista/provider/MainActivity.java

@@ -1,4 +1,4 @@
-package inf.br.softpar.skeleton;
+package br.com.diarista.provider;
 
 import android.os.Bundle;
 import android.webkit.WebView;

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

@@ -2,6 +2,6 @@
 <resources>
     <string name="app_name">Quasar App</string>
     <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="package_name">br.com.diarista.provider</string>
+    <string name="custom_url_scheme">br.com.diarista.provider</string>
 </resources>

+ 1 - 1
src-capacitor/capacitor.config.json

@@ -1,5 +1,5 @@
 {
-  "appId": "inf.br.softpar.skeleton",
+  "appId": "br.com.diarista.provider",
   "appName": "Quasar App",
   "webDir": "www",
   "plugins": {

+ 2 - 2
src-capacitor/ios/App/App.xcodeproj/project.pbxproj

@@ -354,7 +354,7 @@
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
 				MARKETING_VERSION = 1.0;
 				OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
-				PRODUCT_BUNDLE_IDENTIFIER = inf.br.softpar.skeleton;
+				PRODUCT_BUNDLE_IDENTIFIER = br.com.diarista.provider;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
 				SWIFT_VERSION = 5.0;
@@ -373,7 +373,7 @@
 				IPHONEOS_DEPLOYMENT_TARGET = 14.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
 				MARKETING_VERSION = 1.0;
-				PRODUCT_BUNDLE_IDENTIFIER = inf.br.softpar.skeleton;
+				PRODUCT_BUNDLE_IDENTIFIER = br.com.diarista.provider;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
 				SWIFT_VERSION = 5.0;

+ 30 - 0
src-capacitor/ios/App/App/GoogleService-Info.plist

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>API_KEY</key>
+	<string>AIzaSyC0jyxunkfvw9i-jXclS24EEsdhX9UI4hM</string>
+	<key>GCM_SENDER_ID</key>
+	<string>616416880897</string>
+	<key>PLIST_VERSION</key>
+	<string>1</string>
+	<key>BUNDLE_ID</key>
+	<string>br.com.diarista.provider</string>
+	<key>PROJECT_ID</key>
+	<string>diariaappsfp</string>
+	<key>STORAGE_BUCKET</key>
+	<string>diariaappsfp.firebasestorage.app</string>
+	<key>IS_ADS_ENABLED</key>
+	<false></false>
+	<key>IS_ANALYTICS_ENABLED</key>
+	<false></false>
+	<key>IS_APPINVITE_ENABLED</key>
+	<true></true>
+	<key>IS_GCM_ENABLED</key>
+	<true></true>
+	<key>IS_SIGNIN_ENABLED</key>
+	<true></true>
+	<key>GOOGLE_APP_ID</key>
+	<string>1:616416880897:ios:2cec8e44798687a1185300</string>
+</dict>
+</plist>

+ 13 - 0
src/api/deviceToken.js

@@ -0,0 +1,13 @@
+import api from "src/api";
+
+export const registerDeviceToken = async (token, platform) => {
+  await api.post("/device-tokens", {
+    token,
+    platform,
+    app_type: "prestador",
+  });
+};
+
+export const removeDeviceToken = async (token) => {
+  await api.delete(`/device-tokens/${token}`);
+};

+ 16 - 1
src/api/review.js

@@ -1,7 +1,22 @@
 import api from 'src/api'
 
 export const createReview = async (reviewData) => {
-  const { data } = await api.post('/reviews', reviewData)
+  const { photos, ...rest } = reviewData
+
+  if (photos && photos.length > 0) {
+    const form = new FormData()
+    Object.entries(rest).forEach(([key, val]) => {
+      if (val === null || val === undefined) return
+      if (Array.isArray(val)) val.forEach(v => form.append(`${key}[]`, v))
+      else if (typeof val === 'boolean') form.append(key, val ? '1' : '0')
+      else form.append(key, val)
+    })
+    photos.forEach(file => form.append('photos[]', file))
+    const { data } = await api.post('/reviews', form)
+    return data.payload
+  }
+
+  const { data } = await api.post('/reviews', rest)
   return data.payload
 }
 

+ 39 - 0
src/boot/push-notifications.js

@@ -0,0 +1,39 @@
+import { defineBoot } from "#q-app/wrappers";
+import { Capacitor } from "@capacitor/core";
+import { PushNotifications } from "@capacitor/push-notifications";
+import { registerDeviceToken } from "src/api/deviceToken";
+
+const registerPushNotifications = async () => {
+  let permission = await PushNotifications.checkPermissions();
+
+  if (permission.receive === "prompt") {
+    permission = await PushNotifications.requestPermissions();
+  }
+
+  if (permission.receive !== "granted") {
+    return;
+  }
+
+  await PushNotifications.register();
+};
+
+export default defineBoot(() => {
+  if (!Capacitor.isNativePlatform()) {
+    return;
+  }
+
+  PushNotifications.addListener("registration", async (token) => {
+    const platform = Capacitor.getPlatform();
+    try {
+      await registerDeviceToken(token.value, platform);
+    } catch {
+      // falha silenciosa — não bloqueia o uso do app
+    }
+  });
+
+  PushNotifications.addListener("registrationError", () => {
+    // falha silenciosa
+  });
+
+  registerPushNotifications();
+});

+ 65 - 5
src/components/dashboard/DashboardHeaderBar.vue

@@ -17,16 +17,69 @@
       <img :src="LogoDiariaColorida" alt="Diária" class="dashboard-logo" />
     </div>
 
-    <div class="col row justify-end items-center">
-      <q-btn flat round dense icon="mdi-bell-outline" color="grey-7" size="sm" />
-    </div>
+   <div class="col row justify-end items-center">
+
+  <q-btn
+    flat
+    round
+    dense
+    color="grey-7"
+    size="sm"
+    @click="goToNotifications"
+  >
+
+    <q-icon
+      name="mdi-bell-outline"
+      size="20px"
+    />
+
+    <q-badge
+      v-if="unreadNotifications > 0"
+      floating
+      rounded
+      color="pink"
+      class="notification-badge"
+    >
+      {{ unreadNotifications }}
+    </q-badge>
+
+  </q-btn>
+
+</div>
   </div>
 </template>
 
 <script setup>
-import LogoDiariaColorida from 'src/assets/logo_diaria_colorido_sem_texto.svg';
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
+import LogoDiariaColorida from 'src/assets/logo_diaria_colorido_sem_texto.svg'
+
+const router = useRouter()
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => null
+  },
 
-defineProps({ data: { type: Object, default: () => null } });
+  notifications: {
+    type: Array,
+    default: () => []
+  }
+})
+
+const unreadNotifications = computed(() => {
+  return props.notifications.filter((notification) => !notification.read).length
+})
+
+const goToNotifications = () => {
+  router.push({
+    name: 'NotificationsPage',
+    query: {
+      notifications: JSON.stringify(props.notifications)
+    }
+  })
+}
 </script>
 
 <style scoped lang="scss">
@@ -53,4 +106,11 @@ defineProps({ data: { type: Object, default: () => null } });
   color: #999;
   line-height: 1;
 }
+
+.notification-badge {
+  min-width: 16px;
+  height: 16px;
+  font-size: 10px;
+  font-weight: 700;
+}
 </style>

+ 10 - 2
src/components/dashboard/DashboardNextSchedules.vue

@@ -13,7 +13,8 @@
           <q-card-section class="q-pa-sm">
             <div class="row no-wrap items-center q-gutter-x-sm">
               <q-avatar size="48px">
-                <img :src="item.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'">
+                <img v-if="item.customer_photo" :src="item.customer_photo" style="object-fit:cover" />
+                <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:14px;border-radius:50%">{{ (item.customer_name ?? item.client_name)?.slice(0,2).toUpperCase() ?? '??' }}</span>
               </q-avatar>
               <div class="column flex-1">
                 <div class="row items-center q-gutter-x-xs">
@@ -37,7 +38,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>
 
@@ -89,6 +90,13 @@ const emit = defineEmits(['view-details']);
 const t = useI18n().t;
 const $q = useQuasar();
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 const formatWeekday = (iso) => {
   if (!iso) return '';
   const d = new Date(iso);

+ 14 - 6
src/components/dashboard/DashboardOpportunities.vue

@@ -16,7 +16,8 @@
           <q-card-section class="q-pa-sm">
             <div class="row no-wrap items-center q-gutter-x-sm">
               <q-avatar size="48px">
-                <img :src="item.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'">
+                <img v-if="item.customer_photo" :src="item.customer_photo" style="object-fit:cover" />
+                <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:14px;border-radius:50%">{{ (item.client_name ?? item.client?.user?.name)?.slice(0,2).toUpperCase() ?? '??' }}</span>
               </q-avatar>
               <div class="column flex-1">
                 <div class="row items-center q-gutter-x-xs">
@@ -41,7 +42,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>
 
@@ -84,16 +85,23 @@ defineProps({
 
 
 const $q = useQuasar();
-
 const router = useRouter();
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 
 const openOpportunityDetails = (item) => {
   $q.dialog({
     component: OpportunityDetailsDialog,
-   componentProps: {
-  opportunityId: item.custom_schedule?.id
-}
+    componentProps: {
+      opportunityId: item.custom_schedule?.id,
+      distanceKm: item.distance_km ?? null,
+    }
   })
 }
 

+ 10 - 2
src/components/dashboard/DashboardSolicitations.vue

@@ -16,7 +16,8 @@
           <q-card-section class="q-pa-sm">
             <div class="row no-wrap items-center q-gutter-x-sm">
               <q-avatar size="48px">
-                <img :src="item.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'">
+                <img v-if="item.customer_photo" :src="item.customer_photo" style="object-fit:cover" />
+                <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:14px;border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
               </q-avatar>
               <div class="column flex-1">
                 <div class="row items-center q-gutter-x-xs">
@@ -46,7 +47,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>
@@ -111,6 +112,13 @@ const emit = defineEmits(['accept', 'reject', 'view-details']);
 
 const t = useI18n().t;
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 const parseLocalDate = (iso) => {
   if (!iso) return null;
   const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);

+ 3 - 2
src/components/dashboard/DashboardSummaryInfos.vue

@@ -3,8 +3,9 @@
     <q-card-section class="q-pa-md">
       <div class="row items-center no-wrap q-gutter-x-md">
         <div class="row items-center no-wrap q-gutter-x-sm col">
-          <q-avatar size="54px" :style="avatarStyle" class="text-weight-bold text-h6">
-            {{ data?.name?.slice(0, 2).toUpperCase() ?? '??' }}
+          <q-avatar size="54px" :style="!data?.profile_photo ? avatarStyle : undefined" class="text-weight-bold text-h6">
+            <img v-if="data?.profile_photo" :src="data.profile_photo" style="object-fit:cover;width:100%;height:100%;border-radius:50%" />
+            <span v-else>{{ data?.name?.slice(0, 2).toUpperCase() ?? '??' }}</span>
           </q-avatar>
           <div class="column q-gutter-y-xs min-width-0">
             <span class="summary-greeting text-greeting">{{ $t('provider.dashboard.summary.welcome') }}</span>

+ 26 - 18
src/components/dashboard/DashboardTodayServices.vue

@@ -290,27 +290,35 @@ const openHelp = () => {
   font-size: 9px;
 }
 
-.code-input-row {
+.code-input-row { 
   display: flex;
-  gap: 5px;
+  align-items: center; 
+  justify-content: center; 
+  gap: 4px; background: #d1d5db; 
+  border-radius: 999px; 
+  padding: 4px 8px; 
+  width: 110px; 
+  height: 28px; 
+  margin: 0 auto; 
 }
 
-.code-input-box {
-  width: 16px;
-  height: 22px;
-  background: #d1d5db;
-  border: none;
-  border-radius: 6px;
-  text-align: center;
-  font-size: 11px;
-  font-weight: 700;
-  color: #1a1a2e;
-  outline: none;
-  caret-color: transparent;
-  &:focus {
-    background: #bec3cc;
-    box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.4);
-  }
+.code-input-box { 
+  width: 16px; 
+  height: 18px; 
+  background: transparent; 
+  border: none; 
+  border-bottom: 2px solid white; 
+  border-radius: 0; 
+  text-align: center; 
+  font-size: 11px; 
+  font-weight: 700; 
+  color: #1a1a2e; 
+  outline: none; 
+  caret-color: transparent; 
+  &:focus { 
+    border-bottom: 2px solid #ffffff; 
+    box-shadow: none; 
+  } 
 }
 
 .code-error-text {

+ 9 - 1
src/components/dashboard/NextSchedulesDetailsDialog.vue

@@ -8,7 +8,8 @@
 
       <q-card-section class="column items-center q-pt-xs q-pb-sm">
         <q-avatar size="72px" class="q-mb-sm">
-          <img :src="schedule.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" />
+          <img v-if="schedule.customer_photo" :src="schedule.customer_photo" style="object-fit:cover" />
+          <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[schedule.id % avatarColors.length]" style="font-size:20px;border-radius:50%">{{ (schedule.customer_name ?? schedule.client_name)?.slice(0,2).toUpperCase() ?? '??' }}</span>
         </q-avatar>
         <div class="text-subtitle1 text-weight-bold text-text">
           {{ schedule.customer_name ?? schedule.client_name }}
@@ -114,6 +115,13 @@ import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.j
 import ScheduleCancelDialog from './ScheduleCancelDialog.vue'
 import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 const props = defineProps({
   schedule: {
     type: Object,

+ 90 - 8
src/components/dashboard/ScheduleRatingDialog.vue

@@ -6,7 +6,6 @@
         <q-btn flat round dense icon="close" color="grey-6" size="sm" @click="onDialogCancel" />
       </div>
 
-      <!-- Avatar -->
       <div class="column items-center q-pb-sm">
         <q-avatar size="64px" class="q-mb-sm">
           <span
@@ -23,7 +22,6 @@
         </div>
       </div>
 
-      <!-- Estrelas -->
       <div class="column items-center q-pb-xs">
         <q-rating
           v-model="stars"
@@ -36,7 +34,6 @@
         />
       </div>
 
-      <!-- Tags de melhoria/qualidade -->
       <q-card-section v-if="stars > 0" class="q-pt-xs q-pb-xs">
         <div class="text-caption text-grey-7 text-center q-mb-sm">
           {{ isNegative ? $t('provider.dashboard.schedule_rating.negative_label') : $t('provider.dashboard.schedule_rating.positive_label') }}
@@ -57,7 +54,6 @@
         </div>
       </q-card-section>
 
-      <!-- Comentário -->
       <q-card-section class="q-pt-xs q-pb-xs q-px-lg">
         <div class="text-caption text-grey-7 q-mb-xs">
           {{ $t('provider.dashboard.schedule_rating.comment_placeholder') }}
@@ -74,7 +70,27 @@
         />
       </q-card-section>
 
-      <!-- Checkbox — apenas para avaliação negativa -->
+      <q-card-section class="q-pt-xs q-pb-xs q-px-lg">
+        <input
+          ref="photoInputRef"
+          type="file"
+          accept="image/jpeg,image/png,image/webp"
+          multiple
+          class="hidden"
+          @change="onPhotosSelected"
+        />
+        <div class="row q-gutter-xs">
+          <div v-for="(preview, idx) in photoPreviews" :key="idx" class="photo-thumb">
+            <img :src="preview" />
+            <q-btn round dense flat icon="close" size="xs" class="photo-thumb__remove" @click="removePhoto(idx)" />
+          </div>
+          <div v-if="photos.length < 5" class="photo-add" @click="photoInputRef.click()">
+            <q-icon name="mdi-camera-plus-outline" size="24px" color="grey-5" />
+            <div class="photo-add__label">{{ $t('provider.dashboard.schedule_rating.add_photo') }}</div>
+          </div>
+        </div>
+      </q-card-section>
+
       <q-card-section v-if="isNegative" class="q-pt-xs q-pb-xs q-px-lg">
         <q-checkbox
           v-model="blockClient"
@@ -87,7 +103,6 @@
         />
       </q-card-section>
 
-      <!-- Botão enviar -->
       <q-card-section class="q-pt-sm q-pb-xs q-px-lg row">
         <q-btn
           unelevated
@@ -103,7 +118,6 @@
         />
       </q-card-section>
 
-      <!-- Ajuda -->
       <q-card-section class="q-pt-xs q-pb-lg text-center">
         <span class="text-caption text-grey-6 cursor-pointer" @click="openHelp">
           {{ $t('provider.dashboard.schedule_rating.help_link') }}
@@ -142,9 +156,11 @@ const blockClient = ref(false)
 const tags = ref([])
 const loadingTags = ref(false)
 const loading = ref(false)
+const photos = ref([])
+const photoPreviews = ref([])
+const photoInputRef = ref(null)
 
 const isNegative = computed(() => stars.value > 0 && stars.value <= 2)
-// const isPositive = computed(() => stars.value >= 3)
 
 const avatarColors = [
   { background: '#ffd5df', color: '#932e57' },
@@ -173,6 +189,22 @@ const toggleTag = (id) => {
   else selectedTagIds.value.splice(idx, 1)
 }
 
+const onPhotosSelected = (event) => {
+  const files = Array.from(event.target.files)
+  const remaining = 5 - photos.value.length
+  files.slice(0, remaining).forEach(file => {
+    photos.value.push(file)
+    photoPreviews.value.push(URL.createObjectURL(file))
+  })
+  event.target.value = ''
+}
+
+const removePhoto = (idx) => {
+  URL.revokeObjectURL(photoPreviews.value[idx])
+  photos.value.splice(idx, 1)
+  photoPreviews.value.splice(idx, 1)
+}
+
 const openHelp = () => {
   $q.dialog({ component: ProfileHelpDialog })
 }
@@ -191,6 +223,7 @@ const submit = async () => {
       block_provider: false,
       block_client: isNegative.value && blockClient.value,
       favorite_provider: false,
+      photos: photos.value,
     })
 
     onDialogOK(true)
@@ -249,4 +282,53 @@ onMounted(async () => {
   font-weight: 700;
   padding: 10px 0;
 }
+
+.photo-thumb {
+  position: relative;
+  width: 64px;
+  height: 64px;
+  border-radius: 8px;
+  overflow: hidden;
+  flex-shrink: 0;
+
+  img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
+  &__remove {
+    position: absolute !important;
+    top: 2px;
+    right: 2px;
+    background: rgba(0, 0, 0, 0.5) !important;
+    color: white !important;
+    min-height: unset !important;
+  }
+}
+
+.photo-add {
+  width: 64px;
+  height: 64px;
+  border-radius: 8px;
+  border: 1.5px dashed #d1d5db;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  flex-shrink: 0;
+
+  &__label {
+    font-size: 9px;
+    color: #9ca3af;
+    text-align: center;
+    margin-top: 2px;
+    line-height: 1.2;
+  }
+
+  &:active { background: rgba(0, 0, 0, 0.03); }
+}
+
+.hidden { display: none; }
 </style>

+ 12 - 3
src/components/dashboard/SolicitationDetailsDialog.vue

@@ -11,7 +11,8 @@
 
         <q-card-section class="column items-center q-pt-xs q-pb-xs">
           <q-avatar size="72px" class="q-mb-sm">
-            <img :src="solicitation.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" />
+            <img v-if="solicitation.customer_photo" :src="solicitation.customer_photo" style="object-fit:cover" />
+            <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[solicitation.id % avatarColors.length]" style="font-size:20px;border-radius:50%">{{ solicitation.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
           </q-avatar>
           <div class="text-subtitle1 text-weight-bold text-text">
             {{ solicitation.client_name }}
@@ -110,7 +111,8 @@
 
         <q-card-section class="column items-center q-pt-sm q-pb-sm">
           <q-avatar size="72px" class="q-mb-sm">
-            <img :src="solicitation.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" />
+            <img v-if="solicitation.customer_photo" :src="solicitation.customer_photo" style="object-fit:cover" />
+            <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[solicitation.id % avatarColors.length]" style="font-size:20px;border-radius:50%">{{ solicitation.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
           </q-avatar>
 
           <div class="text-subtitle1 text-weight-bold text-text">{{ solicitation.client_name }}</div>
@@ -151,7 +153,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">
@@ -273,6 +275,13 @@ import { formatCurrency } from 'src/helpers/utils'
 import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.js'
 import ScheduleCancelDialog from './ScheduleCancelDialog.vue'
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 const props = defineProps({
   solicitation: {
     type: Object,

+ 32 - 17
src/components/login/LoginStepFourPanel.vue

@@ -182,6 +182,10 @@ const tempDocFront = ref(null);
 const tempDocBack = ref(null);
 const tempSelfie = ref(null);
 
+const tempSelfieFile = ref(null);
+const tempDocFrontFile = ref(null);
+const tempDocBackFile = ref(null);
+
 const stream = ref(null);
 const videoRef = ref(null);
 const canvasRef = ref(null);
@@ -192,8 +196,8 @@ const PREVIEW_STATES = ['selfie_preview', 'doc_front_preview', 'doc_back_preview
 const isCameraState = computed(() => CAMERA_STATES.includes(subStep.value));
 const isPreviewState = computed(() => PREVIEW_STATES.includes(subStep.value));
 
-const selfieDone = computed(() => !!form.value.selfie_base64);
-const docDone = computed(() => !!form.value.document_front_base64 && !!form.value.document_back_base64);
+const selfieDone = computed(() => !!form.value.selfie);
+const docDone = computed(() => !!form.value.document_front && !!form.value.document_back);
 
 const currentPreview = computed(() => {
   if (subStep.value === 'selfie_preview') return tempSelfie.value;
@@ -265,29 +269,40 @@ const capturePhoto = () => {
   canvas.width = video.videoWidth || 1280;
   canvas.height = video.videoHeight || 720;
   canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
-  const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
-
-  if (subStep.value === 'selfie_camera') {
-    tempSelfie.value = dataUrl;
-    subStep.value = 'selfie_preview';
-  } else if (subStep.value === 'doc_front_camera') {
-    tempDocFront.value = dataUrl;
-    subStep.value = 'doc_front_preview';
-  } else if (subStep.value === 'doc_back_camera') {
-    tempDocBack.value = dataUrl;
-    subStep.value = 'doc_back_preview';
-  }
+
+  const capturing = subStep.value;
+  canvas.toBlob((blob) => {
+    const file = new File([blob], 'photo.jpg', { type: 'image/jpeg' });
+    const preview = URL.createObjectURL(blob);
+
+    if (capturing === 'selfie_camera') {
+      if (tempSelfie.value) URL.revokeObjectURL(tempSelfie.value);
+      tempSelfie.value = preview;
+      tempSelfieFile.value = file;
+      subStep.value = 'selfie_preview';
+    } else if (capturing === 'doc_front_camera') {
+      if (tempDocFront.value) URL.revokeObjectURL(tempDocFront.value);
+      tempDocFront.value = preview;
+      tempDocFrontFile.value = file;
+      subStep.value = 'doc_front_preview';
+    } else if (capturing === 'doc_back_camera') {
+      if (tempDocBack.value) URL.revokeObjectURL(tempDocBack.value);
+      tempDocBack.value = preview;
+      tempDocBackFile.value = file;
+      subStep.value = 'doc_back_preview';
+    }
+  }, 'image/jpeg', 0.85);
 };
 
 const usePhoto = () => {
   if (subStep.value === 'selfie_preview') {
-    form.value.selfie_base64 = tempSelfie.value;
+    form.value.selfie = tempSelfieFile.value;
     subStep.value = 'selfie_result';
   } else if (subStep.value === 'doc_front_preview') {
     subStep.value = 'doc_back_camera';
   } else if (subStep.value === 'doc_back_preview') {
-    form.value.document_front_base64 = tempDocFront.value;
-    form.value.document_back_base64 = tempDocBack.value;
+    form.value.document_front = tempDocFrontFile.value;
+    form.value.document_back = tempDocBackFile.value;
     docResult.value = 'success';
     subStep.value = 'doc_result';
   }

+ 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 };
+};

+ 21 - 3
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": {
@@ -273,7 +276,20 @@
         "in_progress": "Service in progress!",
         "end_time_label": "Service ends at",
         "rate_btn": "Rate",
-        "help": "help"
+        "help": "help",
+        "reviewed_badge": "reviewed!"
+      },
+      "schedule_rating": {
+        "title": "How was the service by",
+        "positive_label": "What did you like most?",
+        "negative_label": "What could be improved?",
+        "comment_placeholder": "Would you like to leave a comment?",
+        "block_label": "Stop receiving requests from this client",
+        "add_photo": "Add photo",
+        "submit_btn": "submit review",
+        "help_link": "Help",
+        "already_reviewed": "You have already reviewed this service.",
+        "reviewed_badge": "reviewed!"
       },
       "opportunities": {
         "title": "Opportunities",
@@ -550,7 +566,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",

+ 21 - 3
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": {
@@ -271,7 +274,20 @@
         "in_progress": "Servicio en progreso!",
         "end_time_label": "Término del servicio en",
         "rate_btn": "Evaluar",
-        "help": "ayuda"
+        "help": "ayuda",
+        "reviewed_badge": "¡evaluado!"
+      },
+      "schedule_rating": {
+        "title": "¿Cómo fue el servicio de",
+        "positive_label": "¿Qué te gustó más?",
+        "negative_label": "¿Qué podría mejorar?",
+        "comment_placeholder": "¿Deseas dejar un comentario?",
+        "block_label": "No recibir más pedidos de este cliente",
+        "add_photo": "Agregar foto",
+        "submit_btn": "enviar evaluación",
+        "help_link": "Ayuda",
+        "already_reviewed": "Ya evaluaste este servicio.",
+        "reviewed_badge": "¡evaluado!"
       },
       "opportunities": {
         "title": "Oportunidades",
@@ -548,7 +564,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",

+ 13 - 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": {
@@ -282,6 +285,7 @@
         "negative_label": "O que poderia melhorar?",
         "comment_placeholder": "Deseja deixar um comentário?",
         "block_label": "Não receber mais pedidos deste cliente",
+        "add_photo": "Adicionar foto",
         "submit_btn": "enviar avaliação",
         "help_link": "Ajuda",
         "already_reviewed": "Você já avaliou este serviço.",
@@ -565,7 +569,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",
@@ -808,5 +814,10 @@
     "payments": "Pagamentos",
     "agenda": "Agenda",
     "profile": "Perfil"
+  },
+  "notifications": {
+    "title": "Notificações",
+    "unread": "Não lidas",
+    "mark_all_read": " Marcar todas como lidas"
   }
 }

+ 50 - 64
src/pages/LoginPage.vue

@@ -286,12 +286,9 @@ const stepThreeForm = ref({
 });
 
 const stepFourForm = ref({
-  selfie_file: null,
-  selfie_base64: "",
-  document_front_file: null,
-  document_front_base64: "",
-  document_back_file: null,
-  document_back_base64: "",
+  selfie: null,
+  document_front: null,
+  document_back: null,
 });
 
 const stepFiveForm = ref({
@@ -364,10 +361,10 @@ const validateCurrentStep = async () => {
     return false;
   }
 
-  if (steps.value === 4) {
-    const hasSelfie = !!stepFourForm.value.selfie_base64;
-    const hasDocumentFront = !!stepFourForm.value.document_front_base64;
-    const hasDocumentBack = !!stepFourForm.value.document_back_base64;
+  if(steps.value === 4) {
+    const hasSelfie = !!stepFourForm.value.selfie;
+    const hasDocumentFront = !!stepFourForm.value.document_front;
+    const hasDocumentBack = !!stepFourForm.value.document_back;
 
     if (!hasSelfie || !hasDocumentFront || !hasDocumentBack) {
       $q.notify({
@@ -432,66 +429,55 @@ const validateCodeInput = async () => {
 
 const registerUserAndProvider = async () => {
   const workingDays = mapWorkingDays();
-  const providerEmail = stepThreeForm.value.email || email.value;
-  const providerPhone = stepThreeForm.value.phone || phone.value;
-  const providerName = stepThreeForm.value.name;
-  const providerDocument = stepThreeForm.value.document;
-
-  const payload = {
-    ...stepThreeForm.value,
-    email: providerEmail || undefined,
-    phone: providerPhone || undefined,
-    code: code.value,
-    birth_date: toISODate(stepThreeForm.value.birth_date),
-    has_complement: !stepThreeForm.value.no_complement,
-    complement: stepThreeForm.value.no_complement
-      ? null
-      : stepThreeForm.value.complement,
-
-    recipient_name: providerName,
-    recipient_email: providerEmail,
-    recipient_description: `Prestador ${providerName}`,
-    recipient_document: providerDocument,
-    recipient_type: "individual",
-    recipient_code: providerDocument,
-    recipient_payment_mode: "bank_transfer",
-    recipient_default_bank_account: {
-      holder_name: providerName,
-      holder_type: "individual",
-      holder_document: providerDocument,
-      bank: stepSixForm.value.bank,
-      branch_number: stepSixForm.value.branch_number,
-      branch_check_digit: stepSixForm.value.branch_check_digit || null,
-      account_number: stepSixForm.value.account_number,
-      account_check_digit: stepSixForm.value.account_check_digit,
-      type: stepSixForm.value.account_type,
-      metadata: [],
-      pix_key: stepSixForm.value.pix_key || null,
-    },
-    recipient_metadata: [],
-
-    selfie_base64: stepFourForm.value.selfie_base64,
-    document_front_base64: stepFourForm.value.document_front_base64,
-    document_back_base64: stepFourForm.value.document_back_base64,
-
-    daily_price_8h: normalizeCurrency(stepFiveForm.value.daily_price_8h),
-    daily_price_6h: normalizeCurrency(stepFiveForm.value.daily_price_6h),
-    daily_price_4h: normalizeCurrency(stepFiveForm.value.daily_price_4h),
-    daily_price_2h: normalizeCurrency(stepFiveForm.value.daily_price_2h),
-    services_types_ids: stepFiveForm.value.services_types_ids,
-    service_types_ids: stepFiveForm.value.services_types_ids,
-
-    working_days: workingDays,
+  const form = new FormData();
+
+  const append = (key, val) => {
+    if (val === null || val === undefined) return;
+    if (typeof val === 'boolean') form.append(key, val ? '1' : '0');
+    else form.append(key, val);
   };
 
-  const response = await createUserAndProvider(payload);
-  if ([200, 201].includes(response.status)) {
-    steps.value = 8;
+  append('name', stepThreeForm.value.name);
+  append('email', stepThreeForm.value.email || email.value);
+  append('phone', stepThreeForm.value.phone || phone.value);
+  append('code', code.value);
+  append('rg', stepThreeForm.value.rg);
+  append('document', stepThreeForm.value.document);
+  append('birth_date', toISODate(stepThreeForm.value.birth_date));
+  append('zip_code', stepThreeForm.value.zip_code);
+  append('address', stepThreeForm.value.address);
+  append('has_complement', !stepThreeForm.value.no_complement);
+  append('complement', stepThreeForm.value.no_complement ? null : stepThreeForm.value.complement);
+  append('nickname', stepThreeForm.value.nickname);
+  append('instructions', stepThreeForm.value.instructions);
+  append('city', stepThreeForm.value.city);
+  append('state', stepThreeForm.value.state);
+  append('address_type', stepThreeForm.value.address_type);
+
+  append('daily_price_8h', Number(stepFiveForm.value.daily_price_8h));
+  append('daily_price_6h', Number(stepFiveForm.value.daily_price_6h));
+  append('daily_price_4h', Number(stepFiveForm.value.daily_price_4h));
+  append('daily_price_2h', Number(stepFiveForm.value.daily_price_2h));
+
+  (stepFiveForm.value.services_types_ids ?? []).forEach(id => form.append('services_types_ids[]', id));
+
+  workingDays.forEach((wd, i) => {
+    form.append(`working_days[${i}][day]`, wd.day);
+    form.append(`working_days[${i}][period]`, wd.period);
+  });
+
+  form.append('selfie', stepFourForm.value.selfie);
+  form.append('document_front', stepFourForm.value.document_front);
+  form.append('document_back', stepFourForm.value.document_back);
+
+  const response = await createUserAndProvider(form);
+  if (response.status === 200) {
+    steps.value = 7;
   }
 };
 
 const onSubmit = async () => {
-  if (showSubStep.value) return; // Não submete o form principal se estiver em um sub-passo
+  if (showSubStep.value) return;
 
   const isValid = await loginForm.value.validate();
   if (!isValid) return;

+ 10 - 3
src/pages/calendar/CalendarPage.vue

@@ -26,7 +26,8 @@
             <q-card-section class="q-pa-sm">
               <div class="row no-wrap items-start q-gutter-x-sm">
                 <q-avatar size="44px">
-                  <img :src="item.customer_photo || defaultAvatar">
+                  <img v-if="item.customer_photo" :src="item.customer_photo" style="object-fit:cover" />
+                  <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:13px;border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
                 </q-avatar>
 
                 <div class="col columns">
@@ -94,7 +95,8 @@
             <q-card-section class="q-pa-sm">
               <div class="row no-wrap items-start q-gutter-x-sm">
                 <q-avatar size="44px">
-                  <img :src="item.customer_photo || defaultAvatar">
+                  <img v-if="item.customer_photo" :src="item.customer_photo" style="object-fit:cover" />
+                  <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:13px;border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
                 </q-avatar>
 
                 <div class="col columns">
@@ -177,7 +179,12 @@ import ScheduleRatingDialog from 'src/components/dashboard/ScheduleRatingDialog.
 const $q = useQuasar();
 const { t } = useI18n();
 
-const defaultAvatar = 'https://cdn.quasar.dev/img/avatar.png';
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
 const loading = ref(true);
 const upcomingSchedules = ref([]);
 const completedSchedules = ref([]);

+ 6 - 1
src/pages/dashboard/DashboardPage.vue

@@ -6,7 +6,10 @@
       </div>
     </template>
     <template v-else>
-      <DashboardHeaderBar :data="headerBar" />
+      <DashboardHeaderBar
+  :data="headerBar"
+  :notifications="notifications"
+/>
       <DashboardSummaryInfos :data="summaryInfos" />
       <DashboardPriceSuggest :data="priceSuggestion"/>
       <DashboardTodayServices v-if="todayServices?.length > 0" :data="todayServices" @refresh="loadDashboard" @rate="openRatingDialog" />
@@ -52,6 +55,7 @@ const solicitations = ref([]);
 const todayServices = ref([]);
 const nextSchedules = ref([]);
 const opportunities = ref([]);
+const notifications = ref([]);
 
 const showSuccessModal = ref(router.currentRoute.value.fullPath.includes('showSuccessModal') || false);
 
@@ -69,6 +73,7 @@ const loadDashboard = async () => {
     todayServices.value = response.todayServices ?? [];
     nextSchedules.value = response.nextSchedules ?? [];
     opportunities.value = response.opportunities ?? [];
+    notifications.value = response.notifications ?? [];
   }
 
   if( showSuccessModal.value == true) {

+ 274 - 0
src/pages/notifications/NotificationsPage.vue

@@ -0,0 +1,274 @@
+<template>
+  <q-page class="notifications-page">
+
+    <!-- HEADER -->
+    <div class="header row items-center">
+
+      <q-btn
+        flat
+        round
+        dense
+        icon="chevron_left"
+        class="back-btn"
+        @click="router.back()"
+      />
+
+      <div class="header-title">
+        {{ $t('notifications.title') }}
+      </div>
+
+    </div>
+
+    <!-- ACTIONS -->
+    <div class="actions row justify-between items-center">
+
+      <div class="unread-text">
+        {{ $t('notifications.unread') }} {{ unreadCount }}
+      </div>
+
+      <q-btn
+        flat
+        no-caps
+        class="mark-read-btn"
+        label="✓ Marcar todas como lidas"
+        @click="markAllAsRead"
+      />
+
+    </div>
+
+    <!-- LIST -->
+    <div class="notifications-list">
+
+       <q-card
+    v-for="item in notifications"
+    :key="item.id"
+    flat
+    clickable
+    class="notification-card"
+    :class="{ unread: !item.read }"
+    @click="markAsRead(item)"
+  >
+
+    <div class="row no-wrap items-start">
+
+      <!-- AVATAR -->
+      <q-avatar size="42px" class="q-mr-md">
+        <img :src="logoDiaria" />
+      </q-avatar>
+
+      <!-- CONTENT -->
+      <div class="col">
+
+        <div class="row justify-between items-start">
+
+          <div class="notification-title">
+            {{ item.title }}
+          </div>
+
+          <!-- STATUS -->
+          <div
+            class="status-dot"
+            :class="{ active: !item.read }"
+          />
+
+        </div>
+
+        <div class="notification-description">
+          {{ item.description }}
+        </div>
+
+        <div class="notification-time">
+          {{ item.time }}
+        </div>
+
+      </div>
+
+    </div>
+
+  </q-card>
+
+</div>
+
+  </q-page>
+</template>
+
+<script setup>
+
+import { computed, ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+
+import { api } from 'boot/axios'
+
+import logoDiaria from 'src/assets/logo_diaria_colorido_sem_texto.svg'
+
+const router = useRouter()
+
+const notifications = ref([])
+
+onMounted(() => {
+  loadNotifications()
+})
+
+const unreadCount = computed(() => {
+  return notifications.value.filter((n) => !n.read).length
+})
+
+const loadNotifications = async () => {
+  try {
+
+    const response = await api.get('/notifications')
+
+    notifications.value = response.data.payload || []
+
+  } catch (error) {
+    console.error(error)
+  }
+}
+
+const markAsRead = async (notification) => {
+  try {
+
+    await api.put(`/notifications/${notification.id}/read`)
+
+    notifications.value = notifications.value.map((item) => {
+
+      if (item.id === notification.id) {
+        return {
+          ...item,
+          read: true
+        }
+      }
+
+      return item
+    })
+
+    if (
+      notification.origin === 'schedule'
+      && notification.origin_id
+    ) {
+      router.push(`/schedule/${notification.origin_id}`)
+    }
+
+  } catch (error) {
+    console.error(error)
+  }
+}
+
+const markAllAsRead = async () => {
+  try {
+
+    await api.put('/notifications/read-all')
+
+    notifications.value = notifications.value.map((notification) => ({
+      ...notification,
+      read: true
+    }))
+
+  } catch (error) {
+    console.error(error)
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.notifications-page {
+  background: #f5f5f7;
+  min-height: 100vh;
+}
+
+/* HEADER */
+.header {
+  position: relative;
+  padding: 18px 16px 12px;
+  background: white;
+}
+
+.back-btn {
+  position: absolute;
+  left: 10px;
+}
+
+.header-title {
+  width: 100%;
+  text-align: center;
+
+  font-size: 16px;
+  font-weight: 700;
+
+  color: #8B5CF6;
+}
+
+/* ACTIONS */
+.actions {
+  padding: 14px 16px;
+}
+
+.unread-text {
+  font-size: 13px;
+  color: #666;
+}
+
+.mark-read-btn {
+  font-size: 12px;
+  color: #ff4fd8;
+}
+
+/* LIST */
+.notifications-list {
+  display: flex;
+  flex-direction: column;
+}
+
+/* CARD */
+.notification-card {
+  border-radius: 0;
+  padding: 16px;
+  background: white;
+
+  border-bottom: 1px solid #ececec;
+
+  transition: 0.2s ease;
+}
+
+.notification-card.unread {
+  background: #f8eff7;
+}
+
+/* TITLE */
+.notification-title {
+  font-size: 14px;
+  font-weight: 700;
+  color: #555;
+}
+
+/* DESCRIPTION */
+.notification-description {
+  margin-top: 4px;
+
+  font-size: 12px;
+  line-height: 1.4;
+
+  color: #777;
+}
+
+/* TIME */
+.notification-time {
+  margin-top: 10px;
+
+  font-size: 11px;
+  color: #aaa;
+}
+
+/* STATUS */
+.status-dot {
+  width: 10px;
+  height: 10px;
+
+  border-radius: 50%;
+  background: #ddd;
+}
+
+.status-dot.active {
+  background: #ff5be1;
+}
+</style>

+ 20 - 5
src/pages/opportunities/OpportunitiesPage.vue

@@ -25,7 +25,8 @@
     <div v-else class="opportunity-list">
       <q-card v-for="item in opportunities" :key="item.id" flat class="opportunity-card">
         <div class="avatar-column">
-          <img :src="item.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" alt="Avatar" class="client-avatar" />
+          <img v-if="item.customer_photo" :src="item.customer_photo" alt="Avatar" class="client-avatar" />
+          <div v-else class="client-avatar client-avatar--initials" :style="avatarColors[item.id % avatarColors.length]">{{ (item.client_name ?? item.client?.user?.name)?.slice(0,2).toUpperCase() ?? '??' }}</div>
           <div class="service-type">
             {{ item.custom_schedule?.service_type.description }}
           </div>
@@ -62,7 +63,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>
 
           
@@ -82,20 +83,26 @@ import { userStore } from 'src/stores/user'
 import OpportunityDetailsDialog from './components/OpportunityDetailsDialog.vue'
 
 const $q = useQuasar()
-
 const user = userStore()
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 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,
     }
   })
 }
@@ -222,6 +229,14 @@ onMounted(loadOpportunities)
   object-fit: cover;
 }
 
+.client-avatar--initials {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 700;
+  font-size: 14px;
+}
+
 .service-type {
   margin-top: 8px;
   font-size: 11px;

+ 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
   }
 })
 

+ 8 - 2
src/pages/payments/PaymentsPage.vue

@@ -129,7 +129,8 @@
           <q-card-section class="q-pa-sm">
             <div class="row no-wrap items-start q-gutter-x-sm">
               <q-avatar size="44px">
-                <img :src="item.client_photo || defaultAvatar">
+                <img v-if="item.client_photo" :src="item.client_photo" style="object-fit:cover" />
+              <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:13px;border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
               </q-avatar>
 
               <div class="col column">
@@ -178,7 +179,12 @@ import WithdrawConfirmDialog from 'src/components/payments/WithdrawConfirmDialog
 const $q = useQuasar();
 const { t } = useI18n();
 
-const defaultAvatar = 'https://cdn.quasar.dev/img/avatar.png';
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
 const earningsExpanded = ref(false);
 const selectedPeriod = ref('week');
 const servicesLoading = ref(true);

+ 2 - 1
src/pages/profile/ProfilePage.vue

@@ -13,7 +13,8 @@
         
         <q-card-section class="column items-center q-pb-md">
           <q-avatar size="70px" class="shadow-card">
-            <img src="https://cdn.quasar.dev/img/avatar.png">
+            <img v-if="user.provider?.profile_media?.url" :src="user.provider.profile_media.url" style="object-fit:cover">
+            <q-icon v-else name="mdi-account-circle" size="70px" color="grey-4" />
           </q-avatar>
           
           <div class="fonte-nome-profile text-weight-bold q-mt-md text-dark">{{ user.name || '—' }}</div>

+ 11 - 0
src/router/routes/notifications.route.js

@@ -0,0 +1,11 @@
+export default [
+  {
+    path: '/notifications',
+    name: 'NotificationsPage',
+    component: () => import('src/pages/notifications/NotificationsPage.vue'),
+    meta: {
+      title: 'Notificações',
+      requireAuth: true
+    }
+  }
+]