Просмотр исходного кода

implementacao didit - validacao de documentos

Gustavo Zanatta 3 дней назад
Родитель
Сommit
9a686c2976

+ 5 - 0
src-capacitor/android/app/src/debug/AndroidManifest.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <application android:usesCleartextTraffic="true" />
+</manifest>

+ 8 - 0
src-capacitor/android/app/src/main/AndroidManifest.xml

@@ -27,6 +27,14 @@
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
 
+
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+                <data android:scheme="br.com.diarista.client" />
+            </intent-filter>
+
         </activity>
 
         <provider

+ 19 - 0
src/api/verification.js

@@ -0,0 +1,19 @@
+import { Capacitor } from "@capacitor/core";
+
+import api from "src/api";
+
+export const createVerificationSession = async () => {
+  const { data: response } = await api.post(
+    "/verification/session",
+    { native: Capacitor.isNativePlatform() },
+    { skipSuccessNotify: true },
+  );
+
+  return response.payload;
+};
+
+export const getMyVerification = async () => {
+  const { data: response } = await api.get("/verification/me");
+
+  return response.payload;
+};

+ 10 - 0
src/boot/axios.js

@@ -3,6 +3,7 @@ import { Cookies, Notify } from "quasar";
 import axios from "axios";
 import { useRouter } from "vue-router";
 import { useAuth } from "src/composables/useAuth";
+import { useVerificationGateStore } from "src/stores/verificationGate";
 import { userStore } from "src/stores/user";
 
 const api = axios.create({
@@ -57,6 +58,15 @@ const errorInterceptor = async (error, router) => {
     return Promise.reject(error);
   }
 
+  if (
+    error.response?.status === 403 &&
+    error.response?.data?.payload?.identity_verification_status !== undefined
+  ) {
+    useVerificationGateStore().requestVerification();
+
+    return Promise.reject(error);
+  }
+
   if (
     error.response?.status !== 401 ||
     originalRequest.url.includes("/refresh")

+ 1 - 0
src/components/login/LoginStep1Panel.vue

@@ -56,6 +56,7 @@
         hide-bottom-space
         input-class="text-text"
         lazy-rules
+        type="tel"
         mask="(##) #####-####"
         no-error-icon
         :bottom-slots="false"

+ 1 - 0
src/components/login/LoginStep2Panel.vue

@@ -23,6 +23,7 @@
       hide-bottom-space
       inputmode="numeric"
       lazy-rules
+      type="tel"
       mask="######"
       no-error-icon
       :error="!!serverErrors.code"

+ 59 - 0
src/components/login/LoginStep3Panel.vue

@@ -53,6 +53,33 @@
       />
     </div>
 
+    <div>
+      <div class="text-text">
+        <span class="font14 fontbold">
+          {{ $t("common.terms.birth_date") }}
+        </span>
+      </div>
+
+      <q-input
+        v-model="form.birth_date"
+        bg-color="surface"
+        class="q-mt-sm q-mb-md"
+        hide-bottom-space
+        input-class="text-text"
+        lazy-rules
+        mask="##/##/####"
+        no-error-icon
+        outlined
+        placeholder="00/00/0000"
+        rounded
+        type="tel"
+        :error="!!serverErrors.birth_date"
+        :error-message="serverErrors.birth_date"
+        :rules="[inputRules.required, validateBirthDate]"
+        @update:model-value="clearServerError('birth_date')"
+      />
+    </div>
+
     <div>
       <div class="text-text">
         <span class="font14 fontbold">
@@ -67,6 +94,7 @@
         hide-bottom-space
         input-class="text-text"
         lazy-rules
+        type="tel"
         mask="(##) #####-####"
         no-error-icon
         outlined
@@ -93,6 +121,7 @@
         hide-bottom-space
         input-class="text-text"
         lazy-rules
+        type="tel"
         mask="#####-###"
         no-error-icon
         outlined
@@ -465,4 +494,34 @@ const useLocation = async () => {
     loadingLocation.value = false;
   }
 };
+
+const validateBirthDate = (value) => {
+  if (!value) {
+    return t("validation.rules.required");
+  }
+
+  const matches = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(value);
+
+  if (!matches) {
+    return t("validation.rules.date_invalid");
+  }
+
+  const day = Number(matches[1]);
+  const month = Number(matches[2]);
+  const year = Number(matches[3]);
+
+  const date = new Date(year, month - 1, day);
+
+  const isValidDate =
+    date.getFullYear() === year &&
+    date.getMonth() === month - 1 &&
+    date.getDate() === day;
+
+  if (!isValidDate || date > new Date()) {
+    return t("validation.rules.date_invalid");
+  }
+
+  return true;
+};
+
 </script>

+ 124 - 0
src/components/verification/IdentityVerificationBanner.vue

@@ -0,0 +1,124 @@
+<template>
+  <div
+    v-if="visible"
+    class="incomplete-banner q-mx-md q-mb-md"
+    @click="open"
+  >
+    <div class="row items-center no-wrap q-pa-sm q-px-md">
+      <q-icon
+        class="q-mr-md"
+        color="primary"
+        :name="icon"
+        size="26px"
+      />
+
+      <div class="col banner-text font12 fontmedium text-primary">
+        {{ title }}
+      </div>
+
+      <q-btn
+        class="q-ml-sm resolver-btn font9"
+        color="primary"
+        no-caps
+        text-color="white"
+        unelevated
+        :label="cta"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, watch } from "vue";
+import { useI18n } from "vue-i18n";
+
+import {
+  useIdentityVerification,
+  VERIFICATION_STATUS,
+} from "src/composables/useIdentityVerification";
+import { useVerificationGateStore } from "src/stores/verificationGate";
+
+defineOptions({
+  name: "IdentityVerificationBanner",
+});
+
+const { t } = useI18n();
+
+const gate = useVerificationGateStore();
+const { hasOpenSession, refresh, status, verification } = useIdentityVerification();
+
+onMounted(() => {
+  refresh().catch(() => {
+  });
+});
+
+watch(() => gate.open, (isOpen, wasOpen) => {
+  if (wasOpen && !isOpen) refresh().catch(() => {});
+});
+
+const visible = computed(() => {
+  if (!verification.value) return false;
+
+  if (verification.value.required === false) return false;
+
+  return status.value !== VERIFICATION_STATUS.APPROVED;
+});
+
+const icon = computed(() =>
+  status.value === VERIFICATION_STATUS.DECLINED
+    ? "mdi-alert-circle-outline"
+    : "mdi-card-account-details-outline",
+);
+
+const title = computed(() => {
+  switch (status.value) {
+    case VERIFICATION_STATUS.PENDING:
+      return hasOpenSession.value
+        ? t("verification.banner.resume")
+        : t("verification.banner.in_progress");
+    case VERIFICATION_STATUS.IN_REVIEW:
+      return t("verification.banner.in_review");
+    case VERIFICATION_STATUS.DECLINED:
+      return t("verification.banner.declined");
+    default:
+      return t("verification.banner.title");
+  }
+});
+
+const cta = computed(() => {
+  if (status.value === VERIFICATION_STATUS.PENDING) {
+    return hasOpenSession.value
+      ? t("verification.banner.cta_resume")
+      : t("verification.banner.cta_status");
+  }
+
+  return status.value === VERIFICATION_STATUS.IN_REVIEW
+    ? t("verification.banner.cta_status")
+    : t("verification.banner.cta");
+});
+
+const open = () => {
+  gate.requestVerification();
+};
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss";
+
+.incomplete-banner {
+  border-radius: 12px;
+  background: $card-incomplete-profile;
+  cursor: pointer;
+}
+
+.banner-text {
+  line-height: 1.3;
+}
+
+.resolver-btn {
+  border-radius: 20px;
+  padding: 0px 4px;
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+</style>

+ 189 - 0
src/components/verification/IdentityVerificationDialog.vue

@@ -0,0 +1,189 @@
+<template>
+  <q-dialog v-model="open" persistent>
+    <q-card class="verification-dialog">
+      <q-card-section class="column items-center text-center q-gutter-y-md">
+        <q-spinner-dots v-if="busy" color="primary" size="48px" />
+
+        <q-icon v-else :color="iconColor" :name="icon" size="48px" />
+
+        <div class="font16 fontbold text-text">{{ title }}</div>
+
+        <div
+          v-for="(line, index) in lines"
+          :key="index"
+          class="font14 text-text-secondary"
+        >
+          {{ line }}
+        </div>
+
+        <div
+          v-if="showAttempts"
+          class="font12 text-text-secondary"
+        >
+          {{ $t("verification.attempts_left", { count: attemptsLeft }) }}
+        </div>
+      </q-card-section>
+
+      <q-card-actions align="right" class="q-pb-md q-px-md">
+        <q-btn
+          v-close-popup
+          color="primary"
+          flat
+          no-caps
+          rounded
+          :label="$t('common.actions.close')"
+          @click="onClose"
+        />
+
+        <q-btn
+          v-if="showAction"
+          color="primary"
+          no-caps
+          rounded
+          unelevated
+          :label="actionLabel"
+          :loading="loading"
+          @click="start"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, watch } from "vue";
+import { useI18n } from "vue-i18n";
+
+import {
+  useIdentityVerification,
+  VERIFICATION_STATUS,
+} from "src/composables/useIdentityVerification";
+
+defineOptions({
+  name: "IdentityVerificationDialog",
+});
+
+const emit = defineEmits(["approved"]);
+
+const open = defineModel({
+  required: true,
+  type: Boolean,
+});
+
+const { t } = useI18n();
+
+const {
+  attemptsLeft,
+  canRetry,
+  hasOpenSession,
+  isApproved,
+  loading,
+  polling,
+  refresh,
+  start,
+  status,
+} = useIdentityVerification();
+
+watch(open, (visible) => {
+  if (visible) refresh().catch(() => {});
+}, { immediate: true });
+
+watch(isApproved, (approved) => {
+  if (approved && open.value) emit("approved");
+});
+
+const busy = computed(() => loading.value || polling.value);
+
+const icon = computed(() => {
+  switch (status.value) {
+    case VERIFICATION_STATUS.APPROVED:
+      return "mdi-check-circle-outline";
+    case VERIFICATION_STATUS.DECLINED:
+      return "mdi-alert-circle-outline";
+    case VERIFICATION_STATUS.IN_REVIEW:
+      return "mdi-clock-outline";
+    default:
+      return "mdi-card-account-details-outline";
+  }
+});
+
+const iconColor = computed(() =>
+  status.value === VERIFICATION_STATUS.APPROVED ? "positive" : "primary",
+);
+
+const title = computed(() => {
+  if (busy.value) return t("verification.title");
+
+  switch (status.value) {
+    case VERIFICATION_STATUS.APPROVED:
+      return t("verification.approved_title");
+    case VERIFICATION_STATUS.DECLINED:
+      return t("verification.declined_title");
+    case VERIFICATION_STATUS.IN_REVIEW:
+      return t("verification.in_review_title");
+    case VERIFICATION_STATUS.PENDING:
+      return t("verification.in_progress_title");
+    default:
+      return t("verification.title");
+  }
+});
+
+const lines = computed(() => {
+  if (busy.value) {
+    return [loading.value ? t("verification.opening") : t("verification.checking")];
+  }
+
+  switch (status.value) {
+    case VERIFICATION_STATUS.APPROVED:
+      return [t("verification.approved")];
+    case VERIFICATION_STATUS.DECLINED:
+      return canRetry.value
+        ? [t("verification.declined")]
+        : [t("verification.attempts_exhausted")];
+    case VERIFICATION_STATUS.IN_REVIEW:
+      return [t("verification.in_review")];
+    case VERIFICATION_STATUS.PENDING:
+      return hasOpenSession.value
+        ? [t("verification.resume_hint")]
+        : [t("verification.in_progress")];
+    default:
+      return [t("verification.intro"), t("verification.documents_hint")];
+  }
+});
+
+const showAction = computed(() => {
+  if (busy.value || isApproved.value) return false;
+
+  if (status.value === VERIFICATION_STATUS.DECLINED) return canRetry.value;
+
+  if (status.value === VERIFICATION_STATUS.PENDING) return hasOpenSession.value;
+
+  return [
+    VERIFICATION_STATUS.NOT_STARTED,
+    VERIFICATION_STATUS.EXPIRED,
+  ].includes(status.value);
+});
+
+const actionLabel = computed(() => {
+  if (status.value === VERIFICATION_STATUS.DECLINED) return t("verification.retry");
+  if (status.value === VERIFICATION_STATUS.PENDING) return t("verification.resume");
+
+  return t("verification.start");
+});
+
+const showAttempts = computed(
+  () => showAction.value && Number.isFinite(attemptsLeft.value),
+);
+
+const onClose = () => {
+  open.value = false;
+};
+</script>
+
+<style lang="scss" scoped>
+.verification-dialog {
+  border-radius: 16px;
+  max-width: 420px;
+  width: 90vw;
+}
+</style>

+ 175 - 0
src/composables/useIdentityVerification.js

@@ -0,0 +1,175 @@
+import { computed, onUnmounted, ref } from "vue";
+import { Browser } from "@capacitor/browser";
+import { useI18n } from "vue-i18n";
+import { Notify } from "quasar";
+
+import {
+  createVerificationSession,
+  getMyVerification,
+} from "src/api/verification";
+import { userStore } from "src/stores/user";
+
+export const VERIFICATION_STATUS = Object.freeze({
+  NOT_STARTED: "not_started",
+  PENDING: "pending",
+  APPROVED: "approved",
+  IN_REVIEW: "in_review",
+  DECLINED: "declined",
+  EXPIRED: "expired",
+});
+
+const POLL_INTERVAL_MS = 3000;
+const POLL_TIMEOUT_MS = 180000;
+
+export const useIdentityVerification = () => {
+  const { t } = useI18n();
+  const user = userStore();
+
+  const loading = ref(false);
+  const polling = ref(false);
+  const verification = ref(null);
+
+  let pollTimer = null;
+  let pollDeadline = 0;
+  let browserListener = null;
+
+  const status = computed(
+    () =>
+      verification.value?.identity_verification_status ??
+      user.user?.client?.identity_verification_status ??
+      VERIFICATION_STATUS.NOT_STARTED,
+  );
+
+  const attemptsLeft = computed(() => verification.value?.attempts_left ?? null);
+
+  const isApproved = computed(() => status.value === VERIFICATION_STATUS.APPROVED);
+  const isInReview = computed(() => status.value === VERIFICATION_STATUS.IN_REVIEW);
+  const isDeclined = computed(() => status.value === VERIFICATION_STATUS.DECLINED);
+
+  const canRetry = computed(() => verification.value?.can_retry ?? false);
+
+  const hasOpenSession = computed(
+    () => verification.value?.has_open_session ?? false,
+  );
+
+  const needsVerification = computed(() =>
+    [
+      VERIFICATION_STATUS.NOT_STARTED,
+      VERIFICATION_STATUS.DECLINED,
+      VERIFICATION_STATUS.EXPIRED,
+    ].includes(status.value),
+  );
+
+  const refresh = async () => {
+    verification.value = await getMyVerification();
+
+    return verification.value;
+  };
+
+  const start = async () => {
+    if (loading.value) return;
+
+    loading.value = true;
+
+    try {
+      const session = await createVerificationSession();
+
+      await watchForReturn();
+      await Browser.open({ url: session.verification_url });
+    } catch (error) {
+      if (error?.response?.status !== 422) {
+        Notify.create({ type: "negative", message: t("verification.start_failed") });
+      }
+
+      await stopWatching();
+    } finally {
+      loading.value = false;
+    }
+  };
+
+  const watchForReturn = async () => {
+    await stopWatching();
+
+    browserListener = await Browser.addListener("browserFinished", startPolling);
+
+    document.addEventListener("visibilitychange", onVisibilityChange);
+  };
+
+  const onVisibilityChange = () => {
+    if (document.visibilityState === "visible") startPolling();
+  };
+
+  const startPolling = () => {
+    if (polling.value) return;
+
+    polling.value = true;
+    pollDeadline = Date.now() + POLL_TIMEOUT_MS;
+
+    const tick = async () => {
+      try {
+        const current = await refresh();
+
+        const settled = [
+          VERIFICATION_STATUS.APPROVED,
+          VERIFICATION_STATUS.IN_REVIEW,
+          VERIFICATION_STATUS.DECLINED,
+        ].includes(current?.identity_verification_status);
+
+        if (settled) {
+          if (current.identity_verification_status === VERIFICATION_STATUS.APPROVED) {
+            await user.fetchUser();
+          }
+
+          return stopPolling();
+        }
+      } catch {
+        // rede instável ao voltar do navegador: tenta de novo no próximo tick
+      }
+
+      if (Date.now() >= pollDeadline) return stopPolling();
+
+      pollTimer = setTimeout(tick, POLL_INTERVAL_MS);
+    };
+
+    tick();
+  };
+
+  const stopPolling = () => {
+    polling.value = false;
+
+    if (pollTimer) {
+      clearTimeout(pollTimer);
+      pollTimer = null;
+    }
+  };
+
+  const stopWatching = async () => {
+    document.removeEventListener("visibilitychange", onVisibilityChange);
+
+    if (browserListener) {
+      await browserListener.remove();
+      browserListener = null;
+    }
+  };
+
+  onUnmounted(async () => {
+    stopPolling();
+    await stopWatching();
+  });
+
+  return {
+    attemptsLeft,
+    canRetry,
+    hasOpenSession,
+    isApproved,
+    isDeclined,
+    isInReview,
+    loading,
+    needsVerification,
+    polling,
+    refresh,
+    start,
+    status,
+    verification,
+  };
+};

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

@@ -76,7 +76,8 @@
       "year": "Year",
       "all": "All",
       "certificate": "Certificate",
-      "version": "Version"
+      "version": "Version",
+      "birth_date": "Date of birth"
     },
     "months": {
       "january": "January",
@@ -209,7 +210,8 @@
       "cnpj": "This field must be a valid CNPJ",
       "cpf_or_cnpj": "This field must be a valid CPF or CNPJ",
       "cep": "This field must be a valid ZIP code",
-      "value_smaller_than_zero": "Value cannot be less than zero"
+      "value_smaller_than_zero": "Value cannot be less than zero",
+      "date_invalid": "Invalid date"
     },
     "permissions": {
       "view": "You don't have permission to view this",
@@ -1006,5 +1008,37 @@
       "chat_header": "--- Conversation with the assistant ---",
       "user_message_header": "--- User message ---"
     }
+  },
+  "verification": {
+    "title": "Identity verification",
+    "intro": "To release your registration we need to confirm your identity. It is quick: photograph a document and take a selfie.",
+    "documents_hint": "Have your ID or driver's license at hand.",
+    "start": "Verify identity",
+    "retry": "Try again",
+    "attempts_left": "You have {count} attempt(s) left.",
+    "opening": "Opening verification...",
+    "checking": "Confirming the result...",
+    "in_progress_title": "Verification in progress",
+    "in_progress": "We are processing your verification. This usually takes less than a minute.",
+    "in_review_title": "Verification under review",
+    "in_review": "Our team is checking your data. We will let you know by e-mail once it is done.",
+    "declined_title": "We could not confirm your identity",
+    "declined": "Check that the document is legible and that your registration data matches it.",
+    "attempts_exhausted": "You reached the attempt limit. Our team will review your registration manually.",
+    "approved_title": "Identity verified",
+    "approved": "All set! Your identity has been confirmed.",
+    "start_failed": "Could not start the verification. Please try again shortly.",
+    "banner": {
+      "title": "Verify your identity to book",
+      "cta": "Verify",
+      "in_progress": "Identity verification in progress",
+      "in_review": "Your verification is under review",
+      "declined": "We could not confirm your identity",
+      "cta_status": "View status",
+      "resume": "Resume your identity verification",
+      "cta_resume": "Resume"
+    },
+    "resume": "Resume verification",
+    "resume_hint": "You started the verification and did not finish. Pick up where you left off — this does not use a new attempt."
   }
 }

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

@@ -76,7 +76,8 @@
       "year": "Año",
       "all": "Todos",
       "certificate": "Certificado",
-      "version": "Versión"
+      "version": "Versión",
+      "birth_date": "Fecha de nacimiento"
     },
     "months": {
       "january": "Enero",
@@ -209,7 +210,8 @@
       "cnpj": "Este campo debe ser un CNPJ válido",
       "cpf_or_cnpj": "Este campo debe ser un CPF o CNPJ válido",
       "cep": "Este campo debe ser un código postal válido",
-      "value_smaller_than_zero": "El valor no puede ser menor que cero"
+      "value_smaller_than_zero": "El valor no puede ser menor que cero",
+      "date_invalid": "Fecha inválida"
     },
     "permissions": {
       "view": "No tienes permiso para ver esto",
@@ -1003,5 +1005,37 @@
       "chat_header": "--- Conversación con el asistente ---",
       "user_message_header": "--- Mensaje del usuario ---"
     }
+  },
+  "verification": {
+    "title": "Verificación de identidad",
+    "intro": "Para liberar tu registro necesitamos confirmar tu identidad. Es rápido: fotografías un documento y te tomas una selfie.",
+    "documents_hint": "Ten a mano tu documento de identidad o licencia de conducir.",
+    "start": "Verificar identidad",
+    "retry": "Intentar de nuevo",
+    "attempts_left": "Te quedan {count} intento(s).",
+    "opening": "Abriendo la verificación...",
+    "checking": "Confirmando el resultado...",
+    "in_progress_title": "Verificación en curso",
+    "in_progress": "Estamos procesando tu verificación. Suele tardar menos de un minuto.",
+    "in_review_title": "Verificación en análisis",
+    "in_review": "Nuestro equipo está revisando tus datos. Te avisaremos por correo cuando termine.",
+    "declined_title": "No pudimos confirmar tu identidad",
+    "declined": "Comprueba que el documento sea legible y que tus datos de registro coincidan.",
+    "attempts_exhausted": "Alcanzaste el límite de intentos. Nuestro equipo revisará tu registro manualmente.",
+    "approved_title": "Identidad verificada",
+    "approved": "¡Todo listo! Tu identidad fue confirmada.",
+    "start_failed": "No fue posible iniciar la verificación. Inténtalo de nuevo en unos instantes.",
+    "banner": {
+      "title": "Verifica tu identidad para agendar",
+      "cta": "Verificar",
+      "in_progress": "Verificación de identidad en curso",
+      "in_review": "Tu verificación está en análisis",
+      "declined": "No pudimos confirmar tu identidad",
+      "cta_status": "Ver estado",
+      "resume": "Continúa tu verificación de identidad",
+      "cta_resume": "Continuar"
+    },
+    "resume": "Continuar verificación",
+    "resume_hint": "Empezaste la verificación y no la terminaste. Continúa donde lo dejaste: esto no consume un nuevo intento."
   }
 }

+ 37 - 3
src/i18n/locales/pt.json

@@ -76,7 +76,8 @@
       "year": "Ano",
       "all": "Todos",
       "certificate": "Certificado",
-      "version": "Versão"
+      "version": "Versão",
+      "birth_date": "Data de nascimento"
     },
     "months": {
       "january": "Janeiro",
@@ -209,7 +210,8 @@
       "cnpj": "Este campo deve ser um CNPJ válido",
       "cpf_or_cnpj": "Este campo deve ser um CPF ou CNPJ válido",
       "cep": "Este campo deve ser um CEP válido",
-      "value_smaller_than_zero": "O valor não pode ser menor que zero"
+      "value_smaller_than_zero": "O valor não pode ser menor que zero",
+      "date_invalid": "Data inválida"
     },
     "permissions": {
       "view": "Você não tem permissão para visualizar isto",
@@ -876,7 +878,7 @@
     "pix_total": "Total com Pix",
     "credit_card_fee": "Total no cartão",
     "credit_card_total": "Total no cartão",
-    "total" : "Total",
+    "total": "Total",
     "pix_discount": "Desconto no Pix: economize R$ {value}",
     "discount_saved": "Desconto no total dos pedidos: R$ {value}",
     "discount_hint": "Adicione mais {count} horário(s) para receber desconto nos pedidos.",
@@ -1019,5 +1021,37 @@
       "chat_header": "--- Conversa com o assistente ---",
       "user_message_header": "--- Mensagem do usuário ---"
     }
+  },
+  "verification": {
+    "title": "Verificação de identidade",
+    "intro": "Para liberar seu cadastro precisamos confirmar sua identidade. É rápido: você fotografa um documento e faz uma selfie.",
+    "documents_hint": "Tenha em mãos seu RG, CNH ou CIN.",
+    "start": "Verificar identidade",
+    "retry": "Tentar novamente",
+    "attempts_left": "Você tem {count} tentativa(s) restante(s).",
+    "opening": "Abrindo a verificação...",
+    "checking": "Confirmando o resultado...",
+    "in_progress_title": "Verificação em andamento",
+    "in_progress": "Estamos processando sua verificação. Isso costuma levar menos de um minuto.",
+    "in_review_title": "Verificação em análise",
+    "in_review": "Nossa equipe está conferindo seus dados. Avisaremos por e-mail assim que terminar.",
+    "declined_title": "Não conseguimos confirmar sua identidade",
+    "declined": "Verifique se o documento está legível e se seus dados de cadastro conferem com ele.",
+    "attempts_exhausted": "Você atingiu o limite de tentativas. Nossa equipe vai analisar seu cadastro manualmente.",
+    "approved_title": "Identidade verificada",
+    "approved": "Tudo certo! Sua identidade foi confirmada.",
+    "start_failed": "Não foi possível iniciar a verificação. Tente novamente em instantes.",
+    "banner": {
+      "title": "Verifique sua identidade para agendar",
+      "cta": "Verificar",
+      "in_progress": "Verificação de identidade em andamento",
+      "in_review": "Sua verificação está em análise",
+      "declined": "Não conseguimos confirmar sua identidade",
+      "cta_status": "Ver status",
+      "resume": "Continue sua verificação de identidade",
+      "cta_resume": "Continuar"
+    },
+    "resume": "Continuar verificação",
+    "resume_hint": "Você começou a verificação e não concluiu. Continue de onde parou — isso não consome uma nova tentativa."
   }
 }

+ 5 - 0
src/layouts/MainLayout.vue

@@ -10,6 +10,8 @@
     >
     </q-header>
 
+    <IdentityVerificationDialog v-model="verificationGate.open" />
+
     <q-page-container>
       <q-page
         class="bg-surface main-layout-page"
@@ -75,6 +77,8 @@
 </template>
 
 <script setup>
+import IdentityVerificationDialog from "src/components/verification/IdentityVerificationDialog.vue";
+import { useVerificationGateStore } from "src/stores/verificationGate";
 import { computed, useTemplateRef, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { useQuasar } from "quasar";
@@ -91,6 +95,7 @@ const scrollAreaRef = useTemplateRef("scrollAreaRef");
 const $q = useQuasar();
 
 const servicePackage = useServicePackageStore();
+const verificationGate = useVerificationGateStore();
 
 const servicePackageCount = computed(() => servicePackage.items.length);
 

+ 14 - 0
src/pages/LoginPage.vue

@@ -187,6 +187,7 @@ import { useRegistrationFlowStore } from "src/stores/registrationFlow";
 import { useRouter } from "vue-router";
 import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { useVerificationGateStore } from "src/stores/verificationGate";
 import { userStore } from "src/stores/user";
 
 import LoginStep1Panel from "src/components/login/LoginStep1Panel.vue";
@@ -223,6 +224,7 @@ const steps = ref(1);
 
 const stepThreeForm = ref({
   address: "",
+  birth_date: "",
   address_type: "home",
   city: "",
   complement: "",
@@ -360,6 +362,14 @@ const onSubmit = async () => {
   }
 };
 
+const toISODate = (value) => {
+  const matches = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(value || "");
+
+  if (!matches) return null;
+
+  return `${matches[3]}-${matches[2]}-${matches[1]}`;
+};
+
 const registerUserAndClient = async () => {
   const payload = {};
 
@@ -381,6 +391,8 @@ const registerUserAndClient = async () => {
 
   payload.complement = stepThreeForm.value.complement || "";
 
+  payload.birth_date = toISODate(stepThreeForm.value.birth_date);
+
   append("code", code.value);
   append("email", email.value);
   append("has_complement", true);
@@ -399,6 +411,8 @@ const registerUserAndClient = async () => {
       userStore().setUser(user);
     }
 
+    useVerificationGateStore().requestVerification();
+
     router.push({ name: "DashboardPage" });
   });
 };

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

@@ -16,6 +16,8 @@
 
         <DashboardSummaryInfos v-else :data="summaryInfos" />
 
+        <IdentityVerificationBanner />
+
         <DashboardPaymentIncomplete v-if="showPaymentBanner" />
 
         <AddressIncompleteBanner v-if="!hasLocation" @resolved="reloadDashboard" />
@@ -74,6 +76,7 @@ import { useRoute, useRouter } from 'vue-router'
 import { userStore } from 'src/stores/user';
 
 import AddressIncompleteBanner from 'src/components/shared/AddressIncompleteBanner.vue';
+import IdentityVerificationBanner from 'src/components/verification/IdentityVerificationBanner.vue';
 import DashboardClientProposals from 'src/pages/dashboard/components/DashboardClientProposals.vue';
 import DashboardFavoriteProviders from 'src/components/dashboard/DashboardFavoriteProviders.vue';
 import DashboardHeaderBar from 'src/components/dashboard/DashboardHeaderBar.vue';

+ 51 - 0
src/pages/location/AddressCompletionPage.vue

@@ -52,6 +52,25 @@
           :rules="[inputRules.required, inputRules.cpf]"
         />
 
+        <div class="text-text">
+          <span class="font14 fontbold">{{ $t("common.terms.birth_date") }}</span>
+        </div>
+
+        <q-input
+          v-model="form.birth_date"
+          class="bg-surface q-mt-sm q-mb-md"
+          hide-bottom-space
+          input-class="text-text"
+          lazy-rules
+          mask="##/##/####"
+          no-error-icon
+          outlined
+          placeholder="00/00/0000"
+          rounded
+          type="tel"
+          :rules="[inputRules.required, validateBirthDate]"
+        />
+
         <div class="text-text">
           <span class="font14 fontbold">{{ $t("common.terms.phone") }}</span>
         </div>
@@ -62,6 +81,7 @@
           hide-bottom-space
           input-class="text-text"
           lazy-rules
+          type="tel"
           mask="(##) #####-####"
           no-error-icon
           outlined
@@ -256,6 +276,7 @@ import { useAuth } from "src/composables/useAuth";
 import { useI18n } from "vue-i18n";
 import { useInputRules } from "src/composables/useInputRules";
 import { useQuasar } from "quasar";
+import { useVerificationGateStore } from "src/stores/verificationGate";
 import { useRegistrationFlowStore } from "src/stores/registrationFlow";
 import { useRouter } from "vue-router";
 
@@ -273,6 +294,7 @@ const submitting = ref(false);
 const form = ref({
   name: flowStore.name || "",
   document: (flowStore.document || "").replace(/\D/g, ""),
+  birth_date: flowStore.birthDate || "",
   number: "",
   no_complement: false,
   complement: "",
@@ -300,6 +322,32 @@ const addressTypes = computed(() => [
   },
 ]);
 
+const toISODate = (value) => {
+  const matches = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(value || "");
+
+  if (!matches) return null;
+
+  return `${matches[3]}-${matches[2]}-${matches[1]}`;
+};
+
+const validateBirthDate = (value) => {
+  const matches = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(value || "");
+
+  if (!matches) return t("validation.rules.date_invalid");
+
+  const [, day, month, year] = matches.map(Number);
+  const date = new Date(year, month - 1, day);
+
+  const isValidDate =
+    date.getFullYear() === year &&
+    date.getMonth() === month - 1 &&
+    date.getDate() === day;
+
+  if (!isValidDate || date > new Date()) return t("validation.rules.date_invalid");
+
+  return true;
+};
+
 const handleConfirm = async () => {
   const isValid = await addressForm.value?.validate();
 
@@ -312,6 +360,7 @@ const handleConfirm = async () => {
       email: flowStore.email || undefined,
       phone: form.value.phone,
       document: form.value.document,
+      birth_date: toISODate(form.value.birth_date),
       name: form.value.name?.trim() || undefined,
       code: flowStore.code,
       zip_code: flowStore.confirmedZipCode || undefined,
@@ -338,6 +387,8 @@ const handleConfirm = async () => {
 
       flowStore.clear();
 
+      useVerificationGateStore().requestVerification();
+
       router.push({ name: "DashboardPage" });
     }
   } catch {

+ 42 - 2
src/pages/schedules/SobMedidaPage.vue

@@ -9,6 +9,21 @@
     </div>
     <div class="page-shell">
 
+      <div
+        v-if="loading"
+        class="row items-center justify-center q-py-xl"
+      >
+        <q-spinner-dots color="primary" size="40px" />
+      </div>
+
+      <AddressIncompleteBanner
+        v-else-if="!hasLocation"
+        class="q-mt-md"
+        @resolved="loadAddress"
+      />
+
+      <template v-else>
+
       <q-card flat bordered class="figma-card compact-card">
         <div class="card-title text-left font16 fontbold gradient-diarista">
           {{ $t('sob_medida.your_order') }}
@@ -168,6 +183,8 @@
           :options="dateOptions"
         />
       </div>
+
+      </template>
     </div>
   </q-page>
 </template>
@@ -177,6 +194,7 @@ import { ref, computed, watch, onMounted } from 'vue'
 import { useQuasar, date } from 'quasar'
 import { useRouter } from 'vue-router'
 
+import AddressIncompleteBanner from 'src/components/shared/AddressIncompleteBanner.vue'
 import ServiceSelectionSheet from 'src/pages/search/components/ServiceSelectionSheet.vue'
 import ServiceTimeSelectionDialog from 'src/pages/search/components/ServiceTimeSelectionDialog.vue'
 
@@ -196,6 +214,14 @@ const { t } = useI18n()
 const serviceTypes = ref([])
 const specialties = ref([])
 const address = ref(null)
+const loading = ref(true)
+
+const hasLocation = computed(() =>
+  address.value?.latitude !== null &&
+  address.value?.latitude !== undefined &&
+  address.value?.longitude !== null &&
+  address.value?.longitude !== undefined
+)
 
 const selectedServiceType = ref(null)
 const selectedSpecialties = ref([])
@@ -275,6 +301,8 @@ const openServiceTimeSelection = (serviceType) => {
 }
 
 const saveFinalOrder = async (payloadFinal) => {
+  if (!hasLocation.value) return
+
   let [startHour, endHour] = payloadFinal.slot.value.split('-')
 
   startHour = String(startHour).padStart(2, '0')
@@ -324,9 +352,21 @@ watch(selectedDate, (newDate, oldDate) => {
   openServiceSelection()
 })
 
+const loadAddress = async () => {
+  loading.value = true
+
+  try {
+    const { data } = await getPrimaryAddress(user.user.client.id, 'client')
+    address.value = data?.payload ?? null
+  } catch {
+    address.value = null
+  } finally {
+    loading.value = false
+  }
+}
+
 onMounted(async () => {
-  const { data } = await getPrimaryAddress(user.user.client.id, 'client')
-  address.value = data.payload
+  await loadAddress()
 
   serviceTypes.value = await getPublicServiceTypes()
   specialties.value = await getPublicSpecialties()

+ 4 - 0
src/stores/registrationFlow.js

@@ -7,6 +7,7 @@ export const useRegistrationFlowStore = defineStore("registrationFlow", () => {
   const code = ref("");
   const document = ref("");
   const name = ref("");
+  const birthDate = ref("");
 
   const initialLat = ref(null);
   const initialLng = ref(null);
@@ -34,6 +35,7 @@ export const useRegistrationFlowStore = defineStore("registrationFlow", () => {
   const setRegistrationData = (data) => {
     document.value = data.document ?? "";
     name.value = data.name ?? "";
+    birthDate.value = data.birth_date ?? "";
     phone.value = data.phone ?? phone.value;
   };
 
@@ -54,6 +56,7 @@ export const useRegistrationFlowStore = defineStore("registrationFlow", () => {
     code.value = "";
     document.value = "";
     name.value = "";
+    birthDate.value = "";
     initialLat.value = null;
     initialLng.value = null;
     confirmedLat.value = null;
@@ -78,6 +81,7 @@ export const useRegistrationFlowStore = defineStore("registrationFlow", () => {
     code,
     document,
     name,
+    birthDate,
     initialLat,
     initialLng,
     confirmedLat,

+ 16 - 0
src/stores/verificationGate.js

@@ -0,0 +1,16 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+
+export const useVerificationGateStore = defineStore("verificationGate", () => {
+  const open = ref(false);
+
+  const requestVerification = () => {
+    open.value = true;
+  };
+
+  const close = () => {
+    open.value = false;
+  };
+
+  return { open, requestVerification, close };
+});