Forráskód Böngészése

feat: ✨ feat (push aprovacao) criado push notification para aprovacao de cadastro do prestador + layout do solicitations

foi criado push notification para o prestador quando o cadastro dele for aprovado + refeito o layout das solicitacoes para o prestador

fase:dev | origin:escopo
Gustavo Zanatta 22 órája
szülő
commit
b6cceaba02

+ 28 - 23
src/boot/push-notifications.js

@@ -1,39 +1,44 @@
 import { defineBoot } from "#q-app/wrappers";
 import { Capacitor } from "@capacitor/core";
 import { PushNotifications } from "@capacitor/push-notifications";
-import { registerDeviceToken } from "src/api/deviceToken";
+import { useAuth } from "src/composables/useAuth";
+import { usePushNotifications } from "src/composables/usePushNotifications";
 
-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(() => {
+export default defineBoot(({ router }) => {
   if (!Capacitor.isNativePlatform()) {
     return;
   }
 
+  const { requestPermissionAndRegister, setDeviceToken, syncDeviceToken } =
+    usePushNotifications();
+
   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
-    }
+    setDeviceToken(token.value);
+
+    // Se o app abriu já autenticado, envia agora; caso contrário o envio
+    // acontece no login (useAuth), quando o access token existir.
+    await syncDeviceToken();
   });
 
   PushNotifications.addListener("registrationError", () => {
     // falha silenciosa
   });
 
-  registerPushNotifications();
+  // Ao tocar em uma push, reconsulta a aprovação: se o cadastro acabou de ser
+  // aprovado, a dashboard completa é liberada sem exigir novo login.
+  PushNotifications.addListener("pushNotificationActionPerformed", async () => {
+    try {
+      const approved = await useAuth().refreshApprovalStatus();
+
+      if (!approved) {
+        return;
+      }
+
+      await router.replace({ name: "DashboardPage", query: { approved: "true" } });
+    } catch (error) {
+      console.error(error);
+    }
+  });
+
+  requestPermissionAndRegister();
 });

+ 4 - 0
src/components/dashboard/DashboardPendingApproval.vue

@@ -19,6 +19,10 @@
         {{ $t("provider.dashboard.pending_approval.release") }}
       </div>
 
+      <div class="pending-alert__text font14 fontbold q-mb-md">
+        {{ $t("provider.dashboard.pending_approval.email_notice") }}
+      </div>
+
       <div class="pending-alert__text font14 fontregular">
         {{ $t("provider.dashboard.pending_approval.come_back_later") }}
       </div>

+ 106 - 134
src/components/dashboard/DashboardSolicitations.vue

@@ -43,64 +43,54 @@
             'solicitation-card--multiple': visibleSolicitations.length > 1,
           }"
           :flat="false"
-          class="bg-surface card-border q-ma-md shadow-card solicitation-card"
+          class="bg-surface q-ma-md shadow-card solicitation-card"
         >
           <q-card-section class="q-pa-sm solicitation-card-section">
-            <div class="items-start no-wrap q-gutter-x-sm row text-text">
-              <div class="solicitation-avatar-column">
-                <q-avatar size="48px">
-                  <img
-                    v-if="item.customer_photo"
-                    :src="item.customer_photo"
-                    style="object-fit: cover"
-                  />
-
-                  <span
-                    v-else
-                    class="flex flex-center full-height full-width"
-                    style="border-radius: 50%"
-                    :style="avatarColors[item.id % avatarColors.length]"
-                  >
-                    {{
-                      getFirstName(item.client_name)
-                        ?.slice(0, 2)
-                        .toUpperCase() ?? "??"
-                    }}
-                  </span>
-                </q-avatar>
+            <div class="items-center no-wrap q-gutter-x-sm row text-text">
+              <q-avatar size="48px">
+                <img
+                  v-if="item.customer_photo"
+                  :src="item.customer_photo"
+                  style="object-fit: cover"
+                />
 
-              </div>
+                <span
+                  v-else
+                  class="flex flex-center full-height full-width"
+                  style="border-radius: 50%"
+                  :style="avatarColors[item.id % avatarColors.length]"
+                >
+                  {{
+                    getFirstName(item.client_name)
+                      ?.slice(0, 2)
+                      .toUpperCase() ?? "??"
+                  }}
+                </span>
+              </q-avatar>
 
               <div class="column flex-1 solicitation-info">
-                <div class="info-line solicitation-top-line no-wrap row">
-                  <div class="items-center no-wrap row info-line__main">
-                    <span class="ellipsis font12 fontbold">
-                      {{ getFirstName(item.client_name) || "—" }}
-                    </span>
+                <div class="items-center no-wrap q-gutter-x-xs row">
+                  <span class="ellipsis font12 fontmedium text-name">
+                    {{ getFirstName(item.client_name) || "—" }}
+                  </span>
 
+                  <div class="items-center no-wrap row">
                     <q-icon
-                      class="q-ml-xs"
                       color="warning"
                       name="mdi-star"
-                      size="12px"
+                      size="14px"
                     />
 
-                    <span class="font9 text-text no-wrap">
+                    <span class="font11 no-wrap text-text">
                       {{ item.average_rating }}
                     </span>
                   </div>
-
-                  <div class="column items-end no-wrap q-ml-xs solicitation-price">
-                    <span class="font12 fontbold no-wrap">
-                      {{ formatCurrency(packageTotal(item)) }}
-                    </span>
-                  </div>
                 </div>
 
-                <div class="schedule-list font9">
+                <div class="schedule-list">
                   <div
                     v-if="item.package_items.length > 1"
-                    class="package-summary font8"
+                    class="font8 package-summary"
                   >
                     {{ $t("provider.dashboard.solicitations.package_count", { count: item.package_items.length }) }}
                   </div>
@@ -110,49 +100,68 @@
                     :key="scheduleItem.id"
                     class="solicitation-date-time"
                   >
-                    <span class="schedule-weekday">
-                      {{ formatWeekday(scheduleItem.date) }}
-                    </span>
+                    <div class="items-center no-wrap row">
+                      <span class="font9 fontbold text-schedule-date-bold">
+                        {{ formatWeekday(scheduleItem.date) }}
+                      </span>
 
-                    <span class="fontbold schedule-day-month">
-                      {{ formatDayMonth(scheduleItem.date) }}
-                    </span>
+                      <span class="font9 text-schedule-date-regular">
+                        {{ ", " + formatDayMonth(scheduleItem.date) }}
+                      </span>
+                    </div>
 
-                    <span class="fontbold schedule-time">
+                    <div class="font9 text-schedule-date-regular">
                       {{ $t("common.from") }}
-                      {{ scheduleItem.start_time?.slice(0, 5) }}
+
+                      <span class="fontbold text-schedule-date-bold">
+                        {{ scheduleItem.start_time?.slice(0, 5) }}
+                      </span>
+
                       {{ $t("common.to") }}
-                      {{ scheduleItem.end_time?.slice(0, 5) }}
-                    </span>
+
+                      <span class="fontbold text-schedule-date-bold">
+                        {{ scheduleItem.end_time?.slice(0, 5) }}
+                      </span>
+                    </div>
                   </div>
                 </div>
+
+                <div class="font9 text-time-since">
+                  {{ formatTimeSinceRequest(item.time_since_request) }}
+                </div>
               </div>
-            </div>
 
-            <div class="font9 info-line solicitation-address-line row text-text">
-              {{ formatTimeSinceRequest(item.time_since_request) }} -
+              <div class="column items-end no-wrap solicitation-meta text-text">
+                <div class="font14 fontbold no-wrap text-price">
+                  {{ formatCurrency(packageTotal(item)) }}
+                </div>
+
+                <div class="font10 fontmedium text-type">
+                  {{ periodTypeLabel(item) }}
+                </div>
 
-              <span class="info-line__main solicitation-address">
-                {{ item.address?.district || "N/A" }}
+                <div class="ellipsis font10 text-region">
+                  {{ item.address?.district || "N/A" }}
+                </div>
 
-                <span class="fontbold no-wrap solicitation-distance">
+                <div class="font10 no-wrap text-distance">
                   {{ item.distance_km ?? 0 }} {{ $t("common.km") }}
-                </span>
-              </span>
+                </div>
+              </div>
             </div>
 
             <div class="items-center q-gutter-x-sm q-mt-sm row solicitation-actions">
-              <div class="solicitation-details">
-                <q-btn
-                  class="col-auto q-px-none solicitation-details-btn"
-                  color="primary"
-                  flat
-                  no-caps
-                  size="xs"
-                  :label="$t('common.details')"
-                  @click="emit('view-details', item)"
-                />
-              </div>
+              <q-btn
+                class="col-auto solicitation-details-btn"
+                color="primary"
+                flat
+                no-caps
+                padding="4px 8px"
+                size="xs"
+                unelevated
+                :label="$t('common.details')"
+                @click="emit('view-details', item)"
+              />
 
               <q-space />
 
@@ -186,6 +195,7 @@
 <script setup>
 import { avatarColors, formatCurrency, getFirstName } from "src/helpers/utils";
 import { computed } from "vue";
+import { labelsPeriodTypes } from "src/helpers/labelsPeriodTypes.js";
 import { useI18n } from "vue-i18n";
 
 const props = defineProps({
@@ -223,6 +233,14 @@ const visibleSolicitations = computed(() =>
   groupedSolicitations.value.slice(0, 3)
 );
 
+const periodTypeLabel = (item) => {
+  const label = labelsPeriodTypes.find(
+    (periodType) => periodType.value == item.period_type,
+  )?.label;
+
+  return label ? t(label) : "";
+};
+
 const packageTotal = (item) =>
   item.package_items.reduce(
     (total, packageItem) => total + (Number(packageItem.total_amount) || 0),
@@ -306,7 +324,7 @@ const formatTimeSinceRequest = (value) => {
 }
 
 .solicitation-card {
-  box-shadow: -4px 2px 12px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06) !important;
+  border-radius: 12px;
   overflow: visible;
 }
 
@@ -338,93 +356,47 @@ const formatTimeSinceRequest = (value) => {
   min-width: 0;
 }
 
-.solicitation-avatar-column {
-  flex: 0 0 48px;
-  text-align: center;
-}
-
-.solicitation-time {
-  margin-bottom: 6px;
-  white-space: nowrap;
-}
-
 .solicitation-info {
-  align-self: stretch;
-  justify-content: space-around;
   gap: 2px;
-}
-
-.info-line {
-  align-items: baseline;
-  justify-content: space-between;
-  min-width: 0;
-}
-
-.solicitation-top-line {
-  align-items: flex-start;
-}
-
-.info-line__main {
   min-width: 0;
 }
 
-.solicitation-address {
-  max-width: 100%;
-  overflow-wrap: anywhere;
-  white-space: normal;
-}
-
-.solicitation-distance::before {
-  content: " - ";
+.solicitation-meta {
+  gap: 2px;
+  max-width: 42%;
+  text-align: right;
 }
 
 .schedule-list {
   display: flex;
   flex-direction: column;
   gap: 2px;
-  margin-bottom: 0;
 }
 
 .solicitation-date-time {
-  align-items: center;
-  display: grid;
-  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.4fr);
-  border-bottom: 1px dotted #d5d5df;
-  padding: 1px 0;
-  width: 100%;
   line-height: 1.35;
+  min-width: 0;
 }
 
-.schedule-weekday {
-  text-align: left;
-}
-
-.schedule-day-month {
-  text-align: center;
+.font11 {
+  font-size: 11px;
 }
 
-.schedule-time {
-  min-width: 0;
-  text-align: right;
-  white-space: nowrap;
+.text-name,
+.text-price,
+.text-schedule-date-bold {
+  color: #3a3a4a;
 }
 
-.solicitation-address-line {
-  display: block;
-  margin: 8px -8px 0;
-  padding: 8px 8px 6px;
-  text-align: center;
-
-  &::before {
-    color: var(--q-primary);
-    content: "●";
-    margin-right: 4px;
-  }
+.text-distance,
+.text-region,
+.text-schedule-date-regular,
+.text-type {
+  color: #666;
 }
 
-.solicitation-details {
-  align-items: center;
-  display: flex;
+.text-time-since {
+  color: #9a9aa5;
 }
 
 .solicitation-details-btn {

+ 28 - 0
src/composables/useAuth.js

@@ -2,6 +2,7 @@ import api from "src/api";
 import { permissionStore } from "src/stores/permission";
 import { useAuthStorage } from "src/composables/useAuthStorage";
 import { APPROVAL_STATUS, useProviderApproval } from "src/composables/useProviderApproval";
+import { usePushNotifications } from "src/composables/usePushNotifications";
 import { userStore } from "src/stores/user";
 
 let refreshPromise = null;
@@ -26,6 +27,10 @@ export const useAuth = () => {
 
     await setRefreshToken(refresh_token);
 
+    // Fire-and-forget: o boot só consegue registrar o token quando o app abre
+    // já autenticado. Quem acabou de se cadastrar ou de entrar registra aqui.
+    registerPushDevice();
+
     if (String(user?.type).toUpperCase() === "PROVIDER" && !user?.provider) {
       await store.fetchUser();
     }
@@ -63,6 +68,26 @@ export const useAuth = () => {
     return true;
   };
 
+  /**
+   * Garante que o dispositivo esteja apto a receber push depois do login:
+   * envia o token já capturado e (re)solicita o registro no FCM.
+   */
+  const registerPushDevice = async () => {
+    const { isSupported, requestPermissionAndRegister, syncDeviceToken } =
+      usePushNotifications();
+
+    if (!isSupported()) {
+      return;
+    }
+
+    try {
+      await syncDeviceToken();
+      await requestPermissionAndRegister();
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
   //
 
   const login = async (email, password) => {
@@ -84,6 +109,9 @@ export const useAuth = () => {
 
   const logout = async () => {
     try {
+      // Precisa acontecer antes do /logout, enquanto o token ainda é válido.
+      await usePushNotifications().unregisterDeviceToken();
+
       const response = await api.post("/logout");
 
       if (response.status === 200) {

+ 79 - 0
src/composables/usePushNotifications.js

@@ -0,0 +1,79 @@
+import { Capacitor } from "@capacitor/core";
+import { PushNotifications } from "@capacitor/push-notifications";
+import { registerDeviceToken, removeDeviceToken } from "src/api/deviceToken";
+import { userStore } from "src/stores/user";
+
+// Token FCM do dispositivo, capturado pelo listener "registration" do boot.
+// Fica em escopo de módulo para continuar acessível no logout.
+let deviceToken = null;
+
+export const usePushNotifications = () => {
+  const isSupported = () => Capacitor.isNativePlatform();
+
+  const setDeviceToken = (token) => {
+    deviceToken = token;
+  };
+
+  /**
+   * Envia o token do dispositivo para a API.
+   * Só faz sentido autenticado: sem access token o POST volta 401, o
+   * dispositivo fica sem push e o interceptor ainda tenta um refresh à toa.
+   */
+  const syncDeviceToken = async () => {
+    if (!isSupported() || !deviceToken || !userStore().accessToken) {
+      return;
+    }
+
+    try {
+      await registerDeviceToken(deviceToken, Capacitor.getPlatform());
+    } catch {
+      // falha silenciosa — não bloqueia o uso do app
+    }
+  };
+
+  /**
+   * Desativa o token na API para o dispositivo parar de receber push
+   * depois que o usuário sai da conta.
+   */
+  const unregisterDeviceToken = async () => {
+    if (!isSupported() || !deviceToken || !userStore().accessToken) {
+      return;
+    }
+
+    try {
+      await removeDeviceToken(deviceToken);
+    } catch {
+      // falha silenciosa — não pode impedir o logout
+    }
+  };
+
+  /**
+   * Solicita a permissão de notificações e registra o app no FCM,
+   * o que dispara o listener "registration" configurado no boot.
+   */
+  const requestPermissionAndRegister = async () => {
+    if (!isSupported()) {
+      return;
+    }
+
+    let permission = await PushNotifications.checkPermissions();
+
+    if (permission.receive === "prompt") {
+      permission = await PushNotifications.requestPermissions();
+    }
+
+    if (permission.receive !== "granted") {
+      return;
+    }
+
+    await PushNotifications.register();
+  };
+
+  return {
+    isSupported,
+    requestPermissionAndRegister,
+    setDeviceToken,
+    syncDeviceToken,
+    unregisterDeviceToken,
+  };
+};

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

@@ -269,6 +269,7 @@
         "title": "Profile under review",
         "analyzing": "Your profile is being reviewed by the diária app team.",
         "release": "Your access will be released shortly, if approved.",
+        "email_notice": "The approval confirmation will be sent to your registered email address.",
         "come_back_later": "Please come back later.",
         "approved": "Registration approved! Welcome to diária app."
       },

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

@@ -269,6 +269,7 @@
         "title": "Perfil en revisión",
         "analyzing": "Tu perfil está siendo analizado por el equipo de diária app.",
         "release": "En breve se liberará tu acceso, en caso de ser aprobado.",
+        "email_notice": "La confirmación de la aprobación será enviada al correo electrónico registrado.",
         "come_back_later": "Por favor, vuelve más tarde.",
         "approved": "¡Registro aprobado! Bienvenido a diária app."
       },

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

@@ -269,6 +269,7 @@
         "title": "Perfil em análise",
         "analyzing": "Seu perfil está sendo analisado pela equipe do diária app.",
         "release": "Em breve será liberado seu acesso, caso seja aprovado.",
+        "email_notice": "A confirmação da aprovação será enviada para o e-mail cadastrado.",
         "come_back_later": "Por favor, volte mais tarde.",
         "approved": "Cadastro aprovado! Boas-vindas ao diária app."
       },

+ 25 - 0
src/pages/dashboard/DashboardPage.vue

@@ -240,6 +240,31 @@ const openRatingDialog = (schedule) => {
   });
 };
 
+// Chegada pela push de cadastro aprovado: o status já foi revalidado no boot,
+// aqui só recarregamos a dashboard liberada e avisamos o prestador.
+watch(
+  () => route.query.approved,
+  async (val) => {
+    if (val !== "true") {
+      return;
+    }
+
+    stopApprovalPolling();
+
+    if (!loading.value) {
+      await loadDashboard();
+    }
+
+    $q.notify({
+      message: t("provider.dashboard.pending_approval.approved"),
+      type: "positive",
+    });
+
+    router.replace({ path: route.path, query: {} });
+  },
+  { immediate: true },
+);
+
 watch(
   () => route.query.showSuccessModal,
   async (val) => {