浏览代码

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

Gustavo Zanatta 4 小时之前
父节点
当前提交
06803a5a73

+ 3 - 3
package-lock.json

@@ -4020,9 +4020,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001727",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz",
-      "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==",
+      "version": "1.0.30001806",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+      "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
       "dev": true,
       "funding": [
         {

+ 8 - 0
src/boot/axios.js

@@ -46,6 +46,14 @@ const errorInterceptor = async (error, router) => {
   const user_store = userStore();
 
   if (error.response?.status === 422) {
+    if (error.response?.data?.error === "missing_payment_data") {
+      Notify.create({
+        message: error.response.data.message,
+        type: "negative",
+      });
+      router.push({ name: "ProfilePage" });
+    }
+
     return Promise.reject(error);
   }
 

+ 6 - 23
src/components/dashboard/ScheduleAcceptedDialog.vue

@@ -56,25 +56,11 @@
           </span>
         </div>
 
-        <div
-          v-if="hasServicePackageDiscount"
-          class="detail-row"
-        >
-          <span class="text-primary font14 fontmedium">
-            {{ $t("dashboard_client.pending_schedules.detail_total") }}
-          </span>
-
-          <span class="total-value font14 fontbold">
-            {{ formatCurrency(pixTotal) }}
-          </span>
-        </div>
-
-        <div
-          v-if="!hasServicePackageDiscount"
-          class="detail-row"
-        >
+        <div class="detail-row">
           <span class="text-primary font14 fontmedium">
-            {{ $t("dashboard_client.pending_schedules.detail_pix_total") }}
+            {{ hasServicePackageDiscount
+                ? $t("dashboard_client.pending_schedules.detail_package_total")
+                : $t("dashboard_client.pending_schedules.detail_pix_total") }}
           </span>
 
           <span class="total-value font14 fontbold">
@@ -83,7 +69,7 @@
         </div>
 
         <div
-          v-if="!hasServicePackageDiscount && pixDiscount > 0"
+          v-if="pixDiscount > 0"
           class="detail-row discount-row"
         >
           <span class="font13 fontmedium">
@@ -95,10 +81,7 @@
           </span>
         </div>
 
-        <div
-          v-if="!hasServicePackageDiscount"
-          class="detail-row"
-        >
+        <div class="detail-row">
           <span class="text-primary font14 fontmedium">
             {{
               $t("dashboard_client.pending_schedules.detail_credit_card_total")

+ 5 - 1
src/components/dashboard/SchedulePaymentPixDialog.vue

@@ -247,7 +247,11 @@ onMounted(async () => {
     startPolling()
   } catch (e) {
     console.error('Erro ao criar pagamento Pix:', e)
-    $q.notify({ type: 'negative', message: e?.response?.data?.message ?? 'Erro ao criar pagamento Pix.' })
+
+    if (e.response?.data?.error !== 'missing_payment_data') {
+      $q.notify({ type: 'negative', message: e?.response?.data?.message ?? 'Erro ao criar pagamento Pix.' })
+    }
+
     onDialogCancel()
   } finally {
     processing.value = false

+ 5 - 1
src/components/dashboard/SchedulePaymentProcessingDialog.vue

@@ -78,7 +78,11 @@ onMounted(async () => {
     success.value = true
   } catch (e) {
     console.error('Erro ao pagar:', e)
-    $q.notify({ type: 'negative', message: e?.response?.data?.message ?? e.message ?? 'Erro ao processar pagamento.' })
+
+    if (e.response?.data?.error !== 'missing_payment_data') {
+      $q.notify({ type: 'negative', message: e?.response?.data?.message ?? e.message ?? 'Erro ao processar pagamento.' })
+    }
+
     onDialogCancel()
   }
 })

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

@@ -16,8 +16,11 @@
       outlined
       rounded
       :bottom-slots="false"
+      :error="!!serverErrors.email"
+      :error-message="serverErrors.email"
       :placeholder="$t('common.terms.email')"
       :rules="!smsEnabled || !phone ? [inputRules.requiredHideMessage, inputRules.email] : [inputRules.email]"
+      @update:model-value="clearServerError('email')"
     />
 
     <template v-if="smsEnabled">
@@ -54,14 +57,18 @@
         mask="(##) #####-####"
         no-error-icon
         :bottom-slots="false"
+        :error="!!serverErrors.phone"
+        :error-message="serverErrors.phone"
         :placeholder="$t('common.terms.phone')"
         :rules="!email ? [inputRules.requiredHideMessage, inputRules.phone] : [inputRules.phone]"
+        @update:model-value="clearServerError('phone')"
       />
     </template>
   </q-card-section>
 </template>
 
 <script setup>
+import { inject } from "vue";
 import { useInputRules } from "src/composables/useInputRules";
 
 const email = defineModel("email", {
@@ -75,11 +82,17 @@ const phone = defineModel("phone", {
 });
 
 defineProps({
+  serverErrors: {
+    type: Object,
+    default: () => ({}),
+  },
   smsEnabled: {
     type: Boolean,
     default: false,
   },
 });
 
+const clearServerError = inject("clearServerError", () => {});
+
 const { inputRules } = useInputRules();
 </script>

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

@@ -25,13 +25,16 @@
       lazy-rules
       mask="######"
       no-error-icon
+      :error="!!serverErrors.code"
+      :error-message="serverErrors.code"
       :rules="[inputRules.required]"
+      @update:model-value="clearServerError('code')"
     />
   </q-card-section>
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { inject, ref } from "vue";
 
 import { useInputRules } from "src/composables/useInputRules";
 
@@ -40,6 +43,15 @@ const code = defineModel("code", {
   type: String,
 });
 
+defineProps({
+  serverErrors: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const clearServerError = inject("clearServerError", () => {});
+
 const { inputRules } = useInputRules();
 
 const codeInputRef = ref(null);

+ 54 - 32
src/components/login/LoginStep3Panel.vue

@@ -17,8 +17,11 @@
         no-error-icon
         outlined
         rounded
+        :error="!!serverErrors.name"
+        :error-message="serverErrors.name"
         :placeholder="$t('auth.full_name')"
         :rules="[inputRules.required]"
+        @update:model-value="clearServerError('name')"
       />
     </div>
 
@@ -39,8 +42,11 @@
         no-error-icon
         outlined
         rounded
+        :error="!!serverErrors.document"
+        :error-message="serverErrors.document"
         :placeholder="$t('common.terms.document')"
         :rules="[inputRules.cpfOrCnpj]"
+        @update:model-value="clearServerError('document')"
       />
     </div>
 
@@ -62,8 +68,11 @@
         no-error-icon
         outlined
         rounded
+        :error="!!serverErrors.phone"
+        :error-message="serverErrors.phone"
         :placeholder="$t('common.terms.phone')"
         :rules="[inputRules.required, inputRules.phone]"
+        @update:model-value="clearServerError('phone')"
       />
     </div>
 
@@ -86,9 +95,11 @@
         outlined
         placeholder="00000-000"
         rounded
+        :error="!!serverErrors.zip_code"
+        :error-message="serverErrors.zip_code"
         :loading="loadingCep"
         :rules="[inputRules.required, inputRules.cep]"
-        @update:model-value="onCepChange"
+        @update:model-value="(clearServerError('zip_code'), onCepChange($event))"
       />
     </div>
 
@@ -120,6 +131,8 @@
         outlined
         readonly
         rounded
+        :error="!!serverErrors.address"
+        :error-message="serverErrors.address"
         :placeholder="`${$t('common.terms.address')}...`"
         :rules="[inputRules.required]"
       />
@@ -144,7 +157,10 @@
           outlined
           placeholder="0000"
           rounded
+          :error="!!serverErrors.number"
+          :error-message="serverErrors.number"
           :rules="[inputRules.required]"
+          @update:model-value="clearServerError('number')"
         />
       </div>
 
@@ -166,6 +182,8 @@
           outlined
           readonly
           rounded
+          :error="!!serverErrors.district"
+          :error-message="serverErrors.district"
           :placeholder="`${$t('common.terms.district')}...`"
           :rules="[inputRules.required]"
         />
@@ -191,6 +209,8 @@
           outlined
           readonly
           rounded
+          :error="!!serverErrors.city"
+          :error-message="serverErrors.city"
           :rules="[inputRules.required]"
         />
       </div>
@@ -213,43 +233,36 @@
           outlined
           readonly
           rounded
+          :error="!!serverErrors.state"
+          :error-message="serverErrors.state"
           :rules="[inputRules.required]"
         />
       </div>
     </div>
 
     <div>
-      <q-checkbox
-        v-model="form.no_complement"
-        class="q-mb-md text-text font14 fontbold"
-        color="primary"
-        keep-color
-        :label="$t('auth.no_complement')"
-      />
-    </div>
-
-    <div>
-      <template v-if="!form.no_complement">
-        <div class="text-text">
-          <span class="font14 fontbold">
-            {{ $t("common.terms.complement") }}
-          </span>
-        </div>
+      <div class="text-text">
+        <span class="font14 fontbold">
+          {{ $t("common.terms.complement") }}
+        </span>
+      </div>
 
-        <q-input
-          v-model="form.complement"
-          bg-color="surface"
-          class="q-mt-sm q-mb-md"
-          hide-bottom-space
-          input-class="text-text"
-          lazy-rules
-          no-error-icon
-          outlined
-          rounded
-          :placeholder="`${$t('common.ui.misc.example')}: Apartamento, Conjunto, Casa`"
-          :rules="!form.no_complement ? [inputRules.required] : []"
-        />
-      </template>
+      <q-input
+        v-model="form.complement"
+        bg-color="surface"
+        class="q-mt-sm q-mb-md"
+        hide-bottom-space
+        input-class="text-text"
+        lazy-rules
+        no-error-icon
+        outlined
+        rounded
+        :error="!!serverErrors.complement"
+        :error-message="serverErrors.complement"
+        :placeholder="`${$t('common.ui.misc.example')}: Apartamento, Conjunto, Casa`"
+        :rules="[inputRules.required]"
+        @update:model-value="clearServerError('complement')"
+      />
     </div>
 
     <div>
@@ -320,7 +333,7 @@
 <script setup>
 import axios from "axios";
 
-import { ref } from "vue";
+import { inject, ref } from "vue";
 import { useGeolocation } from "src/composables/useGeolocation";
 import { useI18n } from "vue-i18n";
 import { useInputRules } from "src/composables/useInputRules";
@@ -333,6 +346,15 @@ const form = defineModel({
   type: Object,
 });
 
+defineProps({
+  serverErrors: {
+    type: Object,
+    default: () => ({}),
+  },
+});
+
+const clearServerError = inject("clearServerError", () => {});
+
 const { requestPermission, getCurrentPosition } = useGeolocation();
 const { inputRules } = useInputRules();
 const { t } = useI18n();

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

@@ -204,7 +204,7 @@
               class="q-mb-md"
             >
               <q-banner
-                class="bg-orange-1 text-orange-9 q-mb-sm"
+                class="bg-orange-1 text-orange-9 q-mb-xl"
                 dense
                 rounded
               >

+ 11 - 11
src/composables/usePaymentPlatformFees.js

@@ -2,9 +2,10 @@ import { getPaymentPlatformFees } from 'src/api/payment'
 import { ref } from 'vue'
 
 const platformFees = ref({
-  pix:                           null,
-  credit_card:                   null,
-  service_package_min_3_schedules: null,
+  pix: null,
+  credit_card: null,
+  service_package_min_3_schedules_credit_card: null,
+  service_package_min_3_schedules_pix: null,
 })
 
 let loadingPromise = null
@@ -37,14 +38,13 @@ const normalizePlatformFees = (fees) => {
       fees?.credit_card_fee_rate,
       fees?.platform_credit_card_fee_rate,
     )),
-    service_package_min_3_schedules: normalizeFee(firstDefined(
-      fees?.service_package_min_3_schedules,
-      fees?.servicePackageMin3Schedules,
-      fees?.cart_min_3_schedules,
-      fees?.cartMin3Schedules,
-      fees?.cart_min_3_schedules_fee,
-      fees?.cart_min_3_schedules_fee_rate,
-      fees?.platform_cart_min_3_schedules_fee_rate,
+    service_package_min_3_schedules_credit_card: normalizeFee(firstDefined(
+      fees?.service_package_min_3_schedules_credit_card,
+      fees?.servicePackageMin3SchedulesCreditCard,
+    )),
+    service_package_min_3_schedules_pix: normalizeFee(firstDefined(
+      fees?.service_package_min_3_schedules_pix,
+      fees?.servicePackageMin3SchedulesPix,
     )),
   }
 

+ 46 - 34
src/composables/useScroll.js

@@ -1,4 +1,10 @@
-import { unref } from 'vue'
+import { unref } from "vue";
+
+const resolveElement = (reference) => {
+  const value = unref(reference);
+
+  return value?.$el ?? value;
+};
 
 export const useScroll = () => {
   /**
@@ -11,49 +17,55 @@ export const useScroll = () => {
    * @returns {boolean} - Whether the scroll was successful
    */
   const scrollToComponent = (targetRef, containerRef, options = {}) => {
-    const {
-      offset = 50,
-      duration = 150
-    } = options
+    const { offset = 50, duration = 150 } = options;
 
-    const target = unref(targetRef)
-    const container = unref(containerRef)
+    const targetElement = resolveElement(targetRef);
+    const container = unref(containerRef);
+    const containerElement = resolveElement(containerRef);
 
-    const targetElement = target?.$el
-    const containerElement = container?.$el
-
-    if (!targetElement || !containerElement) {
-      console.warn('useScroll: Target or container element not found')
-      return false
+    if (
+      !targetElement?.getBoundingClientRect ||
+      !containerElement?.getBoundingClientRect
+    ) {
+      console.warn("useScroll: Target or container element not found");
+      return false;
     }
 
     try {
-      let currentElement = targetElement
-      let offsetTop = 0
-
-      // Calculate total offset up to the scroll container
-      while (currentElement && currentElement !== containerElement) {
-        offsetTop += currentElement.offsetTop
-        currentElement = currentElement.offsetParent
+      if (!containerElement.contains(targetElement)) {
+        console.warn("useScroll: Target is not a child of the container");
+        return false;
       }
 
-      if (!currentElement) {
-        console.warn('useScroll: Target is not a child of the container')
-        return false
-      }
-
-      const targetPosition = Math.max(0, offsetTop - offset)
+      const currentPosition =
+        container?.getScrollPosition?.().top ?? containerElement.scrollTop ?? 0;
+      const targetPosition = Math.max(
+        0,
+        currentPosition +
+          targetElement.getBoundingClientRect().top -
+          containerElement.getBoundingClientRect().top -
+          offset,
+      );
 
-      container.setScrollPosition('vertical', targetPosition, duration)
+      if (typeof container?.setScrollPosition === "function") {
+        container.setScrollPosition("vertical", targetPosition, duration);
+      } else if (typeof containerElement.scrollTo === "function") {
+        containerElement.scrollTo({
+          top: targetPosition,
+          behavior: duration > 0 ? "smooth" : "auto",
+        });
+      } else {
+        containerElement.scrollTop = targetPosition;
+      }
 
-      return true
+      return true;
     } catch (error) {
-      console.error('useScroll: Error while scrolling', error)
-      return false
+      console.error("useScroll: Error while scrolling", error);
+      return false;
     }
-  }
+  };
 
   return {
-    scrollToComponent
-  }
-}
+    scrollToComponent,
+  };
+};

+ 19 - 4
src/helpers/paymentPlatformFees.js

@@ -1,11 +1,26 @@
-export const scheduleUsesServicePackageDiscount = (schedule) =>
-  Number(schedule?.service_package_items_count ?? schedule?.cart_items_count ?? schedule?.cartItemsCount ?? schedule?.cart?.items_count ?? 0) >= 3
+export const scheduleUsesServicePackageDiscount = (item) => {
+  const count = Number(
+    item?.schedules?.[0]?.service_package_items_count
+    ?? item?.service_package_items_count
+    ?? item?.cart_items_count
+    ?? item?.cartItemsCount
+    ?? item?.cart?.items_count
+    ?? 0
+  )
+  return count >= 3
+}
 
 export const getSchedulePlatformFeeRate = (schedule, paymentType, platformFees) => {
   const fees = platformFees?.value ?? platformFees ?? {}
 
-  if (scheduleUsesServicePackageDiscount(schedule) && fees.service_package_min_3_schedules !== null && fees.service_package_min_3_schedules !== undefined) {
-    return fees.service_package_min_3_schedules
+  if (scheduleUsesServicePackageDiscount(schedule)) {
+    const key = paymentType === 'credit_card'
+      ? 'service_package_min_3_schedules_credit_card'
+      : 'service_package_min_3_schedules_pix'
+
+    if (fees[key] !== null && fees[key] !== undefined) {
+      return fees[key]
+    }
   }
 
   return fees[paymentType]

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

@@ -528,6 +528,7 @@
       "detail_value": "Amount:",
       "detail_service_fee": "Total:",
       "detail_total": "Total:",
+      "detail_package_total": "Package total (Pix):",
       "detail_pix_total": "Pix total:",
       "detail_credit_card_total": "Credit card total:",
       "detail_pix_discount": "Pix discount:",

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

@@ -525,6 +525,7 @@
       "detail_value": "Valor:",
       "detail_service_fee": "Total:",
       "detail_total": "Total:",
+      "detail_package_total": "Total del paquete (Pix):",
       "detail_pix_total": "Total en Pix:",
       "detail_credit_card_total": "Total en tarjeta de crédito:",
       "detail_pix_discount": "Descuento en Pix:",

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

@@ -528,6 +528,7 @@
       "detail_value": "Valor:",
       "detail_service_fee": "Total:",
       "detail_total": "Total:",
+      "detail_package_total": "Total do pacote (Pix):",
       "detail_pix_total": "Total no Pix:",
       "detail_credit_card_total": "Total no cartão de crédito:",
       "detail_pix_discount": "Desconto no Pix:",

+ 181 - 133
src/pages/LoginPage.vue

@@ -60,42 +60,6 @@
         key="flow"
         class="flow-screen"
       >
-        <div class="flow-header items-center row">
-          <q-btn
-            v-if="steps >= 4"
-            class="flow-back-btn"
-            color="primary"
-            dense
-            flat
-            icon="mdi-arrow-left"
-            round
-            @click="goBack"
-          />
-        </div>
-
-        <div class="flow-logo q-my-xl">
-          <q-img
-            :src="LogoDiariaCampos"
-            style="max-width: 180px"
-          />
-        </div>
-
-        <div
-          v-if="steps === 3"
-          class="register-later-row"
-        >
-          <q-btn
-            align="right"
-            class="register-later-btn"
-            color="primary"
-            flat
-            icon-right="mdi-chevron-right-circle-outline"
-            no-caps
-            :label="$t('auth.register_later')"
-            @click="goToLocationStep"
-          />
-        </div>
-
         <q-form
           ref="loginForm"
           autocapitalize="off"
@@ -104,33 +68,81 @@
           class="flow-form"
           spellcheck="false"
           @submit="onSubmit"
+          @validation-error="scrollToInvalidField"
         >
-          <div
-            class="flow-content"
-            :class="{ 'flow-content--centered': steps < 3, 'flow-content--email-only': steps === 1 && !smsEnabled }"
+          <q-scroll-area
+            ref="flowScrollArea"
+            class="flow-scroll"
           >
-            <LoginStep1Panel
-              v-if="steps === 1"
-              v-model:email="email"
-              v-model:phone="phone"
-              :sms-enabled="smsEnabled"
-            />
-
-            <LoginStep2Panel
-              v-else-if="steps === 2"
-              v-model:code="code"
-            />
-
-            <LoginStep3Panel
-              v-else-if="steps === 3"
-              v-model="stepThreeForm"
-            />
-
-            <LoginStepAvatarPanel
-              v-else-if="steps === 4"
-              v-model="stepAvatarForm"
-            />
-          </div>
+            <div class="flow-screen-content">
+              <div class="flow-header items-center row">
+                <q-btn
+                  v-if="steps >= 4"
+                  class="flow-back-btn"
+                  color="primary"
+                  dense
+                  flat
+                  icon="mdi-arrow-left"
+                  round
+                  @click="goBack"
+                />
+              </div>
+
+              <div class="flow-logo q-my-xl">
+                <q-img
+                  :src="LogoDiariaCampos"
+                  style="max-width: 180px"
+                />
+              </div>
+
+              <div
+                v-if="steps === 3"
+                class="register-later-row"
+              >
+                <q-btn
+                  align="right"
+                  class="register-later-btn"
+                  color="primary"
+                  flat
+                  icon-right="mdi-chevron-right-circle-outline"
+                  no-caps
+                  :label="$t('auth.register_later')"
+                  @click="goToLocationStep"
+                />
+              </div>
+
+              <div
+                ref="flowContent"
+                class="flow-content"
+                :class="{ 'flow-content--centered': steps < 3, 'flow-content--email-only': steps === 1 && !smsEnabled }"
+              >
+                <LoginStep1Panel
+                  v-if="steps === 1"
+                  v-model:email="email"
+                  v-model:phone="phone"
+                  :server-errors="serverErrors"
+                  :sms-enabled="smsEnabled"
+                />
+
+                <LoginStep2Panel
+                  v-else-if="steps === 2"
+                  v-model:code="code"
+                  :server-errors="serverErrors"
+                />
+
+                <LoginStep3Panel
+                  v-else-if="steps === 3"
+                  v-model="stepThreeForm"
+                  :server-errors="serverErrors"
+                />
+
+                <LoginStepAvatarPanel
+                  v-else-if="steps === 4"
+                  v-model="stepAvatarForm"
+                />
+              </div>
+            </div>
+          </q-scroll-area>
 
           <div class="flow-footer">
             <q-btn
@@ -140,7 +152,7 @@
               rounded
               type="submit"
               :label="$t('auth.continue')"
-              :loading="submitting"
+              :loading="loading"
             >
               <template #loading>
                 <q-spinner />
@@ -167,12 +179,14 @@ import {
 } from "src/api/user";
 
 import { getServicesConfig } from "src/api/services";
-import { onMounted, ref } from "vue";
+import { nextTick, onMounted, provide, ref, watch } from "vue";
 import { useAuth } from "src/composables/useAuth";
 import { useI18n } from "vue-i18n";
 import { useQuasar } from "quasar";
 import { useRegistrationFlowStore } from "src/stores/registrationFlow";
 import { useRouter } from "vue-router";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import { userStore } from "src/stores/user";
 
 import LoginStep1Panel from "src/components/login/LoginStep1Panel.vue";
@@ -194,6 +208,8 @@ const email = ref("");
 const isLogin = ref(false);
 
 const loginForm = ref(null);
+const flowContent = ref(null);
+const flowScrollArea = ref(null);
 
 const phone = ref("");
 
@@ -224,7 +240,12 @@ const stepThreeForm = ref({
   zip_code: "",
 });
 
-const submitting = ref(false);
+const { loading, serverErrors, execute } = useSubmitHandler();
+const { scrollToComponent } = useScroll();
+
+provide("clearServerError", (field) => {
+  serverErrors.value[field] = null;
+});
 
 const goBack = () => {
   if (steps.value === 4) {
@@ -267,20 +288,46 @@ const onDeviceLocation = ({ lat, lng }) => {
 };
 
 const onSubmit = async () => {
-  submitting.value = true;
-
   try {
     switch (steps.value) {
       case 1: {
-        const response = await sendValidationCode();
+        await execute(() => sendCode(email.value, phone.value));
+
+        if (Object.keys(serverErrors.value).length === 0) {
+          steps.value = 2;
+        }
 
-        isLogin.value = response.data.payload.isLogin;
         break;
       }
 
-      case 2:
-        await validateCodeInput();
+      case 2: {
+        let response;
+
+        await execute(async () => {
+          response = await validateCode(
+            email.value,
+            phone.value,
+            code.value,
+            isLogin.value,
+          );
+        });
+
+        if (
+          Object.keys(serverErrors.value).length === 0 &&
+          response?.status === 200
+        ) {
+          if (isLogin.value === true) {
+            await setAuthDataFromPayload(response.data.payload);
+
+            router.push({ name: "DashboardPage" });
+          } else {
+            stepThreeForm.value.phone = phone.value;
+            steps.value = 3;
+          }
+        }
+
         break;
+      }
 
       case 3:
         steps.value = 4;
@@ -288,13 +335,23 @@ const onSubmit = async () => {
 
       case 4:
         await registerUserAndClient();
+
+        if (Object.keys(serverErrors.value).length > 0) {
+          steps.value = 3;
+        }
+
         break;
 
       default:
         break;
     }
-  } finally {
-    submitting.value = false;
+  } catch (error) {
+    console.error("Login flow error", error);
+
+    $q.notify({
+      type: "negative",
+      message: t("auth.register_error"),
+    });
   }
 };
 
@@ -310,46 +367,35 @@ const registerUserAndClient = async () => {
   };
 
   Object.entries(stepThreeForm.value).forEach(([key, value]) => {
-    if (key === "no_complement") {
-      return;
-    }
-
-    if (key === "complement" && stepThreeForm.value.no_complement) {
+    if (key === "no_complement" || key === "complement") {
       return;
     }
 
     append(key, value);
   });
 
+  payload.complement = stepThreeForm.value.complement || "";
+
   append("code", code.value);
   append("email", email.value);
-  append("has_complement", !stepThreeForm.value.no_complement);
+  append("has_complement", true);
   append("phone", stepThreeForm.value.phone || phone.value);
 
-  try {
+  await execute(async () => {
     const response = await createUserAndClient(payload);
 
-    if (response.status >= 200 && response.status < 300) {
-      await setAuthDataFromPayload(response.data.payload);
+    await setAuthDataFromPayload(response.data.payload);
 
-      if (stepAvatarForm.value.avatar) {
-        const user = await updateMe({
-          avatar_base64: await fileToBase64(stepAvatarForm.value.avatar),
-        });
+    if (stepAvatarForm.value.avatar) {
+      const user = await updateMe({
+        avatar_base64: await fileToBase64(stepAvatarForm.value.avatar),
+      });
 
-        userStore().setUser(user);
-      }
-
-      router.push({ name: "DashboardPage" });
+      userStore().setUser(user);
     }
-  } catch (error) {
-    console.error("Failed to create user and client", error);
 
-    $q.notify({
-      type: "negative",
-      message: t("auth.register_error"),
-    });
-  }
+    router.push({ name: "DashboardPage" });
+  });
 };
 
 //
@@ -374,40 +420,25 @@ const saveRegistrationData = () => {
   });
 };
 
-const sendValidationCode = async () => {
-  const response = await sendCode(email.value, phone.value);
+const scrollToInvalidField = async (field) => {
+  await nextTick();
 
-  if (response.status === 201) {
-    steps.value = 2;
-  } else {
-    console.error("Failed to send validation code");
-  }
-
-  return response;
-};
+  const target = field ?? flowContent.value?.querySelector(".q-field--error");
 
-const validateCodeInput = async () => {
-  const response = await validateCode(
-    email.value,
-    phone.value,
-    code.value,
-    isLogin.value,
-  );
-
-  if (response.status === 200) {
-    if (isLogin.value === true) {
-      await setAuthDataFromPayload(response.data.payload);
-      router.push({ name: "DashboardPage" });
-    } else {
-      stepThreeForm.value.phone = phone.value;
-      steps.value = 3;
-    }
-  } else {
-    console.error("Invalid validation code");
+  if (target && flowScrollArea.value) {
+    scrollToComponent(target, flowScrollArea);
   }
 };
 
-//
+watch(
+  [steps, () => Object.values(serverErrors.value).some(Boolean)],
+  ([, hasErrors]) => {
+    if (hasErrors) {
+      scrollToInvalidField();
+    }
+  },
+  { flush: "post" },
+);
 
 onMounted(() => {
   loadServicesConfig();
@@ -498,12 +529,19 @@ onMounted(() => {
 }
 
 .flow-screen {
-  display: flex;
-  flex-direction: column;
   width: 100%;
   max-width: 500px;
-  min-height: 100vh;
-  padding: 16px 20px;
+  height: 100vh;
+  height: 100dvh;
+  background: var(--q-surface-dark);
+}
+
+.flow-screen-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 100%;
+  padding: 16px 20px 0;
   background: var(--q-surface-dark);
 }
 
@@ -525,16 +563,25 @@ onMounted(() => {
 }
 
 .flow-form {
-  flex: 1;
+  height: 100%;
   display: flex;
   flex-direction: column;
 }
 
-.flow-content {
+.flow-scroll {
   flex: 1;
-  overflow-y: auto;
+  height: 0;
   min-height: 0;
 
+  :deep(.q-scrollarea__content) {
+    display: flex;
+    flex-direction: column;
+  }
+}
+
+.flow-content {
+  flex: 1;
+
   &--centered {
     display: flex;
     align-items: center;
@@ -550,7 +597,8 @@ onMounted(() => {
 
 .flow-footer {
   flex-shrink: 0;
-  padding: 8px 0 calc(12px + env(safe-area-inset-bottom));
+  padding: 8px 20px calc(12px + env(safe-area-inset-bottom));
+  background: var(--q-surface-dark);
 }
 
 .register-later-row {

+ 17 - 30
src/pages/location/AddressCompletionPage.vue

@@ -166,28 +166,22 @@
           </div>
         </div>
 
-        <q-checkbox
-          v-model="form.no_complement"
-          class="q-mb-md text-text"
-          color="primary"
-          keep-color
-          :label="$t('auth.no_complement')"
-        />
-
-        <template v-if="!form.no_complement">
-          <div class="text-text">
-            <span class="font14 fontbold">{{ $t("auth.complement") }}</span>
-          </div>
+        <div class="text-text">
+          <span class="font14 fontbold">{{ $t("auth.complement") }}</span>
+        </div>
 
-          <q-input
-            v-model="form.complement"
-            class="bg-surface q-mt-sm q-mb-md"
-            input-class="text-text"
-            outlined
-            rounded
-            :placeholder="$t('auth.complement_placeholder')"
-          />
-        </template>
+        <q-input
+          v-model="form.complement"
+          class="bg-surface q-mt-sm q-mb-md"
+          hide-bottom-space
+          input-class="text-text"
+          lazy-rules
+          no-error-icon
+          outlined
+          rounded
+          :placeholder="$t('auth.complement_placeholder')"
+          :rules="[inputRules.required]"
+        />
 
         <div class="text-text">
           <span class="font14 fontbold">{{ $t("auth.address_nickname") }}</span>
@@ -308,11 +302,6 @@ const handleConfirm = async () => {
 
   if (!isValid) return;
 
-  if (!form.value.no_complement && !form.value.complement?.trim()) {
-    $q.notify({ type: "warning", message: t("auth.complement_required") });
-    return;
-  }
-
   submitting.value = true;
 
   try {
@@ -330,11 +319,9 @@ const handleConfirm = async () => {
       state: flowStore.confirmedState || undefined,
       latitude: flowStore.confirmedLat,
       longitude: flowStore.confirmedLng,
-      has_complement: !form.value.no_complement,
+      has_complement: true,
 
-      complement: form.value.no_complement
-        ? null
-        : form.value.complement || null,
+      complement: form.value.complement || "",
 
       nickname: form.value.nickname || null,
       instructions: form.value.instructions || null,

+ 41 - 105
src/pages/orders/ServicePackagePage.vue

@@ -90,12 +90,6 @@
     <template v-else>
       <div class="q-px-md q-pt-md column q-gutter-y-sm">
         <div class="service-package-discount-banner row items-center no-wrap q-pa-sm">
-          <q-icon
-            color="positive"
-            name="mdi-sale-outline"
-            size="20px"
-          />
-
           <span class="font12 fontbold text-positive q-ml-sm">
             {{ servicePackageDiscountMessage }}
           </span>
@@ -171,50 +165,17 @@
               </div>
 
               <div class="item-prices q-mt-sm">
-                <div
-                  v-if="!hasServicePackageDiscount"
-                  class="row items-center no-wrap"
-                >
-                  <span class="font11 fontbold text-primary">{{
-                    $t("service_package.pix_total")
-                  }}</span>
-
-                  <q-space />
-
-                  <span class="font12 fontbold text-primary">
-                    {{ formatCurrencyValue(getStandardPixItemTotal(item)) }}
-                  </span>
-                </div>
-
-                <div
-                  v-if="!hasServicePackageDiscount"
-                  class="row items-center no-wrap q-mt-xs"
-                >
-                  <span class="font11 text-grey-7">
+                <div class="row items-center no-wrap">
+                  <span class="font11 fontbold text-primary">
                     {{ $t("service_package.credit_card_total") }}
                   </span>
 
                   <q-space />
 
-                  <span class="font12 fontbold text-text">
+                  <span class="font12 fontbold text-primary">
                     {{ formatCurrencyValue(getCreditCardItemTotal(item)) }}
                   </span>
                 </div>
-
-                <div
-                  v-if="hasServicePackageDiscount"
-                  class="row items-center no-wrap q-mt-xs"
-                >
-                  <span class="font11 fontbold text-positive">
-                    {{ $t("service_package.discount_total") }}
-                  </span>
-
-                  <q-space />
-
-                  <span class="font12 fontbold text-positive">
-                    {{ formatCurrencyValue(getServicePackageDiscountItemTotal(item)) }}
-                  </span>
-                </div>
               </div>
             </div>
 
@@ -284,50 +245,26 @@
               </span>
             </div>
 
-            <div
-              v-if="!hasServicePackageDiscount"
-              class="summary-row row items-center no-wrap q-py-xs"
-            >
+            <div class="summary-row row items-center no-wrap q-py-xs">
               <span class="font12 fontbold text-primary">
-                {{ $t("service_package.pix_total") }}
+                {{ $t("service_package.credit_card_total") }}
               </span>
 
               <q-space />
 
               <span class="font14 fontbold text-primary">
-                {{ formatCurrencyValue(pixTotalAmount) }}
+                {{ formatCurrencyValue(creditCardTotalAmount) }}
               </span>
             </div>
 
             <div
-              v-if="hasServicePackageDiscount"
-              class="summary-row row items-center no-wrap q-py-xs"
-            >
-              <span class="font12 fontbold text-positive">
-                {{ $t("service_package.discount_total") }}
-              </span>
-
-              <q-space />
-
-              <span class="font14 fontbold text-positive">
-                {{ formatCurrencyValue(servicePackageDiscountTotalAmount) }}
-              </span>
-            </div>
-
-            <div
-              v-if="pixDiscountAmount > 0"
+              v-if="creditCardDiscountAmount > 0"
               class="summary-discount row items-center no-wrap q-py-xs q-px-sm q-mt-sm"
             >
-              <q-icon
-                color="positive"
-                name="mdi-tag-outline"
-                size="18px"
-              />
-
               <span class="font12 fontbold text-positive q-ml-xs">
                 {{
                   $t("service_package.discount_saved", {
-                    value: formatPrice(pixDiscountAmount),
+                    value: formatPrice(creditCardDiscountAmount),
                   })
                 }}
               </span>
@@ -410,17 +347,39 @@ const getTotalWithFee = (item, fee) => {
   return roundMoney(Number(item.base_amount ?? 0) * (1 + Number(fee)));
 };
 
-const getServicePackageDiscountItemTotal = (item) =>
-  getTotalWithFee(item, platformFees.value.service_package_min_3_schedules);
+const hasServicePackageDiscount = computed(() => servicePackage.items.length >= 3);
+
+const getStandardCreditCardItemTotal = (item) =>
+  getTotalWithFee(item, platformFees.value.credit_card);
 
-const getStandardPixItemTotal = (item) =>
-  getTotalWithFee(item, platformFees.value.pix);
+const getCreditCardItemTotal = (item) =>
+  getTotalWithFee(
+    item,
+    hasServicePackageDiscount.value
+      ? platformFees.value.service_package_min_3_schedules_credit_card
+      : platformFees.value.credit_card,
+  );
 
-const servicePackageDiscountTotalAmount = computed(() =>
-  roundNullableMoney(sumItems(getServicePackageDiscountItemTotal)),
+const creditCardTotalAmount = computed(() =>
+  roundNullableMoney(sumItems(getCreditCardItemTotal)),
 );
 
-const hasServicePackageDiscount = computed(() => servicePackage.items.length >= 3);
+const creditCardDiscountAmount = computed(() => {
+  if (!hasServicePackageDiscount.value) return 0;
+
+  return (
+    roundNullableMoney(
+      sumItems((item) => {
+        const standardTotal = getStandardCreditCardItemTotal(item);
+        const discountedTotal = getCreditCardItemTotal(item);
+
+        if (standardTotal === null || discountedTotal === null) return null;
+
+        return standardTotal - discountedTotal;
+      }),
+    ) ?? 0
+  );
+});
 
 const missingSchedulesForDiscount = computed(() =>
   Math.max(3 - servicePackage.items.length, 0),
@@ -438,10 +397,11 @@ const servicePackageDiscountMessage = computed(() => {
 
 const platformFeesReady = computed(
   () =>
-    isValidFee(platformFees.value.pix) &&
     isValidFee(platformFees.value.credit_card) &&
     (!hasServicePackageDiscount.value ||
-      isValidFee(platformFees.value.service_package_min_3_schedules)),
+      isValidFee(
+        platformFees.value.service_package_min_3_schedules_credit_card,
+      )),
 );
 
 const hasSingleProvider = computed(() => {
@@ -452,28 +412,6 @@ const hasSingleProvider = computed(() => {
   return providers.size <= 1;
 });
 
-const pixDiscountAmount = computed(() => {
-  if (!hasServicePackageDiscount.value) return 0;
-
-  return (
-    roundNullableMoney(
-      sumItems((item) => {
-        const standardPixItemTotal = getStandardPixItemTotal(item);
-        const discountedPixItemTotal = getServicePackageDiscountItemTotal(item);
-
-        if (standardPixItemTotal === null || discountedPixItemTotal === null)
-          return null;
-
-        return standardPixItemTotal - discountedPixItemTotal;
-      }),
-    ) ?? 0
-  );
-});
-
-const pixTotalAmount = computed(() =>
-  roundNullableMoney(sumItems(getStandardPixItemTotal)),
-);
-
 const primaryAddressLabel = computed(() => {
   if (addressLoading.value) return t("service_package.loading");
   if (!primaryAddress.value) return t("service_package.no_primary_address");
@@ -540,9 +478,6 @@ const formatCurrencyValue = (value) => {
   return t("service_package.currency_value", { value: formatPrice(value) });
 };
 
-const getCreditCardItemTotal = (item) =>
-  getTotalWithFee(item, platformFees.value.credit_card);
-
 const getProviderInitial = (item) =>
   item.provider_name?.slice(0, 1).toUpperCase() ??
   t("service_package.provider_initial");
@@ -679,4 +614,5 @@ onMounted(() => {
   background: rgba(34, 197, 94, 0.12);
   border-radius: 8px;
 }
+
 </style>