ソースを参照

Merge remote-tracking branch 'origin/development' into fix-diaria-kay-ajustes-prestador

Gustavo Zanatta 1 日 前
コミット
04a7812521

+ 11 - 0
package-lock.json

@@ -16,6 +16,7 @@
         "@capacitor/geolocation": "^7.1.8",
         "@capacitor/google-maps": "^7.2.2",
         "@capacitor/push-notifications": "^7.0.7",
+        "@ionic/pwa-elements": "^3.4.0",
         "@capacitor/share": "^7.0.4",
         "@quasar/cli": "^2.5.0",
         "@quasar/extras": "^1.17.0",
@@ -1567,6 +1568,16 @@
         "node": ">=16.0.0"
       }
     },
+    "node_modules/@ionic/pwa-elements": {
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/@ionic/pwa-elements/-/pwa-elements-3.4.0.tgz",
+      "integrity": "sha512-WmL7An4NOsvJvct4V/A8hudr+yXgLBHYmxzqWGsetZmzN7C6suLRS/KIh2P1t8cSYCYL7HRrbv8NrIChPkh6Ow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16.0.0",
+        "npm": ">=8.0.0"
+      }
+    },
     "node_modules/@ionic/utils-array": {
       "version": "2.1.6",
       "resolved": "https://registry.npmjs.org/@ionic/utils-array/-/utils-array-2.1.6.tgz",

+ 1 - 0
package.json

@@ -30,6 +30,7 @@
     "@capacitor/geolocation": "^7.1.8",
     "@capacitor/google-maps": "^7.2.2",
     "@capacitor/push-notifications": "^7.0.7",
+    "@ionic/pwa-elements": "^3.4.0",
     "@quasar/cli": "^2.5.0",
     "@quasar/extras": "^1.17.0",
     "axios": "^1.9.0",

+ 1 - 0
quasar.config.js

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

+ 11 - 0
src/boot/pwa-elements.js

@@ -0,0 +1,11 @@
+import { defineBoot } from "#q-app/wrappers";
+import { Capacitor } from "@capacitor/core";
+import { defineCustomElements } from "@ionic/pwa-elements/loader";
+
+export default defineBoot(() => {
+  if (Capacitor.isNativePlatform()) {
+    return;
+  }
+
+  defineCustomElements(window);
+});

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

@@ -176,6 +176,7 @@
 <script setup>
 import { avatarColors, formatAddress, formatCurrency, getFirstName } from "src/helpers/utils";
 import { labelsPeriodTypes } from "src/helpers/labelsPeriodTypes.js";
+import { formatDayMonth, formatWeekday } from "src/helpers/scheduleDate";
 import { useI18n } from "vue-i18n";
 import { useQuasar } from "quasar";
 
@@ -207,28 +208,6 @@ const copyAddress = (address) => {
   });
 };
 
-const formatDayMonth = (iso) => {
-  if (!iso) {
-    return "";
-  }
-
-  return new Date(iso).toLocaleDateString("pt-BR", {
-    day: "2-digit",
-    month: "2-digit",
-  });
-};
-
-const formatWeekday = (iso) => {
-  if (!iso) {
-    return "";
-  }
-
-  const date = new Date(iso);
-
-  const weekday = date.toLocaleDateString("pt-BR", { weekday: "long" });
-
-  return weekday.charAt(0).toUpperCase() + weekday.slice(1);
-};
 </script>
 
 <style scoped lang="scss">

+ 1 - 22
src/components/dashboard/DashboardOpportunities.vue

@@ -173,6 +173,7 @@
 <script setup>
 import { avatarColors, formatCurrency, getFirstName } from "src/helpers/utils";
 import { labelsPeriodTypes } from "src/helpers/labelsPeriodTypes.js";
+import { formatDayMonth, formatWeekday } from "src/helpers/scheduleDate";
 import { useQuasar } from "quasar";
 import { useRouter } from "vue-router";
 
@@ -191,28 +192,6 @@ const router = useRouter();
 
 
 
-const formatDayMonth = (iso) => {
-  if (!iso) {
-    return "";
-  }
-
-  return new Date(iso).toLocaleDateString("pt-BR", {
-    day: "2-digit",
-    month: "2-digit",
-  });
-};
-
-const formatWeekday = (iso) => {
-  if (!iso) {
-    return "";
-  }
-
-  const date = new Date(iso);
-
-  const weekday = date.toLocaleDateString("pt-BR", { weekday: "long" });
-
-  return weekday.charAt(0).toUpperCase() + weekday.slice(1);
-};
 
 const openAllOpportunities = () => {
   router.push({

+ 1 - 22
src/components/dashboard/DashboardPendingConfirmation.vue

@@ -176,6 +176,7 @@
 <script setup>
 import { avatarColors, formatAddress, formatCurrency, getFirstName } from "src/helpers/utils";
 import { labelsPeriodTypes } from "src/helpers/labelsPeriodTypes.js";
+import { formatDayMonth, formatWeekday } from "src/helpers/scheduleDate";
 import { useI18n } from "vue-i18n";
 import { useQuasar } from "quasar";
 
@@ -207,28 +208,6 @@ const copyAddress = (address) => {
   });
 };
 
-const formatDayMonth = (iso) => {
-  if (!iso) {
-    return "";
-  }
-
-  return new Date(iso).toLocaleDateString("pt-BR", {
-    day: "2-digit",
-    month: "2-digit",
-  });
-};
-
-const formatWeekday = (iso) => {
-  if (!iso) {
-    return "";
-  }
-
-  const date = new Date(iso);
-
-  const weekday = date.toLocaleDateString("pt-BR", { weekday: "long" });
-
-  return weekday.charAt(0).toUpperCase() + weekday.slice(1);
-};
 </script>
 
 <style scoped lang="scss">

+ 83 - 22
src/components/dashboard/DashboardTodayServices.vue

@@ -3,6 +3,16 @@
     v-if="visibleItems.length > 0"
     class="q-mx-md q-mb-md"
   >
+    <div class="row items-center no-wrap q-mb-sm">
+      <span class="font16 fontbold gradient-diarista">
+        {{ $t('provider.dashboard.today_services.title') }}
+      </span>
+
+      <div class="attention-badge q-ml-sm">
+        {{ $t('provider.dashboard.today_services.attention_badge') }}
+      </div>
+    </div>
+
     <div class="scroll-wrapper">
       <div
         class="scroll-track"
@@ -47,6 +57,14 @@
                       {{ ' ' + $t('provider.dashboard.today_services.finished_suffix') }}
                     </template>
 
+                    <template v-else-if="cardState(item) === 'not_started'">
+                      {{ $t('provider.dashboard.today_services.not_started_label') }}
+
+                      <span class="fontbold">
+                        {{ ' ' + (getFirstName(item.client_name) || '—') }}
+                      </span>
+                    </template>
+
                     <template v-else>
                       {{ $t('provider.dashboard.today_services.start_label') }}
 
@@ -122,6 +140,21 @@
                 </div>
               </div>
 
+              <div
+                v-else-if="cardState(item) === 'not_started'"
+                class="col-12 text-center q-mb-xs"
+              >
+                <div class="column items-center">
+                  <div class="clock-badge">
+                    <q-icon
+                      color="white"
+                      name="mdi-clock-outline"
+                      size="15px"
+                    />
+                  </div>
+                </div>
+              </div>
+
               <div v-else class="">
                 <div
                   v-if="item.provider_reviewed"
@@ -168,25 +201,7 @@
             />
           </div>
 
-          <template v-if="cardState(item) === 'in_progress'">
-            <div class="row items-center no-wrap">
-              <q-icon
-                class="q-mr-xs flex-shrink-0 gradient-diarista"
-                name="mdi-clock-outline"
-                size="13px"
-              />
-  
-              <span class="text-grey-6 text-right text-no-wrap font9 fontmedium">
-                {{ $t('provider.dashboard.today_services.end_time_label') }}
-  
-                <span class="gradient-diarista">
-                  {{ calculateTimeUntilEnd(item.end_time) }}
-                </span>
-              </span>
-            </div>
-          </template>
-
-          <template v-else-if="cardState(item) === 'awaiting_code'">
+          <template v-if="cardState(item) === 'not_started'">
             <div class="address-row">
               <q-icon
                 class="flex-shrink-0"
@@ -194,10 +209,10 @@
                 name="mdi-map-marker-outline"
                 size="13px"
               />
-  
+
               <span class="text-grey-6 address-text text-right font9 fontmedium">
                 {{ [item.address?.address, item.address?.number, item.address?.district].filter(Boolean).join(', ') || '—' }}
-  
+
               </span>
               <q-icon
                 class="flex-shrink-0 q-ml-sm"
@@ -209,7 +224,7 @@
             </div>
           </template>
 
-          <div class="row items-center no-wrap">
+          <div class="row items-center no-wrap justify-between">
             <q-btn
               class="flex-shrink-0"
               color="primary"
@@ -218,6 +233,23 @@
               :label="$t('provider.dashboard.today_services.help')"
               @click.stop="openHelp"
             />
+
+            <span
+              v-if="cardState(item) === 'awaiting_code' || cardState(item) === 'in_progress'"
+              class="row items-center no-wrap text-grey-6 text-right text-no-wrap font9 fontmedium"
+            >
+              <q-icon
+                class="q-mr-xs flex-shrink-0 gradient-diarista"
+                name="mdi-clock-outline"
+                size="13px"
+              />
+
+              {{ $t('provider.dashboard.today_services.end_time_label') }}
+
+              <span class="gradient-diarista q-ml-xs">
+                {{ calculateTimeUntilEnd(item.end_time) }}
+              </span>
+            </span>
           </div>
           </q-card-section>
         </q-card>
@@ -362,6 +394,14 @@ const calculateTimeUntilEnd = (endTimeStr) => {
 }
 
 const cardState = (item) => {
+  const [sh, sm] = (item.start_time || '00:00').slice(0, 5).split(':').map(Number)
+
+  const startTime = new Date()
+
+  startTime.setHours(sh, sm, 0, 0)
+
+  if (now.value < startTime.getTime()) return 'not_started'
+
   const verified = item.code_verified || !!localVerified[item.id]
 
   if (!verified) return 'awaiting_code'
@@ -394,6 +434,7 @@ const copyAddress = (address) => {
 const progressByState = (item) => {
   const state = cardState(item)
 
+  if (state === 'not_started') return 0
   if (state === 'awaiting_code') return 50
   if (state === 'finished') return 100
 
@@ -421,6 +462,26 @@ const openHelp = () => {
 </script>
 
 <style scoped lang="scss">
+.attention-badge {
+  background: #22c55e;
+  color: white;
+  font-size: 11px;
+  font-weight: 700;
+  border-radius: 999px;
+  padding: 1px 8px;
+  white-space: nowrap;
+}
+
+.clock-badge {
+  width: 26px;
+  height: 26px;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #8B5CF6, #EC4899);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
 .scroll-wrapper { overflow: hidden; }
 .scroll-track {
   display: flex;

+ 297 - 57
src/components/login/LoginStep4Panel.vue

@@ -58,7 +58,7 @@
           :ref="fieldRefs.document_front"
           class="photo-circle"
           :class="docDone ? 'photo-circle--done' : 'photo-circle--pending'"
-          @click="takeDocFront"
+          @click="openDocSubStep"
         >
           <q-icon
             color="white"
@@ -74,6 +74,14 @@
           {{ serverErrors.document_front }}
         </div>
 
+        <div
+          v-if="serverErrors.document_back"
+          :ref="fieldRefs.document_back"
+          class="text-negative font12 text-center"
+        >
+          {{ serverErrors.document_back }}
+        </div>
+
         <template v-if="!docDone">
           <q-btn
             color="primary-button"
@@ -81,8 +89,7 @@
             padding="10px 28px"
             rounded
             :label="t('provider.login.steps.step_4.btn_document')"
-            :loading="loadingDoc"
-            @click="takeDocFront"
+            @click="openDocSubStep"
           />
 
           <div class="font12 text-center text-text">
@@ -161,12 +168,167 @@
               : t('provider.login.steps.step_4.btn_continue')"
             @click="closeResult"
           />
+        </div>
+      </div>
+    </Teleport>
+
+    <Teleport to="body">
+      <div
+        v-if="showDocSubStep"
+        class="fs-overlay doc-substep"
+      >
+        <div class="doc-substep__header">
+          <q-btn
+            flat
+            icon="arrow_back"
+            round
+            @click="closeDocSubStep"
+          />
+        </div>
+
+        <div class="doc-substep__body column items-center q-gutter-y-lg">
+          <div class="doc-slot column items-center q-gutter-y-sm">
+            <div class="text-primary">
+              {{ t('provider.login.steps.step_4.document_front') }}
+            </div>
+
+            <div class="font12 text-center text-text">
+              {{ t('provider.login.steps.step_4.document_front_desc') }}
+            </div>
+
+            <div
+              class="doc-slot__preview"
+              :class="docFrontDraft ? 'doc-slot__preview--done' : 'doc-slot__preview--pending'"
+            >
+              <q-img
+                v-if="docFrontDraft"
+                class="doc-slot__thumb"
+                :src="docFrontPreviewUrl"
+              />
+
+              <q-icon
+                v-else
+                color="white"
+                name="mdi-card-account-details-outline"
+                size="48px"
+              />
+            </div>
+
+            <div
+              v-if="docFrontDraft"
+              class="items-center q-gutter-x-xs row"
+            >
+              <q-icon
+                color="positive"
+                name="check_circle"
+                size="16px"
+              />
+
+              <span class="font12 text-primary">
+                {{ t('provider.login.steps.step_4.photo_captured') }}
+              </span>
+            </div>
+
+            <div
+              v-if="docFrontError"
+              class="text-negative font12 text-center"
+            >
+              {{ t('provider.login.steps.step_4.error_message') }}
+            </div>
+
+            <q-btn
+              color="primary-button"
+              no-caps
+              padding="8px 22px"
+              rounded
+              :label="docFrontDraft
+                ? t('provider.login.steps.step_4.btn_retake')
+                : t('provider.login.steps.step_4.btn_capture_front')"
+              :loading="loadingDocFront"
+              @click="captureDocFront"
+            />
+          </div>
+
+          <div class="doc-slot column items-center q-gutter-y-sm">
+            <div class="text-primary">
+              {{ t('provider.login.steps.step_4.document_back') }}
+            </div>
+
+            <div class="font12 text-center text-text">
+              {{ t('provider.login.steps.step_4.document_back_desc') }}
+            </div>
+
+            <div
+              class="doc-slot__preview"
+              :class="docBackDraft ? 'doc-slot__preview--done' : 'doc-slot__preview--pending'"
+            >
+              <q-img
+                v-if="docBackDraft"
+                class="doc-slot__thumb"
+                :src="docBackPreviewUrl"
+              />
+
+              <q-icon
+                v-else
+                color="white"
+                name="mdi-card-account-details-outline"
+                size="48px"
+              />
+            </div>
+
+            <div
+              v-if="docBackDraft"
+              class="items-center q-gutter-x-xs row"
+            >
+              <q-icon
+                color="positive"
+                name="check_circle"
+                size="16px"
+              />
+
+              <span class="font12 text-primary">
+                {{ t('provider.login.steps.step_4.photo_captured') }}
+              </span>
+            </div>
+
+            <div
+              v-if="docBackError"
+              class="text-negative font12 text-center"
+            >
+              {{ t('provider.login.steps.step_4.error_message') }}
+            </div>
+
+            <q-btn
+              color="primary-button"
+              no-caps
+              padding="8px 22px"
+              rounded
+              :label="docBackDraft
+                ? t('provider.login.steps.step_4.btn_retake')
+                : t('provider.login.steps.step_4.btn_capture_back')"
+              :loading="loadingDocBack"
+              @click="captureDocBack"
+            />
+          </div>
+        </div>
+
+        <div class="doc-substep__footer">
+          <q-btn
+            v-if="docSubStepDone"
+            color="primary-button"
+            no-caps
+            padding="14px 48px"
+            rounded
+            style="min-width: 200px"
+            :label="t('provider.login.steps.step_4.btn_continue')"
+            @click="confirmDocCapture"
+          />
 
           <div
-            v-if="!resultError && showDocHint"
-            class="font12 q-mt-sm text-center text-grey-6"
+            v-else
+            class="font12 text-center text-grey-6"
           >
-            {{ t('provider.login.steps.step_4.document_hint_result') }}
+            {{ t('provider.login.steps.step_4.upload_all_photos') }}
           </div>
         </div>
       </div>
@@ -181,7 +343,9 @@ import {
   CameraResultType,
   CameraSource,
 } from '@capacitor/camera';
-import { computed, inject, ref } from 'vue';
+import {
+  computed, inject, onBeforeUnmount, ref,
+} from 'vue';
 import { useI18n } from 'vue-i18n';
 
 import LogoDiaria from 'src/assets/logo_diaria_campos_login.svg';
@@ -200,28 +364,39 @@ const serverErrors = inject("serverErrors", ref({}));
 const fieldRefs = {
   selfie: ref(null),
   document_front: ref(null),
+  document_back: ref(null),
 };
 
 defineExpose({
-  getFieldRef: (field) => field === 'document_back'
-    ? fieldRefs.document_front
-    : fieldRefs[field],
+  getFieldRef: (field) => fieldRefs[field] ?? null,
 });
 
-const loadingDoc = ref(false);
 const loadingSelfie = ref(false);
 const resultError = ref(false);
 const resultIcon = ref('photo_camera');
 const resultMessage = ref('');
-const showDocHint = ref(false);
 const showResult = ref(false);
-const tempDocFrontFile = ref(null);
+
+const showDocSubStep = ref(false);
+const docFrontDraft = ref(null);
+const docBackDraft = ref(null);
+const docFrontPreviewUrl = ref(null);
+const docBackPreviewUrl = ref(null);
+const loadingDocFront = ref(false);
+const loadingDocBack = ref(false);
+const docFrontError = ref(false);
+const docBackError = ref(false);
 
 const docDone = computed(() => (
   !!form.value.document_back
   && !!form.value.document_front
 ));
 
+const docSubStepDone = computed(() => (
+  !!docFrontDraft.value
+  && !!docBackDraft.value
+));
+
 const selfieDone = computed(() => !!form.value.selfie);
 
 const base64ToFile = (base64, filename, mimeType = 'image/jpeg') => {
@@ -267,20 +442,33 @@ const openCamera = async (direction) => {
   );
 };
 
-const takeDocBack = async () => {
+const setDraftFile = (side, file) => {
+  const draftRef = side === 'front' ? docFrontDraft : docBackDraft;
+  const previewRef = side === 'front' ? docFrontPreviewUrl : docBackPreviewUrl;
+
+  if (previewRef.value) {
+    URL.revokeObjectURL(previewRef.value);
+  }
+
+  draftRef.value = file;
+  previewRef.value = file ? URL.createObjectURL(file) : null;
+};
+
+const captureDocSide = async (side) => {
+  const loadingRef = side === 'front' ? loadingDocFront : loadingDocBack;
+  const errorRef = side === 'front' ? docFrontError : docBackError;
+
+  if (loadingRef.value) {
+    return;
+  }
+
+  loadingRef.value = true;
+  errorRef.value = false;
+
   try {
     const file = await openCamera('rear');
 
-    form.value.document_back = file;
-    form.value.document_front = tempDocFrontFile.value;
-    resultError.value = false;
-    resultIcon.value = 'mdi-card-account-details-outline';
-    resultMessage.value = t('provider.login.steps.step_4.document_success');
-    showDocHint.value = true;
-    showResult.value = true;
-    tempDocFrontFile.value = null;
-
-    emit('update:show-sub-step', true);
+    setDraftFile(side, file);
   } catch (err) {
     const msg = (err?.message || '').toLowerCase();
 
@@ -289,51 +477,60 @@ const takeDocBack = async () => {
       && !msg.includes('dismiss')
       && !msg.includes('no image')
     ) {
-      resultError.value = true;
-      resultIcon.value = 'mdi-card-account-details-outline';
-      showDocHint.value = false;
-      showResult.value = true;
-
-      emit('update:show-sub-step', true);
+      errorRef.value = true;
     }
-
-    tempDocFrontFile.value = null;
+  } finally {
+    loadingRef.value = false;
   }
 };
 
-const takeDocFront = async () => {
-  if (loadingDoc.value) {
-    return;
+const captureDocFront = () => captureDocSide('front');
+const captureDocBack = () => captureDocSide('back');
+
+const openDocSubStep = () => {
+  if (!docFrontDraft.value && form.value.document_front) {
+    setDraftFile('front', form.value.document_front);
   }
 
-  loadingDoc.value = true;
+  if (!docBackDraft.value && form.value.document_back) {
+    setDraftFile('back', form.value.document_back);
+  }
 
-  try {
-    const file = await openCamera('rear');
+  docFrontError.value = false;
+  docBackError.value = false;
+  showDocSubStep.value = true;
 
-    tempDocFrontFile.value = file;
+  emit('update:show-sub-step', true);
+};
 
-    await takeDocBack();
-  } catch (err) {
-    const msg = (err?.message || '').toLowerCase();
+const closeDocSubStep = () => {
+  showDocSubStep.value = false;
 
-    if (
-      !msg.includes('cancel')
-      && !msg.includes('dismiss')
-      && !msg.includes('no image')
-    ) {
-      resultError.value = true;
-      resultIcon.value = 'mdi-card-account-details-outline';
-      showDocHint.value = false;
-      showResult.value = true;
+  emit('update:show-sub-step', false);
+};
 
-      emit('update:show-sub-step', true);
-    }
-  } finally {
-    loadingDoc.value = false;
+const confirmDocCapture = () => {
+  if (!docSubStepDone.value) {
+    return;
   }
+
+  form.value.document_front = docFrontDraft.value;
+  form.value.document_back = docBackDraft.value;
+  showDocSubStep.value = false;
+
+  emit('update:show-sub-step', false);
 };
 
+onBeforeUnmount(() => {
+  if (docFrontPreviewUrl.value) {
+    URL.revokeObjectURL(docFrontPreviewUrl.value);
+  }
+
+  if (docBackPreviewUrl.value) {
+    URL.revokeObjectURL(docBackPreviewUrl.value);
+  }
+});
+
 const takeSelfie = async () => {
   if (loadingSelfie.value) {
     return;
@@ -348,7 +545,6 @@ const takeSelfie = async () => {
     resultError.value = false;
     resultIcon.value = 'photo_camera';
     resultMessage.value = t('provider.login.steps.step_4.selfie_success');
-    showDocHint.value = false;
     showResult.value = true;
 
     emit('update:show-sub-step', true);
@@ -362,7 +558,6 @@ const takeSelfie = async () => {
     ) {
       resultError.value = true;
       resultIcon.value = 'photo_camera';
-      showDocHint.value = false;
       showResult.value = true;
 
       emit('update:show-sub-step', true);
@@ -430,4 +625,49 @@ const takeSelfie = async () => {
     background-color: rgba(139, 92, 246, 0.18);
   }
 }
+
+.doc-substep {
+  display: flex;
+  flex-direction: column;
+
+  &__header {
+    padding: 12px;
+  }
+
+  &__body {
+    flex: 1;
+    padding: 0 24px;
+    overflow-y: auto;
+  }
+
+  &__footer {
+    padding: 24px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+  }
+}
+
+.doc-slot__preview {
+  width: 160px;
+  height: 110px;
+  border-radius: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+
+  &--pending {
+    background-color: rgba(139, 92, 246, 0.18);
+  }
+
+  &--done {
+    background: linear-gradient(-90deg, #ec48d1 5%, #6b11cb 65%, #2574fc 100%);
+  }
+}
+
+.doc-slot__thumb {
+  width: 100%;
+  height: 100%;
+}
 </style>

+ 24 - 0
src/helpers/scheduleDate.js

@@ -0,0 +1,24 @@
+const parseLocalDate = (dateStr) => {
+  if (!dateStr) return null;
+
+  const iso = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
+  if (iso) return new Date(+iso[1], +iso[2] - 1, +iso[3]);
+  const dmy = dateStr.match(/^(\d{2})\/(\d{2})\/(\d{4})/);
+  if (dmy) return new Date(+dmy[3], +dmy[2] - 1, +dmy[1]);
+  return null;
+};
+
+const formatWeekday = (dateStr) => {
+  const d = parseLocalDate(dateStr);
+  if (!d) return "";
+  const w = d.toLocaleDateString("pt-BR", { weekday: "long" });
+  return w.charAt(0).toUpperCase() + w.slice(1);
+};
+
+const formatDayMonth = (dateStr) => {
+  const d = parseLocalDate(dateStr);
+  if (!d) return "";
+  return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit" });
+};
+
+export { parseLocalDate, formatWeekday, formatDayMonth };

+ 10 - 3
src/i18n/locales/en.json

@@ -285,15 +285,18 @@
         "my_price_label_2": "my daily rate:"
       },
       "today_services": {
+        "title": "Today",
+        "attention_badge": "attention, daily service!",
         "finished_label": "Service with",
         "finished_suffix": "completed!",
-        "start_label": "In Progress",
+        "start_label": "Service start with",
+        "not_started_label": "Starting soon for",
         "code_hint": "Enter the client's code",
-        "in_progress": "In progress",
+        "in_progress": "Service in progress!",
         "reviewed_badge": "Reviewed",
         "rate_btn": "Rate",
         "help": "help",
-        "end_time_label": "Ends at:",
+        "end_time_label": "Ends in:",
         "code_error": "Invalid code. Please try again."
       },
       "solicitations": {
@@ -531,6 +534,10 @@
     "placeholder_phone": "Enter your phone",
     "notifications": "Notifications",
     "push_notifications": "Receive notifications",
+    "language": "Language",
+    "lang_pt": "PT-br",
+    "lang_en": "EN-us",
+    "lang_es": "ES-es",
     "bank_data": {
       "title": "Bank data",
       "description": "Accounts to receive your payments",

+ 10 - 3
src/i18n/locales/es.json

@@ -285,15 +285,18 @@
         "my_price_label_2": "mi jornada:"
       },
       "today_services": {
+        "title": "Hoy",
+        "attention_badge": "¡atención, diaria!",
         "finished_label": "Servicio con",
         "finished_suffix": "¡completado!",
-        "start_label": "En curso",
+        "start_label": "Inicio del servicio con",
+        "not_started_label": "Comienza pronto para",
         "code_hint": "Ingrese el código del cliente",
-        "in_progress": "En progreso",
+        "in_progress": "¡Servicio en curso!",
         "reviewed_badge": "Evaluado",
         "rate_btn": "Evaluar",
         "help": "ayuda",
-        "end_time_label": "Termina a las:",
+        "end_time_label": "Termina en:",
         "code_error": "Código inválido. Inténtelo de nuevo."
       },
       "solicitations": {
@@ -531,6 +534,10 @@
     "placeholder_phone": "Escriba su teléfono",
     "notifications": "Notificaciones",
     "push_notifications": "Recibir notificaciones",
+    "language": "Idioma",
+    "lang_pt": "PT-br",
+    "lang_en": "EN-us",
+    "lang_es": "ES-es",
     "bank_data": {
       "title": "Datos bancarios",
       "description": "Cuentas para recibir tus pagos",

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

@@ -285,15 +285,18 @@
         "my_price_label_2": "minha diária:"
       },
       "today_services": {
+        "title": "Hoje",
+        "attention_badge": "atenção, diária!",
         "finished_label": "Serviço com",
         "finished_suffix": "concluído!",
-        "start_label": "Em andamento",
+        "start_label": "Início do serviço com",
+        "not_started_label": "Início em breve para",
         "code_hint": "Informe o código do cliente",
-        "in_progress": "Em andamento",
+        "in_progress": "Serviço em andamento!",
         "reviewed_badge": "Avaliado",
         "rate_btn": "Avaliar",
         "help": "ajuda",
-        "end_time_label": "Término:",
+        "end_time_label": "Termina em:",
         "code_error": "Código inválido. Tente novamente."
       },
       "solicitations": {
@@ -534,6 +537,10 @@
     "placeholder_phone": "Digite seu telefone",
     "notifications": "Notificações",
     "push_notifications": "Receber notificações",
+    "language": "Idioma",
+    "lang_pt": "PT-br",
+    "lang_en": "EN-us",
+    "lang_es": "ES-es",
     "bank_data": {
       "title": "Dados bancários",
       "description": "Contas para receber seus pagamentos",

+ 20 - 22
src/pages/opportunities/components/OpportunityDetailsDialog.vue

@@ -204,26 +204,28 @@
         />
       </div>
 
-      <q-btn
-        class="refuse-btn"
-        flat
-        label="Recusar oportunidade"
-        no-caps
-        rounded
-        @click="openRefuseDialog"
-      />
-
-      <!-- ALERTA -->
-      <div class="alert-box font12">
-        <q-icon
-          class="alert-icon"
-          name="warning"
-          size="18px"
+      <div class="q-px-md">
+        <q-btn
+          class="refuse-btn font11 fontsemibold full-width"
+          flat
+          label="Recusar oportunidade"
+          no-caps
+          rounded
+          @click="openRefuseDialog"
         />
 
-        <span class="alert-text">
-          {{ $t('provider.dashboard.opportunity_details.alert_text') }}
-        </span>
+        <!-- ALERTA -->
+        <div class="alert-box font12">
+          <q-icon
+            class="alert-icon"
+            name="warning"
+            size="18px"
+          />
+
+          <span class="alert-text">
+            {{ $t('provider.dashboard.opportunity_details.alert_text') }}
+          </span>
+        </div>
       </div>
     </div>
   </q-dialog>
@@ -586,12 +588,8 @@ onMounted(async () => {
 
 .refuse-btn {
   width: 100%;
-  height: 52px;
   margin-top: 12px;
   color: #ff4d67;
-  font-size: 15px;
-  font-weight: 600;
-  background: white;
   border: 1px solid #ff4d67;
   border-radius: 28px;
 }

+ 62 - 1
src/pages/profile/ProfileEditDialog.vue

@@ -135,6 +135,44 @@
               />
             </div>
 
+            <div>
+              <div class="font12 fontbold q-mb-sm text-grey-8">
+                {{ $t("profile.language") }}
+              </div>
+
+              <div class="row">
+                <q-radio
+                  v-model="selectedLocale"
+                  class="text-text col-4 font10 fontmedium"
+                  color="primary"
+                  keep-color
+                  val="pt"
+                  :label="$t('profile.lang_pt')"
+                  @update:model-value="onLocaleChange"
+                />
+
+                <q-radio
+                  v-model="selectedLocale"
+                  class="text-text col-4 font10 fontmedium"
+                  color="primary"
+                  keep-color
+                  val="en"
+                  :label="$t('profile.lang_en')"
+                  @update:model-value="onLocaleChange"
+                />
+
+                <q-radio
+                  v-model="selectedLocale"
+                  class="text-text col-4 font10 fontmedium"
+                  color="primary"
+                  keep-color
+                  val="es"
+                  :label="$t('profile.lang_es')"
+                  @update:model-value="onLocaleChange"
+                />
+              </div>
+            </div>
+
             <!-- NOVA SEÇÃO -->
             <div>
               <div class="font12 fontbold q-mb-sm text-grey-8">
@@ -184,11 +222,12 @@ import {
   CameraSource,
 } from "@capacitor/camera";
 
+import { Cookies, useDialogPluginComponent, useQuasar } from "quasar";
 import { computed, onMounted, ref } from "vue";
 import { genderOptions } from "src/helpers/genderOptions";
 import { getUser, updateUser } from "src/api/user";
+import { i18n } from "src/boot/i18n";
 import { updateProviderWithFiles } from "src/api/provider";
-import { useDialogPluginComponent, useQuasar } from "quasar";
 import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
 import { useI18n } from "vue-i18n";
 
@@ -223,6 +262,22 @@ const selfieFile     = ref(null);
 const submitting     = ref(false);
 const userId         = ref(null);
 
+const normalizeLocale = (loc) => {
+  if (!loc) return "pt";
+
+  const l = String(loc).toLowerCase();
+
+  if (l.startsWith("pt")) return "pt";
+  if (l.startsWith("en")) return "en";
+  if (l.startsWith("es")) return "es";
+
+  return "pt";
+};
+
+const selectedLocale = ref(
+  normalizeLocale(i18n.global.locale.value ?? i18n.global.locale),
+);
+
 const hasChanges = computed(
   () => hasUpdatedFields.value || !!selfieFile.value,
 );
@@ -300,6 +355,12 @@ const takeSelfie = async () => {
   }
 };
 
+const onLocaleChange = (val) => {
+  i18n.global.locale.value = val;
+
+  Cookies.set("locale", val, { expires: 365, path: "/" });
+};
+
 const submitUpdate = async () => {
   if (!hasChanges.value) return;