Quellcode durchsuchen

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 vor 2 Wochen
Ursprung
Commit
2b333ed31b

+ 16 - 1
src/api/review.js

@@ -6,7 +6,22 @@ export const getProviderReceivedReviews = async (providerId) => {
 }
 
 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
 }
 

+ 10 - 0
src/api/user.js

@@ -46,6 +46,16 @@ export const createUserAndClient = async (data) => {
 }
 
 export const updateMe = async (data) => {
+  if (data.avatar instanceof File) {
+    const form = new FormData();
+    form.append('_method', 'PUT');
+    Object.entries(data).forEach(([key, val]) => {
+      if (val !== null && val !== undefined) form.append(key, val);
+    });
+    const { data: res } = await api.post('/me', form);
+    return res.payload;
+  }
+
   const { data: res } = await api.put('/me', data);
   return res.payload;
 };

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

@@ -12,8 +12,9 @@
         <q-card-section class="q-pa-sm column text-text">
           <div class="row items-start no-wrap">
             <div class="col-3 q-my-auto">
-              <q-avatar :style="avatarColors[item.provider_provider_id % avatarColors.length]" size="46px" class="text-weight-bold">
-                {{ item.provider_name?.slice(0,1).toUpperCase() ?? '—' }}
+              <q-avatar :style="avatarColors[item.provider_id % avatarColors.length]" size="46px" class="text-weight-bold">
+                <img v-if="item.provider_photo" :src="item.provider_photo" style="object-fit:cover;border-radius:50%;" />
+                <span v-else>{{ item.provider_name?.slice(0,1).toUpperCase() ?? '—' }}</span>
               </q-avatar>
             </div>
             <div class="col-5 column q-gutter-y-xs q-my-auto">

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

@@ -10,8 +10,9 @@
         :flat="false"
       >
         <q-card-section class="column q-pa-md q-gutter-y-xs text-text">
-          <q-avatar :style="avatarColors[item.provider_provider_id % avatarColors.length]" size="56px" class="text-weight-bold q-mx-auto">
-            {{ item.provider_name?.slice(0,1).toUpperCase() ?? '—' }}
+          <q-avatar :style="avatarColors[item.provider_id % avatarColors.length]" size="56px" class="text-weight-bold q-mx-auto">
+            <img v-if="item.provider_photo" :src="item.provider_photo" style="object-fit:cover;border-radius:50%;" />
+            <span v-else>{{ item.provider_name?.slice(0,1).toUpperCase() ?? '—' }}</span>
           </q-avatar>
           <span class="text-done-name">{{ item.provider_name ?? 'Prestador' }}</span>
           <span v-if="item.provider_district" class="text-done-district">{{ item.provider_district != null ? item.provider_district : $t('dashboard_client.last_schedules.no_address') }}</span>

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

@@ -14,7 +14,8 @@
             <div class="col-3 column text-center">
               <div class="col-7">
                 <q-avatar :style="avatarColors[item.id % avatarColors.length]" class="text-weight-bold q-mx-auto">
-                  {{ item.provider_name?.slice(0,1) ?? '—' }}
+                  <img v-if="item.provider_photo" :src="item.provider_photo" style="object-fit:cover;border-radius:50%;" />
+                  <span v-else>{{ item.provider_name?.slice(0,1) ?? '—' }}</span>
                 </q-avatar>
               </div>
               <div class="col-5 column justify-end">

+ 2 - 1
src/components/dashboard/DashboardPendingSchedules.vue

@@ -13,7 +13,8 @@
 
             <div class="row no-wrap items-start q-mb-sm">
               <q-avatar size="40px" :style="avatarColors[item.id % avatarColors.length]" class="text-weight-bold q-mr-sm flex-shrink-0">
-                {{ item.provider_name?.slice(0, 2).toUpperCase() ?? '??' }}
+                <img v-if="item.provider_photo" :src="item.provider_photo" style="object-fit:cover;border-radius:50%;" />
+                <span v-else>{{ item.provider_name?.slice(0, 2).toUpperCase() ?? '??' }}</span>
               </q-avatar>
 
               <div class="col column no-wrap overflow-hidden">

+ 2 - 1
src/components/dashboard/DashboardProvidersClose.vue

@@ -20,7 +20,8 @@
           <div class="row no-wrap full-width">
             <div class="col-2">
               <q-avatar :style="avatarColors[p.provider_id % avatarColors.length]" class="text-weight-bold">
-                {{ p.provider_name?.slice(0,1).toUpperCase() ?? '—' }}
+                <img v-if="p.provider_photo" :src="p.provider_photo" style="object-fit:cover;border-radius:50%;" />
+                <span v-else>{{ p.provider_name?.slice(0,1).toUpperCase() ?? '—' }}</span>
               </q-avatar>
             </div>
 

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

@@ -4,7 +4,8 @@
       <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() ?? '??' }}
+            <img v-if="data?.profile_photo" :src="data.profile_photo" style="object-fit:cover;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('dashboard_client.summary.welcome') }}</span>

+ 90 - 7
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('dashboard_client.schedule_rating.negative_label') : $t('dashboard_client.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('dashboard_client.schedule_rating.comment_placeholder') }}
@@ -74,7 +70,27 @@
         />
       </q-card-section>
 
-      <!-- Checkbox condicional -->
+      <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('dashboard_client.schedule_rating.add_photo') }}</div>
+          </div>
+        </div>
+      </q-card-section>
+
       <q-card-section v-if="stars > 0" class="q-pt-xs q-pb-xs q-px-lg">
         <q-checkbox
           v-model="checkboxValue"
@@ -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('dashboard_client.schedule_rating.help_link') }}
@@ -142,6 +156,9 @@ const checkboxValue = 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)
@@ -173,6 +190,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 +224,7 @@ const submit = async () => {
       block_provider: isNegative.value && checkboxValue.value,
       block_client: false,
       favorite_provider: isPositive.value && checkboxValue.value,
+      photos: photos.value,
     })
 
     onDialogOK(true)
@@ -249,4 +283,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>

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

@@ -511,6 +511,19 @@
       "btn_payment": "go to payment",
       "btn_cancel": "Cancel request"
     },
+    "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?",
+      "favorite_label": "Add this cleaner to favorites",
+      "block_label": "Stop requesting this cleaner",
+      "add_photo": "Add photo",
+      "submit_btn": "submit review",
+      "help_link": "Help",
+      "already_reviewed": "You have already reviewed this service.",
+      "reviewed_badge": "reviewed!"
+    },
     "registration_incomplete_title": "Complete your profile information!",
     "registration_incomplete_cta": "Resolve now",
     "payment_incomplete_title": "Update your payment information!",

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

@@ -507,6 +507,19 @@
       "btn_payment": "ir al pago",
       "btn_cancel": "Cancelar pedido"
     },
+    "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?",
+      "favorite_label": "Agregar este diarista a favoritos",
+      "block_label": "Dejar de solicitar este diarista",
+      "add_photo": "Agregar foto",
+      "submit_btn": "enviar evaluación",
+      "help_link": "Ayuda",
+      "already_reviewed": "Ya evaluaste este servicio.",
+      "reviewed_badge": "¡evaluado!"
+    },
     "registration_incomplete_title": "¡Completa la información de tu perfil!",
     "registration_incomplete_cta": "Resolver ahora",
     "payment_incomplete_title": "¡Actualiza tus datos de pago!",

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

@@ -518,6 +518,7 @@
       "comment_placeholder": "Deseja deixar um comentário?",
       "favorite_label": "Favoritar este diarista",
       "block_label": "Não solicitar mais este diarista",
+      "add_photo": "Adicionar foto",
       "submit_btn": "enviar avaliação",
       "help_link": "Ajuda",
       "already_reviewed": "Você já avaliou este serviço.",

+ 10 - 3
src/pages/agenda/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.provider_photo || defaultAvatar">
+                  <img v-if="item.provider_photo" :src="item.provider_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.provider_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
                 </q-avatar>
 
                 <div class="col column">
@@ -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.provider_photo || defaultAvatar">
+                  <img v-if="item.provider_photo" :src="item.provider_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.provider_name?.slice(0,2).toUpperCase() ?? '??' }}</span>
                 </q-avatar>
 
                 <div class="col column">
@@ -188,7 +190,12 @@ import SchedulingDialog from 'src/pages/search/components/SchedulingDialog.vue';
 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([]);

+ 2 - 1
src/pages/dashboard/components/DashboardClientProposals.vue

@@ -6,7 +6,8 @@
         <div class="row no-wrap items-center">
 
           <q-avatar :style="avatarColors[item.id % avatarColors.length]" class="text-weight-bold q-mx-auto">
-            {{ item.provider_name?.slice(0, 1) ?? '—' }}
+            <img v-if="item.provider_photo" :src="item.provider_photo" style="object-fit:cover;border-radius:50%;" />
+            <span v-else>{{ item.provider_name?.slice(0, 1) ?? '—' }}</span>
           </q-avatar>
 
           <!-- LABEL -->

+ 28 - 4
src/pages/profile/ProfileEditDialog.vue

@@ -17,9 +17,17 @@
         <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() : '' }}
+              <img v-if="avatarPreview" :src="avatarPreview" style="object-fit: cover; width: 100%; height: 100%;" />
+              <span v-else>{{ form.name ? form.name.charAt(0).toUpperCase() : '' }}</span>
             </q-avatar>
-            <q-btn flat no-caps color="grey-6" class="q-mt-sm" :label="$t('profile.change_photo')" />
+            <input
+              ref="fileInputRef"
+              type="file"
+              accept="image/jpeg,image/png,image/webp"
+              class="hidden"
+              @change="onFileSelected"
+            />
+            <q-btn flat no-caps color="grey-6" class="q-mt-sm" :label="$t('profile.change_photo')" @click="fileInputRef.click()" />
           </div>
 
           <q-form ref="formRef" @submit.prevent="onSubmit">
@@ -108,8 +116,8 @@
                 padding="8px 16px"
                 class="full-width q-py-md text-weight-bold"
                 :label="$t('profile.update')"
-                :color="hasUpdatedFields ? 'primary' : 'grey-4'"
-                :disable="!hasUpdatedFields"
+                :color="hasUpdatedFields || avatarFile ? 'primary' : 'grey-4'"
+                :disable="!hasUpdatedFields && !avatarFile"
                 :loading="submitting"
               />
             </div>
@@ -161,7 +169,10 @@ const { loading: submitting, serverErrors, execute: submitForm } = useSubmitHand
 const { inputRules } = useInputRules();
 
 const formRef = ref(null);
+const fileInputRef = ref(null);
 const loading = ref(false);
+const avatarFile = ref(null);
+const avatarPreview = ref(null);
 const selectedLocale = ref(normalizeLocale(i18n.global.locale.value ?? i18n.global.locale));
 
 const onLocaleChange = (val) => {
@@ -169,6 +180,13 @@ const onLocaleChange = (val) => {
   Cookies.set('locale', val, { expires: 365, path: '/' });
 };
 
+const onFileSelected = (event) => {
+  const file = event.target.files[0];
+  if (!file) return;
+  avatarFile.value = file;
+  avatarPreview.value = URL.createObjectURL(file);
+};
+
 const onSubmit = async () => {
   const valid = await formRef.value.validate();
   if (!valid) return;
@@ -178,6 +196,7 @@ const onSubmit = async () => {
     email: form.email,
     phone: form.phone,
     document: form.document || null,
+    ...(avatarFile.value ? { avatar: avatarFile.value } : {}),
   }));
 };
 
@@ -188,6 +207,7 @@ onMounted(async () => {
     form.email = data.email || '';
     form.phone = data.phone || '';
     form.document = data.client_document || '';
+    avatarPreview.value = data.client?.profile_media?.url ?? null;
     setUpdateFormAsOriginal(data);
     return;
   }
@@ -201,4 +221,8 @@ onMounted(async () => {
   border-radius: 8px;
   &::before { border: 1px solid #e0e0e0; }
 }
+
+.hidden {
+  display: none;
+}
 </style>

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

@@ -12,8 +12,9 @@
         <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 size="70px" color="indigo-1" text-color="indigo-4" class="shadow-card text-weight-bold text-h5">
+            <img v-if="user.client?.profile_media?.url" :src="user.client.profile_media.url" style="object-fit: cover;" />
+            <span v-else>{{ user.name ? user.name.charAt(0).toUpperCase() : '' }}</span>
           </q-avatar>
           
           <div class="fonte-nome-profile text-weight-bold q-mt-md text-dark">{{ user.name || '—' }}</div>
@@ -130,6 +131,7 @@ const openEditProfile = () => {
   }).onOk((updatedUser) => {
     user.value = { ...user.value, ...updatedUser };
     store.setUser({ ...store.user, ...updatedUser });
+    getUser().then(data => { user.value = data; });
   });
 };