Kaynağa Gözat

last commit before new pr pattern | implementacao do dashboard com dados da API + perfil com as abas funcionais

Gustavo Zanatta 3 hafta önce
ebeveyn
işleme
c4b7aa4741

+ 1 - 1
quasar.config.js

@@ -102,7 +102,7 @@ export default defineConfig((ctx) => {
     // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
     devServer: {
       // https: true
-      open: true, // opens browser window automatically
+      open: false, // opens browser window automatically
     },
 
     // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework

+ 1 - 1
src-capacitor/android/.settings/org.eclipse.buildship.core.prefs

@@ -1,4 +1,4 @@
-arguments=--init-script /home/denis/.local/share/zed/extensions/work/java/jdtls/jdt-language-server-1.47.0-202505151856/configuration/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle
+arguments=--init-script /home/softpar/.config/Antigravity/User/globalStorage/redhat.java/1.53.0/config_linux/org.eclipse.osgi/58/0/.cp/gradle/init/init.gradle --init-script /home/softpar/.config/Antigravity/User/globalStorage/redhat.java/1.53.0/config_linux/org.eclipse.osgi/58/0/.cp/gradle/protobuf/init.gradle --init-script /home/softpar/.config/Antigravity/User/globalStorage/redhat.java/1.53.0/config_linux/org.eclipse.osgi/58/0/.cp/gradle/kotlin/init.gradle --init-script /home/softpar/.config/Antigravity/User/globalStorage/redhat.java/1.53.0/config_linux/org.eclipse.osgi/58/0/.cp/gradle/groovy/init.gradle
 auto.sync=false
 build.scans.enabled=false
 connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)

+ 62 - 0
src/api/address.js

@@ -0,0 +1,62 @@
+import api from "src/api";
+
+export const getAddresses = async (origin, originId) => {
+  const { data } = await api.get(`/addresses?source=${origin}&source_id=${originId}`);
+  return data.payload;
+};
+
+export const getAddress = async (id) => {
+  const { data } = await api.get(`/addresses/${id}`);
+  return data.payload;
+};
+
+export const createAddress = async (address) => {
+  const { data } = await api.post(`/addresses`, { ...address });
+  return data.payload;
+};
+
+export const updateAddress = async (address, id) => {
+  const { data } = await api.put(`/addresses/${id}`, { ...address });
+  return data.payload;
+};
+
+export const deleteAddress = async (id) => {
+  const { data } = await api.delete(`/addresses/${id}`);
+  return data.payload;
+};
+
+export const searchAddressByCEP = async (cep) => {
+  try {
+    const responseCep = await fetch(`https://viacep.com.br/ws/${cep}/json/`);
+    const data = await responseCep.json();
+
+    if (data.erro) {
+      return null;
+    }
+
+    const response = await api.get('busca-estado-cidade-por-descricao', {
+      params: {
+        stateUf: data.uf,
+        cityName: data.localidade
+      }
+    });
+    
+    return {
+      zip_code: data.cep,
+      address: data.logradouro,
+      district: data.bairro,
+      city_id: response.data.payload.city.value,
+      state_id: response.data.payload.state.value,
+      city: {
+        id: response.data.payload.city.value,
+        name: response.data.payload.city.label
+      },
+      state: {
+        id: response.data.payload.state.value,
+        name: response.data.payload.state.label
+      }
+    };
+  } catch {
+    return null;
+  }
+};

+ 11 - 0
src/api/clientFavoriteProvider.js

@@ -0,0 +1,11 @@
+import api from "src/api";
+
+export const getClientFavoriteProviders = async (clientId) => {
+  const { data } = await api.get(`/client/favorite-providers/${clientId}`);
+  return data.payload;
+}
+
+export const deleteClientFavoriteProvider = async (id) => {
+  const { data } = await api.delete(`/client/favorite-provider/${id}`);
+  return data.payload;
+}

+ 26 - 0
src/api/clientPaymentMethod.js

@@ -0,0 +1,26 @@
+import api from "src/api";
+
+export const getClientPaymentMethods = async (clientId) => {
+  const { data } = await api.get(`/client/payment-methods/${clientId}`);
+  return data.payload;
+}
+
+export const getClientPaymentMethod = async (id) => {
+  const { data } = await api.get(`/client/payment-method/${id}`);
+  return data.payload;
+}
+
+export const createClientPaymentMethod = async (info) => {
+  const { data } = await api.post(`/client/payment-method`, info);
+  return data.payload;
+}
+
+export const updateClientPaymentMethod = async (id, info) => {
+  const { data } = await api.put(`/client/payment-method/${id}`, info);
+  return data.payload;
+}
+
+export const deleteClientPaymentMethod = async (id) => {
+  const { data } = await api.delete(`/client/payment-method/${id}`);
+  return data.payload;
+}

Dosya farkı çok büyük olduğundan ihmal edildi
+ 6 - 0
src/assets/diarinho_perfil_cliente_favoritos.svg


Dosya farkı çok büyük olduğundan ihmal edildi
+ 6 - 0
src/assets/diarinho_suporte.svg


+ 29 - 7
src/components/dashboard/DashboardNextSchedules.vue

@@ -18,8 +18,8 @@
                 </q-avatar>
               </div>
               <div class="col-5 column justify-end">
-                <span class="text-pill text-primary">
-                  {{ item.schedule_type === 'custom' ? $t('dashboard_client.next_schedules.custom') : $t('dashboard_client.next_schedules.default') }}
+                <span class="text-pill text-primary customColor">
+                  {{ item.schedule_type === 'custom' ? $t('dashboard_client.next_schedules.tag_custom') : $t('dashboard_client.next_schedules.tag_default') }}
                 </span>
               </div>
             </div>
@@ -43,7 +43,7 @@
                 <div class="full-height column justify-end">
                   <div class="row text-pill-place">
                     <q-icon :name="addressIcon(item.address_type)" size="15px" color="primary" />
-                    <span class="row items-end">{{ addressLabel(item.address_type) }}</span>
+                    <span class="row items-end">{{ addressLabel(item.address?.address_type) }}</span>
                   </div>
                 </div>
               </div>
@@ -51,11 +51,12 @@
 
             <div class="col-4 column text-text">
               <div class="column col-5">
-                <span class="text-price-main col-6 q-mx-auto">
+                <span class="text-price-main col-6">
                   {{ item.total_amount && item.total_amount !== '0.00' ? formatCurrency(item.total_amount) : $t('dashboard_client.next_schedules.to_combine') }}
                 </span>
-                <span class="text-price-label col-6 q-mx-auto">
-                  {{ item.schedule_type === 'custom' ? $t('dashboard_client.next_schedules.tag_custom') : $t('dashboard_client.next_schedules.tag_default') }}
+                <span class="text-price-label col-6">
+                  <!-- {{ item.schedule_type === 'custom' ? $t('dashboard_client.next_schedules.custom') : $t('dashboard_client.next_schedules.default') }} -->
+                  {{ formatLabelByPeriodType(item.period_type) }}
                 </span>
               </div>
               <div class="col-7 column justify-end items-end">
@@ -106,10 +107,24 @@ const formatDayMonth = (iso) => {
 const addressIcon = (type) => type === 'home' ? 'mdi-home-outline' : 'mdi-office-building-outline';
 
 const addressLabel = (type) => {
-  if (type === 'home') return t('dashboard_client.next_schedules.place_home');
+  console.log(type)
+  if (type === 'home') return t('address.types.commercial.home');
   if (type === 'apartment') return t('dashboard_client.next_schedules.place_apartment');
+  if (type === 'commercial') return t('address.types.commercial');
+
   return t('dashboard_client.next_schedules.place_unknown');
 };
+
+const formatLabelByPeriodType = (type) => {
+  switch (type) {
+    case '2': return t('period_types.2');
+    case '4': return t('period_types.4');
+    case '6': return t('period_types.6');
+    case '8': return t('period_types.8');
+    default: return t('period_types.unknown');
+
+  }
+};
 </script>
 
 <style scoped lang="scss">
@@ -129,4 +144,11 @@ const addressLabel = (type) => {
   min-width: 80%;
   min-height: 90px;
 }
+
+.customColor {
+  background: linear-gradient(180deg, #8B5CF6 50%, #EC4899 50%);
+  background-clip: text;
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+}
 </style>

+ 66 - 10
src/components/dashboard/DashboardProvidersClose.vue

@@ -2,10 +2,10 @@
   <div class="q-mx-md q-mb-lg">
     <div class="row items-center justify-between no-wrap q-mb-sm">
       <div class="dashboard-section-title gradient-diarista">{{ $t('dashboard_client.providers_close.title') }}</div>
-      <div>
-        <q-icon name="mdi-chevron-left" />
-        <span>{{ $t('dashboard_client.providers_close.until_8h') }}</span>
-        <q-icon name="mdi-chevron-right" />
+      <div class="text-text-light">
+        <q-btn flat color="text-light" icon="mdi-chevron-left" @click="setPeriodTypePrevious"/>
+        <span>{{ showCorrectLabels() }}</span>
+        <q-btn flat color="text-light" icon="mdi-chevron-right" @click="setPeriodTypeNext"/>
       </div>
     </div>
 
@@ -27,20 +27,20 @@
             <div class="col-10 row">
               <div class="column col-9 justify-between">
                 <span class="text-provider-close-name">{{ p.provider_name ?? 'Prestador' }}</span>
-                <span class="text-provider-close-region">{{ p.address_type === 'home' ? $t('dashboard_client.providers_close.place_home') : '' }}</span>
+                <span class="text-provider-close-region">{{ p.district }}</span>
                 <div class="row items-center justify-between q-pr-lg">
                   <div class="row items-center">
-                    <q-icon name="mdi-star" color="warning" size="10px" />
+                    <q-icon name="mdi-star" color="warning" size="16px" />
                     <span class="text-provider-close-rating">
                       {{ (p.average_rating ?? '-') + ' (' + (p.total_reviews ?? 0) + ')' }}
                     </span>
                   </div>
                   <div class="row items-center">
-                    <q-icon name="mdi-broom" color="secondary" size="12px" />
+                    <q-icon name="mdi-broom" color="secondary" size="16px" />
                     <span class="text-provider-close-jobs">{{ p.total_services ?? 0 }}</span>
                   </div>
                   <div class="row items-center">
-                    <q-icon name="mdi-map-marker-outline" color="text" size="12px" />
+                    <q-icon name="mdi-map-marker-outline" color="text" size="16px" />
                     <span class="text-provider-close-jobs">{{ 0 + ' km' }}</span>
                   </div>
                 </div>
@@ -48,7 +48,8 @@
 
               <div class="column col-3 justify-between text-center items-center">
                 <span class="text-provider-close-price">
-                  {{ p.daily_price_8h ? formatCurrency(p.daily_price_8h) : noPrice }}</span>
+                  {{ showCorrectValues(p) }}
+                </span>
                 <div class="full-width">
                   <q-btn
                     unelevated rounded no-caps
@@ -69,10 +70,65 @@
 
 <script setup>
 import { formatCurrency } from 'src/helpers/utils';
+import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
 
 defineProps({ data: { type: Array, default: () => [] } });
 
-const noPrice = '\u2014';
+const { t } = useI18n();
+
+const currentPeriodType = ref(8);
+const periodTypeMap = ref({
+  2: 'daily_price_2h',
+  4: 'daily_price_4h',
+  6: 'daily_price_6h',
+  8: 'daily_price_8h',
+});
+
+
+const showCorrectValues = (p) => {
+  switch (currentPeriodType.value) {
+    case 8:
+      return p.daily_price_8h ? formatCurrency(p.daily_price_8h) : '-';
+    case 6:
+      return p.daily_price_6h ? formatCurrency(p.daily_price_6h) : '-';
+    case 4:
+      return p.daily_price_4h ? formatCurrency(p.daily_price_4h) : '-';
+    case 2:
+      return p.daily_price_2h ? formatCurrency(p.daily_price_2h) : '-';
+    default:
+      return '-';
+  }
+};
+
+const showCorrectLabels = () => {
+  switch (currentPeriodType.value) {
+    case 8:
+      return t('dashboard_client.providers_close.until_8h');
+    case 6:
+      return t('dashboard_client.providers_close.until_6h');
+    case 4:
+      return t('dashboard_client.providers_close.until_4h');
+    case 2:
+      return t('dashboard_client.providers_close.until_2h');
+    default:
+      return '';
+  }
+};
+
+const setPeriodTypePrevious = () => {
+  const previousPeriod = currentPeriodType.value - 2;
+  if (periodTypeMap.value[previousPeriod]) {
+    currentPeriodType.value = previousPeriod;
+  }
+};
+
+const setPeriodTypeNext = () => {
+  const nextPeriod = currentPeriodType.value + 2;
+  if (periodTypeMap.value[nextPeriod]) {
+    currentPeriodType.value = nextPeriod;
+  }
+};
 
 const avatarColors = [
   { background: '#ffd5df', color: '#932e57' },

+ 4 - 2
src/components/dashboard/DashboardSummaryInfos.vue

@@ -21,8 +21,8 @@
       </div>
 
       <div class="row items-center justify-between no-wrap q-mt-xs">
-        <div class="summary-address text-address text-grey-6 ellipsis col">
-          {{ data?.address ?? '' }}
+        <div class="summary-address text-caption text-text ellipsis col">
+          {{ formatAddress(data.address) }}
         </div>
         <q-icon name="mdi-chevron-down" color="secondary" size="18px" class="col-auto" />
       </div>
@@ -31,12 +31,14 @@
 </template>
 
 <script setup>
+import { formatAddress } from 'src/helpers/utils';
 defineProps({ data: { type: Object, default: () => null } });
 
 const avatarStyle = {
   background: 'linear-gradient(135deg, #ffd7e8 0%, #ff9acc 100%)',
   color: '#7a154f',
 };
+
 </script>
 
 <style scoped lang="scss">

+ 224 - 0
src/components/profile/ProfileAddressDialog.vue

@@ -0,0 +1,224 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-left" transition-hide="slide-right">
+    <div class="bg-page full-height no-shadow">
+      <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-profile bg-surface">
+        <q-btn v-close-popup icon="mdi-chevron-left" flat round dense color="primary" />
+        <q-space />
+        <span class="text-subtitle1 text-weight-bold text-primary">{{ $t('profile.address.title') }}</span>
+        <q-space />
+        <div style="width: 32px"></div>
+      </div>
+
+      <div class="col column q-px-md q-pt-lg overflow-auto">
+        <span class="text-h6 text-weight-bold gradient-diarista q-mb-xs">{{ $t('profile.address.my_addresses') }}</span>
+        <span class="text-grey-7 q-mb-lg" style="font-size: 14px; line-height: 1.3;">{{ $t('profile.address.manage_addresses') }}</span>
+
+        <div class="address-card-container q-pa-lg">
+          <div v-if="addresses.length > 0" class="column q-gutter-y-lg">
+            <div v-for="address in addresses" :key="address.id" class="address-item-wrapper column">
+              <span class="text-weight-bold text-dark q-mb-xs" style="font-size: 16px;">
+                {{ address.nickname || addressTypeLabel(address.address_type) }}
+              </span>
+
+              <div class="card-item-box row items-center no-wrap q-pa-md" :class="!address.is_primary ? 'text-grey-6' : 'text-dark'" style="border: 1px solid #b5b5b5; border-radius: 4px;">
+                
+                <div class="col column">
+                  <span class="text-weight-medium" style="font-size: 14px; line-height: 1.3;">
+                    {{ address.address + ', ' + address.number }}
+                  </span>
+                  <span style="font-size: 14px; line-height: 1.3;" class="q-mt-xs">
+                    {{  address.district ? address.district + ' - ' : '' }}{{ address.city?.name || '' }}{{ address.state?.name ? '/' + address.state?.name : '' }}
+                  </span>
+                  <span v-if="address.complement" style="font-size: 14px; line-height: 1.3;">
+                    {{ address.complement }}
+                  </span>
+                </div>
+
+                <div class="row items-center q-gutter-x-sm q-pl-sm">
+                  <q-icon v-if="address.is_primary" name="mdi-check" color="positive" size="24px" />
+                  <q-btn v-else flat round dense icon="mdi-trash-can-outline" color="dark" size="14px" @click="deleteAddress(address)" />
+
+                  <q-btn
+                    flat
+                    round
+                    dense
+                    icon="mdi-dots-vertical"
+                    color="dark"
+                    size="sm"
+                  >
+                    <q-menu anchor="bottom right" self="top right" class="bg-surface shadow-card card-border">
+                      <q-list style="min-width: 200px">
+                        <q-item v-close-popup clickable @click="editAddress(address)">
+                          <q-item-section avatar>
+                            <q-icon name="mdi-pencil" color="primary" />
+                          </q-item-section>
+                          <q-item-section>
+                            <q-item-label class="text-text">{{ $t('profile.address.edit') }}</q-item-label>
+                          </q-item-section>
+                        </q-item>
+
+                        <q-item v-if="!address.is_primary" v-close-popup clickable @click="markAsPrimary(address)">
+                          <q-item-section avatar>
+                            <q-icon name="mdi-star" color="primary" />
+                          </q-item-section>
+                          <q-item-section>
+                            <q-item-label class="text-text">{{ $t('profile.address.mark_as_primary') }}</q-item-label>
+                          </q-item-section>
+                        </q-item>
+
+                        <q-separator />
+                      </q-list>
+                    </q-menu>
+                  </q-btn>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div v-else class="text-center q-py-lg">
+            <q-icon name="mdi-home-outline" size="48px" color="grey-5" class="q-mb-md block" />
+            <div class="text-grey-6">{{ $t('profile.address.no_addresses') }}</div>
+          </div>
+
+          <div class="q-mt-xl">
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="primary"
+              class="full-width save-btn text-weight-bold"
+              padding="14px 16px"
+              style="font-size: 16px;"
+              :label="$t('profile.address.add_address')"
+              @click="openAddAddressForm"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
+import { getAddresses, deleteAddress as deleteAddressAPI, updateAddress } from 'src/api/address';
+import { userStore } from 'src/stores/user';
+import { useI18n } from 'vue-i18n';
+import ProfileAddressFormDialog from './ProfileAddressFormDialog.vue';
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+const user = userStore();
+
+const addresses = ref([]);
+const loading = ref(false);
+const clientId = ref(user.user.client.id);
+
+const addressTypeOptions = {
+  home: 'profile.address.type.home',
+  commercial: 'profile.address.type.commercial',
+  other: 'profile.address.type.other',
+};
+
+const addressTypeLabel = (type) => t(addressTypeOptions[type] || 'profile.address.type.home');
+
+const loadAddresses = async () => {
+  loading.value = true;
+  try {
+    const data = await getAddresses('client', clientId.value);
+    addresses.value = data || [];
+  } catch (error) {
+    console.error('Erro ao carregar endereços:', error);
+    $q.notify({ type: 'negative', message: t('profile.address.error_loading') });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const openAddAddressForm = () => {
+  $q.dialog({
+    component: ProfileAddressFormDialog,
+    componentProps: {
+      isEditing: false,
+    }
+  }).onOk(() => {
+    loadAddresses();
+  });
+};
+
+const editAddress = (address) => {
+  $q.dialog({
+    component: ProfileAddressFormDialog,
+    componentProps: {
+      isEditing: true,
+      addressData: address,
+    }
+  }).onOk(() => {
+    loadAddresses();
+  });
+};
+
+const markAsPrimary = async (address) => {
+  loading.value = true;
+  try {
+    await updateAddress({ is_primary: true }, address.id);
+    await loadAddresses();
+  } catch (error) {
+    console.error('Erro ao marcar como principal:', error);
+    $q.notify({ type: 'negative', message: t('profile.address.error_marking_primary') });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const deleteAddress = async (address) => {
+  $q.dialog({
+    title: t('common.confirm'),
+    message: t('profile.address.confirm_delete'),
+    persistent: true,
+    class: 'bg-surface text-text',
+    ok: {
+      label: t('common.actions.delete'),
+      color: 'primary'
+    },
+    cancel: {
+      label: t('common.actions.cancel'),
+      color: 'secondary'
+    }
+  }).onOk(async () => {
+    loading.value = true;
+    try {
+      await deleteAddressAPI(address.id);
+      await loadAddresses();
+    } catch (error) {
+      console.error('Erro ao deletar endereço:', error);
+      $q.notify({ type: 'negative', message: t('profile.address.error_deleting') });
+    } finally {
+      loading.value = false;
+    }
+  });
+};
+
+onMounted(() => {
+  loadAddresses();
+});
+</script>
+
+<style scoped lang="scss">
+.address-card-container {
+  background: white;
+  border-radius: 30px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
+  margin-bottom: 40px;
+}
+.shadow-profile {
+  box-shadow: 0px 1px 8px rgba(0, 0, 0, 0.1);
+}
+.card-item-box {
+  background: white;
+}
+</style>

+ 240 - 0
src/components/profile/ProfileAddressFormDialog.vue

@@ -0,0 +1,240 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-left" transition-hide="slide-right">
+    <div class="bg-page full-height no-shadow">
+      <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-profile bg-surface">
+        <q-btn v-close-popup icon="mdi-chevron-left" flat round dense color="primary" />
+        <q-space />
+        <span class="text-subtitle1 text-weight-bold text-primary">
+          {{ isEditing ? $t('profile.address.edit_address') : $t('profile.address.add_new_address') }}
+        </span>
+        <q-space />
+        <div style="width: 32px"></div>
+      </div>
+
+      <q-card-section class="col">
+        <div class="q-px-md q-pt-lg text-text">
+          <q-card class="q-pa-lg bg-white shadow-card" style="border-radius: 25px;" :flat="false">
+            <div class="text-weight-bold text-text q-mb-xs">{{ $t('profile.address.cep') }}</div>
+            <q-input
+              v-model="form.zip_code"
+              input-class="text-text"
+              outlined
+              dense
+              mask="#####-###"
+              unmasked-value
+              placeholder="00000-000"
+              class="q-mb-md"
+              @update:model-value="onCepChange"
+            >
+              <template #append>
+                <q-spinner v-if="loadingCep" size="xs" color="primary" />
+              </template>
+            </q-input>
+
+
+            <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.address_label') }}</div>
+            <q-input
+              v-model="form.address"
+              outlined
+              dense
+              class="q-mb-md"
+              input-class="text-text"
+              :placeholder="$t('profile.address.address_placeholder')"
+            />
+
+            <div class="row q-col-gutter-sm q-mb-md">
+              <div class="col-4">
+                <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.number') }}</div>
+                <q-input v-model="form.number" outlined dense input-class="text-text" placeholder="0000" />
+              </div>
+              <div class="col-8">
+                <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.complement') }}</div>
+                <q-input
+                  v-model="form.complement"
+                  outlined
+                  dense
+                  input-class="text-text"
+                  :placeholder="$t('profile.address.complement_placeholder')"
+                />
+              </div>
+            </div>
+
+            <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.district_label') }}</div>
+            <q-input v-model="form.district" outlined dense class="q-mb-md" input-class="text-text" />
+
+            <div class="row q-col-gutter-sm q-mb-lg">
+              <div class="col-8">
+                <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.city_label') }}</div>
+                <q-input :model-value="form.city?.name" readonly outlined dense input-class="text-text" />
+              </div>
+              <div class="col-4">
+                <div class="text-weight-bold q-mb-xs text-text">{{ $t('profile.address.state_label') }}</div>
+                <q-input :model-value="form.state?.name" readonly outlined dense input-class="text-text" />
+              </div>
+            </div>
+
+            <div class="q-mb-lg">
+              <div class="row q-gutter-sm">
+                <q-chip
+                  v-for="type in addressTypes"
+                  :key="type.value"
+                  :selected="form.address_type === type.value"
+                  clickable
+                  color="primary"
+                  :outline="form.address_type !== type.value"
+                  text-color="surface"
+                  :icon="type.icon"
+                  :icon-selected="type.icon"
+                  @click="form.address_type = type.value"
+                >
+                  {{ $t(type.label) }}
+                </q-chip>
+              </div>
+            </div>
+
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              color="primary"
+              class="full-width q-py-md text-weight-bold"
+              padding="8px 16px"
+              style="font-size: 1.1rem;"
+              :label="$t('common.actions.save')"
+              :loading="saving"
+              :disable="!hasUpdatedFields"
+              @click="save"
+            />
+          </q-card>
+        </div>
+        <div class="q-pb-xl"></div>
+      </q-card-section>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
+import { searchAddressByCEP, updateAddress, createAddress } from 'src/api/address';
+import { userStore } from 'src/stores/user';
+import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  isEditing: {
+    type: Boolean,
+    default: false,
+  },
+  addressData: {
+    type: Object,
+    default: null,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogOK } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+const user = userStore();
+const clientId = user.user.client.id;
+
+const initialFormData = {
+  zip_code: '',
+  address: '',
+  number: '',
+  complement: '',
+  district: '',
+  city_id: null,
+  state_id: null,
+  city: null,
+  state: null,
+  source: 'client',
+  source_id: clientId,
+  address_type: 'home',
+};
+
+const { form, hasUpdatedFields, getUpdatedFields, setUpdateFormAsOriginal } =
+  useFormUpdateTracker(initialFormData);
+
+const loadingCep = ref(false);
+const saving = ref(false);
+const addressId = ref(null);
+
+const addressTypes = [
+  { value: 'home', label: 'profile.address.type.home', icon: 'mdi-home-outline' },
+  { value: 'commercial', label: 'profile.address.type.commercial', icon: 'mdi-briefcase-variant-outline' },
+  { value: 'other', label: 'profile.address.type.other', icon: 'mdi-map-marker-outline' },
+];
+
+const onCepChange = async (val) => {
+  if (val?.length === 8) {
+    loadingCep.value = true;
+    try {
+      const data = await searchAddressByCEP(val);
+      if (data) {
+        form.address = data.address;
+        form.district = data.district;
+        form.city_id = data.city_id;
+        form.state_id = data.state_id;
+        form.city = data.city;
+        form.state = data.state;
+      } else {
+        $q.notify({ type: 'negative', message: t('profile.address.cep_not_found') });
+      }
+    } finally {
+      loadingCep.value = false;
+    }
+  }
+};
+
+const save = async () => {
+  saving.value = true;
+  try {
+    let response;
+    if (props.isEditing && addressId.value) {
+      response = await updateAddress(getUpdatedFields.value, addressId.value);
+    } else {
+      response = await createAddress({ ...form });
+    }
+
+    if (response) {
+      setUpdateFormAsOriginal();
+      onDialogOK(response);
+    }
+  } catch (error) {
+    console.error('Erro ao salvar endereço:', error);
+    $q.notify({ type: 'negative', message: t('profile.address.error_saving') });
+  } finally {
+    saving.value = false;
+  }
+};
+
+onMounted(() => {
+  if (props.isEditing && props.addressData) {
+    addressId.value = props.addressData.id;
+
+    const initialData = {
+      zip_code: props.addressData.zip_code || '',
+      address: props.addressData.address || '',
+      number: props.addressData.number || '',
+      complement: props.addressData.complement || '',
+      district: props.addressData.district || '',
+      city_id: props.addressData.city_id || null,
+      state_id: props.addressData.state_id || null,
+      city: props.addressData.city || null,
+      state: props.addressData.state || null,
+      source: 'client',
+      source_id: clientId,
+      address_type: props.addressData.address_type || 'home',
+    };
+
+    Object.assign(form, initialData);
+    setUpdateFormAsOriginal();
+  }
+});
+</script>
+
+<style scoped>
+</style>

+ 72 - 0
src/components/profile/ProfileFavoriteRemoveDialog.vue

@@ -0,0 +1,72 @@
+<template>
+  <q-dialog ref="dialogRef">
+    <div class="remove-dialog-wrapper column items-center">
+
+      <q-card class="remove-card bg-white">
+        <q-card-section class="q-pt-lg q-pb-sm q-px-lg text-center">
+          <p class="remove-title text-primary text-weight-bold">
+            {{ $t('profile.favorites.remove_title') }}
+          </p>
+        </q-card-section>
+
+        <q-card-actions class="row q-px-lg q-pb-lg q-gutter-x-sm justify-center">
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            text-color="grey-2"
+            color="grey-6"
+            class="col remove-btn"
+            :label="$t('profile.favorites.remove_confirm')"
+            @click="onDialogOK"
+          />
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            color="secondary"
+            class="col remove-btn"
+            :label="$t('profile.favorites.remove_cancel')"
+            @click="onDialogCancel"
+          />
+        </q-card-actions>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { useDialogPluginComponent } from 'quasar';
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+</script>
+
+<style scoped lang="scss">
+.remove-dialog-wrapper {
+  width: 320px;
+  max-width: 90vw;
+}
+
+.remove-card {
+  width: 100%;
+  border-radius: 20px;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
+}
+
+.remove-title {
+  font-family: 'Inter', sans-serif;
+  font-size: 18px;
+  font-weight: 700;
+  line-height: 1.4;
+  margin: 0;
+}
+
+.remove-btn {
+  font-family: 'Inter', sans-serif;
+  font-size: 15px;
+  font-weight: 600;
+  padding: 6px 12px;
+}
+</style>

+ 359 - 0
src/components/profile/ProfileFavoritesDialog.vue

@@ -0,0 +1,359 @@
+<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-left" transition-hide="slide-right">
+    <div class="bg-page full-height column no-shadow">
+
+      <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-profile bg-surface">
+        <q-btn v-close-popup icon="mdi-chevron-left" flat round dense color="primary" />
+        <q-space />
+        <span class="text-subtitle1 text-weight-bold text-primary">{{ $t('profile.favorites.title') }}</span>
+        <q-space />
+        <div style="width: 32px"></div>
+      </div>
+
+      <div v-if="loading" class="col flex flex-center">
+        <q-spinner color="primary" size="3em" />
+      </div>
+
+      <div v-else-if="favorites.length === 0" class="col column items-center justify-center q-px-xl q-pb-xl">
+        <q-img
+          :src="diarinho"
+          style="width: 220px; height: 220px;"
+          fit="contain"
+          class="q-mb-lg"
+        />
+        <p class="text-text text-center q-mb-xs" style="font-size: 15px;">
+          {{ $t('profile.favorites.empty_message') }}
+        </p>
+        <p class="text-primary text-center text-weight-bold q-mb-xl" style="font-size: 15px;">
+          {{ $t('profile.favorites.empty_cta') }}
+        </p>
+        <q-btn
+          v-close-popup
+          unelevated
+          rounded
+          no-caps
+          color="primary"
+          class="full-width q-py-sm"
+          padding="10px 16px"
+          style="font-size: 1rem; font-weight: 700;"
+          :label="$t('profile.favorites.search_btn')"
+        />
+      </div>
+
+      <div v-else class="col overflow-auto q-pb-xl">
+
+        <div class="q-mx-md q-mt-md q-pa-md">
+          <p class="text-weight-bold gradient-diarista text-center q-mb-md indicate-title">
+            {{ $t('profile.favorites.indicate_title') }}
+          </p>
+          <div class="row items-center">
+            <div class="col column items-center">
+              <q-avatar :style="avatarStyle(highlightedFavorite)" size="72px" class="text-weight-bold text-h5 q-mb-sm shadow-1">
+                {{ highlightedFavorite?.provider_name?.charAt(0)?.toUpperCase() ?? '?' }}
+              </q-avatar>
+              <span class="text-weight-bold text-dark" style="font-size: 15px;">{{ highlightedFavorite?.provider_name ?? '—' }}</span>
+              <span class="text-caption text-grey-6">{{ highlightedFavorite?.city_name ?? '—' }}</span>
+              <div class="row items-center q-gutter-sm q-mt-sm">
+                <q-btn flat round dense icon="mdi-chevron-left" color="primary" :disable="favorites.length <= 1" @click="prevHighlight" />
+                <q-btn
+                  rounded
+                  unelevated
+                  no-caps
+                  color="primary"
+                  padding="4px 20px"
+                  size="sm"
+                  :label="$t('profile.favorites.indicate_btn')"
+                  @click="openIndicateDialog(highlightedFavorite)"
+                />
+                <q-btn flat round dense icon="mdi-chevron-right" color="primary" :disable="favorites.length <= 1" @click="nextHighlight" />
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <div class="q-mx-md q-mt-lg">
+          <div class="row items-center justify-between q-mb-md">
+            <span class="text-h6 text-weight-bold gradient-diarista">{{ $t('profile.favorites.providers_title') }}</span>
+            <div class="row items-center">
+              <q-icon
+                name="mdi-chevron-left"
+                size="20px"
+                :color="selectedHours === '2h' ? 'grey-4' : 'grey-6'"
+                :class="selectedHours === '2h' ? '' : 'cursor-pointer'"
+                @click="nextSelectedHours"
+              />
+              <span class="text-caption text-grey-6 text-weight-medium q-px-xs" style="user-select: none; font-size: 13px;">
+                {{ $t('profile.favorites.until') }} {{ selectedHours }}
+              </span>
+              <q-icon
+                name="mdi-chevron-right"
+                size="20px"
+                :color="selectedHours === '8h' ? 'grey-4' : 'grey-6'"
+                :class="selectedHours === '8h' ? '' : 'cursor-pointer'"
+                @click="prevSelectedHours"
+              />
+            </div>
+          </div>
+
+          <div
+            v-for="(item, index) in favorites"
+            :key="item.id"
+            class="favorite-item row items-center no-wrap q-py-md"
+            :class="{ 'item-separator': index < favorites.length - 1 }"
+          >
+            <div class="relative-position q-mr-md" style="margin-right: 16px;">
+              <q-avatar :style="avatarStyle(item)" size="60px" class="text-weight-bold avatar-font shadow-1">
+                {{ item.provider_name?.charAt(0)?.toUpperCase() ?? '?' }}
+              </q-avatar>
+              <q-icon
+                name="mdi-heart"
+                size="26px"
+                class="absolute cursor-pointer"
+                style="bottom: -4px; right: -4px; color: #f518e3; filter: drop-shadow(0px 1px 2px rgba(0,0,0,0.2));"
+                @click.stop="confirmRemove(item)"
+              />
+            </div>
+
+            <div class="col column justify-between" style="min-height: 60px;">
+              <div class="row items-start justify-between">
+                <div class="column">
+                  <span class="provider-name text-weight-regular" style="font-size: 16px; color: #4a4a4a; line-height: 1.1;">
+                    {{ item.provider_name ?? '—' }}
+                  </span>
+                  <span class="provider-city text-grey-6" style="font-size: 13px;">
+                    {{ item.city_name ?? '—' }}
+                  </span>
+                </div>
+                <span class="provider-price text-weight-regular text-grey-7" style="font-size: 16px; line-height: 1.1;">
+                  R${{ getPriceByHours(item) ? formatPrice(getPriceByHours(item)) : '—' }}
+                </span>
+              </div>
+
+              <div class="row items-end justify-between q-mt-xs">
+                <div class="row items-center text-grey-5" style="font-size: 12px; margin-bottom: 2px;">
+                  <q-icon name="mdi-star" color="warning" size="18px" />
+                  <span class="q-ml-xs text-weight-medium" style="color: #666; font-size: 13px;">{{ item.average_rating ?? '—' }}</span>
+                  <span class="q-ml-xs" style="margin-right: 2px;">({{ item.total_services ?? 0 }})</span>
+                  <q-icon name="mdi-circle-small" color="grey-4" size="16px" class="q-mx-xs" />
+                  <q-icon name="mdi-broom" size="16px" style="transform: scaleX(-1); color: #f518e3;" />
+                  <span class="q-ml-xs text-weight-medium" style="color: #666; font-size: 13px;">{{ item.total_services ?? '0' }}</span>
+                  <q-icon name="mdi-circle-small" color="grey-4" size="16px" class="q-mx-xs" />
+                  <span>0Km</span>
+                </div>
+                
+                <q-btn
+                  rounded
+                  unelevated
+                  no-caps
+                  style="background: #a63df7; color: white;"
+                  padding="4px 20px"
+                  size="sm"
+                  class="text-weight-medium"
+                  :label="$t('profile.favorites.schedule_btn')"
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import { userStore } from 'src/stores/user';
+import { getClientFavoriteProviders, deleteClientFavoriteProvider } from 'src/api/clientFavoriteProvider';
+import ProfileFavoriteRemoveDialog from './ProfileFavoriteRemoveDialog.vue';
+import diarinho from 'src/assets/diarinho_perfil_cliente_favoritos.svg';
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+const store = userStore();
+
+const favorites = ref([]);
+const loading = ref(false);
+const highlightIndex = ref(0);
+const selectedHours = ref('8h');
+
+const hoursOptions = ['8h', '6h', '4h', '2h'];
+
+const prevSelectedHours = () => {
+  const currentIndex = hoursOptions.indexOf(selectedHours.value);
+  if (currentIndex > 0) {
+    selectedHours.value = hoursOptions[currentIndex - 1];
+  }
+};
+
+const nextSelectedHours = () => {
+  const currentIndex = hoursOptions.indexOf(selectedHours.value);
+  if (currentIndex < hoursOptions.length - 1) {
+    selectedHours.value = hoursOptions[currentIndex + 1];
+  }
+};
+
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+  { background: '#ede0ff', color: '#6200ea' },
+];
+
+const avatarStyle = (item) => {
+  const idx = (item?.id ?? 0) % avatarColors.length;
+  return avatarColors[idx];
+};
+
+const formatPrice = (value) => {
+  return Number(value).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+};
+
+const getPriceByHours = (item) => {
+  const priceKey = `daily_price_${selectedHours.value}`;
+  return item[priceKey] ?? item.daily_price_8h ?? 0;
+};
+
+const highlightedFavorite = computed(() => favorites.value[highlightIndex.value] ?? null);
+
+const prevHighlight = () => {
+  highlightIndex.value = highlightIndex.value === 0
+    ? favorites.value.length - 1
+    : highlightIndex.value - 1;
+};
+
+const nextHighlight = () => {
+  highlightIndex.value = (highlightIndex.value + 1) % favorites.value.length;
+};
+
+const confirmRemove = (item) => {
+  $q.dialog({
+    component: ProfileFavoriteRemoveDialog,
+  }).onOk(async () => {
+    try {
+      await deleteClientFavoriteProvider(item.id);
+      favorites.value = favorites.value.filter(f => f.id !== item.id);
+      if (highlightIndex.value >= favorites.value.length) {
+        highlightIndex.value = Math.max(0, favorites.value.length - 1);
+      }
+    } catch (error) {
+      console.error('Erro ao remover favorito:', error);
+    }
+  });
+};
+
+const openIndicateDialog = (item) => {
+  const message = t('profile.favorites.indicate_whatsapp_message', {
+    name: item?.provider_name ?? '',
+    city: item?.city_name ?? '',
+  });
+  console.log('[Indicar diarista - WhatsApp]', message);
+  $q.notify({ type: 'info', message: t('profile.favorites.indicate_coming_soon') });
+};
+
+onMounted(async () => {
+  loading.value = true;
+  try {
+    const clientId = store.user?.client_id;
+    if (clientId) {
+      favorites.value = await getClientFavoriteProviders(clientId);
+    }
+  } catch (error) {
+    console.error('Erro ao carregar favoritos:', error);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style scoped lang="scss">
+.indicate-title {
+  font-family: 'Inter', sans-serif;
+  font-size: 15px;
+  font-weight: 700;
+}
+
+.heart-badge {
+  position: absolute;
+  bottom: -2px;
+  right: -2px;
+  background: white;
+  border-radius: 50%;
+  padding: 1px;
+}
+
+.avatar-font {
+  font-family: 'Inter', sans-serif;
+  font-size: 20px;
+}
+
+.favorite-item {
+  background: transparent;
+}
+
+.item-separator {
+  border-bottom: 1px solid rgba(0, 0, 0, 0.07);
+}
+
+.provider-name {
+  font-family: 'Inter', sans-serif;
+  font-size: 14px;
+  font-weight: 700;
+  color: #2c3e50;
+  line-height: 1.3;
+}
+
+.provider-city {
+  font-family: 'Inter', sans-serif;
+  font-size: 12px;
+  font-weight: 400;
+  color: #888;
+  line-height: 1.2;
+}
+
+.provider-metrics {
+  gap: 3px;
+}
+
+.metric-value {
+  font-family: 'Inter', sans-serif;
+  font-size: 11px;
+  font-weight: 700;
+  color: #3a3a4a;
+  line-height: 1;
+}
+
+.metric-meta {
+  font-family: 'Inter', sans-serif;
+  font-size: 10px;
+  font-weight: 400;
+  color: #999;
+  line-height: 1;
+}
+
+.metric-dot {
+  font-family: 'Inter', sans-serif;
+  font-size: 10px;
+  color: #ccc;
+  line-height: 1;
+  padding: 0 1px;
+}
+
+.provider-price {
+  font-family: 'Inter', sans-serif;
+  font-size: 14px;
+  font-weight: 700;
+  color: #2c3e50;
+}
+
+.shadow-profile {
+  box-shadow: 0px 1px 8px rgba(0, 0, 0, 0.1);
+}
+</style>

+ 225 - 0
src/components/profile/ProfileHelpDialog.vue

@@ -0,0 +1,225 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-left" transition-hide="slide-right">
+    <div class="bg-page full-height column no-shadow">
+      <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-profile bg-surface">
+        <q-btn v-close-popup icon="mdi-chevron-left" flat round dense color="primary" />
+        <q-space />
+        <span class="text-subtitle1 text-weight-bold text-primary">{{ $t('profile.help.title') }}</span>
+        <q-space />
+        <div style="width: 32px"></div>
+      </div>
+
+      <div class="col overflow-auto">
+
+        <div class="support-banner row no-wrap">
+          <div class="col-8 q-pa-md">
+            <div class="row items-center q-mb-xs q-gutter-x-sm">
+              <span class="text-h6 text-white text-weight-bold">{{ $t('profile.help.support_title') }}</span>
+            </div>
+            <div class="row items-center q-gutter-x-xs q-mb-md">
+              <q-icon name="mdi-circle" color="green-4" size="10px" />
+              <span class="text-caption text-white">{{ $t('profile.help.online_status') }}</span>
+            </div>
+            <div class="row items-center q-gutter-x-sm">
+              <q-icon name="mdi-robot-outline" color="white" size="18px" />
+              <span class="text-caption text-white">{{ $t('profile.help.ai_assistant_label') }}</span>
+            </div>
+          </div>
+          <div class="col-4 flex items-end">
+            <q-img
+              :src="diarinho_suporte"
+              style="width: 110px; height: 110px; object-fit: cover;"
+            >
+              <template #error>
+                <div class="support-avatar-placeholder column flex-center">
+                  <q-icon name="mdi-face-agent" size="48px" color="white" />
+                </div>
+              </template>
+            </q-img>
+          </div>
+        </div>
+
+        <div class="q-px-md q-pt-lg q-pb-xl">
+
+          <q-card class="bg-surface shadow-card q-mb-lg border-message-support" style="border-radius: 16px;">
+            <q-card-section class="q-pb-xs">
+              <div class="row items-center q-gutter-x-sm q-mb-sm">
+                <q-icon name="mdi-message-outline" color="primary" size="18px" />
+                <span class="text-caption text-weight-bold text-primary">{{ $t('profile.help.virtual_assistant') }}</span>
+              </div>
+              <p class="text-text q-mb-xs">{{ $t('profile.help.greeting_message') }}</p>
+              <span class="text-caption text-grey-5">{{ currentTime }}</span>
+            </q-card-section>
+          </q-card>
+          <div class="q-pt-sm">
+            <div class="col-12 text-caption text-grey-6 q-mb-sm">{{ $t('profile.help.quick_suggestions') }}</div>
+            <div 
+              v-for="suggestion in suggestions"
+              :key="suggestion"
+              class="row col-12 q-py-xs"
+            >
+              <span class="text-text bg-surface suggestion-btn q-py-sm q-px-md">{{ suggestion }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div class="bg-surface chat-footer q-px-md q-py-sm shadow-up">
+        <div class="row items-center q-gutter-x-sm">
+          <q-input
+            v-model="messageInput"
+            dense
+            class="col input-suporte"
+            borderless
+            input-class="text-text"
+            :placeholder="$t('profile.help.message_placeholder')"
+            @keyup.enter="sendMessage"
+          />
+          <q-btn
+            round
+            unelevated
+            color="primary"
+            icon="mdi-send"
+            size="sm"
+            :disable="!messageInput.trim()"
+            @click="sendMessage"
+          />
+        </div>
+        <div class="footer-disclaimer text-text text-center q-my-md">
+          {{ $t('profile.help.footer_disclaimer') }}
+        </div>
+      </div>
+
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import diarinho_suporte from 'src/assets/diarinho_suporte.svg';
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+
+const messageInput = ref('');
+
+const currentTime = computed(() => {
+  return new Date().toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
+});
+
+const suggestions = computed(() => [
+  t('profile.help.suggestion_cancel'),
+  t('profile.help.suggestion_data'),
+  t('profile.help.suggestion_payment'),
+  t('profile.help.suggestion_human'),
+]);
+
+const sendMessage = () => {
+  if (!messageInput.value.trim()) return;
+  $q.notify({ type: 'info', message: t('profile.help.coming_soon') });
+  messageInput.value = '';
+};
+</script>
+
+<style scoped lang="scss">
+.support-banner {
+  background: linear-gradient(180deg, #6C54C1 0%, #9A7FF6 100%);
+  min-height: 120px;
+}
+
+.support-avatar-placeholder {
+  width: 90px;
+  height: 90px;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.2);
+}
+
+.suggestion-btn {
+  border-radius: 32px;
+  font-size: 12px;
+  height: auto;
+  min-height: unset;
+  border: 1.15px solid #d8d7d7ce;
+  font-family: Inter;
+  font-weight: 400;
+  font-style: Regular;
+  font-size: 12px;
+  line-height: 16px;
+  letter-spacing: 0px;
+}
+
+.chat-footer {
+  border-top: 1px solid rgba(0, 0, 0, 0.06);
+}
+
+.shadow-up {
+  box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.08);
+}
+
+.border-message-support {
+  border: 1px solid rgba(0, 0, 0, 0.151);
+}
+
+.input-suporte {
+  :deep(.q-field__control) {
+    background: transparent;
+    border: none;
+    box-shadow: none;
+    padding: 0;
+  }
+
+  :deep(.q-field__control-container) {
+    background: var(--q-page, #f5f5f5);
+    border: 1px solid rgba(0, 0, 0, 0.12);
+    border-radius: 32px;
+
+    padding: 10px 14px;
+  }
+
+  :deep(.q-field__native) {
+    padding: 0 !important;
+  }
+
+  :deep(.q-field__control::before),
+  :deep(.q-field__control::after) {
+    display: none !important;
+  }
+
+  :deep(.q-field--focused .q-field__control-container) {
+    box-shadow: none;
+  }
+
+  :deep(.q-field__bottom),
+  :deep(.q-field__marginal) {
+    display: none;
+  }
+
+  :deep(.q-field__control),
+  :deep(.q-field__control:before),
+  :deep(.q-field__control:after) {
+    border: none !important;
+    box-shadow: none !important;
+    outline: none !important;
+  }
+
+  :deep(.q-field--focused .q-field__control),
+  :deep(.q-field--focused .q-field__control-container) {
+    box-shadow: none !important;
+    border-color: rgba(0, 0, 0, 0.12) !important;
+  }
+}
+
+.footer-disclaimer {
+  font-family: Inter;
+  font-weight: 400;
+  font-style: Regular;
+  font-size: 12px;
+  line-height: 15px;
+  letter-spacing: 0px;
+  text-align: center;
+}
+</style>

+ 340 - 0
src/components/profile/ProfilePaymentAddDialog.vue

@@ -0,0 +1,340 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down">
+    <div class="bg-page full-height column no-shadow">
+
+      <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-profile bg-surface">
+        <q-btn icon="mdi-chevron-left" flat round dense color="primary" @click="onDialogCancel" />
+        <q-space />
+        <span class="text-subtitle1 text-weight-bold text-primary">
+          {{ $t('profile.payments.title') }}
+        </span>
+        <q-space />
+        <div style="width: 32px"></div>
+      </div>
+
+      <div class="col overflow-auto q-px-md q-pt-lg q-pb-xl">
+        <div class="text-h6 text-weight-bold text-primary q-mb-md" style="text-transform: capitalize;">
+          {{ paymentMethod ? $t('profile.payments.edit_title') : $t('profile.payments.add_card') }}
+        </div>
+
+        <div class="virtual-card q-mb-xl">
+          <div class="card-top-row row items-start justify-between">
+            <span class="card-brand-label">{{ brandDisplayName }}</span>
+            <q-icon name="mdi-wifi-strength-4" size="24px" color="white" class="nfc-icon" style="transform: rotate(90deg);" />
+          </div>
+          <div class="card-number-preview">
+            {{ maskedCardNumberPreview }}
+          </div>
+          <div class="card-bottom-row row items-end justify-between">
+            <div class="column">
+              <span class="card-holder-name">{{ form.holder_name || 'Nome do Titular' }}</span>
+            </div>
+            <div class="column items-end">
+              <span class="card-expiry">{{ form.expiration || '**/****' }}</span>
+            </div>
+          </div>
+        </div>
+
+        <q-form ref="formRef" class="column q-gutter-y-sm text-text" @submit="onOKClick">
+          <div>
+            <div class="input-label">
+              {{ $t('profile.payments.card_number') }}
+            </div>
+            <q-input
+              v-model="form.card_number"
+              :rules="[inputRules.requiredHideMessage]"
+              :error="!!serverErrors.card_number"
+              :error-message="serverErrors.card_number"
+              mask="#### #### #### ####"
+              unmasked-value
+              outlined
+              input-class="text-text"
+              hide-bottom-space
+              class="input-field bg-surface input-border-dark"
+              @update:model-value="onCardNumberChange"
+            />
+          </div>
+
+          <div class="q-pt-lg">
+            <div class="input-label">
+              {{ $t('profile.payments.holder_name') }}
+            </div>
+            <q-input
+              v-model="form.holder_name"
+              placeholder="Nome como está no cartão"
+              :rules="[inputRules.requiredHideMessage]"
+              :error="!!serverErrors.holder_name"
+              :error-message="serverErrors.holder_name"
+              outlined
+              input-class="text-text"
+              hide-bottom-space
+              class="input-field bg-surface input-border-dark"
+              @update:model-value="serverErrors.holder_name = null"
+            />
+          </div>
+
+          <div class="row col-12 q-col-gutter-sm q-pt-lg">
+            <div class="col-7">
+              <div class="input-label">
+                {{ $t('profile.payments.expiration') }}
+              </div>
+              <q-input
+                v-model="form.expiration"
+                :placeholder="$t('profile.payments.mmyyyy')"
+                :rules="[inputRules.requiredHideMessage, validateExpiration]"
+                :error="!!serverErrors.expiration"
+                :error-message="serverErrors.expiration"
+                mask="##/####"
+                outlined
+                class="col input-field bg-surface input-border-dark"
+                input-class="text-text"
+                hide-bottom-space
+                @update:model-value="serverErrors.expiration = null"
+              />
+            </div>
+            <div class="col-5">
+              <div class="input-label row items-center q-gutter-x-xs">
+                <span>{{ $t('profile.payments.cvv') }}</span>
+                <q-icon name="mdi-help-circle-outline" color="grey-8" size="16px" class="cursor-pointer">
+                  <q-tooltip>{{ $t('profile.payments.cvv_help') }}</q-tooltip>
+                </q-icon>
+              </div>
+              <q-input
+                v-model="form.cvv"
+                placeholder="***"
+                :rules="[inputRules.requiredHideMessage]"
+                :error="!!serverErrors.cvv"
+                :error-message="serverErrors.cvv"
+                mask="####"
+                unmasked-value
+                type="password"
+                outlined
+                input-class="text-text"
+                class="col input-field bg-surface input-border-dark"
+                hide-bottom-space
+                @update:model-value="serverErrors.cvv = null"
+              />
+            </div>
+          </div>
+
+          <div class="row items-center justify-center q-gutter-x-xs q-mt-sm q-mb-xs">
+            <q-icon name="mdi-shield-check" color="positive" size="18px" />
+            <span class="security-text-new">{{ $t('profile.payments.security_badge') }}</span>
+          </div>
+
+          <q-btn
+            type="submit"
+            unelevated
+            rounded
+            no-caps
+            color="primary"
+            class="full-width q-mt-md save-btn q-mb-md"
+            padding="14px 16px"
+            :label="paymentMethod ? $t('profile.payments.save_btn') : $t('profile.payments.add_card')"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+          />
+        </q-form>
+      </div>
+
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue';
+import { useDialogPluginComponent } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
+import { useSubmitHandler } from 'src/composables/useSubmitHandler';
+import { useInputRules } from 'src/composables/useInputRules';
+import { /*validateCardNumberLuhn,*/ detectCardBrand, validateCardExpiration } from 'src/helpers/utils';
+import { createClientPaymentMethod, updateClientPaymentMethod } from 'src/api/clientPaymentMethod';
+
+const props = defineProps({
+  paymentMethod: {
+    type: Object,
+    default: null,
+  },
+  clientId: {
+    type: Number,
+    required: true,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+const formRef = ref(null);
+
+const { form, hasUpdatedFields } = useFormUpdateTracker({
+  client_id: props.paymentMethod ? props.paymentMethod.client_id : props.clientId,
+  card_number: props.paymentMethod ? props.paymentMethod.card_number : null,
+  holder_name: props.paymentMethod ? props.paymentMethod.holder_name : null,
+  expiration: props.paymentMethod ? (props.paymentMethod.expiration?.includes('/') && props.paymentMethod.expiration.split('/')[1].length === 2 ? `${props.paymentMethod.expiration.split('/')[0]}/20${props.paymentMethod.expiration.split('/')[1]}` : props.paymentMethod.expiration) : null,
+  cvv: props.paymentMethod ? props.paymentMethod.cvv : null,
+  card_name: props.paymentMethod ? props.paymentMethod.card_name : null,
+  brand: props.paymentMethod ? props.paymentMethod.brand : null,
+  last_four_digits: props.paymentMethod ? props.paymentMethod.last_four_digits : null,
+  is_active: props.paymentMethod ? props.paymentMethod.is_active : true,
+});
+
+const { loading, serverErrors, execute: submitForm } = useSubmitHandler(() => onDialogOK(true));
+
+const brandDisplayName = computed(() => {
+  if (!form.brand) return '';
+  const names = {
+    visa: 'VISA',
+    mastercard: 'Mastercard',
+    elo: 'Elo',
+    hipercard: 'Hipercard',
+    diners: 'Diners',
+    discover: 'Discover',
+  };
+  return names[form.brand] ?? form.brand;
+});
+
+const maskedCardNumberPreview = computed(() => {
+  const raw = form.card_number ? String(form.card_number).replace(/\D/g, '') : '';
+  if (!raw || raw.includes('*')) return '**** **** **** ****';
+  const lastFour = raw.slice(-4).padStart(4, '*');
+  return `**** **** **** ${lastFour}`;
+});
+
+// const validateCardNumber = (val) => {
+//   if (!val) return true;
+//   if (val.includes('*')) return true;
+//   if (!validateCardNumberLuhn(val)) return t('profile.payments.invalid_card_number');
+//   return true;
+// };
+
+const validateExpiration = (val) => {
+  if (!val) return true;
+  if (!validateCardExpiration(val)) return t('profile.payments.expired_card');
+  return true;
+};
+
+const onCardNumberChange = () => {
+  serverErrors.value.card_number = null;
+  const raw = form.card_number ? String(form.card_number).replace(/\D/g, '') : '';
+  if (raw.length >= 6 && !String(form.card_number).includes('*')) {
+    const brand = detectCardBrand(raw);
+    if (brand) form.brand = brand;
+    if (raw.length >= 4) form.last_four_digits = raw.slice(-4);
+  }
+};
+
+const onOKClick = async () => {
+  const valid = await formRef.value?.validate();
+  if (!valid) return;
+  if (props.paymentMethod) {
+    await submitForm(() => updateClientPaymentMethod(props.paymentMethod.id, { ...form }));
+  } else {
+    await submitForm(() => createClientPaymentMethod({ ...form }));
+  }
+};
+</script>
+
+<style scoped lang="scss">
+.shadow-profile {
+  box-shadow: 0px 1px 8px rgba(0, 0, 0, 0.1);
+}
+
+.virtual-card {
+  background: linear-gradient(135deg, #6c3fc5 0%, #9c27b0 100%);
+  border-radius: 20px;
+  padding: 20px 24px;
+  min-height: 180px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  box-shadow: 0 8px 24px rgba(108, 63, 197, 0.4);
+  color: white;
+}
+
+.nfc-icon {
+  opacity: 0.85;
+}
+
+.card-brand-label {
+  font-family: 'Inter', sans-serif;
+  font-size: 16px;
+  font-weight: 700;
+  letter-spacing: 1px;
+  opacity: 0.9;
+}
+
+.card-number-preview {
+  font-family: 'Inter', sans-serif;
+  font-size: 20px;
+  font-weight: 600;
+  letter-spacing: 3px;
+  text-align: center;
+  margin: 12px 0;
+}
+
+.card-label-sm {
+  font-family: 'Inter', sans-serif;
+  font-size: 10px;
+  font-weight: 400;
+  opacity: 0.7;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
+.card-holder-name {
+  font-family: 'Inter', sans-serif;
+  font-size: 13px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 1px;
+}
+
+.card-expiry {
+  font-family: 'Inter', sans-serif;
+  font-size: 13px;
+  font-weight: 600;
+  letter-spacing: 1px;
+}
+
+.input-field {
+  font-family: 'Inter', sans-serif;
+}
+
+.input-label {
+  font-family: 'Inter', sans-serif;
+  font-size: 14px;
+  font-weight: 800;
+  color: #444;
+  margin-bottom: 2px;
+}
+
+.input-border-dark :deep(.q-field__control:before) {
+  border: 1px solid #afafaf !important;
+  opacity: 1 !important;
+}
+
+.input-border-dark :deep(.q-field__control:after) {
+  border-color: #666 !important;
+  opacity: 1 !important;
+}
+
+.input-border-dark :deep(.q-field__control) {
+  border-radius: 6px !important;
+}
+
+.security-text-new {
+  font-family: 'Inter', sans-serif;
+  font-size: 13px;
+  font-weight: 500;
+  color: #555;
+}
+
+.save-btn {
+  font-family: 'Inter', sans-serif;
+  font-size: 16px;
+  font-weight: 700;
+}
+</style>

+ 75 - 0
src/components/profile/ProfilePaymentRemoveDialog.vue

@@ -0,0 +1,75 @@
+<template>
+  <q-dialog ref="dialogRef">
+    <div class="remove-dialog-wrapper column items-center">
+      <q-card class="remove-card bg-white">
+        <q-card-section class="q-pt-lg q-pb-sm q-px-lg text-center">
+          <p class="remove-title text-primary text-weight-bold">
+            {{ $t('profile.payments.remove_title') }}
+          </p>
+        </q-card-section>
+
+        <q-card-actions class="row q-px-lg q-pb-lg q-gutter-x-sm justify-center">
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            color="grey-3"
+            text-color="grey-7"
+            class="col remove-btn"
+            :label="$t('profile.payments.remove_confirm')"
+            @click="onDialogOK"
+          />
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            color="secondary"
+            class="col remove-btn"
+            :label="$t('profile.payments.remove_cancel')"
+            @click="onDialogCancel"
+          />
+        </q-card-actions>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { useDialogPluginComponent } from 'quasar';
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+</script>
+
+<style scoped lang="scss">
+.remove-dialog-wrapper {
+  width: 320px;
+  max-width: 90vw;
+}
+
+.card-icon {
+  filter: drop-shadow(0 2px 6px rgba(229, 57, 53, 0.35));
+}
+
+.remove-card {
+  width: 100%;
+  border-radius: 20px;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
+}
+
+.remove-title {
+  font-family: 'Inter', sans-serif;
+  font-size: 18px;
+  font-weight: 700;
+  line-height: 1.4;
+  margin: 0;
+}
+
+.remove-btn {
+  font-family: 'Inter', sans-serif;
+  font-size: 15px;
+  font-weight: 600;
+  padding: 6px 12px;
+}
+</style>

+ 222 - 0
src/components/profile/ProfilePaymentsDialog.vue

@@ -0,0 +1,222 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-left" transition-hide="slide-right">
+    <div class="bg-page full-height column no-shadow">
+
+      <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-profile bg-surface">
+        <q-btn v-close-popup icon="mdi-chevron-left" flat round dense color="primary" />
+        <q-space />
+        <span class="text-subtitle1 text-weight-bold text-primary">{{ $t('profile.payments.subtitle') }}</span>
+        <q-space />
+        <div style="width: 32px"></div>
+      </div>
+
+      <div v-if="loading" class="col flex flex-center">
+        <q-spinner color="primary" size="3em" />
+      </div>
+
+      <div v-else class="col column q-px-md q-pt-lg overflow-auto">
+        <span class="text-h6 text-weight-bold text-primary q-mb-lg ml-xs">{{ $t('profile.payments.description')
+          }}</span>
+
+        <div class="payment-card-container q-pa-lg">
+          <div>
+            <div v-if="paymentMethods.length === 0" class="column items-center justify-center q-py-xl">
+              <q-icon name="mdi-credit-card-off-outline" size="64px" color="grey-4" class="q-mb-md" />
+              <p class="text-text text-center q-mb-xs" style="font-size: 15px;">
+                {{ $t('profile.payments.no_cards') }}
+              </p>
+              <p class="text-grey-6 text-center q-mb-xl" style="font-size: 14px;">
+                {{ $t('profile.payments.add_first_card') }}
+              </p>
+            </div>
+
+            <div v-else class="column q-gutter-y-md">
+              <div v-for="item in paymentMethods" :key="item.id" class="card-item-box row items-center no-wrap q-pa-md">
+                <div class="brand-logo-wrapper q-mr-md">
+                  <q-icon name="mdi-credit-card-chip-outline" color="grey-7" size="32px" />
+                </div>
+
+                <div class="col column">
+                  <span class="card-type text-dark">{{ $t('profile.payments.credit') }}</span>
+                  <div class="row items-center no-wrap">
+                    <span class="card-bank-new">{{ cardLabel(item) }}</span>
+                  </div>
+                  <span class="card-number-new">{{ '**** ' + item.last_four_digits }}</span>
+                </div>
+
+                <div class="row items-end">
+                  <q-btn flat round icon="mdi-pencil-outline" color="grey-8" size="15px" @click="openEditDialog(item)" />
+                  <q-btn flat round icon="mdi-trash-can-outline" color="grey-8" size="15px" @click="confirmRemove(item)" />
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <div class="q-mt-xl">
+            <q-btn unelevated rounded no-caps color="primary" class="full-width save-btn" padding="8px 16px" :label="$t('profile.payments.add_card')" @click="openAddDialog" />
+          </div>
+        </div>
+      </div>
+
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
+import { userStore } from 'src/stores/user';
+import { getClientPaymentMethods, deleteClientPaymentMethod } from 'src/api/clientPaymentMethod';
+import ProfilePaymentAddDialog from './ProfilePaymentAddDialog.vue';
+import ProfilePaymentRemoveDialog from './ProfilePaymentRemoveDialog.vue';
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef } = useDialogPluginComponent();
+const $q = useQuasar();
+const store = userStore();
+
+const paymentMethods = ref([]);
+const loading = ref(false);
+
+// const brandIcon = (brand) => {
+//   const icons = {
+//     visa: 'mdi-credit-card-outline',
+//     mastercard: 'mdi-credit-card-outline',
+//     elo: 'mdi-credit-card-outline',
+//     hipercard: 'mdi-credit-card-outline',
+//     diners: 'mdi-credit-card-outline',
+//     discover: 'mdi-credit-card-outline',
+//   };
+//   return icons[brand] ?? 'mdi-credit-card-outline';
+// };
+
+// const brandColor = (brand) => {
+//   const colors = {
+//     visa: 'blue-8',
+//     mastercard: 'orange-8',
+//     elo: 'yellow-9',
+//     hipercard: 'red-8',
+//     diners: 'grey-7',
+//     discover: 'orange-6',
+//   };
+//   return colors[brand] ?? 'grey-6';
+// };
+
+const cardLabel = (item) => {
+  const parts = [];
+  if (item.card_name) parts.push(item.card_name);
+  if (item.brand) parts.push(item.brand.charAt(0).toUpperCase() + item.brand.slice(1));
+  return parts.join(' - ') || '—';
+};
+
+const openAddDialog = () => {
+  $q.dialog({
+    component: ProfilePaymentAddDialog,
+    componentProps: {
+      clientId: store.user?.client_id,
+    },
+  }).onOk(() => {
+    loadPaymentMethods();
+  });
+};
+
+const openEditDialog = (item) => {
+  $q.dialog({
+    component: ProfilePaymentAddDialog,
+    componentProps: {
+      clientId: store.user?.client_id,
+      paymentMethod: item,
+    },
+  }).onOk(() => {
+    loadPaymentMethods();
+  });
+};
+
+const confirmRemove = (item) => {
+  $q.dialog({
+    component: ProfilePaymentRemoveDialog,
+  }).onOk(async () => {
+    try {
+      await deleteClientPaymentMethod(item.id);
+      paymentMethods.value = paymentMethods.value.filter(p => p.id !== item.id);
+    } catch (error) {
+      console.error('Erro ao remover cartão:', error);
+    }
+  });
+};
+
+const loadPaymentMethods = async () => {
+  loading.value = true;
+  try {
+    const clientId = store.user?.client_id;
+    if (clientId) {
+      paymentMethods.value = await getClientPaymentMethods(clientId);
+    }
+  } catch (error) {
+    console.error('Erro ao carregar métodos de pagamento:', error);
+  } finally {
+    loading.value = false;
+  }
+};
+
+onMounted(loadPaymentMethods);
+</script>
+
+<style scoped lang="scss">
+.payment-card-container {
+  background: white;
+  border-radius: 30px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
+  margin-bottom: 40px;
+}
+
+.card-item-box {
+  border: 1px solid #b5b5b5;
+  border-radius: 4px;
+  background: white;
+}
+
+.brand-logo-wrapper {
+  width: 50px;
+  height: 32px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.card-type {
+  font-family: 'Inter', sans-serif;
+  font-size: 14px;
+  font-weight: 500;
+  line-height: 1.2;
+}
+
+.card-bank-new {
+  font-family: 'Inter', sans-serif;
+  font-size: 13px;
+  font-weight: 400;
+  color: #666;
+  line-height: 1.4;
+}
+
+.card-number-new {
+  font-family: 'Inter', sans-serif;
+  font-size: 13px;
+  font-weight: 500;
+  color: #333;
+  letter-spacing: 0.5px;
+  line-height: 1.2;
+}
+
+.save-btn {
+  font-family: 'Inter', sans-serif;
+  font-size: 16px;
+  font-weight: 700;
+}
+
+.ml-xs {
+  margin-left: 4px;
+}
+</style>

+ 5 - 5
src/css/app.scss

@@ -262,15 +262,15 @@ input[type="number"]::-webkit-outer-spin-button {
 .text-provider-close-name {
   font-family: "Inter", sans-serif;
   font-weight: 700;
-  font-size: 8px;
+  font-size: 12px;
   line-height: 100%;
   letter-spacing: -0.04em;
 }
 
 .text-provider-close-region {
   font-family: "Inter", sans-serif;
-  font-weight: 600;
-  font-size: 8px;
+  font-weight: 500;
+  font-size: 10px;
   line-height: 100%;
   letter-spacing: 0;
 }
@@ -287,7 +287,7 @@ input[type="number"]::-webkit-outer-spin-button {
 .text-provider-close-rating {
   font-family: "Inter", sans-serif;
   font-weight: 400;
-  font-size: 8px;
+  font-size: 10px;
   line-height: 100%;
   letter-spacing: 0;
 }
@@ -295,7 +295,7 @@ input[type="number"]::-webkit-outer-spin-button {
 .text-provider-close-jobs {
   font-family: "Inter", sans-serif;
   font-weight: 400;
-  font-size: 8px;
+  font-size: 10px;
   line-height: 100%;
   letter-spacing: 0;
   text-align: center;

+ 87 - 0
src/helpers/utils.js

@@ -213,6 +213,89 @@ const formatCurrency = (value) => {
   return value;
 };
 
+// algoritmo de luhn que valida se o cartao tem numero valido (nao garante que ele existe, só garante que poderia ser um numero de cartao realmente valido)
+const validateCardNumberLuhn = (cardNumber) => {
+  const digits = cardNumber.replace(/\D/g, '');
+
+  if (digits.length < 13 || digits.length > 19) return false;
+
+  let sum = 0;
+  let isEven = false;
+
+  for (let i = digits.length - 1; i >= 0; i--) {
+    let digit = parseInt(digits[i], 10);
+
+    if (isEven) {
+      digit *= 2;
+      if (digit > 9) digit -= 9;
+    }
+
+    sum += digit;
+    isEven = !isEven;
+  }
+
+  return sum % 10 === 0;
+};
+
+const detectCardBrand = (cardNumber) => {
+  const digits = (cardNumber || '').replace(/\D/g, '');
+
+  if (!digits) return null;
+
+  // Visa
+  if (/^4/.test(digits)) return 'visa';
+
+  // Mastercard (inclui range novo 2221–2720)
+  if (/^(5[1-5]|2[2-7][0-9]{2})/.test(digits)) return 'mastercard';
+
+  // Elo (principais BINs conhecidos)
+  if (/^(4011|4312|4389|4514|4576|5041|5066|5067|5090|6277|6362|6363|6504|6505|6516|6550)/.test(digits)) return 'elo';
+
+  // Hipercard
+  if (/^(3841|60)/.test(digits)) return 'hipercard';
+
+  // American Express
+  if (/^3[47]/.test(digits)) return 'amex';
+
+  // Diners Club
+  if (/^(30[0-5]|36|38|39)/.test(digits)) return 'diners';
+
+  // Discover
+  if (/^(6011|65|64[4-9])/.test(digits)) return 'discover';
+
+  // JCB
+  if (/^35/.test(digits)) return 'jcb';
+
+  // Aura (Banco do Brasil)
+  if (/^50/.test(digits)) return 'aura';
+
+  return null;
+};
+
+const validateCardExpiration = (expiration) => {
+  if (!expiration || !/^\d{2}\/\d{4}$/.test(expiration)) return false;
+  const [month, year] = expiration.split('/').map(Number);
+  if (month < 1 || month > 12) return false;
+  const now = new Date();
+  const currentYear = now.getFullYear();
+  const currentMonth = now.getMonth() + 1;
+  if (year < currentYear) return false;
+  if (year === currentYear && month < currentMonth) return false;
+  return true;
+};
+
+const formatAddress = (address) => {
+  if (!address) return '';
+  const { address: street, number, district, city, state } = address;
+  let parts = [];
+  if(street && street !== null && street != 'null') parts.push(street);
+  if(number && number !== null && number != 'null') parts.push(number);
+  if(district && district !== null && district != 'null') parts.push(district);
+  if(city?.name && city.name !== null && city.name != 'null') parts.push(city.name);
+  if(state?.code && state.code !== null && state.code != 'null') parts.push(state.code);
+  return parts.join(', ');
+};
+
 export {
   formatDateDMYtoYMD,
   formatDateYMDtoDMY,
@@ -226,4 +309,8 @@ export {
   validaDataHora,
   formatQuantity,
   formatCurrency,
+  validateCardNumberLuhn,
+  detectCardBrand,
+  validateCardExpiration,
+  formatAddress
 };

+ 162 - 5
src/i18n/locales/en.json

@@ -1,5 +1,6 @@
 {
   "common": {
+    "confirm": "Confirm",
     "actions": {
       "save": "Save",
       "cancel": "Cancel",
@@ -315,8 +316,14 @@
     }
   },
   "dashboard_client": {
-    "header": { "rating": "Rating", "services": "Services" },
-    "summary": { "welcome": "Welcome,", "my_schedules": "My schedules" },
+    "header": {
+      "rating": "Rating",
+      "services": "Services"
+    },
+    "summary": {
+      "welcome": "Welcome,",
+      "my_schedules": "My schedules"
+    },
     "next_schedules": {
       "title": "Next services",
       "no_provider": "No provider assigned",
@@ -332,9 +339,143 @@
       "place_unknown": "N/A",
       "details": "view details"
     },
-    "last_schedules": { "title": "Last services", "reschedule": "reschedule" },
-    "favorites": { "title": "Favorites", "view_schedule": "view schedule" },
-    "providers_close": { "title": "Near you", "schedule": "book", "until_8h": "Up to 8h", "place_home": "Home" }
+    "last_schedules": {
+      "title": "Last services",
+      "reschedule": "reschedule"
+    },
+    "favorites": {
+      "title": "Favorites",
+      "view_schedule": "view schedule"
+    },
+    "providers_close": {
+      "title": "Near you",
+      "schedule": "book",
+      "until_8h": "Up to 8h",
+      "until_6h": "Up to 6h",
+      "until_4h": "Up to 4h",
+      "until_2h": "Up to 2h",
+      "place_home": "Home"
+    }
+  },
+  "profile": {
+    "title": "Profile",
+    "edit_profile": "Edit profile",
+    "edit_data": "Edit data",
+    "change_photo": "Change photo",
+    "full_name": "Full name",
+    "placeholder_name": "Enter your name",
+    "email": "Email",
+    "placeholder_email": "Enter your email",
+    "phone": "Phone",
+    "placeholder_phone": "(11) 99999-9999",
+    "update": "Update",
+    "payments": {
+      "title": "Payments",
+      "description": "My cards",
+      "subtitle": "Payment Methods",
+      "add_card": "add new card",
+      "credit": "Credit",
+      "debit": "Debit",
+      "no_cards": "No cards registered",
+      "add_first_card": "Add your first card.",
+      "card_number": "Card number",
+      "holder_name": "Cardholder name",
+      "expiration": "Expiration",
+      "expiration_placeholder": "MM/YYYY",
+      "cvv": "CVV",
+      "cvv_help": "The 3 or 4 digits on the back of your card",
+      "brand": "Brand",
+      "add_title": "Add Card",
+      "edit_title": "Edit Card",
+      "save_btn": "Save card",
+      "security_badge": "Your data is secure",
+      "invalid_card_number": "Invalid card number",
+      "expired_card": "Expired card or invalid date",
+      "remove_title": "Do you want to remove this card?",
+      "remove_confirm": "Remove",
+      "remove_cancel": "Cancel",
+      "active": "Active",
+      "inactive": "Inactive",
+      "mmyyyy": "mm/yyyy"
+    },
+    "favorites": {
+      "title": "Favorites",
+      "description": "Favorite cleaners",
+      "empty_message": "You don't have any cleaners in your favorites list yet.",
+      "empty_cta": "Search for one now!",
+      "search_btn": "search",
+      "indicate_title": "Refer a cleaner to a friend.",
+      "indicate_btn": "Refer",
+      "indicate_whatsapp_message": "Hi! I use the Diária app to hire cleaners, and I want to recommend a professional: {name} ({city}). You can download the Diária app and view their profile here 👇",
+      "indicate_coming_soon": "Soon you will be able to refer cleaners",
+      "providers_title": "Cleaners",
+      "until": "Until",
+      "schedule_btn": "book",
+      "remove_title": "Do you want to remove the cleaner from favorites?",
+      "remove_confirm": "Remove",
+      "remove_cancel": "Cancel"
+    },
+    "address": {
+      "title": "Addresses",
+      "description": "Cleaning location",
+      "my_addresses": "My addresses",
+      "manage_addresses": "Manage your addresses and choose where the service will be scheduled.",
+      "address_subtitle": "My addresses",
+      "address_description": "Add and manage your cleaning addresses",
+      "search_placeholder": "Search address",
+      "cep": "ZIP Code",
+      "cep_not_found": "ZIP Code not found",
+      "address_label": "Address",
+      "address_placeholder": "Enter your address",
+      "number": "Number",
+      "complement": "Complement",
+      "complement_placeholder": "Complement (optional)",
+      "district_label": "District",
+      "city_label": "City",
+      "state_label": "State",
+      "update_address": "Update address",
+      "add_address": "Add address",
+      "add_new_address": "Add new address",
+      "edit_address": "Edit address",
+      "edit": "Edit",
+      "mark_as_primary": "Mark as primary",
+      "main": "Primary",
+      "no_addresses": "No addresses registered",
+      "marked_as_primary": "Address marked as primary",
+      "error_marking_primary": "Error marking address as primary",
+      "deleted": "Address deleted successfully",
+      "error_deleting": "Error deleting address",
+      "error_saving": "Error saving address",
+      "error_loading": "Error loading addresses",
+      "confirm_delete": "Do you want to delete this address?",
+      "address_type": "Address type",
+      "type": {
+        "home": "Residential",
+        "commercial": "Commercial",
+        "other": "Other"
+      }
+    },
+    "help": {
+      "title": "Help",
+      "description": "Questions and support",
+      "support_title": "Support Center",
+      "online_status": "Online",
+      "ai_assistant_label": "AI Assistant",
+      "virtual_assistant": "Virtual Assistant",
+      "greeting_message": "Hello! How can I help you today?",
+      "quick_suggestions": "Quick suggestions",
+      "message_placeholder": "Type your message",
+      "footer_disclaimer": "AI Assistant powered by Diarinho",
+      "coming_soon": "Coming soon",
+      "suggestion_cancel": "How to cancel a daily service?",
+      "suggestion_data": "How to update my data?",
+      "suggestion_payment": "How does payment work?",
+      "suggestion_human": "Talk to a human"
+    },
+    "logout": {
+      "title": "Logout",
+      "description": "Disconnect from your account"
+    }
   },
   "dashboard": {
     "currency_format": "$ {value}",
@@ -371,5 +512,21 @@
         }
       }
     }
+  },
+  "address": {
+    "types": {
+      "commercial": {
+        "home": "Residential",
+        "commercial": "Commercial",
+        "other": "Other"
+      }
+    }
+  },
+  "period_types": {
+    "2": "Quick (up to 2h)",
+    "4": "Medium (up to 4h)",
+    "6": "Standard (up to 6h)",
+    "8": "Full day (up to 8h)",
+    "unknown": "No information"
   }
 }

+ 162 - 5
src/i18n/locales/es.json

@@ -1,5 +1,6 @@
 {
   "common": {
+    "confirm": "Confirmar",
     "actions": {
       "save": "Guardar",
       "cancel": "Cancelar",
@@ -315,8 +316,14 @@
     }
   },
   "dashboard_client": {
-    "header": { "rating": "Calificación", "services": "Servicios" },
-    "summary": { "welcome": "Bienvenido (a),", "my_schedules": "Mis jornadas" },
+    "header": {
+      "rating": "Calificación",
+      "services": "Servicios"
+    },
+    "summary": {
+      "welcome": "Bienvenido (a),",
+      "my_schedules": "Mis jornadas"
+    },
     "next_schedules": {
       "title": "Próximos servicios",
       "no_provider": "Sin prestador asignado",
@@ -332,9 +339,143 @@
       "place_unknown": "N/A",
       "details": "ver detalles"
     },
-    "last_schedules": { "title": "Últimos servicios", "reschedule": "reagendar" },
-    "favorites": { "title": "Favoritos", "view_schedule": "ver agenda" },
-    "providers_close": { "title": "Cerca de ti", "schedule": "reservar", "until_8h": "Hasta 8h", "place_home": "Casa" }
+    "last_schedules": {
+      "title": "Últimos servicios",
+      "reschedule": "reagendar"
+    },
+    "favorites": {
+      "title": "Favoritos",
+      "view_schedule": "ver agenda"
+    },
+    "providers_close": {
+      "title": "Cerca de ti",
+      "schedule": "reservar",
+      "until_8h": "Hasta 8h",
+      "until_6h": "Hasta 6h",
+      "until_4h": "Hasta 4h",
+      "until_2h": "Hasta 2h",
+      "place_home": "Casa"
+    }
+  },
+  "profile": {
+    "title": "Perfil",
+    "edit_profile": "Editar perfil",
+    "edit_data": "Editar datos",
+    "change_photo": "Cambiar foto",
+    "full_name": "Nombre completo",
+    "placeholder_name": "Ingrese su nombre",
+    "email": "Correo electrónico",
+    "placeholder_email": "Ingrese su correo electrónico",
+    "phone": "Teléfono",
+    "placeholder_phone": "(11) 99999-9999",
+    "update": "Actualizar",
+    "payments": {
+      "title": "Pagos",
+      "description": "Mis tarjetas",
+      "subtitle": "Métodos de Pago",
+      "add_card": "agregar nueva tarjeta",
+      "credit": "Crédito",
+      "debit": "Débito",
+      "no_cards": "No hay tarjetas registradas",
+      "add_first_card": "Agrega tu primera tarjeta.",
+      "card_number": "Número de tarjeta",
+      "holder_name": "Nombre del titular",
+      "expiration": "Vencimiento",
+      "expiration_placeholder": "MM/AAAA",
+      "cvv": "CVV",
+      "cvv_help": "Los 3 o 4 dígitos en el reverso de la tarjeta",
+      "brand": "Marca",
+      "add_title": "Agregar Tarjeta",
+      "edit_title": "Editar Tarjeta",
+      "save_btn": "Guardar tarjeta",
+      "security_badge": "Tus datos están seguros",
+      "invalid_card_number": "Número de tarjeta inválido",
+      "expired_card": "Tarjeta vencida o fecha inválida",
+      "remove_title": "¿Deseas eliminar esta tarjeta?",
+      "remove_confirm": "Eliminar",
+      "remove_cancel": "Cancelar",
+      "active": "Activa",
+      "inactive": "Inactiva",
+      "mmyyyy": "mm/aaaa"
+    },
+    "favorites": {
+      "title": "Favoritos",
+      "description": "Limpiadores favoritos",
+      "empty_message": "Aún no tienes limpiadores en tu lista de favoritos.",
+      "empty_cta": "¡Busca uno ahora!",
+      "search_btn": "buscar",
+      "indicate_title": "Recomienda un limpiador a un amigo.",
+      "indicate_btn": "Recomendar",
+      "indicate_whatsapp_message": "¡Hola! Uso la app Diária para contratar limpiadores y quiero recomendarte un profesional: {name} ({city}). Puedes descargar la app Diária y ver su perfil aquí 👇",
+      "indicate_coming_soon": "Pronto podrás recomendar limpiadores",
+      "providers_title": "Limpiadores",
+      "until": "hasta",
+      "schedule_btn": "reservar",
+      "remove_title": "¿Deseas eliminar al limpiador de favoritos?",
+      "remove_confirm": "Eliminar",
+      "remove_cancel": "Cancelar"
+    },
+    "address": {
+      "title": "Direcciones",
+      "description": "Lugar de limpieza",
+      "my_addresses": "Mis direcciones",
+      "manage_addresses": "Administre sus direcciones y elija a dónde se programará el servicio.",
+      "address_subtitle": "Mis direcciones",
+      "address_description": "Agregue y administre sus direcciones de limpieza",
+      "search_placeholder": "Buscar dirección",
+      "cep": "Código Postal",
+      "cep_not_found": "Código Postal no encontrado",
+      "address_label": "Dirección",
+      "address_placeholder": "Ingrese su dirección",
+      "number": "Número",
+      "complement": "Complemento",
+      "complement_placeholder": "Complemento (opcional)",
+      "district_label": "Barrio",
+      "city_label": "Ciudad",
+      "state_label": "Estado/Provincia",
+      "update_address": "Actualizar dirección",
+      "add_address": "Agregar dirección",
+      "add_new_address": "Agregar nueva dirección",
+      "edit_address": "Editar dirección",
+      "edit": "Editar",
+      "mark_as_primary": "Marcar como principal",
+      "main": "Principal",
+      "no_addresses": "Sin direcciones registradas",
+      "marked_as_primary": "Dirección marcada como principal",
+      "error_marking_primary": "Error al marcar dirección como principal",
+      "deleted": "Dirección eliminada con éxito",
+      "error_deleting": "Error al eliminar dirección",
+      "error_saving": "Error al guardar dirección",
+      "error_loading": "Error al cargar direcciones",
+      "confirm_delete": "¿Deseas eliminar esta dirección?",
+      "address_type": "Tipo de dirección",
+      "type": {
+        "home": "Residencial",
+        "commercial": "Comercial",
+        "other": "Otro"
+      }
+    },
+    "help": {
+      "title": "Ayuda",
+      "description": "Preguntas y soporte",
+      "support_title": "Centro de Soporte",
+      "online_status": "En línea",
+      "ai_assistant_label": "Asistente de IA",
+      "virtual_assistant": "Asistente Virtual",
+      "greeting_message": "¡Hola! ¿Cómo puedo ayudarte hoy?",
+      "quick_suggestions": "Sugerencias rápidas",
+      "message_placeholder": "Escribe tu mensaje",
+      "footer_disclaimer": "Asistente de IA impulsado por Diarinho",
+      "coming_soon": "Próximamente",
+      "suggestion_cancel": "¿Cómo cancelar un servicio diario?",
+      "suggestion_data": "¿Cómo actualizar mis datos?",
+      "suggestion_payment": "¿Cómo funciona el pago?",
+      "suggestion_human": "Hablar con un humano"
+    },
+    "logout": {
+      "title": "Cerrar sesión",
+      "description": "Desconectar de tu cuenta"
+    }
   },
   "dashboard": {
     "currency_format": "R$ {value}",
@@ -371,5 +512,21 @@
         }
       }
     }
+  },
+  "address": {
+    "types": {
+      "commercial": {
+        "home": "Residencial",
+        "commercial": "Comercial",
+        "other": "Otro"
+      }
+    }
+  },
+  "period_types": {
+    "2": "Rápido (hasta 2h)",
+    "4": "Medio (hasta 4h)",
+    "6": "Estándar (hasta 6h)",
+    "8": "Día completo (hasta 8h)",
+    "unknown": "Sin información"
   }
 }

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

@@ -1,5 +1,6 @@
 {
   "common": {
+    "confirm": "Confirmar",
     "actions": {
       "save": "Salvar",
       "cancel": "Cancelar",
@@ -350,9 +351,132 @@
       "title": "Perto de você",
       "schedule": "agendar",
       "until_8h": "Até 8h",
+      "until_6h": "Até 6h",
+      "until_4h": "Até 4h",
+      "until_2h": "Até 2h",
       "place_home": "Casa"
     }
   },
+  "profile": {
+    "title": "Perfil",
+    "edit_profile": "Editar perfil",
+    "edit_data": "Editar dados",
+    "change_photo": "Alterar foto",
+    "full_name": "Nome completo",
+    "placeholder_name": "Digite seu nome",
+    "email": "E-mail",
+    "placeholder_email": "Digite seu e-mail",
+    "phone": "Telefone",
+    "placeholder_phone": "(11) 99999-9999",
+    "update": "Atualizar",
+    "payments": {
+      "title": "Pagamentos",
+      "description": "Meus cartões",
+      "subtitle": "Meios de Pagamento",
+      "add_card": "adicionar novo cartão",
+      "credit": "Crédito",
+      "debit": "Débito",
+      "no_cards": "Nenhum cartão cadastrado",
+      "add_first_card": "Adicione seu primeiro cartão.",
+      "card_number": "Número do cartão",
+      "holder_name": "Nome do titular",
+      "expiration": "Validade",
+      "expiration_placeholder": "MM/AAAA",
+      "cvv": "CVV",
+      "cvv_help": "Os 3 ou 4 dígitos no verso do cartão",
+      "brand": "Bandeira",
+      "add_title": "Adicionar Cartão",
+      "edit_title": "Editar Cartão",
+      "save_btn": "Salvar cartão",
+      "security_badge": "Seus dados estão seguros",
+      "invalid_card_number": "Número de cartão inválido",
+      "expired_card": "Cartão vencido ou data inválida",
+      "remove_title": "Deseja remover este cartão?",
+      "remove_confirm": "Remover",
+      "remove_cancel": "Cancelar",
+      "active": "Ativo",
+      "inactive": "Inativo",
+      "mmyyyy": "mm/aaaa"
+    },
+    "favorites": {
+      "title": "Favoritos",
+      "description": "Diaristas favoritos",
+      "empty_message": "Você ainda não tem diaristas na sua lista de favoritos.",
+      "empty_cta": "Busque um agora mesmo!",
+      "search_btn": "buscar",
+      "indicate_title": "Indique um diarista para um amigo.",
+      "indicate_btn": "Indicar",
+      "indicate_whatsapp_message": "Oi! Eu uso o aplicativo Diária pra contratar diaristas, e quero te indicar um profissional: {name} ({city}). Você pode baixar o app Diária e ver o perfil dela por aqui 👇",
+      "indicate_coming_soon": "Em breve você poderá indicar diaristas",
+      "providers_title": "Diaristas",
+      "until": "até",
+      "schedule_btn": "agendar",
+      "remove_title": "Deseja remover o diarista dos favoritos?",
+      "remove_confirm": "Remover",
+      "remove_cancel": "Cancelar"
+    },
+    "address": {
+      "title": "Endereços",
+      "description": "Local de limpeza",
+      "my_addresses": "Meus endereços",
+      "manage_addresses": "Gerencie seus endereços e escolha para onde será agendado o serviço.",
+      "address_subtitle": "Meus endereços",
+      "address_description": "Adicione e gerenciee seus endereços de limpeza",
+      "search_placeholder": "Buscar endereço",
+      "cep": "CEP",
+      "cep_not_found": "CEP não encontrado",
+      "address_label": "Endereço",
+      "address_placeholder": "Digite seu endereço",
+      "number": "Número",
+      "complement": "Complemento",
+      "complement_placeholder": "Complemento (opcional)",
+      "district_label": "Bairro",
+      "city_label": "Cidade",
+      "state_label": "Estado",
+      "update_address": "Atualizar endereço",
+      "add_address": "Adicionar endereço",
+      "add_new_address": "Adicionar novo endereço",
+      "edit_address": "Editar endereço",
+      "edit": "Editar",
+      "mark_as_primary": "Marcar como principal",
+      "main": "Principal",
+      "no_addresses": "Nenhum endereço cadastrado",
+      "marked_as_primary": "Endereço marcado como principal",
+      "error_marking_primary": "Erro ao marcar endereço como principal",
+      "deleted": "Endereço deletado com sucesso",
+      "error_deleting": "Erro ao deletar endereço",
+      "error_saving": "Erro ao salvar endereço",
+      "error_loading": "Erro ao carregar endereços",
+      "confirm_delete": "Deseja deletar este endereço?",
+      "address_type": "Tipo de endereço",
+      "type": {
+        "home": "Residencial",
+        "commercial": "Comercial",
+        "other": "Outro"
+      }
+    },
+    "help": {
+      "title": "Ajuda",
+      "description": "Dúvidas e suporte",
+      "support_title": "Central de Suporte",
+      "online_status": "Online",
+      "ai_assistant_label": "Assistente de IA",
+      "virtual_assistant": "Assistente Virtual",
+      "greeting_message": "Olá! Como posso ajudá-lo hoje?",
+      "quick_suggestions": "Sugestões rápidas",
+      "message_placeholder": "Digite sua mensagem",
+      "footer_disclaimer": "Assistente de IA alimentado por Diarinho",
+      "coming_soon": "Em breve",
+      "suggestion_cancel": "Como cancelar uma diária?",
+      "suggestion_data": "Como atualizar meus dados?",
+      "suggestion_payment": "Como funciona o pagamento?",
+      "suggestion_human": "Falar com um humano"
+    },
+    "logout": {
+      "title": "Sair",
+      "description": "Desconectar da sua conta"
+    }
+  },
   "dashboard": {
     "currency_format": "R$ {value}",
     "cards": {
@@ -388,5 +512,21 @@
         }
       }
     }
+  },
+  "address": {
+    "types": {
+      "commercial": {
+        "home": "Residencial",
+        "commercial": "Comercial",
+        "other": "Outro"
+      }
+    }
+  },
+  "period_types": {
+    "2": "Rápida (até 2h)",
+    "4": "Média (até 4h)",
+    "6": "Padrão (até 6h)",
+    "8": "Dia completo (até 8h)",
+    "unknown": "Sem informação"
   }
 }

+ 10 - 10
src/pages/dashboard/DashboardPage.vue

@@ -9,10 +9,10 @@
       <DashboardHeaderBar :data="headerBar" />
       <DashboardSummaryInfos :data="summaryInfos" />
       <DashboardScrollAreaSchedules />
-      <DashboardNextSchedules :data="nextSchedules" />
-      <DashboardLastDoneSchedules :data="lastDoneSchedules" />
-      <DashboardFavoriteProviders :data="favoriteProviders" />
-      <DashboardProvidersClose :data="providersClose" />
+      <DashboardNextSchedules v-if="nextSchedules.length > 0" :data="nextSchedules" />
+      <DashboardLastDoneSchedules v-if="lastDoneSchedules.length > 0" :data="lastDoneSchedules" />
+      <DashboardFavoriteProviders v-if="favoriteProviders.length > 0" :data="favoriteProviders" />
+      <DashboardProvidersClose v-if="providersClose.length > 0" :data="providersClose" />
     </template>
   </div>
 </template>
@@ -28,12 +28,12 @@ import DashboardProvidersClose from 'src/components/dashboard/DashboardProviders
 import { onMounted, ref } from 'vue';
 import { dadosDashboard } from 'src/api/dashboard';
 
-const headerBar = ref(null);
-const summaryInfos = ref(null);
-const nextSchedules = ref(null);
-const lastDoneSchedules = ref(null);
-const favoriteProviders = ref(null);
-const providersClose = ref(null);
+const headerBar = ref({});
+const summaryInfos = ref({});
+const nextSchedules = ref([]);
+const lastDoneSchedules = ref([]);
+const favoriteProviders = ref([]);
+const providersClose = ref([]);
 const loading = ref(true);
 onMounted( async () => {
   const response = await dadosDashboard();

+ 152 - 0
src/pages/profile/ProfileEditDialog.vue

@@ -0,0 +1,152 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-left" transition-hide="slide-right">
+    <q-card class="bg-white full-width full-height">
+      <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-profile">
+        <q-btn flat round dense icon="mdi-chevron-left" color="primary" @click="onDialogCancel" />
+        <q-space />
+        <span class="text-subtitle1 text-weight-bold text-primary">{{ $t('profile.edit_data') }}</span>
+        <q-space />
+        <div style="width: 32px"></div>
+      </div>
+
+      <div v-if="loading" class="flex flex-center q-pa-xl">
+        <q-spinner color="primary" size="3em" />
+      </div>
+
+      <template v-else>
+        <q-scroll-area class="col" style="height: calc(100vh - 72px)">
+          <div class="column items-center q-mt-xl q-mb-md">
+            <q-avatar size="140px" color="indigo-1" text-color="indigo-4" class="text-weight-bold text-h2 shadow-1">
+              {{ form.name ? form.name.charAt(0).toUpperCase() : '' }}
+            </q-avatar>
+            <q-btn flat no-caps color="grey-6" class="q-mt-sm" :label="$t('profile.change_photo')" />
+          </div>
+
+          <div class="q-px-xl q-gutter-y-lg">
+            <div>
+              <div class="text-weight-bold text-grey-8 q-mb-sm">{{ $t('profile.full_name') }}</div>
+              <q-input
+                v-model="form.name"
+                outlined
+                dense
+                input-class="text-text"
+                :placeholder="$t('profile.placeholder_name')"
+              />
+            </div>
+
+            <div>
+              <div class="text-weight-bold text-grey-8 q-mb-sm">{{ $t('profile.email') }}</div>
+              <q-input
+                v-model="form.email"
+                outlined
+                dense
+                input-class="text-text"
+                :placeholder="$t('profile.placeholder_email')"
+              />
+            </div>
+
+            <div>
+              <div class="text-weight-bold text-grey-8 q-mb-sm">{{ $t('profile.phone') }}</div>
+              <q-input
+                v-model="form.phone"
+                outlined
+                dense
+                input-class="text-text"
+                mask="(##) #####-####"
+                unmasked-value
+                :placeholder="$t('profile.placeholder_phone')"
+              />
+            </div>
+          </div>
+
+          <q-space/>
+
+          <div class="q-pa-xl q-mt-md">
+            <q-btn
+              unelevated
+              rounded
+              no-caps
+              padding="8px 16px"
+              class="full-width q-py-md text-weight-bold"
+              :label="$t('profile.update')"
+              :color="hasUpdatedFields ? 'primary' : 'grey-4'"
+              :disable="!hasUpdatedFields"
+              :loading="submitting"
+              @click="submitUpdate"
+            />
+          </div>
+        </q-scroll-area>
+      </template>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue';
+import { useDialogPluginComponent } from 'quasar';
+import { updateUser } from 'src/api/user';
+import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
+
+const props = defineProps({
+  userData: {
+    type: Object,
+    default: null
+  }
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const loading = ref(false);
+const submitting = ref(false);
+const userId = ref(null);
+
+const { form, hasUpdatedFields, setUpdateFormAsOriginal } = useFormUpdateTracker({
+  name: '',
+  email: '',
+  phone: ''
+});
+
+const submitUpdate = async () => {
+  if (!hasUpdatedFields.value) return;
+
+  submitting.value = true;
+  try {
+    const data = await updateUser({
+      name: form.name,
+      email: form.email,
+      phone: form.phone
+    }, userId.value);
+    console.log(data)
+    setUpdateFormAsOriginal(data);
+    onDialogOK(data);
+  } catch (error) {
+    console.error('Erro ao atualizar perfil:', error);
+  } finally {
+    submitting.value = false;
+  }
+};
+
+onMounted(async () => {
+  if (props.userData) {
+    const data = props.userData;
+    userId.value = data.id;
+    form.name = data.name || '';
+    form.email = data.email || '';
+    form.phone = data.phone || '';
+    setUpdateFormAsOriginal(data);
+    return;
+  }
+
+  loading.value = true;
+});
+</script>
+
+<style scoped lang="scss">
+
+
+:deep(.q-field--outlined .q-field__control) {
+  border-radius: 8px;
+  &::before { border: 1px solid #e0e0e0; }
+}
+</style>

+ 212 - 38
src/pages/profile/ProfilePage.vue

@@ -1,52 +1,226 @@
 <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
 <template>
-  <section class="mobile-placeholder">
-    <div class="mobile-placeholder__badge">
-      <q-icon name="mdi-account-circle-outline" />
+  <q-page class="bg-page q-pb-xl">
+    <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-profile">
+      <q-space />
+      <span class="text-subtitle1 text-weight-bold text-primary">{{ $t('profile.title') }}</span>
+      <q-space />
     </div>
-    <h1 class="mobile-placeholder__title">Perfil</h1>
-    <p class="mobile-placeholder__description">
-      Área reservada para informações da conta, dados profissionais e preferências.
-    </p>
-  </section>
+
+    <div class="q-px-md q-mt-md">
+      <q-card class="profile-card bg-surface shadow-card card-border" :flat="false">
+        <q-btn flat round dense icon="mdi-share-variant-outline" color="grey-6" class="absolute-top-right q-ma-sm" />
+        
+        <q-card-section class="column items-center q-pb-md">
+          <q-avatar size="70px" class="shadow-card">
+            <img src="https://cdn.quasar.dev/img/avatar.png">
+          </q-avatar>
+          
+          <div class="fonte-nome-profile text-weight-bold q-mt-md text-dark">{{ user.name || '—' }}</div>
+          <div class="fonte-email-profile text-grey-6 q-my-sm">{{ user.email || '—' }}</div>
+          <div class="fonte-telefone-profile text-grey-7">{{ user.phone || '—' }}</div> 
+          
+          <q-btn 
+            outline 
+            no-caps 
+            padding="6px 16px"
+            class="full-width q-mt-sm btn-edit-profile text-text" 
+            @click="openEditProfile"
+          >
+            <div class="row items-center q-gutter-x-sm">
+              <q-icon name="mdi-account-outline" size="18px" />
+              <span class="text-weight-medium">{{ $t('profile.edit_profile') }}</span>
+            </div>
+          </q-btn>
+        </q-card-section>
+      </q-card>
+    </div>
+
+    <div class="q-mt-md q-px-md column">
+      <div class="menu-item row items-center no-wrap cursor-pointer q-py-sm" @click="openPaymentsDialog">
+        <div class="column">
+          <span class="menu-title gradient-diarista text-weight-bold">{{ $t('profile.payments.title') }}</span>
+          <span class="menu-description text-text">{{ $t('profile.payments.description') }}</span>
+        </div>
+        <q-space/>
+        <q-icon name="mdi-chevron-right" color="primary" size="md" />
+      </div>
+
+      <div class="menu-item row items-center no-wrap cursor-pointer q-py-sm" @click="openFavoritesDialog">
+        <div class="column">
+          <span class="menu-title gradient-diarista text-weight-bold">{{ $t('profile.favorites.title') }}</span>
+          <span class="menu-description text-text">{{ $t('profile.favorites.description') }}</span>
+        </div>
+        <q-space/>
+        <q-icon name="mdi-chevron-right" color="primary" size="md" />
+      </div>
+
+      <div class="menu-item row items-center no-wrap cursor-pointer q-py-sm" @click="openAddressDialog">
+        <div class="column">
+          <span class="menu-title gradient-diarista text-weight-bold">{{ $t('profile.address.title') }}</span>
+          <span class="menu-description text-text">{{ $t('profile.address.description') }}</span>
+        </div>
+        <q-space/>
+        <q-icon name="mdi-chevron-right" color="primary" size="md" />
+      </div>
+
+      <div class="menu-item row items-center no-wrap cursor-pointer q-py-sm" @click="openHelpDialog">
+        <div class="column">
+          <span class="menu-title gradient-diarista text-weight-bold">{{ $t('profile.help.title') }}</span>
+          <span class="menu-description text-text">{{ $t('profile.help.description') }}</span>
+        </div>
+        <q-space/>
+        <q-icon name="mdi-chevron-right" color="primary" size="md" />
+      </div>
+
+      <q-separator class="q-my-sm bg-grey-3" inset />
+
+      <div class="menu-item row items-center no-wrap cursor-pointer q-py-sm" @click="handleLogout">
+        <div class="column">
+          <span class="menu-title text-weight-bold text-text">{{ $t('profile.logout.title') }}</span>
+          <span class="menu-description text-text">{{ $t('profile.logout.description') }}</span>
+        </div>
+        <q-space/>
+        <q-icon name="mdi-chevron-right" color="primary" size="md" />
+      </div>
+    </div>
+  </q-page>
 </template>
 
-<style scoped>
-.mobile-placeholder {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  min-height: calc(100dvh - 240px);
-  padding: 32px 20px;
-  text-align: center;
+<script setup>
+import { ref, onMounted } from 'vue';
+import { useQuasar } from 'quasar';
+import { useAuth } from 'src/composables/useAuth';
+import { getUser } from 'src/api/user';
+import ProfileEditDialog from './ProfileEditDialog.vue';
+import ProfileAddressDialog from 'src/components/profile/ProfileAddressDialog.vue';
+import ProfilePaymentsDialog from 'src/components/profile/ProfilePaymentsDialog.vue';
+import ProfileFavoritesDialog from 'src/components/profile/ProfileFavoritesDialog.vue';
+import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue';
+import { useRouter } from 'vue-router';
+
+const $q = useQuasar();
+
+const { logout } = useAuth();
+const router = useRouter();
+const user = ref({
+  name: '',
+  email: '',
+  phone: ''
+});
+
+const openEditProfile = () => {
+  $q.dialog({
+    component: ProfileEditDialog,
+    componentProps: {
+      userData: user.value
+    }
+  }).onOk((updatedUser) => {
+    user.value = { ...user.value, ...updatedUser };
+  });
+};
+
+const openPaymentsDialog = () => {
+  $q.dialog({
+    component: ProfilePaymentsDialog
+  });
+};
+
+const openFavoritesDialog = () => {
+  $q.dialog({
+    component: ProfileFavoritesDialog
+  });
+};
+
+const openAddressDialog = () => {
+  $q.dialog({
+    component: ProfileAddressDialog
+  });
+};
+
+const openHelpDialog = () => {
+  $q.dialog({
+    component: ProfileHelpDialog
+  });
+};
+
+const handleLogout = async () => {
+  await logout();
+  router.push('/login');
+};
+
+onMounted(async () => {
+  try {
+    const data = await getUser();
+    user.value = data;
+  } catch (error) {
+    console.error('Erro ao carregar perfil:', error);
+  }
+});
+</script>
+
+<style scoped lang="scss">
+.profile-card {
+  position: relative;
 }
 
-.mobile-placeholder__badge {
-  display: grid;
-  place-items: center;
-  width: 88px;
-  height: 88px;
-  border-radius: 28px;
-  margin-bottom: 20px;
-  background: linear-gradient(180deg, rgba(255, 0, 234, 0.14), rgba(107, 17, 203, 0.08));
-  color: #ff00ea;
-  font-size: 44px;
+.btn-edit-profile {
+  border-radius: 6px;
 }
 
-.mobile-placeholder__title {
-  margin: 0 0 8px;
-  font-size: 28px;
+.menu-item {
+  transition: background 0.3s;
+  &:active {
+    background: rgba(0,0,0,0.05);
+  }
+}
+
+.menu-title {
+  font-size: 18px;
+  line-height: 1.4;
+}
+
+.menu-description {
+  font-size: 14px;
+}
+
+.flex-1 {
+  flex: 1;
+}
+
+.text-dark {
+  color: #2c3e50;
+}
+
+.fonte-email-profile {
+  font-family: Inter;
+  font-weight: 500;
+  font-style: Medium;
+  font-size: 12px;
+  line-height: 100%;
+  letter-spacing: 0%;
+}
+
+.fonte-telefone-profile {
+  font-family: Inter;
+  font-weight: 500;
+  font-style: Medium;
+  font-size: 12px;
+  line-height: 100%;
+  letter-spacing: 0%;
+}
+
+.fonte-nome-profile {
+  font-family: Inter;
   font-weight: 700;
-  line-height: 1.1;
-  color: #4d4d4d;
+  font-style: Bold;
+  font-size: 18px;
+  line-height: 100%;
+  letter-spacing: 0%;
+  text-align: center;
 }
 
-.mobile-placeholder__description {
-  max-width: 280px;
-  margin: 0;
-  font-size: 16px;
-  line-height: 1.5;
-  color: #8d8d8d;
+.shadow-profile {
+  box-shadow: 0px 1px 8px rgba(0, 0, 0, 0.1);
 }
 </style>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor