ソースを参照

feat: :sparkles: criação da página de buscas de agendamentos

foi criada a página de buscas dos agendamentos no aplicativo do cliente
Gustavo Zanatta 3 週間 前
コミット
350ca063fe

+ 8 - 0
src/api/dashboard.js

@@ -3,4 +3,12 @@ import api from "src/api";
 export const dadosDashboard = async () => {
   const { data } = await api.get("/dados-dashboard-cliente");
   return data.payload;
+}
+
+export const buscaPrestadores = async ({ name = '', date = '' } = {}) => {
+  const params = {};
+  if (name) params.name = name;
+  if (date) params.date = date;
+  const { data } = await api.get('/prestadores-busca', { params });
+  return data.payload;
 }

+ 12 - 3
src/components/dashboard/DashboardScrollAreaSchedules.vue

@@ -1,7 +1,13 @@
 <template>
   <section class="promo-scroll-wrapper q-ma-md">
     <div class="promo-scroll">
-      <div v-for="card in cards" :key="card.id" class="promo-card">
+      <div
+        v-for="card in cards"
+        :key="card.id"
+        class="promo-card"
+        :class="{ 'cursor-pointer': card.route }"
+        @click="card.route && router.push({ name: card.route })"
+      >
         <img :src="card.image" :alt="card.alt" class="promo-card__img" />
       </div>
     </div>
@@ -9,12 +15,15 @@
 </template>
 
 <script setup>
+import { useRouter } from 'vue-router';
 import Banner1 from 'src/assets/banner_1.svg';
 import Banner2 from 'src/assets/banner_2.svg';
 
+const router = useRouter();
+
 const cards = [
-  { id: 1, image: Banner1, alt: 'Diária sob medida' },
-  { id: 2, image: Banner2, alt: 'Escolha profissionais' },
+  { id: 1, image: Banner1, alt: 'Diária sob medida', route: null },
+  { id: 2, image: Banner2, alt: 'Escolha profissionais', route: 'SearchPage' },
 ];
 </script>
 

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

@@ -315,6 +315,53 @@
       "detractors": "Detractors"
     }
   },
+  "search_filter": {
+    "title": "Filters",
+    "sort_by": "Sort by:",
+    "filter_by": "Filter by:",
+    "availability": "Availability",
+    "availability_placeholder": "Ex. 01/10/2025",
+    "apply": "Filter",
+    "clear": "Clear",
+    "sort": {
+      "price_asc": "Lowest price",
+      "price_desc": "Highest price",
+      "distance_asc": "Closest (coming soon)",
+      "distance_desc": "Farthest (coming soon)",
+      "reviews_desc": "Most reviews",
+      "reviews_asc": "Fewer reviews",
+      "rating_desc": "Most stars",
+      "rating_asc": "Fewer stars",
+      "services_desc": "Most services hired",
+      "oldest": "Oldest on platform",
+      "higher": "higher",
+      "lower": "lower",
+      "oldest_asc": "oldest",
+      "oldest_desc": "newest"
+    },
+    "groups": {
+      "price": "Price",
+      "distance": "Distance",
+      "reviews": "Reviews",
+      "rating": "Stars",
+      "services": "Services hired",
+      "oldest": "Time on platform"
+    }
+  },
+  "search_page": {
+    "title": "Search",
+    "search_placeholder": "Name",
+    "custom_schedule_btn": "custom",
+    "custom_schedule_description": "If you prefer, select the details and we will find the ideal cleaner for you.",
+    "choose_provider": "Choose a cleaner",
+    "schedule_btn": "schedule",
+    "until_8h": "Up to 8h",
+    "until_6h": "Up to 6h",
+    "until_4h": "Up to 4h",
+    "until_2h": "Up to 2h",
+    "no_price": "to negotiate",
+    "no_results": "No cleaners found for this search."
+  },
   "dashboard_client": {
     "header": {
       "rating": "Rating",

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

@@ -315,6 +315,53 @@
       "detractors": "Detractores"
     }
   },
+  "search_filter": {
+    "title": "Filtros",
+    "sort_by": "Ordenar por:",
+    "filter_by": "Filtrar por:",
+    "availability": "Disponibilidad",
+    "availability_placeholder": "Ej. 10/01/2025",
+    "apply": "Filtrar",
+    "clear": "Limpiar",
+    "sort": {
+      "price_asc": "Menor precio",
+      "price_desc": "Mayor precio",
+      "distance_asc": "Más cercanos (próximamente)",
+      "distance_desc": "Más lejanos (próximamente)",
+      "reviews_desc": "Más valoraciones",
+      "reviews_asc": "Menos valoraciones",
+      "rating_desc": "Más estrellas",
+      "rating_asc": "Menos estrellas",
+      "services_desc": "Más servicios contratados",
+      "oldest": "Más antiguo en la plataforma",
+      "higher": "mayor",
+      "lower": "menor",
+      "oldest_asc": "más antiguo",
+      "oldest_desc": "más reciente"
+    },
+    "groups": {
+      "price": "Precio",
+      "distance": "Distancia",
+      "reviews": "Valoraciones",
+      "rating": "Estrellas",
+      "services": "Servicios contratados",
+      "oldest": "Tiempo en la plataforma"
+    }
+  },
+  "search_page": {
+    "title": "Búsqueda",
+    "search_placeholder": "Nombre",
+    "custom_schedule_btn": "a medida",
+    "custom_schedule_description": "Si prefieres, selecciona los detalles y encontraremos el limpiador ideal para ti.",
+    "choose_provider": "Elige un limpiador",
+    "schedule_btn": "agendar",
+    "until_8h": "Hasta 8h",
+    "until_6h": "Hasta 6h",
+    "until_4h": "Hasta 4h",
+    "until_2h": "Hasta 2h",
+    "no_price": "a combinar",
+    "no_results": "No se encontraron limpiadores para esta búsqueda."
+  },
   "dashboard_client": {
     "header": {
       "rating": "Calificación",

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

@@ -315,6 +315,53 @@
       "detractors": "Detratores"
     }
   },
+  "search_filter": {
+    "title": "Filtros",
+    "sort_by": "Ordenar por:",
+    "filter_by": "Filtrar por:",
+    "availability": "Disponibilidade",
+    "availability_placeholder": "Ex. 10/01/2025",
+    "apply": "Filtrar",
+    "clear": "Limpar",
+    "sort": {
+      "price_asc": "Menor preço",
+      "price_desc": "Maior preço",
+      "distance_asc": "Mais próximos (em breve)",
+      "distance_desc": "Mais distantes (em breve)",
+      "reviews_desc": "Mais avaliações",
+      "reviews_asc": "Menos avaliações",
+      "rating_desc": "Mais estrelas",
+      "rating_asc": "Menos estrelas",
+      "services_desc": "Mais serviços contratados",
+      "oldest": "Mais antigo na plataforma",
+      "higher": "maior",
+      "lower": "menor",
+      "oldest_asc": "mais antigo",
+      "oldest_desc": "mais recente"
+    },
+    "groups": {
+      "price": "Preço",
+      "distance": "Distância",
+      "reviews": "Avaliações",
+      "rating": "Estrelas",
+      "services": "Serviços contratados",
+      "oldest": "Tempo na plataforma"
+    }
+  },
+  "search_page": {
+    "title": "Busca",
+    "search_placeholder": "Nome",
+    "custom_schedule_btn": "sob medida",
+    "custom_schedule_description": "Se preferir, selecione os detalhes e encontraremos um diarista ideal para você.",
+    "choose_provider": "Escolha um diarista",
+    "schedule_btn": "agendar",
+    "until_8h": "Até 8h",
+    "until_6h": "Até 6h",
+    "until_4h": "Até 4h",
+    "until_2h": "Até 2h",
+    "no_price": "a combinar",
+    "no_results": "Nenhum diarista encontrado para essa busca."
+  },
   "dashboard_client": {
     "header": {
       "rating": "Avaliação",

+ 271 - 42
src/pages/search/SearchPage.vue

@@ -1,52 +1,281 @@
 <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
 <template>
-  <section class="mobile-placeholder">
-    <div class="mobile-placeholder__badge">
-      <q-icon name="mdi-magnify" />
+  <q-page class="bg-page">
+
+    <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-search">
+      <q-btn flat round dense icon="mdi-chevron-left" color="primary" @click="router.back()" />
+      <div class="col text-center text-subtitle1 text-weight-bold text-primary">
+        {{ $t('search_page.title') }}
+      </div>
+      <div style="width: 36px" />
+    </div>
+
+    <div class="q-px-md q-pt-md q-pb-sm">
+      <q-card class="custom-schedule-card bg-surface shadow-card q-pa-sm" :flat="false">
+        <q-card-section class="row items-center no-wrap q-pa-sm q-gutter-x-sm">
+          <span class="col text-text fonte-hint">
+            {{ $t('search_page.custom_schedule_description') }}
+          </span>
+          <q-btn
+            color="secondary"
+            no-caps
+            unelevated
+            padding="8px 16px"
+            class="text-weight-bold custom-schedule-btn card-border"
+          >
+            <template #default>
+              <div class="column items-center q-gutter-y-xs">
+                <q-icon name="mdi-scissors-cutting" size="16px" />
+                <span>{{ $t('search_page.custom_schedule_btn') }}</span>
+              </div>
+            </template>
+          </q-btn>
+        </q-card-section>
+      </q-card>
+    </div>
+
+    <div class="row items-center q-px-md q-py-md q-gutter-x-sm">
+      <q-input
+        v-model="searchName"
+        :placeholder="$t('search_page.search_placeholder')"
+        outlined
+        rounded
+        dense
+        clearable
+        debounce="400"
+        class="col bg-white search-input"
+        input-class="text-text"
+        @update:model-value="onNameChange"
+      >
+        <template #append>
+          <q-icon name="mdi-magnify" color="grey-5" />
+        </template>
+      </q-input>
+      <q-btn
+        flat round dense
+        icon="mdi-tune-variant"
+        color="grey-6"
+        size="md"
+        :class="{ 'filter-active': activeSort || activeDate }"
+        @click="openFilterDialog"
+      />
+    </div>
+
+    <div class="row items-center justify-between no-wrap q-px-md q-pb-sm">
+      <div class="dashboard-section-title gradient-diarista">{{ $t('search_page.choose_provider') }}</div>
+      <div class="row items-center no-wrap text-text">
+        <q-btn flat dense round icon="mdi-chevron-left" color="text" size="sm" @click="setPeriodTypePrevious" />
+        <span class="text-caption text-weight-medium">{{ periodLabel }}</span>
+        <q-btn flat dense round icon="mdi-chevron-right" color="text" size="sm" @click="setPeriodTypeNext" />
+      </div>
+    </div>
+
+    <div v-if="loading" class="row items-center justify-center q-py-xl">
+      <q-spinner-dots color="primary" size="40px" />
     </div>
-    <h1 class="mobile-placeholder__title">Busca</h1>
-    <p class="mobile-placeholder__description">
-      Área reservada para a busca de diárias e oportunidades próximas.
-    </p>
-  </section>
+
+    <template v-else>
+      <div v-if="sortedProviders.length === 0" class="text-center text-grey-6 q-px-md q-py-lg text-body2">
+        {{ $t('search_page.no_results') }}
+      </div>
+
+      <div v-else class="column q-px-md q-pb-xl">
+        <q-card
+          v-for="p in sortedProviders"
+          :key="p.provider_id"
+          class="card-border bg-page text-text q-mb-sm"
+          :flat="false"
+        >
+          <q-card-section class="row no-wrap q-pa-sm">
+            <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() ?? '—' }}
+                </q-avatar>
+              </div>
+
+              <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.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="16px" />
+                      <span class="text-provider-close-rating">
+                        {{ p.average_rating != null ? (Number(p.average_rating).toFixed(1) + ' (' + (p.total_reviews ?? 0) + ')') : ('(' + (p.total_reviews ?? 0) + ')') }}
+                      </span>
+                    </div>
+                    <div class="row items-center">
+                      <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="16px" />
+                      <span class="text-provider-close-jobs">{{ 0 + ' km' }}</span>
+                    </div>
+                  </div>
+                </div>
+
+                <div class="column col-3 justify-between text-center items-center">
+                  <span class="text-provider-close-price">{{ priceByPeriod(p) }}</span>
+                  <div class="full-width">
+                    <q-btn
+                      unelevated rounded no-caps
+                      color="primary"
+                      size="sm"
+                      padding="3px 12px"
+                      :label="$t('search_page.schedule_btn')"
+                    />
+                  </div>
+                </div>
+              </div>
+            </div>
+          </q-card-section>
+        </q-card>
+      </div>
+    </template>
+
+  </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, computed, onMounted } from 'vue';
+import { useRouter } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+// import { useQuasar } from 'quasar';
+import { buscaPrestadores } from 'src/api/dashboard';
+import { formatCurrency } from 'src/helpers/utils';
+// import SearchFilterDialog from 'src/pages/search/components/SearchFilterDialog.vue';
 
-.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;
-}
+const { t } = useI18n();
+const router = useRouter();
+// const $q = useQuasar();
 
-.mobile-placeholder__title {
-  margin: 0 0 8px;
-  font-size: 28px;
-  font-weight: 700;
-  line-height: 1.1;
-  color: #4d4d4d;
-}
+const allProviders = ref([]);
+const loading      = ref(true);
+const searchName   = ref('');
+const activeDate   = ref(null);
+const activeSort   = ref(null);
 
-.mobile-placeholder__description {
-  max-width: 280px;
-  margin: 0;
-  font-size: 16px;
-  line-height: 1.5;
-  color: #8d8d8d;
+const currentPeriodType = ref(8);
+const periodTypeMap = { 2: 'daily_price_2h', 4: 'daily_price_4h', 6: 'daily_price_6h', 8: 'daily_price_8h' };
+
+const periodLabel = computed(() => {
+  const labels = { 8: t('search_page.until_8h'), 6: t('search_page.until_6h'), 4: t('search_page.until_4h'), 2: t('search_page.until_2h') };
+  return labels[currentPeriodType.value] ?? '';
+});
+
+const priceByPeriod = (p) => {
+  const key = periodTypeMap[currentPeriodType.value];
+  return p[key] ? formatCurrency(p[key]) : t('search_page.no_price');
+};
+
+const setPeriodTypePrevious = () => {
+  const prev = currentPeriodType.value - 2;
+  if (periodTypeMap[prev]) currentPeriodType.value = prev;
+};
+
+const setPeriodTypeNext = () => {
+  const next = currentPeriodType.value + 2;
+  if (periodTypeMap[next]) currentPeriodType.value = next;
+};
+
+const sortedProviders = computed(() => {
+  const list = [...allProviders.value];
+  const priceKey = periodTypeMap[currentPeriodType.value];
+
+  switch (activeSort.value) {
+    case 'price_asc':
+      return list.sort((a, b) => Number(a[priceKey] ?? 0) - Number(b[priceKey] ?? 0));
+    case 'price_desc':
+      return list.sort((a, b) => Number(b[priceKey] ?? 0) - Number(a[priceKey] ?? 0));
+    case 'rating_desc':
+      return list.sort((a, b) => Number(b.average_rating ?? 0) - Number(a.average_rating ?? 0));
+    case 'rating_asc':
+      return list.sort((a, b) => Number(a.average_rating ?? 0) - Number(b.average_rating ?? 0));
+    case 'reviews_desc':
+      return list.sort((a, b) => Number(b.total_reviews ?? 0) - Number(a.total_reviews ?? 0));
+    case 'reviews_asc':
+      return list.sort((a, b) => Number(a.total_reviews ?? 0) - Number(b.total_reviews ?? 0));
+    case 'services_desc':
+      return list.sort((a, b) => Number(b.total_services ?? 0) - Number(a.total_services ?? 0));
+    case 'services_asc':
+      return list.sort((a, b) => Number(a.total_services ?? 0) - Number(b.total_services ?? 0));
+    case 'oldest':
+      return list.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+    case 'newest':
+      return list.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
+    default:
+      return list.sort((a, b) => Number(b.average_rating ?? 0) - Number(a.average_rating ?? 0));
+  }
+});
+
+const loadProviders = async () => {
+  loading.value = true;
+  try {
+    allProviders.value = await buscaPrestadores({
+      name: searchName.value,
+      date: activeDate.value ?? '',
+    }) ?? [];
+  } catch {
+    allProviders.value = [];
+  } finally {
+    loading.value = false;
+  }
+};
+
+const onNameChange = () => loadProviders();
+
+// const openFilterDialog = () => {
+//   $q.dialog({
+//     component: SearchFilterDialog,
+//     componentProps: {
+//       initialSort: activeSort.value,
+//       initialDate: activeDate.value,
+//     },
+//   }).onOk(({ sort, date }) => {
+//     const dateChanged = date !== activeDate.value;
+//     activeSort.value = sort;
+//     activeDate.value = date;
+//     if (dateChanged) loadProviders();
+//   });
+// };
+
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
+onMounted(() => loadProviders());
+</script>
+
+<style scoped lang="scss">
+.shadow-search {
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+.search-input {
+  :deep(.q-field__control) {
+    border-radius: 28px;
+  }
+}
+.custom-schedule-card {
+  border-radius: 12px;
+}
+.custom-schedule-btn {
+  flex-shrink: 0;
+  min-width: 72px;
+}
+.filter-active {
+  color: var(--q-primary) !important;
+}
+.fonte-hint {
+  font-family: Inter;
+  font-weight: 500;
+  font-size: 14px;
+  line-height: 100%;
+  letter-spacing: -0.04em;
+  vertical-align: middle;
 }
 </style>