Kaynağa Gözat

✨ feat(avaliacao): implementar fluxo de avaliação pós-agendamento

- DashboardTodaySchedules: badge 'avaliado!' e botão de avaliar por agendamento
- ScheduleRatingDialog: dialog de avaliação com estrelas, melhorias e opções de bloquear/favoritar
- DashboardPage: abertura do dialog de avaliação via evento @rate
- api/review: createReview e getImprovementTypes
- api/clientFavoriteProvider: createClientFavoriteProvider
- i18n: chaves de avaliação e privacidade

Fase: dev | Origin: avaliacao
Gustavo Zanatta 4 gün önce
ebeveyn
işleme
0207fc9579

+ 5 - 0
src/api/clientFavoriteProvider.js

@@ -5,6 +5,11 @@ export const getClientFavoriteProviders = async (clientId) => {
   return data.payload;
 }
 
+export const createClientFavoriteProvider = async (info) => {
+  const { data } = await api.post(`/client/favorite-provider`, info);
+  return data.payload;
+}
+
 export const deleteClientFavoriteProvider = async (id) => {
   const { data } = await api.delete(`/client/favorite-provider/${id}`);
   return data.payload;

+ 10 - 0
src/api/review.js

@@ -4,3 +4,13 @@ export const getProviderReceivedReviews = async (providerId) => {
   const { data } = await api.get(`/reviews/provider/${providerId}/received`)
   return data.payload
 }
+
+export const createReview = async (reviewData) => {
+  const { data } = await api.post('/reviews', reviewData)
+  return data.payload
+}
+
+export const getImprovementTypes = async (origin = 'both') => {
+  const { data } = await api.get('/improvement-types', { params: { origin } })
+  return data.payload
+}

+ 23 - 4
src/components/dashboard/DashboardTodaySchedules.vue

@@ -51,12 +51,16 @@
                 </template>
 
                 <template v-else>
+                  <div v-if="item.client_reviewed" class="rate-btn reviewed-badge">
+                    <q-icon name="mdi-star" size="14px" class="q-mr-xs" />
+                    {{ $t('dashboard_client.schedule_rating.reviewed_badge') }}
+                  </div>
                   <q-btn
-                    unelevated rounded no-caps
+                    v-else
+                    unelevated no-caps
                     class="rate-btn"
                     icon="mdi-star-outline"
                     :label="$t('dashboard_client.today_schedules.rate_btn')"
-                    size="sm"
                     @click.stop="emit('rate', item)"
                   />
                 </template>
@@ -181,11 +185,26 @@ const openHelp = () => {
 }
 
 .rate-btn {
-  background: linear-gradient(90deg, #8B5CF6, #EC4899);
+  background: #EC4899;
   color: white;
   font-weight: 700;
-  font-size: 12px;
+  font-size: 13px;
   white-space: nowrap;
+  border-radius: 10px !important;
+  min-width: 72px;
+  min-height: 56px;
+  padding: 6px 10px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+}
+
+.reviewed-badge {
+  cursor: default;
+  opacity: 0.85;
+  font-size: 11px;
 }
 
 .progress-track {

+ 252 - 0
src/components/dashboard/ScheduleRatingDialog.vue

@@ -0,0 +1,252 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="rating-dialog-card bg-surface shadow-card" :flat="false">
+
+      <div class="row justify-end q-pt-sm q-pr-sm">
+        <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
+            :style="avatarStyle"
+            class="text-weight-bold full-width full-height flex flex-center"
+            style="font-size: 20px; border-radius: 50%;"
+          >
+            {{ initials }}
+          </span>
+        </q-avatar>
+        <div class="text-body1 text-text  text-weight-bold text-center q-px-lg" style="line-height:1.3">
+          {{ $t('dashboard_client.schedule_rating.title') }}
+          <span class="text-primary"> {{ schedule.provider_name + '?' }}</span>
+        </div>
+      </div>
+
+      <!-- Estrelas -->
+      <div class="column items-center q-pb-xs">
+        <q-rating
+          v-model="stars"
+          :max="5"
+          size="lg"
+          color="amber"
+          icon="mdi-star-outline"
+          icon-selected="mdi-star"
+          @update:model-value="onStarsChange"
+        />
+      </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') }}
+        </div>
+        <div v-if="loadingTags" class="row justify-center q-py-sm">
+          <q-spinner-dots color="primary" size="24px" />
+        </div>
+        <div v-else class="row justify-center q-gutter-xs">
+          <div
+            v-for="tag in tags"
+            :key="tag.id"
+            class="tag-pill"
+            :class="{ 'tag-pill--selected': selectedTagIds.includes(tag.id) }"
+            @click="toggleTag(tag.id)"
+          >
+            {{ tag.description }}
+          </div>
+        </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') }}
+        </div>
+        <q-input
+          v-model="comment"
+          type="textarea"
+          outlined
+          dense
+          rows="3"
+          color="primary"
+          input-class="text-black"
+          hide-bottom-space
+        />
+      </q-card-section>
+
+      <!-- Checkbox condicional -->
+      <q-card-section v-if="stars > 0" class="q-pt-xs q-pb-xs q-px-lg">
+        <q-checkbox
+          v-model="checkboxValue"
+          :label="isNegative ? $t('dashboard_client.schedule_rating.block_label') : $t('dashboard_client.schedule_rating.favorite_label')"
+          color="primary"
+          keep-color
+          class="text-text"
+          checked-icon="mdi-check-circle"
+          unchecked-icon="mdi-checkbox-blank-circle-outline"
+        />
+      </q-card-section>
+
+      <!-- Botão enviar -->
+      <q-card-section class="q-pt-sm q-pb-xs q-px-lg row">
+        <q-btn
+          unelevated
+          rounded
+          no-caps
+          full-width
+          color="primary"
+          class="submit-btn col-12"
+          :label="$t('dashboard_client.schedule_rating.submit_btn')"
+          :loading="loading"
+          :disable="stars === 0"
+          @click="submit"
+        />
+      </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') }}
+        </span>
+      </q-card-section>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import { createReview, getImprovementTypes } from 'src/api/review'
+import { userStore } from 'src/stores/user'
+import ProfileHelpDialog from 'src/components/profile/ProfileHelpDialog.vue'
+
+const props = defineProps({
+  schedule: {
+    type: Object,
+    required: true
+  }
+})
+
+defineEmits([...useDialogPluginComponent.emits])
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const { t } = useI18n()
+const $q = useQuasar()
+const store = userStore()
+
+const stars = ref(0)
+const selectedTagIds = ref([])
+const comment = ref(null)
+const checkboxValue = ref(false)
+const tags = ref([])
+const loadingTags = ref(false)
+const loading = ref(false)
+
+const isNegative = computed(() => stars.value > 0 && stars.value <= 2)
+const isPositive = computed(() => stars.value >= 3)
+
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+]
+
+const avatarStyle = computed(() => {
+  const c = avatarColors[props.schedule.provider_id % avatarColors.length]
+  return { background: c.background, color: c.color }
+})
+
+const initials = computed(() =>
+  props.schedule.provider_name?.slice(0, 2).toUpperCase() ?? '??'
+)
+
+const onStarsChange = () => {
+  selectedTagIds.value = []
+  checkboxValue.value = false
+}
+
+const toggleTag = (id) => {
+  const idx = selectedTagIds.value.indexOf(id)
+  if (idx === -1) selectedTagIds.value.push(id)
+  else selectedTagIds.value.splice(idx, 1)
+}
+
+const openHelp = () => {
+  $q.dialog({ component: ProfileHelpDialog })
+}
+
+const submit = async () => {
+  if (stars.value === 0) return
+  loading.value = true
+  try {
+    await createReview({
+      schedule_id: props.schedule.id,
+      origin: 'client',
+      origin_id: store.user.client.id,
+      stars: stars.value,
+      comment: comment.value || null,
+      improvements_ids: selectedTagIds.value,
+      block_provider: isNegative.value && checkboxValue.value,
+      block_client: false,
+      favorite_provider: isPositive.value && checkboxValue.value,
+    })
+
+    onDialogOK(true)
+  } catch (error) {
+    const status = error?.response?.status
+    if (status === 422) {
+      $q.notify({ message: t('dashboard_client.schedule_rating.already_reviewed'), color: 'negative', icon: 'mdi-alert-circle-outline' })
+    } else {
+      $q.notify({ message: t('http.errors.failed'), color: 'negative' })
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(async () => {
+  loadingTags.value = true
+  try {
+    const result = await getImprovementTypes('client')
+    tags.value = result ?? []
+  } catch {
+    tags.value = []
+  } finally {
+    loadingTags.value = false
+  }
+})
+</script>
+
+<style scoped lang="scss">
+.rating-dialog-card {
+  width: 320px;
+  max-width: 96vw;
+  border-radius: 20px !important;
+  overflow: hidden;
+}
+
+.tag-pill {
+  border: 1.5px solid #d1d5db;
+  border-radius: 20px;
+  padding: 5px 14px;
+  font-size: 12px;
+  color: #6b7280;
+  cursor: pointer;
+  transition: border-color 0.15s, color 0.15s;
+  user-select: none;
+
+  &--selected {
+    border-color: #8B5CF6;
+    color: #8B5CF6;
+    font-weight: 600;
+  }
+}
+
+.submit-btn {
+  font-size: 15px;
+  font-weight: 700;
+  padding: 10px 0;
+}
+</style>

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

@@ -473,6 +473,18 @@
       "detail_total": "Total:",
       "btn_payment": "ir para o pagamento",
       "btn_cancel": "Cancelar pedido"
+    },
+    "schedule_rating": {
+      "title": "Como foi o serviço de",
+      "positive_label": "O que mais gostou?",
+      "negative_label": "O que poderia melhorar?",
+      "comment_placeholder": "Deseja deixar um comentário?",
+      "favorite_label": "Favoritar este diarista",
+      "block_label": "Não solicitar mais este diarista",
+      "submit_btn": "enviar avaliação",
+      "help_link": "Ajuda",
+      "already_reviewed": "Você já avaliou este serviço.",
+      "reviewed_badge": "avaliado!"
     }
   },
   "profile": {
@@ -594,6 +606,14 @@
       "suggestion_payment": "Como funciona o pagamento?",
       "suggestion_human": "Falar com um humano"
     },
+    "privacy": {
+      "title": "Privacidade",
+      "description": "Usuários bloqueados",
+      "blocked_title": "Contas bloqueadas",
+      "empty_message": "Você não possui diaristas bloqueados",
+      "empty_sub": "Você pode bloquear diaristas que não deseja visualizar mais nas buscas.",
+      "unblock_btn": "desbloquear"
+    },
     "logout": {
       "title": "Sair",
       "description": "Desconectar da sua conta"

+ 11 - 1
src/pages/dashboard/DashboardPage.vue

@@ -14,7 +14,7 @@
         @view-details="openAcceptedDialog"
         @cancel="cancelSchedule"
       />
-      <DashboardTodaySchedules v-if="todaySchedules.length > 0" :data="todaySchedules"/>
+      <DashboardTodaySchedules v-if="todaySchedules.length > 0" :data="todaySchedules" @rate="openRatingDialog" />
       <DashboardScrollAreaSchedules />
       <DashboardPendingCustomSchedules />
       <DashboardNextSchedules v-if="nextSchedules.length > 0" :data="nextSchedules" @view-details="openNextScheduleDialog" />
@@ -44,6 +44,7 @@ import { useDialogPluginComponent, useQuasar } from 'quasar';
 import { dadosDashboard } from 'src/api/dashboard';
 import ScheduleCancelDialog from 'src/components/dashboard/ScheduleCancelDialog.vue';
 import NextSchedulesDetailsDialog from 'src/components/dashboard/NextSchedulesDetailsDialog.vue';
+import ScheduleRatingDialog from 'src/components/dashboard/ScheduleRatingDialog.vue';
 
 const router = useRouter()
 const headerBar = ref({});
@@ -114,6 +115,15 @@ const cancelSchedule = (schedule) => {
   })
 }
 
+const openRatingDialog = (schedule) => {
+  $q.dialog({
+    component: ScheduleRatingDialog,
+    componentProps: { schedule }
+  }).onOk(() => {
+    reloadDashboard()
+  })
+}
+
 onMounted(async () => {
   await reloadDashboard();
 });