Quellcode durchsuchen

Merge remote-tracking branch 'refs/remotes/origin/development' into development

Gustavo Mantovani vor 2 Wochen
Ursprung
Commit
9840199ff5
37 geänderte Dateien mit 1115 neuen und 77 gelöschten Zeilen
  1. 10 0
      package-lock.json
  2. 1 0
      package.json
  3. 1 0
      quasar.config.js
  4. 13 0
      src/api/deviceToken.js
  5. 16 1
      src/api/review.js
  6. 10 0
      src/api/user.js
  7. 0 0
      src/assets/cards/diners.svg
  8. 0 0
      src/assets/cards/discover.svg
  9. 1 0
      src/assets/cards/elo.svg
  10. 0 0
      src/assets/cards/hipercard.svg
  11. 0 0
      src/assets/cards/mastercard.svg
  12. 1 0
      src/assets/cards/visa.svg
  13. 39 0
      src/boot/push-notifications.js
  14. 46 0
      src/components/brandDetector/BrandDetectorPanel.vue
  15. 3 2
      src/components/dashboard/DashboardFavoriteProviders.vue
  16. 67 4
      src/components/dashboard/DashboardHeaderBar.vue
  17. 3 2
      src/components/dashboard/DashboardLastDoneSchedules.vue
  18. 2 1
      src/components/dashboard/DashboardNextSchedules.vue
  19. 2 1
      src/components/dashboard/DashboardPendingSchedules.vue
  20. 3 2
      src/components/dashboard/DashboardProvidersClose.vue
  21. 2 1
      src/components/dashboard/DashboardSummaryInfos.vue
  22. 90 7
      src/components/dashboard/ScheduleRatingDialog.vue
  23. 94 1
      src/components/profile/ProfileAddressFormDialog.vue
  24. 3 27
      src/components/profile/ProfilePaymentsDialog.vue
  25. 206 0
      src/components/shared/LocationMapDialog.vue
  26. 25 1
      src/composables/useGeocodingApi.js
  27. 19 4
      src/i18n/locales/en.json
  28. 16 1
      src/i18n/locales/es.json
  29. 15 7
      src/i18n/locales/pt.json
  30. 10 3
      src/pages/agenda/CalendarPage.vue
  31. 7 1
      src/pages/dashboard/DashboardPage.vue
  32. 3 4
      src/pages/dashboard/components/DashboardClientProposals.vue
  33. 363 0
      src/pages/notifications/NotificationsPage.vue
  34. 28 4
      src/pages/profile/ProfileEditDialog.vue
  35. 4 2
      src/pages/profile/ProfilePage.vue
  36. 1 1
      src/pages/search/SearchPage.vue
  37. 11 0
      src/router/routes/notifications.route.js

+ 10 - 0
package-lock.json

@@ -12,6 +12,7 @@
         "@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",
@@ -478,6 +479,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",

+ 1 - 0
package.json

@@ -22,6 +22,7 @@
     "@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",

+ 1 - 0
quasar.config.js

@@ -24,6 +24,7 @@ export default defineConfig((ctx) => {
       "axios",
       "i18n",
       "defaultPropsComponents",
+      "push-notifications",
       // "socket.io",
     ],
 

+ 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: "cliente",
+  });
+};
+
+export const removeDeviceToken = async (token) => {
+  await api.delete(`/device-tokens/${token}`);
+};

+ 16 - 1
src/api/review.js

@@ -6,7 +6,22 @@ export const getProviderReceivedReviews = async (providerId) => {
 }
 
 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
 }
 

+ 10 - 0
src/api/user.js

@@ -46,6 +46,16 @@ export const createUserAndClient = async (data) => {
 }
 
 export const updateMe = async (data) => {
+  if (data.avatar instanceof File) {
+    const form = new FormData();
+    form.append('_method', 'PUT');
+    Object.entries(data).forEach(([key, val]) => {
+      if (val !== null && val !== undefined) form.append(key, val);
+    });
+    const { data: res } = await api.post('/me', form);
+    return res.payload;
+  }
+
   const { data: res } = await api.put('/me', data);
   return res.payload;
 };

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
src/assets/cards/diners.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
src/assets/cards/discover.svg


+ 1 - 0
src/assets/cards/elo.svg

@@ -0,0 +1 @@
+<svg height="1181" viewBox="0 0 2337.9 1104.7" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m0 0h2337.9v1104.7h-2337.9z"/><path d="m481.7 338.4c22.5-7.5 46.5-11.5 71.4-11.5 109 0 200 77.4 220.8 180.3l154.5-31.5c-35.4-174.8-190-306.4-375.3-306.4-42.5 0-83.2 6.9-121.4 19.7z" fill="#ffcb05"/><path d="m299.4 839 104.5-118.1c-46.6-41.3-76-101.6-76-168.8 0-67.1 29.4-127.4 76-168.6l-104.5-118.2c-79.2 70.2-129.2 172.7-129.2 286.8 0 114.2 50 216.7 129.2 286.9" fill="#00a4e0"/><path d="m773.9 597.3c-20.9 102.8-111.8 180.1-220.7 180.1-25 0-49.1-4-71.5-11.6l-50 149.6c38.2 12.7 79 19.6 121.5 19.6 185.1 0 339.6-131.4 375.2-306z" fill="#ef4123"/><path d="m1063.5 725.8c-5.1-8.2-11.9-21.3-16.1-31-24.5-56.8-25.6-115.6-5-172 22.7-61.9 66.1-109.2 122.2-133.4 70.5-30.3 148.4-24.4 216 15.7 42.9 24.6 73.3 62.6 96.4 116.4 2.9 6.9 5.5 14.2 8.1 20.5zm140.8-245.1c-50.1 21.5-75.9 68.5-70.5 123.5l212-91.3c-36.5-42.8-83.9-57-141.5-32.2zm167.9 198.6c-.1 0-.1.1-.1.1l-4.4-3c-12.7 20.5-32.4 37.2-57.3 48-47.4 20.6-91.3 15.3-122.9-12.4l-2.9 4.4s0-.1-.1-.1l-53.8 80.5c13.4 9.3 27.7 17.1 42.7 23.4 59.4 24.7 120.2 23.5 180.1-2.5 43.3-18.8 77.3-47.4 100.5-83.7zm260.2-408.3v447.4l69.6 28.2-39.5 92.2-76.8-32c-17.2-7.5-29-18.9-37.8-31.8-8.5-13.1-14.8-31.2-14.8-55.4v-448.6zm180.2 333.5c0-38.1 16.9-72.3 43.5-95.5l-71.4-79.6c-48.4 42.8-78.9 105.3-78.9 174.9-.1 69.7 30.4 132.3 78.7 175.1l71.3-79.6c-26.5-23.3-43.2-57.3-43.2-95.3zm126.8 126.9c-14 0-27.6-2.4-40.2-6.6l-34.1 101.3c23.3 7.8 48.2 12.1 74.2 12.1 113 .1 207.3-80.1 229.2-186.6l-104.8-21.4c-11.9 57.9-63 101.3-124.3 101.2zm.3-360.6c-25.9 0-50.8 4.2-74.1 11.9l33.8 101.4c12.7-4.2 26.2-6.5 40.2-6.5 61.4.1 112.6 43.7 124.2 101.7l104.8-21.2c-21.5-106.7-115.8-187.2-228.9-187.3z" fill="#fff"/></svg>

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
src/assets/cards/hipercard.svg


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
src/assets/cards/mastercard.svg


+ 1 - 0
src/assets/cards/visa.svg

@@ -0,0 +1 @@
+<svg height="812" viewBox="0.5 0.5 999 323.684" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="M651.185.5c-70.933 0-134.322 36.766-134.322 104.694 0 77.9 112.423 83.28 112.423 122.415 0 16.478-18.884 31.229-51.137 31.229-45.773 0-79.984-20.611-79.984-20.611l-14.638 68.547s39.41 17.41 91.734 17.41c77.552 0 138.576-38.572 138.576-107.66 0-82.316-112.89-87.537-112.89-123.86 0-12.91 15.501-27.053 47.662-27.053 36.286 0 65.892 14.99 65.892 14.99l14.326-66.204S696.614.5 651.185.5zM2.218 5.497L.5 15.49s29.842 5.461 56.719 16.356c34.606 12.492 37.072 19.765 42.9 42.353l63.51 244.832h85.138L379.927 5.497h-84.942L210.707 218.67l-34.39-180.696c-3.154-20.68-19.13-32.477-38.685-32.477H2.218zm411.865 0L347.449 319.03h80.999l66.4-313.534h-80.765zm451.759 0c-19.532 0-29.88 10.457-37.474 28.73L709.699 319.03h84.942l16.434-47.468h103.483l9.994 47.468H999.5L934.115 5.497h-68.273zm11.047 84.707l25.178 117.653h-67.454z" fill="#1434cb"/></svg>

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

+ 46 - 0
src/components/brandDetector/BrandDetectorPanel.vue

@@ -0,0 +1,46 @@
+<template>
+    <img :src="brandImage" class="card-brand-image" alt="Bandeira do cartão" />
+
+</template>
+
+
+
+<script setup>
+
+import { computed } from 'vue';
+import visa from 'src/assets/cards/visa.svg';
+import mastercard from 'src/assets/cards/mastercard.svg';
+import elo from 'src/assets/cards/elo.svg';
+import hipercard from 'src/assets/cards/hipercard.svg';
+import diners from 'src/assets/cards/diners.svg';
+import discover from 'src/assets/cards/discover.svg';
+
+const props = defineProps({
+    brand:
+    {
+        type: String,
+        default: '',
+    },
+});
+
+const brandImage = computed(() => {
+    const brands = {
+        visa,
+        mastercard,
+        elo,
+        hipercard,
+        diners,
+        discover,
+    };
+    return brands[props.brand?.toLowerCase()] || visa;
+});
+</script>
+
+<style scoped lang="scss">
+.card-brand-image {
+    width: 42px;
+    height: auto;
+    object-fit: contain;
+    display: block;
+}
+</style>

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

@@ -12,8 +12,9 @@
         <q-card-section class="q-pa-sm column text-text">
           <div class="row items-start no-wrap">
             <div class="col-3 q-my-auto">
-              <q-avatar :style="avatarColors[item.provider_provider_id % avatarColors.length]" size="46px" class="text-weight-bold">
-                {{ item.provider_name?.slice(0,1).toUpperCase() ?? '—' }}
+              <q-avatar :style="avatarColors[item.provider_id % avatarColors.length]" size="46px" class="text-weight-bold">
+                <img v-if="item.provider_photo" :src="item.provider_photo" style="object-fit:cover;border-radius:50%;" />
+                <span v-else>{{ item.provider_name?.slice(0,1).toUpperCase() ?? '—' }}</span>
               </q-avatar>
             </div>
             <div class="col-5 column q-gutter-y-xs q-my-auto">

+ 67 - 4
src/components/dashboard/DashboardHeaderBar.vue

@@ -18,15 +18,71 @@
     </div>
 
     <div class="col row justify-end items-center">
-      <q-btn flat round dense icon="mdi-bell-outline" color="grey-7" size="sm" />
-    </div>
+
+  <q-btn
+    flat
+    round
+    dense
+    color="grey-7"
+    size="sm"
+    @click="goToNotifications"
+  >
+
+    <q-icon
+      name="mdi-bell-outline"
+      size="20px"
+    />
+
+    <!-- BADGE -->
+    <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 LogoDiariaColorida from 'src/assets/logo_diaria_colorido_sem_texto.svg'
+
+import { computed } from 'vue'
+import { useRouter } from 'vue-router'
+
+const router = useRouter()
 
-defineProps({ data: { type: Object, default: () => null } });
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => null
+  },
+
+  notifications: {
+    type: Array,
+    default: () => []
+  }
+})
+
+//vai para dashboard as notificações tem que ser mocada no backend
+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 +109,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>

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

@@ -10,8 +10,9 @@
         :flat="false"
       >
         <q-card-section class="column q-pa-md q-gutter-y-xs text-text">
-          <q-avatar :style="avatarColors[item.provider_provider_id % avatarColors.length]" size="56px" class="text-weight-bold q-mx-auto">
-            {{ item.provider_name?.slice(0,1).toUpperCase() ?? '—' }}
+          <q-avatar :style="avatarColors[item.provider_id % avatarColors.length]" size="56px" class="text-weight-bold q-mx-auto">
+            <img v-if="item.provider_photo" :src="item.provider_photo" style="object-fit:cover;border-radius:50%;" />
+            <span v-else>{{ item.provider_name?.slice(0,1).toUpperCase() ?? '—' }}</span>
           </q-avatar>
           <span class="text-done-name">{{ item.provider_name ?? 'Prestador' }}</span>
           <span v-if="item.provider_district" class="text-done-district">{{ item.provider_district != null ? item.provider_district : $t('dashboard_client.last_schedules.no_address') }}</span>

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

@@ -14,7 +14,8 @@
             <div class="column text-center schedule-avatar">
               <div class="col-7">
                 <q-avatar :style="avatarColors[item.id % avatarColors.length]" class="text-weight-bold q-mx-auto">
-                  {{ item.provider_name?.slice(0,1) ?? '—' }}
+                  <img v-if="item.provider_photo" :src="item.provider_photo" style="object-fit:cover;border-radius:50%;" />
+                  <span v-else>{{ item.provider_name?.slice(0,1) ?? '—' }}</span>
                 </q-avatar>
               </div>
 

+ 2 - 1
src/components/dashboard/DashboardPendingSchedules.vue

@@ -13,7 +13,8 @@
 
             <div class="row no-wrap items-start q-mb-sm">
               <q-avatar size="40px" :style="avatarColors[item.id % avatarColors.length]" class="text-weight-bold q-mr-sm flex-shrink-0">
-                {{ item.provider_name?.slice(0, 2).toUpperCase() ?? '??' }}
+                <img v-if="item.provider_photo" :src="item.provider_photo" style="object-fit:cover;border-radius:50%;" />
+                <span v-else>{{ item.provider_name?.slice(0, 2).toUpperCase() ?? '??' }}</span>
               </q-avatar>
 
               <div class="col column no-wrap overflow-hidden">

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

@@ -20,7 +20,8 @@
           <div class="row no-wrap full-width">
             <div class="col-2">
               <q-avatar :style="avatarColors[p.provider_id % avatarColors.length]" class="text-weight-bold">
-                {{ p.provider_name?.slice(0,1).toUpperCase() ?? '—' }}
+                <img v-if="p.provider_photo" :src="p.provider_photo" style="object-fit:cover;border-radius:50%;" />
+                <span v-else>{{ p.provider_name?.slice(0,1).toUpperCase() ?? '—' }}</span>
               </q-avatar>
             </div>
 
@@ -41,7 +42,7 @@
                   </div>
                   <div class="row items-center">
                     <q-icon name="mdi-map-marker-outline" color="text" size="16px" />
-                    <span class="text-provider-close-jobs">{{ formatDistance(p.distance_km) }}</span>
+                    <span class="text-provider-close-jobs">{{ (p.distance_km ?? '--') + ' km' }}</span>
                   </div>
                 </div>
               </div>

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

@@ -4,7 +4,8 @@
       <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() ?? '??' }}
+            <img v-if="data?.profile_photo" :src="data.profile_photo" style="object-fit:cover;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('dashboard_client.summary.welcome') }}</span>

+ 90 - 7
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('dashboard_client.schedule_rating.negative_label') : $t('dashboard_client.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('dashboard_client.schedule_rating.comment_placeholder') }}
@@ -74,7 +70,27 @@
         />
       </q-card-section>
 
-      <!-- Checkbox condicional -->
+      <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('dashboard_client.schedule_rating.add_photo') }}</div>
+          </div>
+        </div>
+      </q-card-section>
+
       <q-card-section v-if="stars > 0" class="q-pt-xs q-pb-xs q-px-lg">
         <q-checkbox
           v-model="checkboxValue"
@@ -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('dashboard_client.schedule_rating.help_link') }}
@@ -142,6 +156,9 @@ const checkboxValue = 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)
@@ -173,6 +190,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 +224,7 @@ const submit = async () => {
       block_provider: isNegative.value && checkboxValue.value,
       block_client: false,
       favorite_provider: isPositive.value && checkboxValue.value,
+      photos: photos.value,
     })
 
     onDialogOK(true)
@@ -249,4 +283,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>

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

@@ -92,6 +92,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"
+                :label="$t('profile.address.update_on_map')"
+                :loading="geocodingCep"
+                @click="openMapDialog"
+              />
+            </div>
+
             <q-btn
               unelevated
               rounded
@@ -114,12 +135,14 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from 'vue';
+import { ref, computed, onMounted } from 'vue';
 import { useDialogPluginComponent, useQuasar } from 'quasar';
 import { searchAddressByCEP, updateAddress, createAddress } from 'src/api/address';
 import { userStore } from 'src/stores/user';
 import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
+import { useGeocodingApi } from 'src/composables/useGeocodingApi';
 import { useI18n } from 'vue-i18n';
+import LocationMapDialog from 'src/components/shared/LocationMapDialog.vue';
 
 const props = defineProps({
   isEditing: {
@@ -139,6 +162,7 @@ const $q = useQuasar();
 const { t } = useI18n();
 const user = userStore();
 const clientId = user.user.client.id;
+const { geocodeFullAddress } = useGeocodingApi();
 
 const initialFormData = {
   zip_code: '',
@@ -153,6 +177,8 @@ const initialFormData = {
   source: 'client',
   source_id: clientId,
   address_type: 'home',
+  latitude: null,
+  longitude: null,
 };
 
 const { form, hasUpdatedFields, getUpdatedFields, setUpdateFormAsOriginal } =
@@ -161,6 +187,11 @@ const { form, hasUpdatedFields, getUpdatedFields, setUpdateFormAsOriginal } =
 const loadingCep = ref(false);
 const saving = ref(false);
 const addressId = ref(null);
+const geocodingCep = ref(false);
+
+const missingCoords = computed(() =>
+  props.isEditing && form.latitude == null && form.longitude == null
+);
 
 const addressTypes = [
   { value: 'home', label: 'profile.address.type.home', icon: 'mdi-home-outline' },
@@ -180,6 +211,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: t('profile.address.cep_not_found') });
       }
@@ -189,6 +222,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 {
@@ -228,6 +319,8 @@ onMounted(() => {
       source: 'client',
       source_id: clientId,
       address_type: props.addressData.address_type || 'home',
+      latitude: props.addressData.latitude ?? null,
+      longitude: props.addressData.longitude ?? null,
     };
 
     Object.assign(form, initialData);

+ 3 - 27
src/components/profile/ProfilePaymentsDialog.vue

@@ -6,8 +6,7 @@
         <q-btn v-close-popup icon="mdi-chevron-left" flat round dense color="primary" />
         <q-space />
         <span class="text-subtitle1 text-weight-bold text-primary">{{ $t('profile.payments.subtitle') }}</span>
-        <q-space />
-        <div style="width: 32px"></div>
+      <q-space />
       </div>
 
       <div v-if="loading" class="col flex flex-center">
@@ -33,7 +32,7 @@
             <div v-else class="column q-gutter-y-md">
               <div v-for="item in paymentMethods" :key="item.id" class="card-item-box row items-center no-wrap q-pa-md">
                 <div class="brand-logo-wrapper q-mr-md">
-                  <q-icon name="mdi-credit-card-chip-outline" color="grey-7" size="32px" />
+                  <BrandDetectorPanel :brand="item.brand" />
                 </div>
 
                 <div class="col column">
@@ -69,6 +68,7 @@ import { userStore } from 'src/stores/user';
 import { getClientPaymentMethods, deleteClientPaymentMethod } from 'src/api/clientPaymentMethod';
 import ProfilePaymentAddDialog from './ProfilePaymentAddDialog.vue';
 import ProfilePaymentRemoveDialog from './ProfilePaymentRemoveDialog.vue';
+import BrandDetectorPanel from '../brandDetector/BrandDetectorPanel.vue';
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -79,30 +79,6 @@ const store = userStore();
 const paymentMethods = ref([]);
 const loading = ref(false);
 
-// const brandIcon = (brand) => {
-//   const icons = {
-//     visa: 'mdi-credit-card-outline',
-//     mastercard: 'mdi-credit-card-outline',
-//     elo: 'mdi-credit-card-outline',
-//     hipercard: 'mdi-credit-card-outline',
-//     diners: 'mdi-credit-card-outline',
-//     discover: 'mdi-credit-card-outline',
-//   };
-//   return icons[brand] ?? 'mdi-credit-card-outline';
-// };
-
-// const brandColor = (brand) => {
-//   const colors = {
-//     visa: 'blue-8',
-//     mastercard: 'orange-8',
-//     elo: 'yellow-9',
-//     hipercard: 'red-8',
-//     diners: 'grey-7',
-//     discover: 'orange-6',
-//   };
-//   return colors[brand] ?? 'grey-6';
-// };
-
 const cardLabel = (item) => {
   const parts = [];
   if (item.card_name) parts.push(item.card_name);

+ 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-button"
+          :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>

+ 25 - 1
src/composables/useGeocodingApi.js

@@ -62,5 +62,29 @@ export const useGeocodingApi = () => {
     return { lat, lng, ...parseAddressComponents(result.address_components) };
   };
 
-  return { geocodeCep, reverseGeocode };
+  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 };
 };

+ 19 - 4
src/i18n/locales/en.json

@@ -407,9 +407,9 @@
       "services": "Services"
     },
     "dashboard_pending_custom_schedules": {
-        "pending_request_title": "Requesting cleaner proposals",
-        "pending_request_time": "15 minutes ago",
-        "waiting_status": "Waiting"
+      "pending_request_title": "Requesting cleaner proposals",
+      "pending_request_time": "15 minutes ago",
+      "waiting_status": "Waiting"
     },
     "summary": {
       "welcome": "Welcome,",
@@ -511,6 +511,19 @@
       "btn_payment": "go to payment",
       "btn_cancel": "Cancel request"
     },
+    "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?",
+      "favorite_label": "Add this cleaner to favorites",
+      "block_label": "Stop requesting this cleaner",
+      "add_photo": "Add photo",
+      "submit_btn": "submit review",
+      "help_link": "Help",
+      "already_reviewed": "You have already reviewed this service.",
+      "reviewed_badge": "reviewed!"
+    },
     "registration_incomplete_title": "Complete your profile information!",
     "registration_incomplete_cta": "Resolve now",
     "payment_incomplete_title": "Update your payment information!",
@@ -618,7 +631,9 @@
         "home": "Residential",
         "commercial": "Commercial",
         "other": "Other"
-      }
+      },
+      "missing_coords": "Coordinates not found for this address.",
+      "update_on_map": "Update on map"
     },
     "help": {
       "title": "Help",

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

@@ -507,6 +507,19 @@
       "btn_payment": "ir al pago",
       "btn_cancel": "Cancelar pedido"
     },
+    "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?",
+      "favorite_label": "Agregar este diarista a favoritos",
+      "block_label": "Dejar de solicitar este diarista",
+      "add_photo": "Agregar foto",
+      "submit_btn": "enviar evaluación",
+      "help_link": "Ayuda",
+      "already_reviewed": "Ya evaluaste este servicio.",
+      "reviewed_badge": "¡evaluado!"
+    },
     "registration_incomplete_title": "¡Completa la información de tu perfil!",
     "registration_incomplete_cta": "Resolver ahora",
     "payment_incomplete_title": "¡Actualiza tus datos de pago!",
@@ -614,7 +627,9 @@
         "home": "Residencial",
         "commercial": "Comercial",
         "other": "Otro"
-      }
+      },
+      "missing_coords": "Coordenadas no encontradas para esta dirección.",
+      "update_on_map": "Actualizar en el mapa"
     },
     "help": {
       "title": "Ayuda",

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

@@ -444,12 +444,12 @@
       "btn_close": "fechar",
       "btn_help": "Ajuda"
     },
-      "client_proposals": {
-        "candidate": "Candidato",
-        "custom": "sob medida",
-        "age": "({idade} anos)",
-        "distance": "Distância"
-      },
+    "client_proposals": {
+      "candidate": "Candidato",
+      "custom": "sob medida",
+      "age": "({idade} anos)",
+      "distance": "Distância"
+    },
     "last_schedules": {
       "title": "Últimos serviços",
       "reschedule": "reagendar",
@@ -518,6 +518,7 @@
       "comment_placeholder": "Deseja deixar um comentário?",
       "favorite_label": "Favoritar este diarista",
       "block_label": "Não solicitar mais este diarista",
+      "add_photo": "Adicionar foto",
       "submit_btn": "enviar avaliação",
       "help_link": "Ajuda",
       "already_reviewed": "Você já avaliou este serviço.",
@@ -630,7 +631,9 @@
         "home": "Residencial",
         "commercial": "Comercial",
         "other": "Outro"
-      }
+      },
+      "missing_coords": "Coordenadas não encontradas para este endereço.",
+      "update_on_map": "Atualizar no mapa"
     },
     "help": {
       "title": "Ajuda",
@@ -820,5 +823,10 @@
         "btn_keep": "Cancelar o serviço"
       }
     }
+  },
+  "notifications": {
+    "title": "Notificações",
+    "unread": "Não lidas",
+    "mark_all_read": " Marcar todas como lidas"
   }
 }

+ 10 - 3
src/pages/agenda/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.provider_photo || defaultAvatar">
+                  <img v-if="item.provider_photo" :src="item.provider_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.provider_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
                 </q-avatar>
 
                 <div class="col column">
@@ -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.provider_photo || defaultAvatar">
+                  <img v-if="item.provider_photo" :src="item.provider_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.provider_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
                 </q-avatar>
 
                 <div class="col column">
@@ -188,7 +190,12 @@ import SchedulingDialog from 'src/pages/search/components/SchedulingDialog.vue';
 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([]);

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

@@ -6,7 +6,10 @@
       </div>
     </template>
     <template v-else>
-      <DashboardHeaderBar :data="headerBar" />
+      <DashboardHeaderBar
+  :data="headerBar"
+  :notifications="notifications"
+/>
       <DashboardRegistrationIncomplete v-if="!registrationComplete" />
       <DashboardSummaryInfos v-else :data="summaryInfos" />
       <DashboardPaymentIncomplete v-if="!hasPaymentMethods" />
@@ -68,11 +71,13 @@ const lastDoneSchedules = ref([]);
 const favoriteProviders = ref([]);
 const providersClose = ref([]);
 const todaySchedules = ref([]);
+const notifications = ref([]);
 const loading = ref(true);
 const showSuccessModal = ref(router.currentRoute.value.fullPath.includes('showSuccessModal') || false);
 
 const registrationComplete = computed(() => store.user?.registration_complete ?? true);
 
+
 const openAcceptedDialog = (schedule) => {
   $q.dialog({
     component: ScheduleAcceptedDialog,
@@ -95,6 +100,7 @@ const reloadDashboard = async () => {
     providersClose.value = response.providersClose ?? [];
     clientProposals.value = response.schedulesProposals ?? [];
     todaySchedules.value = response.todaySchedules ?? [];
+    notifications.value = response.notifications ?? [];
     hasPaymentMethods.value = response.has_payment_methods ?? true;
   }
   if( showSuccessModal.value == true) {

+ 3 - 4
src/pages/dashboard/components/DashboardClientProposals.vue

@@ -6,7 +6,8 @@
         <div class="row no-wrap items-center">
 
           <q-avatar :style="avatarColors[item.id % avatarColors.length]" class="text-weight-bold q-mx-auto">
-            {{ item.provider_name?.slice(0, 1) ?? '—' }}
+            <img v-if="item.provider_photo" :src="item.provider_photo" style="object-fit:cover;border-radius:50%;" />
+            <span v-else>{{ item.provider_name?.slice(0, 1) ?? '—' }}</span>
           </q-avatar>
 
           <!-- LABEL -->
@@ -50,7 +51,7 @@
               </span>
 
               <span class="distance">
-                {{ $t('dashboard_client.client_proposals.distance') }} {{ distancia }}
+                {{ $t('dashboard_client.client_proposals.distance') }} {{ (item.distance_km ?? '--') + ' km' }}
               </span>
             </div>
 
@@ -117,8 +118,6 @@ const formatDate = (date) => {
   return `${weekday}, ${day}-${month}`;
 };
 
-const distancia = '1,5km'
-
 const handleRefuseProposal = async (proposalId) => {
   // isLoading.value = true
   try {

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

@@ -0,0 +1,363 @@
+<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="handleNotification(item)"
+      >
+
+        <div class="notification-wrapper">
+
+          <!-- AVATAR -->
+          <q-avatar size="44px" class="notification-avatar">
+          <img :src="getNotificationIcon(item.type)" />
+          </q-avatar>
+
+          <!-- CONTENT -->
+          <div class="notification-content">
+
+            <div class="notification-header">
+
+              <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 (id) => {
+  try {
+
+    await api.put(`/notifications/${id}/read`)
+
+    notifications.value = notifications.value.map((notification) => {
+
+      if (notification.id === id) {
+        return {
+          ...notification,
+          read: true
+        }
+      }
+
+      return notification
+    })
+
+  } 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)
+  }
+}
+
+const handleNotification = async (notification) => {
+
+  if (!notification.read) {
+    await markAsRead(notification.id)
+  }
+
+  if (
+    notification.origin === 'schedule'
+    && notification.origin_id
+  ) {
+    router.push(`/schedule/${notification.origin_id}`)
+  }
+}
+
+const getNotificationIcon = (type) => {
+
+  switch (type) {
+
+    case 'schedule_client_provider_accepted':
+      return logoDiaria
+
+    case 'schedule_client_provider_refused':
+      return logoDiaria
+
+    case 'schedule_client_provider_cancelled':
+      return logoDiaria
+
+    case 'schedule_client_provider_coming':
+      return logoDiaria
+
+    case 'schedule_client_provider_finished':
+      return logoDiaria
+
+    default:
+      return logoDiaria
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.notifications-page {
+  background: #f4f4f5;
+  min-height: 100vh;
+}
+
+/* HEADER */
+.header {
+  position: relative;
+
+  padding: 18px 16px 12px;
+
+  background: #fff;
+}
+
+.back-btn {
+  position: absolute;
+  left: 10px;
+}
+
+.header-title {
+  width: 100%;
+
+  text-align: center;
+
+  font-size: 17px;
+  font-weight: 700;
+
+  color: #8B5CF6;
+}
+
+/* ACTIONS */
+.actions {
+  padding: 14px 16px 10px;
+}
+
+.unread-text {
+  font-size: 13px;
+  font-weight: 500;
+
+  color: #6b7280;
+}
+
+.mark-read-btn {
+  font-size: 12px;
+  font-weight: 600;
+
+  color: #ff4fd8;
+}
+
+/* LIST */
+.notifications-list {
+  display: flex;
+  flex-direction: column;
+
+  gap: 12px;
+
+  padding: 0 14px 24px;
+}
+
+/* CARD */
+.notification-card {
+  background: #ffffff;
+
+  border-radius: 24px;
+
+  padding: 16px 16px;
+
+  box-shadow:
+    0 2px 10px rgba(0, 0, 0, 0.05);
+
+  transition: 0.2s ease;
+}
+
+.notification-card.unread {
+  background: #ffffff;
+}
+
+/* WRAPPER */
+.notification-wrapper {
+  display: flex;
+
+  align-items: center;
+
+  gap: 12px;
+}
+
+/* AVATAR */
+.notification-avatar {
+  flex-shrink: 0;
+
+  width: 44px !important;
+  height: 44px !important;
+}
+
+/* CONTENT */
+.notification-content {
+  flex: 1;
+
+  min-width: 0;
+}
+
+/* HEADER */
+.notification-header {
+  display: flex;
+
+  align-items: flex-start;
+
+  justify-content: space-between;
+
+  gap: 10px;
+}
+
+/* TITLE */
+.notification-title {
+  font-size: 16px;
+  font-weight: 700;
+
+  color: #5b5b5b;
+
+  line-height: 1.1;
+}
+
+/* DESCRIPTION */
+.notification-description {
+  margin-top: 4px;
+
+  font-size: 12px;
+  font-weight: 500;
+
+  line-height: 1.35;
+
+  color: #8f8f8f;
+}
+
+/* TIME */
+.notification-time {
+  margin-top: 8px;
+
+  font-size: 11px;
+  font-weight: 500;
+
+  color: #b5b5b5;
+}
+
+/* STATUS */
+.status-dot {
+  width: 14px;
+  height: 14px;
+
+  border-radius: 999px;
+
+  background: #e6e6e6;
+
+  flex-shrink: 0;
+}
+
+.status-dot.active {
+  background: #d8d8d8;
+}
+</style>

+ 28 - 4
src/pages/profile/ProfileEditDialog.vue

@@ -17,9 +17,17 @@
         <q-scroll-area class="col" style="height: calc(100vh - 72px)">
           <div class="column items-center q-mt-xl q-mb-md">
             <q-avatar size="140px" color="indigo-1" text-color="indigo-4" class="text-weight-bold text-h2 shadow-1">
-              {{ form.name ? form.name.charAt(0).toUpperCase() : '' }}
+              <img v-if="avatarPreview" :src="avatarPreview" style="object-fit: cover; width: 100%; height: 100%;" />
+              <span v-else>{{ form.name ? form.name.charAt(0).toUpperCase() : '' }}</span>
             </q-avatar>
-            <q-btn flat no-caps color="grey-6" class="q-mt-sm" :label="$t('profile.change_photo')" />
+            <input
+              ref="fileInputRef"
+              type="file"
+              accept="image/jpeg,image/png,image/webp"
+              class="hidden"
+              @change="onFileSelected"
+            />
+            <q-btn flat no-caps color="grey-6" class="q-mt-sm" :label="$t('profile.change_photo')" @click="fileInputRef.click()" />
           </div>
 
           <q-form ref="formRef" @submit.prevent="onSubmit">
@@ -108,8 +116,8 @@
                 padding="8px 16px"
                 class="full-width q-py-md text-weight-bold"
                 :label="$t('profile.update')"
-                :color="hasUpdatedFields ? 'primary' : 'grey-4'"
-                :disable="!hasUpdatedFields"
+                :color="hasUpdatedFields || avatarFile ? 'primary' : 'grey-4'"
+                :disable="!hasUpdatedFields && !avatarFile"
                 :loading="submitting"
               />
             </div>
@@ -161,7 +169,10 @@ const { loading: submitting, serverErrors, execute: submitForm } = useSubmitHand
 const { inputRules } = useInputRules();
 
 const formRef = ref(null);
+const fileInputRef = ref(null);
 const loading = ref(false);
+const avatarFile = ref(null);
+const avatarPreview = ref(null);
 const selectedLocale = ref(normalizeLocale(i18n.global.locale.value ?? i18n.global.locale));
 
 const onLocaleChange = (val) => {
@@ -169,6 +180,13 @@ const onLocaleChange = (val) => {
   Cookies.set('locale', val, { expires: 365, path: '/' });
 };
 
+const onFileSelected = (event) => {
+  const file = event.target.files[0];
+  if (!file) return;
+  avatarFile.value = file;
+  avatarPreview.value = URL.createObjectURL(file);
+};
+
 const onSubmit = async () => {
   const valid = await formRef.value.validate();
   if (!valid) return;
@@ -178,6 +196,7 @@ const onSubmit = async () => {
     email: form.email,
     phone: form.phone,
     document: form.document || null,
+    ...(avatarFile.value ? { avatar: avatarFile.value } : {}),
   }));
 };
 
@@ -188,6 +207,7 @@ onMounted(async () => {
     form.email = data.email || '';
     form.phone = data.phone || '';
     form.document = data.client_document || '';
+    avatarPreview.value = data.client?.profile_media?.url ?? null;
     setUpdateFormAsOriginal(data);
     return;
   }
@@ -201,4 +221,8 @@ onMounted(async () => {
   border-radius: 8px;
   &::before { border: 1px solid #e0e0e0; }
 }
+
+.hidden {
+  display: none;
+}
 </style>

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

@@ -12,8 +12,9 @@
         <q-btn flat round dense icon="mdi-share-variant-outline" color="grey-6" class="absolute-top-right q-ma-sm" />
         
         <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">
+          <q-avatar size="70px" color="indigo-1" text-color="indigo-4" class="shadow-card text-weight-bold text-h5">
+            <img v-if="user.client?.profile_media?.url" :src="user.client.profile_media.url" style="object-fit: cover;" />
+            <span v-else>{{ user.name ? user.name.charAt(0).toUpperCase() : '' }}</span>
           </q-avatar>
           
           <div class="fonte-nome-profile text-weight-bold q-mt-md text-dark">{{ user.name || '—' }}</div>
@@ -130,6 +131,7 @@ const openEditProfile = () => {
   }).onOk((updatedUser) => {
     user.value = { ...user.value, ...updatedUser };
     store.setUser({ ...store.user, ...updatedUser });
+    getUser().then(data => { user.value = data; });
   });
 };
 

+ 1 - 1
src/pages/search/SearchPage.vue

@@ -112,7 +112,7 @@
                     </div>
                     <div class="row items-center">
                       <q-icon name="mdi-map-marker-outline" color="text" size="16px" />
-                      <span class="text-provider-close-jobs">{{ formatDistance(p.distance_km) }}</span>
+                      <span class="text-provider-close-jobs">{{ (p.distance_km ?? '--') + ' km' }}</span>
                     </div>
                   </div>
                 </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
+    }
+  }
+]

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.