Sfoglia il codice sorgente

refactor: :recycle: refactor (images) refatoracao geral imagens com S3

foi retatorada toda a parte de imagens para fazer upload corretamente e anexar na S3 que foi criada + corrigida exibicao das imagens com url presigned da aws em todas as telas

fase:dev | origin:escopo
Gustavo Zanatta 2 settimane fa
parent
commit
084af42cd2

+ 16 - 1
src/api/review.js

@@ -1,7 +1,22 @@
 import api from 'src/api'
 
 export const createReview = async (reviewData) => {
-  const { data } = await api.post('/reviews', reviewData)
+  const { photos, ...rest } = reviewData
+
+  if (photos && photos.length > 0) {
+    const form = new FormData()
+    Object.entries(rest).forEach(([key, val]) => {
+      if (val === null || val === undefined) return
+      if (Array.isArray(val)) val.forEach(v => form.append(`${key}[]`, v))
+      else if (typeof val === 'boolean') form.append(key, val ? '1' : '0')
+      else form.append(key, val)
+    })
+    photos.forEach(file => form.append('photos[]', file))
+    const { data } = await api.post('/reviews', form)
+    return data.payload
+  }
+
+  const { data } = await api.post('/reviews', rest)
   return data.payload
 }
 

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

@@ -13,7 +13,8 @@
           <q-card-section class="q-pa-sm">
             <div class="row no-wrap items-center q-gutter-x-sm">
               <q-avatar size="48px">
-                <img :src="item.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'">
+                <img v-if="item.customer_photo" :src="item.customer_photo" style="object-fit:cover" />
+                <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:14px;border-radius:50%">{{ (item.customer_name ?? item.client_name)?.slice(0,2).toUpperCase() ?? '??' }}</span>
               </q-avatar>
               <div class="column flex-1">
                 <div class="row items-center q-gutter-x-xs">
@@ -89,6 +90,13 @@ const emit = defineEmits(['view-details']);
 const t = useI18n().t;
 const $q = useQuasar();
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 const formatWeekday = (iso) => {
   if (!iso) return '';
   const d = new Date(iso);

+ 9 - 2
src/components/dashboard/DashboardOpportunities.vue

@@ -16,7 +16,8 @@
           <q-card-section class="q-pa-sm">
             <div class="row no-wrap items-center q-gutter-x-sm">
               <q-avatar size="48px">
-                <img :src="item.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'">
+                <img v-if="item.customer_photo" :src="item.customer_photo" style="object-fit:cover" />
+                <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:14px;border-radius:50%">{{ (item.client_name ?? item.client?.user?.name)?.slice(0,2).toUpperCase() ?? '??' }}</span>
               </q-avatar>
               <div class="column flex-1">
                 <div class="row items-center q-gutter-x-xs">
@@ -84,9 +85,15 @@ defineProps({
 
 
 const $q = useQuasar();
-
 const router = useRouter();
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 
 const openOpportunityDetails = (item) => {
   $q.dialog({

+ 9 - 1
src/components/dashboard/DashboardSolicitations.vue

@@ -16,7 +16,8 @@
           <q-card-section class="q-pa-sm">
             <div class="row no-wrap items-center q-gutter-x-sm">
               <q-avatar size="48px">
-                <img :src="item.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'">
+                <img v-if="item.customer_photo" :src="item.customer_photo" style="object-fit:cover" />
+                <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:14px;border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
               </q-avatar>
               <div class="column flex-1">
                 <div class="row items-center q-gutter-x-xs">
@@ -111,6 +112,13 @@ const emit = defineEmits(['accept', 'reject', 'view-details']);
 
 const t = useI18n().t;
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 const parseLocalDate = (iso) => {
   if (!iso) return null;
   const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);

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

@@ -3,8 +3,9 @@
     <q-card-section class="q-pa-md">
       <div class="row items-center no-wrap q-gutter-x-md">
         <div class="row items-center no-wrap q-gutter-x-sm col">
-          <q-avatar size="54px" :style="avatarStyle" class="text-weight-bold text-h6">
-            {{ data?.name?.slice(0, 2).toUpperCase() ?? '??' }}
+          <q-avatar size="54px" :style="!data?.profile_photo ? avatarStyle : undefined" class="text-weight-bold text-h6">
+            <img v-if="data?.profile_photo" :src="data.profile_photo" style="object-fit:cover;width:100%;height:100%;border-radius:50%" />
+            <span v-else>{{ data?.name?.slice(0, 2).toUpperCase() ?? '??' }}</span>
           </q-avatar>
           <div class="column q-gutter-y-xs min-width-0">
             <span class="summary-greeting text-greeting">{{ $t('provider.dashboard.summary.welcome') }}</span>

+ 9 - 1
src/components/dashboard/NextSchedulesDetailsDialog.vue

@@ -8,7 +8,8 @@
 
       <q-card-section class="column items-center q-pt-xs q-pb-sm">
         <q-avatar size="72px" class="q-mb-sm">
-          <img :src="schedule.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" />
+          <img v-if="schedule.customer_photo" :src="schedule.customer_photo" style="object-fit:cover" />
+          <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[schedule.id % avatarColors.length]" style="font-size:20px;border-radius:50%">{{ (schedule.customer_name ?? schedule.client_name)?.slice(0,2).toUpperCase() ?? '??' }}</span>
         </q-avatar>
         <div class="text-subtitle1 text-weight-bold text-text">
           {{ schedule.customer_name ?? schedule.client_name }}
@@ -114,6 +115,13 @@ import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.j
 import ScheduleCancelDialog from './ScheduleCancelDialog.vue'
 import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 const props = defineProps({
   schedule: {
     type: Object,

+ 90 - 8
src/components/dashboard/ScheduleRatingDialog.vue

@@ -6,7 +6,6 @@
         <q-btn flat round dense icon="close" color="grey-6" size="sm" @click="onDialogCancel" />
       </div>
 
-      <!-- Avatar -->
       <div class="column items-center q-pb-sm">
         <q-avatar size="64px" class="q-mb-sm">
           <span
@@ -23,7 +22,6 @@
         </div>
       </div>
 
-      <!-- Estrelas -->
       <div class="column items-center q-pb-xs">
         <q-rating
           v-model="stars"
@@ -36,7 +34,6 @@
         />
       </div>
 
-      <!-- Tags de melhoria/qualidade -->
       <q-card-section v-if="stars > 0" class="q-pt-xs q-pb-xs">
         <div class="text-caption text-grey-7 text-center q-mb-sm">
           {{ isNegative ? $t('provider.dashboard.schedule_rating.negative_label') : $t('provider.dashboard.schedule_rating.positive_label') }}
@@ -57,7 +54,6 @@
         </div>
       </q-card-section>
 
-      <!-- Comentário -->
       <q-card-section class="q-pt-xs q-pb-xs q-px-lg">
         <div class="text-caption text-grey-7 q-mb-xs">
           {{ $t('provider.dashboard.schedule_rating.comment_placeholder') }}
@@ -74,7 +70,27 @@
         />
       </q-card-section>
 
-      <!-- Checkbox — apenas para avaliação negativa -->
+      <q-card-section class="q-pt-xs q-pb-xs q-px-lg">
+        <input
+          ref="photoInputRef"
+          type="file"
+          accept="image/jpeg,image/png,image/webp"
+          multiple
+          class="hidden"
+          @change="onPhotosSelected"
+        />
+        <div class="row q-gutter-xs">
+          <div v-for="(preview, idx) in photoPreviews" :key="idx" class="photo-thumb">
+            <img :src="preview" />
+            <q-btn round dense flat icon="close" size="xs" class="photo-thumb__remove" @click="removePhoto(idx)" />
+          </div>
+          <div v-if="photos.length < 5" class="photo-add" @click="photoInputRef.click()">
+            <q-icon name="mdi-camera-plus-outline" size="24px" color="grey-5" />
+            <div class="photo-add__label">{{ $t('provider.dashboard.schedule_rating.add_photo') }}</div>
+          </div>
+        </div>
+      </q-card-section>
+
       <q-card-section v-if="isNegative" class="q-pt-xs q-pb-xs q-px-lg">
         <q-checkbox
           v-model="blockClient"
@@ -87,7 +103,6 @@
         />
       </q-card-section>
 
-      <!-- Botão enviar -->
       <q-card-section class="q-pt-sm q-pb-xs q-px-lg row">
         <q-btn
           unelevated
@@ -103,7 +118,6 @@
         />
       </q-card-section>
 
-      <!-- Ajuda -->
       <q-card-section class="q-pt-xs q-pb-lg text-center">
         <span class="text-caption text-grey-6 cursor-pointer" @click="openHelp">
           {{ $t('provider.dashboard.schedule_rating.help_link') }}
@@ -142,9 +156,11 @@ const blockClient = ref(false)
 const tags = ref([])
 const loadingTags = ref(false)
 const loading = ref(false)
+const photos = ref([])
+const photoPreviews = ref([])
+const photoInputRef = ref(null)
 
 const isNegative = computed(() => stars.value > 0 && stars.value <= 2)
-// const isPositive = computed(() => stars.value >= 3)
 
 const avatarColors = [
   { background: '#ffd5df', color: '#932e57' },
@@ -173,6 +189,22 @@ const toggleTag = (id) => {
   else selectedTagIds.value.splice(idx, 1)
 }
 
+const onPhotosSelected = (event) => {
+  const files = Array.from(event.target.files)
+  const remaining = 5 - photos.value.length
+  files.slice(0, remaining).forEach(file => {
+    photos.value.push(file)
+    photoPreviews.value.push(URL.createObjectURL(file))
+  })
+  event.target.value = ''
+}
+
+const removePhoto = (idx) => {
+  URL.revokeObjectURL(photoPreviews.value[idx])
+  photos.value.splice(idx, 1)
+  photoPreviews.value.splice(idx, 1)
+}
+
 const openHelp = () => {
   $q.dialog({ component: ProfileHelpDialog })
 }
@@ -191,6 +223,7 @@ const submit = async () => {
       block_provider: false,
       block_client: isNegative.value && blockClient.value,
       favorite_provider: false,
+      photos: photos.value,
     })
 
     onDialogOK(true)
@@ -249,4 +282,53 @@ onMounted(async () => {
   font-weight: 700;
   padding: 10px 0;
 }
+
+.photo-thumb {
+  position: relative;
+  width: 64px;
+  height: 64px;
+  border-radius: 8px;
+  overflow: hidden;
+  flex-shrink: 0;
+
+  img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
+  &__remove {
+    position: absolute !important;
+    top: 2px;
+    right: 2px;
+    background: rgba(0, 0, 0, 0.5) !important;
+    color: white !important;
+    min-height: unset !important;
+  }
+}
+
+.photo-add {
+  width: 64px;
+  height: 64px;
+  border-radius: 8px;
+  border: 1.5px dashed #d1d5db;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  flex-shrink: 0;
+
+  &__label {
+    font-size: 9px;
+    color: #9ca3af;
+    text-align: center;
+    margin-top: 2px;
+    line-height: 1.2;
+  }
+
+  &:active { background: rgba(0, 0, 0, 0.03); }
+}
+
+.hidden { display: none; }
 </style>

+ 11 - 2
src/components/dashboard/SolicitationDetailsDialog.vue

@@ -11,7 +11,8 @@
 
         <q-card-section class="column items-center q-pt-xs q-pb-xs">
           <q-avatar size="72px" class="q-mb-sm">
-            <img :src="solicitation.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" />
+            <img v-if="solicitation.customer_photo" :src="solicitation.customer_photo" style="object-fit:cover" />
+            <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[solicitation.id % avatarColors.length]" style="font-size:20px;border-radius:50%">{{ solicitation.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
           </q-avatar>
           <div class="text-subtitle1 text-weight-bold text-text">
             {{ solicitation.client_name }}
@@ -110,7 +111,8 @@
 
         <q-card-section class="column items-center q-pt-sm q-pb-sm">
           <q-avatar size="72px" class="q-mb-sm">
-            <img :src="solicitation.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" />
+            <img v-if="solicitation.customer_photo" :src="solicitation.customer_photo" style="object-fit:cover" />
+            <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[solicitation.id % avatarColors.length]" style="font-size:20px;border-radius:50%">{{ solicitation.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
           </q-avatar>
 
           <div class="text-subtitle1 text-weight-bold text-text">{{ solicitation.client_name }}</div>
@@ -273,6 +275,13 @@ import { formatCurrency } from 'src/helpers/utils'
 import { labelsPeriodTypes } from 'src/helpers/arraysOptions/labelsPeriodTypes.js'
 import ScheduleCancelDialog from './ScheduleCancelDialog.vue'
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 const props = defineProps({
   solicitation: {
     type: Object,

+ 32 - 17
src/components/login/LoginStepFourPanel.vue

@@ -182,6 +182,10 @@ const tempDocFront = ref(null);
 const tempDocBack = ref(null);
 const tempSelfie = ref(null);
 
+const tempSelfieFile = ref(null);
+const tempDocFrontFile = ref(null);
+const tempDocBackFile = ref(null);
+
 const stream = ref(null);
 const videoRef = ref(null);
 const canvasRef = ref(null);
@@ -192,8 +196,8 @@ const PREVIEW_STATES = ['selfie_preview', 'doc_front_preview', 'doc_back_preview
 const isCameraState = computed(() => CAMERA_STATES.includes(subStep.value));
 const isPreviewState = computed(() => PREVIEW_STATES.includes(subStep.value));
 
-const selfieDone = computed(() => !!form.value.selfie_base64);
-const docDone = computed(() => !!form.value.document_front_base64 && !!form.value.document_back_base64);
+const selfieDone = computed(() => !!form.value.selfie);
+const docDone = computed(() => !!form.value.document_front && !!form.value.document_back);
 
 const currentPreview = computed(() => {
   if (subStep.value === 'selfie_preview') return tempSelfie.value;
@@ -265,29 +269,40 @@ const capturePhoto = () => {
   canvas.width = video.videoWidth || 1280;
   canvas.height = video.videoHeight || 720;
   canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
-  const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
-
-  if (subStep.value === 'selfie_camera') {
-    tempSelfie.value = dataUrl;
-    subStep.value = 'selfie_preview';
-  } else if (subStep.value === 'doc_front_camera') {
-    tempDocFront.value = dataUrl;
-    subStep.value = 'doc_front_preview';
-  } else if (subStep.value === 'doc_back_camera') {
-    tempDocBack.value = dataUrl;
-    subStep.value = 'doc_back_preview';
-  }
+
+  const capturing = subStep.value;
+  canvas.toBlob((blob) => {
+    const file = new File([blob], 'photo.jpg', { type: 'image/jpeg' });
+    const preview = URL.createObjectURL(blob);
+
+    if (capturing === 'selfie_camera') {
+      if (tempSelfie.value) URL.revokeObjectURL(tempSelfie.value);
+      tempSelfie.value = preview;
+      tempSelfieFile.value = file;
+      subStep.value = 'selfie_preview';
+    } else if (capturing === 'doc_front_camera') {
+      if (tempDocFront.value) URL.revokeObjectURL(tempDocFront.value);
+      tempDocFront.value = preview;
+      tempDocFrontFile.value = file;
+      subStep.value = 'doc_front_preview';
+    } else if (capturing === 'doc_back_camera') {
+      if (tempDocBack.value) URL.revokeObjectURL(tempDocBack.value);
+      tempDocBack.value = preview;
+      tempDocBackFile.value = file;
+      subStep.value = 'doc_back_preview';
+    }
+  }, 'image/jpeg', 0.85);
 };
 
 const usePhoto = () => {
   if (subStep.value === 'selfie_preview') {
-    form.value.selfie_base64 = tempSelfie.value;
+    form.value.selfie = tempSelfieFile.value;
     subStep.value = 'selfie_result';
   } else if (subStep.value === 'doc_front_preview') {
     subStep.value = 'doc_back_camera';
   } else if (subStep.value === 'doc_back_preview') {
-    form.value.document_front_base64 = tempDocFront.value;
-    form.value.document_back_base64 = tempDocBack.value;
+    form.value.document_front = tempDocFrontFile.value;
+    form.value.document_back = tempDocBackFile.value;
     docResult.value = 'success';
     subStep.value = 'doc_result';
   }

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

@@ -276,7 +276,20 @@
         "in_progress": "Service in progress!",
         "end_time_label": "Service ends at",
         "rate_btn": "Rate",
-        "help": "help"
+        "help": "help",
+        "reviewed_badge": "reviewed!"
+      },
+      "schedule_rating": {
+        "title": "How was the service by",
+        "positive_label": "What did you like most?",
+        "negative_label": "What could be improved?",
+        "comment_placeholder": "Would you like to leave a comment?",
+        "block_label": "Stop receiving requests from this client",
+        "add_photo": "Add photo",
+        "submit_btn": "submit review",
+        "help_link": "Help",
+        "already_reviewed": "You have already reviewed this service.",
+        "reviewed_badge": "reviewed!"
       },
       "opportunities": {
         "title": "Opportunities",

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

@@ -274,7 +274,20 @@
         "in_progress": "Servicio en progreso!",
         "end_time_label": "Término del servicio en",
         "rate_btn": "Evaluar",
-        "help": "ayuda"
+        "help": "ayuda",
+        "reviewed_badge": "¡evaluado!"
+      },
+      "schedule_rating": {
+        "title": "¿Cómo fue el servicio de",
+        "positive_label": "¿Qué te gustó más?",
+        "negative_label": "¿Qué podría mejorar?",
+        "comment_placeholder": "¿Deseas dejar un comentario?",
+        "block_label": "No recibir más pedidos de este cliente",
+        "add_photo": "Agregar foto",
+        "submit_btn": "enviar evaluación",
+        "help_link": "Ayuda",
+        "already_reviewed": "Ya evaluaste este servicio.",
+        "reviewed_badge": "¡evaluado!"
       },
       "opportunities": {
         "title": "Oportunidades",

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

@@ -285,6 +285,7 @@
         "negative_label": "O que poderia melhorar?",
         "comment_placeholder": "Deseja deixar um comentário?",
         "block_label": "Não receber mais pedidos deste cliente",
+        "add_photo": "Adicionar foto",
         "submit_btn": "enviar avaliação",
         "help_link": "Ajuda",
         "already_reviewed": "Você já avaliou este serviço.",

+ 45 - 31
src/pages/LoginPage.vue

@@ -117,12 +117,9 @@ const stepThreeForm = ref({
 });
 
 const stepFourForm = ref({
-  selfie_file: null,
-  selfie_base64: '',
-  document_front_file: null,
-  document_front_base64: '',
-  document_back_file: null,
-  document_back_base64: '',
+  selfie: null,
+  document_front: null,
+  document_back: null,
 });
 
 const stepFiveForm = ref({
@@ -179,9 +176,9 @@ const validateCurrentStep = async () => {
   }
 
   if(steps.value === 4) {
-    const hasSelfie = !!stepFourForm.value.selfie_base64;
-    const hasDocumentFront = !!stepFourForm.value.document_front_base64;
-    const hasDocumentBack = !!stepFourForm.value.document_back_base64;
+    const hasSelfie = !!stepFourForm.value.selfie;
+    const hasDocumentFront = !!stepFourForm.value.document_front;
+    const hasDocumentBack = !!stepFourForm.value.document_back;
 
     if (!hasSelfie || !hasDocumentFront || !hasDocumentBack) {
       $q.notify({
@@ -229,38 +226,55 @@ const validateCodeInput = async () => {
 
 const registerUserAndProvider = async () => {
   const workingDays = mapWorkingDays();
+  const form = new FormData();
 
-  const payload = {
-    ...stepThreeForm.value,
-    email: stepThreeForm.value.email || email.value,
-    phone: stepThreeForm.value.phone || phone.value,
-    code: code.value,
-    birth_date: toISODate(stepThreeForm.value.birth_date),
-    has_complement: !stepThreeForm.value.no_complement,
-    complement: stepThreeForm.value.no_complement ? null : stepThreeForm.value.complement,
-
-    selfie_base64: stepFourForm.value.selfie_base64,
-    document_front_base64: stepFourForm.value.document_front_base64,
-    document_back_base64: stepFourForm.value.document_back_base64,
-
-    daily_price_8h: Number(stepFiveForm.value.daily_price_8h),
-    daily_price_6h: Number(stepFiveForm.value.daily_price_6h),
-    daily_price_4h: Number(stepFiveForm.value.daily_price_4h),
-    daily_price_2h: Number(stepFiveForm.value.daily_price_2h),
-    services_types_ids: stepFiveForm.value.services_types_ids,
+  const append = (key, val) => {
+    if (val === null || val === undefined) return;
+    if (typeof val === 'boolean') form.append(key, val ? '1' : '0');
+    else form.append(key, val);
+  };
 
-    working_days: workingDays,
+  append('name', stepThreeForm.value.name);
+  append('email', stepThreeForm.value.email || email.value);
+  append('phone', stepThreeForm.value.phone || phone.value);
+  append('code', code.value);
+  append('rg', stepThreeForm.value.rg);
+  append('document', stepThreeForm.value.document);
+  append('birth_date', toISODate(stepThreeForm.value.birth_date));
+  append('zip_code', stepThreeForm.value.zip_code);
+  append('address', stepThreeForm.value.address);
+  append('has_complement', !stepThreeForm.value.no_complement);
+  append('complement', stepThreeForm.value.no_complement ? null : stepThreeForm.value.complement);
+  append('nickname', stepThreeForm.value.nickname);
+  append('instructions', stepThreeForm.value.instructions);
+  append('city', stepThreeForm.value.city);
+  append('state', stepThreeForm.value.state);
+  append('address_type', stepThreeForm.value.address_type);
+
+  append('daily_price_8h', Number(stepFiveForm.value.daily_price_8h));
+  append('daily_price_6h', Number(stepFiveForm.value.daily_price_6h));
+  append('daily_price_4h', Number(stepFiveForm.value.daily_price_4h));
+  append('daily_price_2h', Number(stepFiveForm.value.daily_price_2h));
+
+  (stepFiveForm.value.services_types_ids ?? []).forEach(id => form.append('services_types_ids[]', id));
+
+  workingDays.forEach((wd, i) => {
+    form.append(`working_days[${i}][day]`, wd.day);
+    form.append(`working_days[${i}][period]`, wd.period);
+  });
 
-  };
+  form.append('selfie', stepFourForm.value.selfie);
+  form.append('document_front', stepFourForm.value.document_front);
+  form.append('document_back', stepFourForm.value.document_back);
 
-  const response = await createUserAndProvider(payload);
+  const response = await createUserAndProvider(form);
   if (response.status === 200) {
     steps.value = 7;
   }
 };
 
 const onSubmit = async () => {
-  if (showSubStep.value) return; // Não submete o form principal se estiver em um sub-passo
+  if (showSubStep.value) return;
 
   const isValid = await loginForm.value.validate();
   if (!isValid) return;

+ 10 - 3
src/pages/calendar/CalendarPage.vue

@@ -26,7 +26,8 @@
             <q-card-section class="q-pa-sm">
               <div class="row no-wrap items-start q-gutter-x-sm">
                 <q-avatar size="44px">
-                  <img :src="item.customer_photo || defaultAvatar">
+                  <img v-if="item.customer_photo" :src="item.customer_photo" style="object-fit:cover" />
+                  <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:13px;border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
                 </q-avatar>
 
                 <div class="col columns">
@@ -94,7 +95,8 @@
             <q-card-section class="q-pa-sm">
               <div class="row no-wrap items-start q-gutter-x-sm">
                 <q-avatar size="44px">
-                  <img :src="item.customer_photo || defaultAvatar">
+                  <img v-if="item.customer_photo" :src="item.customer_photo" style="object-fit:cover" />
+                  <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:13px;border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
                 </q-avatar>
 
                 <div class="col columns">
@@ -177,7 +179,12 @@ import ScheduleRatingDialog from 'src/components/dashboard/ScheduleRatingDialog.
 const $q = useQuasar();
 const { t } = useI18n();
 
-const defaultAvatar = 'https://cdn.quasar.dev/img/avatar.png';
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
 const loading = ref(true);
 const upcomingSchedules = ref([]);
 const completedSchedules = ref([]);

+ 17 - 2
src/pages/opportunities/OpportunitiesPage.vue

@@ -25,7 +25,8 @@
     <div v-else class="opportunity-list">
       <q-card v-for="item in opportunities" :key="item.id" flat class="opportunity-card">
         <div class="avatar-column">
-          <img :src="item.customer_photo || 'https://cdn.quasar.dev/img/avatar.png'" alt="Avatar" class="client-avatar" />
+          <img v-if="item.customer_photo" :src="item.customer_photo" alt="Avatar" class="client-avatar" />
+          <div v-else class="client-avatar client-avatar--initials" :style="avatarColors[item.id % avatarColors.length]">{{ (item.client_name ?? item.client?.user?.name)?.slice(0,2).toUpperCase() ?? '??' }}</div>
           <div class="service-type">
             {{ item.custom_schedule?.service_type.description }}
           </div>
@@ -82,9 +83,15 @@ import { userStore } from 'src/stores/user'
 import OpportunityDetailsDialog from './components/OpportunityDetailsDialog.vue'
 
 const $q = useQuasar()
-
 const user = userStore()
 
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
 const opportunities = ref([])
 const loading = ref(false)
 
@@ -222,6 +229,14 @@ onMounted(loadOpportunities)
   object-fit: cover;
 }
 
+.client-avatar--initials {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 700;
+  font-size: 14px;
+}
+
 .service-type {
   margin-top: 8px;
   font-size: 11px;

+ 8 - 2
src/pages/payments/PaymentsPage.vue

@@ -123,7 +123,8 @@
         <q-card-section class="q-pa-sm">
           <div class="row no-wrap items-start q-gutter-x-sm">
             <q-avatar size="44px">
-              <img :src="item.client_photo || defaultAvatar">
+              <img v-if="item.client_photo" :src="item.client_photo" style="object-fit:cover" />
+              <span v-else class="text-weight-bold full-width full-height flex flex-center" :style="avatarColors[item.id % avatarColors.length]" style="font-size:13px;border-radius:50%">{{ item.client_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
             </q-avatar>
 
             <div class="col column">
@@ -188,7 +189,12 @@ import AntecipacaoConfirmDialog from 'src/components/payments/AntecipacaoConfirm
 const $q = useQuasar();
 const { t } = useI18n();
 
-const defaultAvatar = 'https://cdn.quasar.dev/img/avatar.png';
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
 const earningsExpanded = ref(false);
 const selectedPeriod = ref('week');
 

+ 2 - 1
src/pages/profile/ProfilePage.vue

@@ -13,7 +13,8 @@
         
         <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">
+            <img v-if="user.provider?.profile_media?.url" :src="user.provider.profile_media.url" style="object-fit:cover">
+            <q-icon v-else name="mdi-account-circle" size="70px" color="grey-4" />
           </q-avatar>
           
           <div class="fonte-nome-profile text-weight-bold q-mt-md text-dark">{{ user.name || '—' }}</div>