Ver código fonte

Merge branch 'development' of gogs.softpar.inf.br:Softpar/sfp_front_vue_diarista_cliente into development

Gustavo Zanatta 2 semanas atrás
pai
commit
82af79cd06

+ 1 - 0
quasar.config.js

@@ -72,6 +72,7 @@ export default defineConfig((ctx) => {
         WEBSOCKET_API_KEY:
           "7wArC/kl0nTbt4zBu0agw.NXLyjA96I6x1XmBcuokwPqfo3/CIxzqYw.PTthh5eqa08Uf4ubFlOqatpShoz1CRRID9pZReEFvBk3il6E9u",
         GOOGLE_MAPS_API_KEY: process.env.GOOGLE_MAPS_API_KEY ?? "",
+        PAGARME_PUBLIC_KEY: process.env.PAGARME_PUBLIC_KEY ?? "pk_test_1VRWkbvu43Tyk7qG",
       },
       // rawDefine: {}
       // ignorePublicFolder: true,

+ 6 - 0
src/api/payment.js

@@ -0,0 +1,6 @@
+import api from 'src/api';
+
+export const paySchedule = async (scheduleId, payload) => {
+  const { data } = await api.post(`/payment/schedule/${scheduleId}/pay`, payload);
+  return data.payload;
+};

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
src/assets/cards/diners.svg


Diferenças do arquivo suprimidas por serem muito extensas
+ 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>

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
src/assets/cards/hipercard.svg


Diferenças do arquivo suprimidas por serem muito extensas
+ 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>

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

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

+ 9 - 0
src/components/dashboard/DashboardProvidersClose.vue

@@ -90,6 +90,15 @@ const periodTypeMap = ref({
 });
 
 
+const formatDistance = (distance) => {
+  if (distance === null || distance === undefined || distance === '') return '—';
+
+  const numericDistance = Number(distance);
+  if (!Number.isFinite(numericDistance)) return '—';
+
+  return `${numericDistance.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} km`;
+};
+
 const showCorrectValues = (p) => {
   switch (currentPeriodType.value) {
     case 8:

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

@@ -153,7 +153,7 @@ onMounted(async () => {
   }
 })
 
-const SERVICE_FEE_RATE = 0.10
+const SERVICE_FEE_RATE = 0.11
 
 const serviceFee = computed(() => {
   const base = parseFloat(props.schedule.total_amount) || 0

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

@@ -89,7 +89,7 @@ const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent()
 
 const $q = useQuasar()
 
-const SERVICE_FEE_RATE = 0.10
+const SERVICE_FEE_RATE = 0.11
 
 const serviceFee = computed(() => Number(props.schedule.total_amount) * SERVICE_FEE_RATE)
 const total = computed(() => Number(props.schedule.total_amount) + serviceFee.value)

+ 10 - 7
src/components/dashboard/SchedulePaymentDialog.vue

@@ -179,14 +179,17 @@ const onConfirm = () => {
     }).onOk(() => {
       onDialogOK()
     })
-  } else {
-    $q.dialog({
-      component: SchedulePaymentProcessingDialog,
-      componentProps: { schedule: props.schedule },
-    }).onOk(() => {
-      onDialogOK()
-    })
+    return
   }
+
+  const clientPaymentMethodId = Number(String(selectedMethod.value).replace('card_', ''))
+
+  $q.dialog({
+    component: SchedulePaymentProcessingDialog,
+    componentProps: { schedule: props.schedule, clientPaymentMethodId },
+  }).onOk(() => {
+    onDialogOK()
+  })
 }
 
 onMounted(() => {

+ 63 - 85
src/components/dashboard/SchedulePaymentPixDialog.vue

@@ -34,7 +34,17 @@
         </i18n-t>
       </div>
 
-      <div v-else class="col scroll q-px-lg q-pt-lg q-pb-xl column">
+      <div v-else-if="processing" class="col column items-center justify-center q-px-xl">
+        <q-spinner-oval color="primary" size="72px" class="q-mb-lg" />
+        <div class="processing-title text-primary text-weight-bold text-center q-mb-sm">
+          {{ $t('payment.processing_title') }}
+        </div>
+        <div class="processing-message text-grey-6 text-center">
+          {{ $t('payment.processing_message') }}
+        </div>
+      </div>
+
+      <div v-else class="pix-payment-content col scroll q-px-lg q-pt-lg q-pb-xl column">
 
         <div class="row items-center justify-between q-mb-sm">
           <span class="pix-label">{{ $t('payment.pix_total') }}</span>
@@ -52,74 +62,20 @@
         </div>
 
         <div class="flex flex-center q-mb-md">
-          <div class="qrcode-wrapper">
-            <svg viewBox="0 0 200 200" width="180" height="180" xmlns="http://www.w3.org/2000/svg">
-              <rect x="10" y="10" width="60" height="60" rx="4" fill="none" stroke="#000" stroke-width="6"/>
-              <rect x="22" y="22" width="36" height="36" rx="2" fill="#000"/>
-              <rect x="130" y="10" width="60" height="60" rx="4" fill="none" stroke="#000" stroke-width="6"/>
-              <rect x="142" y="22" width="36" height="36" rx="2" fill="#000"/>
-              <rect x="10" y="130" width="60" height="60" rx="4" fill="none" stroke="#000" stroke-width="6"/>
-              <rect x="22" y="142" width="36" height="36" rx="2" fill="#000"/>
-              <g fill="#000">
-                <rect x="85" y="10" width="8" height="8"/>
-                <rect x="100" y="10" width="8" height="8"/>
-                <rect x="85" y="24" width="8" height="8"/>
-                <rect x="108" y="24" width="8" height="8"/>
-                <rect x="85" y="38" width="16" height="8"/>
-                <rect x="85" y="52" width="8" height="8"/>
-                <rect x="100" y="52" width="16" height="8"/>
-                <rect x="10" y="85" width="8" height="8"/>
-                <rect x="24" y="85" width="16" height="8"/>
-                <rect x="48" y="85" width="8" height="8"/>
-                <rect x="62" y="85" width="8" height="8"/>
-                <rect x="10" y="99" width="16" height="8"/>
-                <rect x="34" y="99" width="8" height="8"/>
-                <rect x="54" y="99" width="16" height="8"/>
-                <rect x="10" y="113" width="8" height="8"/>
-                <rect x="26" y="113" width="16" height="8"/>
-                <rect x="56" y="113" width="8" height="8"/>
-                <rect x="85" y="85" width="8" height="8"/>
-                <rect x="100" y="85" width="16" height="8"/>
-                <rect x="124" y="85" width="8" height="8"/>
-                <rect x="140" y="85" width="16" height="8"/>
-                <rect x="166" y="85" width="8" height="8"/>
-                <rect x="85" y="100" width="16" height="8"/>
-                <rect x="108" y="100" width="8" height="8"/>
-                <rect x="130" y="100" width="16" height="8"/>
-                <rect x="154" y="100" width="8" height="8"/>
-                <rect x="170" y="100" width="8" height="8"/>
-                <rect x="85" y="115" width="8" height="8"/>
-                <rect x="100" y="115" width="8" height="8"/>
-                <rect x="116" y="115" width="16" height="8"/>
-                <rect x="140" y="115" width="8" height="8"/>
-                <rect x="156" y="115" width="16" height="8"/>
-                <rect x="85" y="130" width="16" height="8"/>
-                <rect x="108" y="130" width="8" height="8"/>
-                <rect x="124" y="130" width="16" height="8"/>
-                <rect x="148" y="130" width="8" height="8"/>
-                <rect x="164" y="130" width="8" height="8"/>
-                <rect x="85" y="144" width="8" height="8"/>
-                <rect x="100" y="144" width="16" height="8"/>
-                <rect x="124" y="144" width="8" height="8"/>
-                <rect x="140" y="144" width="16" height="8"/>
-                <rect x="166" y="144" width="8" height="8"/>
-                <rect x="85" y="158" width="16" height="8"/>
-                <rect x="110" y="158" width="8" height="8"/>
-                <rect x="126" y="158" width="8" height="8"/>
-                <rect x="144" y="158" width="8" height="8"/>
-                <rect x="160" y="158" width="16" height="8"/>
-                <rect x="85" y="172" width="8" height="8"/>
-                <rect x="100" y="172" width="8" height="8"/>
-                <rect x="116" y="172" width="16" height="8"/>
-                <rect x="142" y="172" width="8" height="8"/>
-                <rect x="158" y="172" width="8" height="8"/>
-                <rect x="174" y="172" width="8" height="8"/>
-              </g>
-            </svg>
+          <q-img
+            v-if="pixQrCodeUrl"
+            :src="pixQrCodeUrl"
+            width="180px"
+            height="180px"
+            fit="contain"
+            class="qrcode-wrapper"
+          />
+          <div v-else class="qrcode-wrapper column items-center justify-center">
+            <q-icon name="mdi-qrcode" size="80px" color="grey-6" />
           </div>
         </div>
 
-        <div class="pix-code-text q-mb-md">{{ pixCode }}</div>
+        <div class="pix-code-text q-mb-md">{{ pixCode || 'Código Pix indisponível.' }}</div>
 
         <q-btn
           unelevated
@@ -132,23 +88,24 @@
           @click="copyCode"
         />
 
+        <!--
         <p class="pix-instructions-text q-mb-sm">
           <strong>{{ $t('payment.pix_instructions') }}</strong>
         </p>
         <p class="pix-instructions-text text-grey-6">
           {{ $t('payment.pix_email_note') }}
         </p>
-
+        !-->
       </div>
     </div>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, onMounted, onUnmounted } from 'vue'
+import { computed, ref, onMounted, onUnmounted } from 'vue'
 import { useDialogPluginComponent, useQuasar, copyToClipboard } from 'quasar'
 import { formatCurrency } from 'src/helpers/utils'
-import { updateScheduleStatus } from 'src/api/schedule'
+import { paySchedule } from 'src/api/payment'
 
 const props = defineProps({
   schedule: { type: Object, required: true },
@@ -160,30 +117,29 @@ defineEmits([...useDialogPluginComponent.emits])
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
 const $q = useQuasar()
 
-const pixCode = `00020126580014br.gov.bcb.pix0136sfp-diaria-${props.schedule.id}-${props.schedule.provider_id}5204000053039865406${(props.total * 100).toFixed(0).padStart(8, '0')}5802BR5913Diaria App6009SAO PAULO62070503***6304ABCD`
-
+const payment = ref(null)
 const success = ref(false)
-const processing = ref(false)
+const processing = ref(true)
+
+const pixData = computed(() => payment.value?.pix ?? {})
+const pixCode = computed(() => pixData.value?.qr_code ?? '')
+const pixQrCodeUrl = computed(() => pixData.value?.qr_code_url ?? '')
+const pixExpiresAt = computed(() => pixData.value?.expires_at ?? payment.value?.expires_at ?? null)
 
 const copyCode = async () => {
   try {
-    await copyToClipboard(pixCode)
+    if (!pixCode.value) {
+      $q.notify({ type: 'negative', message: 'Código Pix indisponível.' })
+      return
+    }
+
+    await copyToClipboard(pixCode.value)
     $q.notify({ type: 'positive', message: 'Código copiado!' })
   } catch {
     $q.notify({ type: 'negative', message: 'Erro ao copiar.' })
     return
   }
 
-  processing.value = true
-  try {
-    await updateScheduleStatus(props.schedule.id, 'paid')
-  } catch (e) {
-    console.error('Erro ao atualizar status:', e)
-  } finally {
-    processing.value = false
-  }
-  success.value = true
-  setTimeout(() => onDialogOK(), 3000)
 }
 
 const totalSeconds = ref(20 * 60)
@@ -191,15 +147,30 @@ const countdown = ref('')
 let timer = null
 
 const updateCountdown = () => {
+  if (pixExpiresAt.value) {
+    totalSeconds.value = Math.max(0, Math.floor((new Date(pixExpiresAt.value).getTime() - Date.now()) / 1000))
+  }
+
   const m = Math.floor(totalSeconds.value / 60)
   const s = totalSeconds.value % 60
   countdown.value = `${m} min, ${String(s).padStart(2, '0')} seg`
-  if (totalSeconds.value > 0) totalSeconds.value--
+  if (!pixExpiresAt.value && totalSeconds.value > 0) totalSeconds.value--
 }
 
-onMounted(() => {
+onMounted(async () => {
   updateCountdown()
   timer = setInterval(updateCountdown, 1000)
+
+  try {
+    payment.value = await paySchedule(props.schedule.id, { payment_method: 'pix' })
+    updateCountdown()
+  } catch (e) {
+    console.error('Erro ao criar pagamento Pix:', e)
+    $q.notify({ type: 'negative', message: e?.response?.data?.message ?? 'Erro ao criar pagamento Pix.' })
+    onDialogCancel()
+  } finally {
+    processing.value = false
+  }
 })
 
 onUnmounted(() => {
@@ -248,9 +219,16 @@ onUnmounted(() => {
 }
 
 .pix-instructions-text {
+  align-self: stretch;
   font-size: 13px;
   line-height: 1.5;
   color: #3a3a4a;
+  margin-left: 0;
+  margin-right: 0;
+  max-width: 100%;
+  overflow-wrap: anywhere;
+  text-align: left;
+  width: 100%;
 }
 
 .success-icon-wrapper {

+ 18 - 7
src/components/dashboard/SchedulePaymentProcessingDialog.vue

@@ -49,27 +49,38 @@
 
 <script setup>
 import { ref, onMounted } from 'vue'
-import { useDialogPluginComponent } from 'quasar'
-import { updateScheduleStatus } from 'src/api/schedule'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { paySchedule } from 'src/api/payment'
 
 const props = defineProps({
   schedule: { type: Object, required: true },
+  clientPaymentMethodId: { type: Number, required: true },
 })
 
 defineEmits([...useDialogPluginComponent.emits])
 
-const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent()
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
 
 const success = ref(false)
 
 onMounted(async () => {
-  await new Promise(resolve => setTimeout(resolve, 4000))
   try {
-    await updateScheduleStatus(props.schedule.id, 'paid')
+    const payment = await paySchedule(props.schedule.id, {
+      payment_method: 'credit_card',
+      client_payment_method_id: props.clientPaymentMethodId,
+    })
+
+    if (payment.status !== 'paid') {
+      throw new Error(payment.failure_message || 'Pagamento não confirmado.')
+    }
+
+    success.value = true
   } catch (e) {
-    console.error('Erro ao atualizar status:', e)
+    console.error('Erro ao pagar agendamento:', e)
+    $q.notify({ type: 'negative', message: e?.response?.data?.message ?? e.message ?? 'Erro ao processar pagamento.' })
+    onDialogCancel()
   }
-  success.value = true
 })
 </script>
 

+ 462 - 94
src/components/profile/ProfilePaymentAddDialog.vue

@@ -1,14 +1,73 @@
 <template>
-  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down">
+  <q-dialog
+    ref="dialogRef"
+    maximized
+    persistent
+    transition-hide="slide-down"
+    transition-show="slide-up"
+  >
     <div class="bg-page full-height column no-shadow">
+      <form
+        ref="pagarmeFormRef"
+        action="#"
+        class="pagarme-token-form"
+        data-pagarmecheckout-form
+        method="POST"
+      >
+        <input
+          v-model="form.holder_name"
+          data-pagarmecheckout-element="holder_name"
+          name="holder-name"
+          type="text"
+        >
+
+        <input
+          v-model="pagarmeCardNumber"
+          data-pagarmecheckout-element="number"
+          name="card-number"
+          type="text"
+        >
+
+        <input
+          v-model="pagarmeExpirationMonth"
+          data-pagarmecheckout-element="exp_month"
+          name="card-exp-month"
+          type="text"
+        >
+
+        <input
+          v-model="pagarmeExpirationYear"
+          data-pagarmecheckout-element="exp_year"
+          name="card-exp-year"
+          type="text"
+        >
+
+        <input
+          v-model="pagarmeCvv"
+          data-pagarmecheckout-element="cvv"
+          name="cvv"
+          type="text"
+        >
+      </form>
 
       <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-profile bg-surface">
-        <q-btn icon="mdi-chevron-left" flat round dense color="primary" @click="onDialogCancel" />
+        <q-btn
+          color="primary"
+          dense
+          flat
+          icon="mdi-chevron-left"
+          round
+          @click="onDialogCancel"
+        />
+
         <q-space />
+
         <span class="text-subtitle1 text-weight-bold text-primary">
           {{ $t('profile.payments.title') }}
         </span>
+
         <q-space />
+
         <div style="width: 32px"></div>
       </div>
 
@@ -19,38 +78,59 @@
 
         <div class="virtual-card q-mb-xl">
           <div class="card-top-row row items-start justify-between">
-            <span class="card-brand-label">{{ brandDisplayName }}</span>
-            <q-icon name="mdi-wifi-strength-4" size="24px" color="white" class="nfc-icon" style="transform: rotate(90deg);" />
+            <span class="card-brand-label">
+              {{ brandDisplayName }}
+            </span>
+
+            <q-icon
+              class="nfc-icon"
+              color="white"
+              name="mdi-wifi-strength-4"
+              size="24px"
+              style="transform: rotate(90deg);"
+            />
           </div>
+
           <div class="card-number-preview">
             {{ maskedCardNumberPreview }}
           </div>
+
           <div class="card-bottom-row row items-end justify-between">
             <div class="column">
-              <span class="card-holder-name">{{ form.holder_name || 'Nome do Titular' }}</span>
+              <span class="card-holder-name">
+                {{ form.holder_name || 'Nome do Titular' }}
+              </span>
             </div>
+
             <div class="column items-end">
-              <span class="card-expiry">{{ form.expiration || '**/****' }}</span>
+              <span class="card-expiry">
+                {{ form.expiration || '**/****' }}
+              </span>
             </div>
           </div>
         </div>
 
-        <q-form ref="formRef" class="column q-gutter-y-sm text-text" @submit="onOKClick">
+        <q-form
+          ref="formRef"
+          class="column q-gutter-y-sm text-text"
+          @submit="onOKClick"
+        >
           <div>
             <div class="input-label">
               {{ $t('profile.payments.card_number') }}
             </div>
+
             <q-input
               v-model="form.card_number"
-              :rules="[inputRules.requiredHideMessage]"
-              :error="!!serverErrors.card_number"
-              :error-message="serverErrors.card_number"
+              class="input-field bg-surface input-border-dark"
+              hide-bottom-space
+              input-class="text-text"
               mask="#### #### #### ####"
-              unmasked-value
               outlined
-              input-class="text-text"
-              hide-bottom-space
-              class="input-field bg-surface input-border-dark"
+              unmasked-value
+              :error="!!serverErrors.card_number"
+              :error-message="serverErrors.card_number"
+              :rules="cardNumberRules"
               @update:model-value="onCardNumberChange"
             />
           </div>
@@ -59,16 +139,17 @@
             <div class="input-label">
               {{ $t('profile.payments.holder_name') }}
             </div>
+
             <q-input
               v-model="form.holder_name"
+              class="input-field bg-surface input-border-dark"
+              hide-bottom-space
+              input-class="text-text"
+              outlined
               placeholder="Nome como está no cartão"
-              :rules="[inputRules.requiredHideMessage]"
               :error="!!serverErrors.holder_name"
               :error-message="serverErrors.holder_name"
-              outlined
-              input-class="text-text"
-              hide-bottom-space
-              class="input-field bg-surface input-border-dark"
+              :rules="[inputRules.requiredHideMessage]"
               @update:model-value="serverErrors.holder_name = null"
             />
           </div>
@@ -78,161 +159,439 @@
               <div class="input-label">
                 {{ $t('profile.payments.expiration') }}
               </div>
+
               <q-input
                 v-model="form.expiration"
-                :placeholder="$t('profile.payments.mmyyyy')"
-                :rules="[inputRules.requiredHideMessage, validateExpiration]"
-                :error="!!serverErrors.expiration"
-                :error-message="serverErrors.expiration"
-                mask="##/####"
-                outlined
                 class="col input-field bg-surface input-border-dark"
-                input-class="text-text"
                 hide-bottom-space
+                input-class="text-text"
+                mask="##/####"
+                outlined
+                :error="!!serverErrors.expiration"
+                :error-message="serverErrors.expiration"
+                :placeholder="$t('profile.payments.mmyyyy')"
+                :rules="[inputRules.requiredHideMessage, validateExpiration]"
                 @update:model-value="serverErrors.expiration = null"
               />
             </div>
+
             <div class="col-5">
               <div class="input-label row items-center q-gutter-x-xs">
-                <span>{{ $t('profile.payments.cvv') }}</span>
-                <q-icon name="mdi-help-circle-outline" color="grey-8" size="16px" class="cursor-pointer">
-                  <q-tooltip>{{ $t('profile.payments.cvv_help') }}</q-tooltip>
+                <span>
+                  {{ $t('profile.payments.cvv') }}
+                </span>
+
+                <q-icon
+                  class="cursor-pointer"
+                  color="grey-8"
+                  name="mdi-help-circle-outline"
+                  size="16px"
+                >
+                  <q-tooltip>
+                    {{ $t('profile.payments.cvv_help') }}
+                  </q-tooltip>
                 </q-icon>
               </div>
+
               <q-input
                 v-model="form.cvv"
+                class="col input-field bg-surface input-border-dark"
+                hide-bottom-space
+                input-class="text-text"
+                mask="####"
+                outlined
                 placeholder="***"
-                :rules="[inputRules.requiredHideMessage]"
+                type="password"
+                unmasked-value
                 :error="!!serverErrors.cvv"
                 :error-message="serverErrors.cvv"
-                mask="####"
-                unmasked-value
-                type="password"
-                outlined
-                input-class="text-text"
-                class="col input-field bg-surface input-border-dark"
-                hide-bottom-space
+                :rules="cvvRules"
                 @update:model-value="serverErrors.cvv = null"
               />
             </div>
           </div>
 
           <div class="row items-center justify-center q-gutter-x-xs q-mt-sm q-mb-xs">
-            <q-icon name="mdi-shield-check" color="positive" size="18px" />
-            <span class="security-text-new">{{ $t('profile.payments.security_badge') }}</span>
+            <q-icon
+              color="positive"
+              name="mdi-shield-check"
+              size="18px"
+            />
+
+            <span class="security-text-new">
+              {{ $t('profile.payments.security_badge') }}
+            </span>
           </div>
 
           <q-btn
-            type="submit"
-            unelevated
-            rounded
-            no-caps
-            color="primary"
             class="full-width q-mt-md save-btn q-mb-md"
+            color="primary"
+            no-caps
             padding="14px 16px"
-            :label="paymentMethod ? $t('profile.payments.save_btn') : $t('profile.payments.add_card')"
-            :loading="loading"
+            rounded
+            type="submit"
+            unelevated
             :disable="!hasUpdatedFields"
+            :label="paymentMethod ? $t('profile.payments.save_btn') : $t('profile.payments.add_card')"
+            :loading="loading || tokenizing"
           />
         </q-form>
       </div>
-
     </div>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, computed } from 'vue';
-import { useDialogPluginComponent } from 'quasar';
+import { computed, ref } from 'vue';
+
+import { useDialogPluginComponent, useQuasar } from 'quasar';
 import { useI18n } from 'vue-i18n';
+
+import { createClientPaymentMethod, updateClientPaymentMethod } from 'src/api/clientPaymentMethod';
+
 import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
-import { useSubmitHandler } from 'src/composables/useSubmitHandler';
 import { useInputRules } from 'src/composables/useInputRules';
-import { /*validateCardNumberLuhn,*/ detectCardBrand, validateCardExpiration } from 'src/helpers/utils';
-import { createClientPaymentMethod, updateClientPaymentMethod } from 'src/api/clientPaymentMethod';
+import { useSubmitHandler } from 'src/composables/useSubmitHandler';
+
+import { detectCardBrand, validateCardExpiration } from 'src/helpers/utils';
 
 const props = defineProps({
-  paymentMethod: {
-    type: Object,
-    default: null,
-  },
   clientId: {
-    type: Number,
     required: true,
+    type: Number,
+  },
+
+  paymentMethod: {
+    default: null,
+    type: Object,
   },
 });
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent();
-const { t } = useI18n();
+const $q = useQuasar();
+
+const { dialogRef, onDialogCancel, onDialogOK } = useDialogPluginComponent();
+
 const { inputRules } = useInputRules();
-const formRef = ref(null);
+
+const { t } = useI18n();
 
 const { form, hasUpdatedFields } = useFormUpdateTracker({
+  brand: props.paymentMethod ? props.paymentMethod.brand : null,
+  card_name: props.paymentMethod ? props.paymentMethod.card_name : null,
+  card_number: null,
   client_id: props.paymentMethod ? props.paymentMethod.client_id : props.clientId,
-  card_number: props.paymentMethod ? props.paymentMethod.card_number : null,
+  cvv: null,
+
+  expiration: props.paymentMethod
+    ? (
+      props.paymentMethod.expiration?.includes('/')
+      && props.paymentMethod.expiration.split('/')[1].length === 2
+        ? `${props.paymentMethod.expiration.split('/')[0]}/20${props.paymentMethod.expiration.split('/')[1]}`
+        : props.paymentMethod.expiration
+    )
+    : null,
+
   holder_name: props.paymentMethod ? props.paymentMethod.holder_name : null,
-  expiration: props.paymentMethod ? (props.paymentMethod.expiration?.includes('/') && props.paymentMethod.expiration.split('/')[1].length === 2 ? `${props.paymentMethod.expiration.split('/')[0]}/20${props.paymentMethod.expiration.split('/')[1]}` : props.paymentMethod.expiration) : null,
-  cvv: props.paymentMethod ? props.paymentMethod.cvv : null,
-  card_name: props.paymentMethod ? props.paymentMethod.card_name : null,
-  brand: props.paymentMethod ? props.paymentMethod.brand : null,
-  last_four_digits: props.paymentMethod ? props.paymentMethod.last_four_digits : null,
   is_active: props.paymentMethod ? props.paymentMethod.is_active : true,
+  last_four_digits: props.paymentMethod ? props.paymentMethod.last_four_digits : null,
 });
 
-const { loading, serverErrors, execute: submitForm } = useSubmitHandler(() => onDialogOK(true));
+const {
+  execute: submitForm,
+  loading,
+  serverErrors,
+} = useSubmitHandler(() => onDialogOK(true));
+
+const formRef = ref(null);
+
+const pagarmeFormRef = ref(null);
+
+const tokenizing = ref(false);
 
 const brandDisplayName = computed(() => {
-  if (!form.brand) return '';
+  if (!form.brand) {
+    return '';
+  }
+
   const names = {
-    visa: 'VISA',
-    mastercard: 'Mastercard',
-    elo: 'Elo',
-    hipercard: 'Hipercard',
     diners: 'Diners',
     discover: 'Discover',
+    elo: 'Elo',
+    hipercard: 'Hipercard',
+    mastercard: 'Mastercard',
+    visa: 'VISA',
   };
+
   return names[form.brand] ?? form.brand;
 });
 
+const cardNumberRules = computed(() => (
+  props.paymentMethod
+    ? []
+    : [inputRules.requiredHideMessage]
+));
+
+const cvvRules = computed(() => (
+  props.paymentMethod
+    ? []
+    : [inputRules.requiredHideMessage]
+));
+
+const expirationMonth = computed(() => (
+  form.expiration
+    ? form.expiration.split('/')[0]
+    : ''
+));
+
+const expirationYear = computed(() => (
+  form.expiration
+    ? form.expiration.split('/')[1]
+    : ''
+));
+
 const maskedCardNumberPreview = computed(() => {
-  const raw = form.card_number ? String(form.card_number).replace(/\D/g, '') : '';
-  if (!raw || raw.includes('*')) return '**** **** **** ****';
-  const lastFour = raw.slice(-4).padStart(4, '*');
-  return `**** **** **** ${lastFour}`;
+  const raw = form.card_number
+    ? String(form.card_number).replace(/\D/g, '')
+    : '';
+
+  if (raw && !raw.includes('*')) {
+    const lastFour = raw.slice(-4).padStart(4, '*');
+
+    return `**** **** **** ${lastFour}`;
+  }
+
+  if (form.last_four_digits) {
+    return `**** **** **** ${String(form.last_four_digits).padStart(4, '*')}`;
+  }
+
+  return '**** **** **** ****';
 });
 
-// const validateCardNumber = (val) => {
-//   if (!val) return true;
-//   if (val.includes('*')) return true;
-//   if (!validateCardNumberLuhn(val)) return t('profile.payments.invalid_card_number');
-//   return true;
-// };
+const pagarmeCardNumber = computed({
+  get: () => (
+    form.card_number
+      ? String(form.card_number).replace(/\D/g, '')
+      : ''
+  ),
+  set: () => {},
+});
 
-const validateExpiration = (val) => {
-  if (!val) return true;
-  if (!validateCardExpiration(val)) return t('profile.payments.expired_card');
-  return true;
+const pagarmeCvv = computed({
+  get: () => (
+    form.cvv
+      ? String(form.cvv).replace(/\D/g, '')
+      : ''
+  ),
+  set: () => {},
+});
+
+const pagarmeExpirationMonth = computed({
+  get: () => expirationMonth.value,
+  set: () => {},
+});
+
+const pagarmeExpirationYear = computed({
+  get: () => expirationYear.value,
+  set: () => {},
+});
+
+const PAGARME_SCRIPT_URL = 'https://checkout.pagar.me/v1/tokenizecard.js';
+
+let pagarmeScriptPromise = null;
+
+const getPagarmePublicKey = () => (
+  process.env.PAGARME_PUBLIC_KEY
+);
+
+const getPagarmeTokenFromResponse = (data) => {
+  const tokenKey = Object.keys(data || {}).find((key) => (
+    key.startsWith('pagarmetoken')
+  ));
+
+  return tokenKey ? data[tokenKey] : null;
+};
+
+const loadPagarmeScript = () => {
+  if (window.PagarmeCheckout) {
+    return Promise.resolve(window.PagarmeCheckout);
+  }
+
+  if (!pagarmeScriptPromise) {
+    pagarmeScriptPromise = new Promise((resolve, reject) => {
+      const publicKey = getPagarmePublicKey();
+
+      if (!publicKey) {
+        reject(new Error('PAGARME_PUBLIC_KEY não configurada'));
+        return;
+      }
+
+      const existingScript = document.querySelector('script[data-pagarmecheckout-script="true"]');
+
+      if (existingScript) {
+        existingScript.addEventListener(
+          'error',
+          () => reject(new Error('Falha ao carregar o tokenizecard.js')),
+          { once: true },
+        );
+
+        existingScript.addEventListener(
+          'load',
+          () => resolve(window.PagarmeCheckout),
+          { once: true },
+        );
+
+        return;
+      }
+
+      const script = document.createElement('script');
+
+      script.async = true;
+      script.onerror = () => reject(new Error('Falha ao carregar o tokenizecard.js'));
+      script.onload = () => resolve(window.PagarmeCheckout);
+      script.src = PAGARME_SCRIPT_URL;
+
+      script.setAttribute('data-pagarmecheckout-app-id', publicKey);
+      script.setAttribute('data-pagarmecheckout-script', 'true');
+
+      document.body.appendChild(script);
+    });
+  }
+
+  return pagarmeScriptPromise;
 };
 
 const onCardNumberChange = () => {
   serverErrors.value.card_number = null;
-  const raw = form.card_number ? String(form.card_number).replace(/\D/g, '') : '';
+
+  const raw = form.card_number
+    ? String(form.card_number).replace(/\D/g, '')
+    : '';
+
+  if (!raw) {
+    form.brand = null;
+    form.last_four_digits = null;
+
+    return;
+  }
+
   if (raw.length >= 6 && !String(form.card_number).includes('*')) {
     const brand = detectCardBrand(raw);
-    if (brand) form.brand = brand;
-    if (raw.length >= 4) form.last_four_digits = raw.slice(-4);
+
+    if (brand) {
+      form.brand = brand;
+    }
+
+    if (raw.length >= 4) {
+      form.last_four_digits = raw.slice(-4);
+    }
   }
 };
 
+const tokenizeCard = async () => {
+  const publicKey = getPagarmePublicKey();
+
+  if (!publicKey) {
+    throw new Error('PAGARME_PUBLIC_KEY não configurada');
+  }
+
+  await loadPagarmeScript();
+
+  return new Promise((resolve, reject) => {
+    const success = (data) => {
+      const token = getPagarmeTokenFromResponse(data);
+
+      if (!token) {
+        reject(new Error('Token do cartão não retornado pela Pagar.me.'));
+        return false;
+      }
+
+      resolve(token);
+      return false;
+    };
+
+    const fail = (error) => {
+      reject(error);
+      return false;
+    };
+
+    window.PagarmeCheckout.init(success, fail);
+
+    if (!pagarmeFormRef.value) {
+      reject(new Error('Formulário de tokenização não encontrado.'));
+      return;
+    }
+
+    pagarmeFormRef.value.dispatchEvent(new Event('submit', {
+      bubbles: true,
+      cancelable: true,
+    }));
+  });
+};
+
+const validateExpiration = (val) => {
+  if (!val) {
+    return true;
+  }
+
+  if (!validateCardExpiration(val)) {
+    return t('profile.payments.expired_card');
+  }
+
+  return true;
+};
+
+//
+
 const onOKClick = async () => {
   const valid = await formRef.value?.validate();
-  if (!valid) return;
+
+  if (!valid) {
+    return;
+  }
+
   if (props.paymentMethod) {
-    await submitForm(() => updateClientPaymentMethod(props.paymentMethod.id, { ...form }));
-  } else {
-    await submitForm(() => createClientPaymentMethod({ ...form }));
+    const payload = {
+      brand: form.brand,
+      card_name: form.card_name,
+      client_id: props.paymentMethod ? props.paymentMethod.client_id : props.clientId,
+      expiration: form.expiration,
+      holder_name: form.holder_name,
+      is_active: form.is_active,
+      last_four_digits: form.last_four_digits,
+    }
+
+    await submitForm(() => updateClientPaymentMethod(props.paymentMethod.id, payload));
+
+    return;
+  }
+
+  tokenizing.value = true;
+
+  try {
+    const token = await tokenizeCard();
+
+    const payload = {
+      client_id: props.paymentMethod ? props.paymentMethod.client_id : props.clientId,
+      expiration: form.expiration,
+      holder_name: form.holder_name,
+      last_four_digits: form.last_four_digits,
+      token,
+    };
+
+    await submitForm(() => createClientPaymentMethod(payload));
+  } catch (error) {
+    console.error('Erro ao tokenizar o cartão:', error);
+
+    $q.notify({
+      message: error instanceof Error
+        ? error.message
+        : 'Não foi possível tokenizar o cartão.',
+      type: 'negative',
+    });
+  } finally {
+    tokenizing.value = false;
   }
 };
 </script>
@@ -254,6 +613,15 @@ const onOKClick = async () => {
   color: white;
 }
 
+.pagarme-token-form {
+  height: 0;
+  opacity: 0;
+  overflow: hidden;
+  pointer-events: none;
+  position: absolute;
+  width: 0;
+}
+
 .nfc-icon {
   opacity: 0.85;
 }
@@ -337,4 +705,4 @@ const onOKClick = async () => {
   font-size: 16px;
   font-weight: 700;
 }
-</style>
+</style>

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

+ 1 - 1
src/composables/useAuth.js

@@ -14,7 +14,7 @@ export const useAuth = () => {
 
   const setAuthDataFromPayload = async (tokens) => {
     const { access_token, refresh_token, user } = tokens;
-    userStore().user = user;
+    userStore().setUser(user);
     userStore().accessToken = access_token;
     await setRefreshToken(refresh_token);
     await permissionStore().fetchScopes();

+ 5 - 0
src/i18n/locales/pt.json

@@ -823,5 +823,10 @@
         "btn_keep": "Cancelar o serviço"
       }
     }
+  },
+  "notifications": {
+    "title": "Notificações",
+    "unread": "Não lidas",
+    "mark_all_read": " Marcar todas como lidas"
   }
 }

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

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

+ 9 - 0
src/pages/search/SearchPage.vue

@@ -173,6 +173,15 @@ const priceByPeriod = (p) => {
   return p[key] ? formatCurrency(p[key]) : t('search_page.no_price');
 };
 
+const formatDistance = (distance) => {
+  if (distance === null || distance === undefined || distance === '') return '—';
+
+  const numericDistance = Number(distance);
+  if (!Number.isFinite(numericDistance)) return '—';
+
+  return `${numericDistance.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} km`;
+};
+
 const setPeriodTypePrevious = () => {
   const prev = currentPeriodType.value - 2;
   if (periodTypeMap[prev]) currentPeriodType.value = prev;

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

+ 18 - 2
src/stores/user.js

@@ -7,9 +7,25 @@ export const userStore = defineStore("user", () => {
   const accessToken = ref(null);
   const isAdmin = ref(false);
 
+  const normalizeUser = (userData) => {
+    if (!userData) return null;
+
+    return {
+      ...userData,
+      client: userData.client ?? (userData.client_id ? {
+        id: userData.client_id,
+        document: userData.client_document ?? null,
+      } : null),
+      provider: userData.provider ?? (userData.provider_id ? {
+        id: userData.provider_id,
+      } : null),
+    };
+  };
+
   const setUser = (userData) => {
-    user.value = userData;
-    isAdmin.value = userData.type === "admin";
+    const normalized = normalizeUser(userData);
+    user.value = normalized;
+    isAdmin.value = normalized?.type === "admin";
   };
 
   const resetUser = () => {

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff