SearchPage.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
  2. <template>
  3. <q-page class="bg-page">
  4. <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-search">
  5. <q-btn flat round dense icon="mdi-chevron-left" color="primary" @click="router.back()" />
  6. <div class="col text-center font16 fontbold gradient-diarista text-primary">
  7. {{ $t('search_page.title') }}
  8. </div>
  9. <div style="width: 36px" />
  10. </div>
  11. <div class="q-px-md q-pt-md q-pb-sm">
  12. <q-card class="custom-schedule-card bg-surface shadow-card q-pa-sm" :flat="false">
  13. <q-card-section class="row items-center no-wrap q-pa-sm q-gutter-x-sm">
  14. <span class="col text-text font12 fontmedium fonte-hint">
  15. {{ $t('search_page.custom_schedule_description') }}
  16. </span>
  17. <q-btn
  18. color="secondary"
  19. no-caps
  20. unelevated
  21. padding="8px 16px"
  22. class="font12 fontmedium custom-schedule-btn card-border"
  23. @click="router.push({ name: 'SobMedidaPage' })"
  24. >
  25. <template #default>
  26. <div class="column items-center q-gutter-y-xs">
  27. <q-icon name="mdi-scissors-cutting" size="16px" />
  28. <span>{{ $t('search_page.custom_schedule_btn') }}</span>
  29. </div>
  30. </template>
  31. </q-btn>
  32. </q-card-section>
  33. </q-card>
  34. </div>
  35. <div class="row items-center q-px-md q-py-md q-gutter-x-sm">
  36. <q-input
  37. v-model="searchName"
  38. :placeholder="$t('search_page.search_placeholder')"
  39. outlined
  40. rounded
  41. dense
  42. clearable
  43. debounce="400"
  44. class="col bg-white search-input"
  45. input-class="text-text"
  46. @update:model-value="onNameChange"
  47. >
  48. <template #append>
  49. <q-icon name="mdi-magnify" color="grey-5" />
  50. </template>
  51. </q-input>
  52. <q-btn
  53. flat round dense
  54. icon="mdi-tune-variant"
  55. color="grey-6"
  56. size="md"
  57. :class="{ 'filter-active': activeSort || activeDate }"
  58. @click="openFilterDialog"
  59. />
  60. </div>
  61. <div class="row items-center justify-between no-wrap q-px-md q-pb-sm">
  62. <div class="dashboard-section-title font16 fontbold gradient-diarista">{{ $t('search_page.choose_provider') }}</div>
  63. <div class="row items-center no-wrap text-text">
  64. <q-btn flat dense round icon="mdi-chevron-left" color="text" size="sm" @click="setPeriodTypePrevious" />
  65. <span class="font10 fontbold">{{ periodLabel }}</span>
  66. <q-btn flat dense round icon="mdi-chevron-right" color="text" size="sm" @click="setPeriodTypeNext" />
  67. </div>
  68. </div>
  69. <div v-if="loading" class="row items-center justify-center q-py-xl">
  70. <q-spinner-dots color="primary" size="40px" />
  71. </div>
  72. <template v-else>
  73. <div v-if="sortedProviders.length === 0" class="text-center text-grey-6 q-px-md q-py-lg text-body2">
  74. {{ $t('search_page.no_results') }}
  75. </div>
  76. <div v-else class="column q-px-md q-pb-xl">
  77. <q-card
  78. v-for="p in sortedProviders"
  79. :key="p.provider_id"
  80. class="card-border bg-page text-text q-mb-sm"
  81. :flat="false"
  82. >
  83. <q-card-section class="row no-wrap q-pa-sm">
  84. <div class="row no-wrap full-width">
  85. <div class="col-2">
  86. <q-avatar :style="p.profile_media_url ? {} : avatarColors[p.provider_id % avatarColors.length]">
  87. <img v-if="p.profile_media_url" :src="p.profile_media_url" style="object-fit: cover; width: 100%; height: 100%;" />
  88. <span v-else>{{ p.provider_name?.slice(0,1).toUpperCase() ?? '—' }}</span>
  89. </q-avatar>
  90. </div>
  91. <div class="col-10 row">
  92. <div class="column col-9 justify-between">
  93. <span class="text-provider-close-nam font12 fontbold">{{ p.provider_name ?? 'Prestador' }}</span>
  94. <span class="text-provider-close-region font9 fontbold text-grey-7">{{ p.district }}</span>
  95. <div class="row items-center justify-between q-pr-lg">
  96. <div class="row items-center">
  97. <q-icon name="mdi-star" color="warning" size="12px" />
  98. <span class="text-provider-close-rating font9 fontmedium">
  99. {{ p.average_rating != null ? (Number(p.average_rating).toFixed(1) + ' (' + (p.total_reviews ?? 0) + ')') : ('(' + (p.total_reviews ?? 0) + ')') }}
  100. </span>
  101. </div>
  102. <div class="row items-center">
  103. <q-icon name="mdi-broom" color="secondary" size="12px" />
  104. <span class="text-provider-close-jobs font9 fontmedium">{{ p.total_services ?? 0 }}</span>
  105. </div>
  106. <div class="row items-center">
  107. <q-icon name="mdi-map-marker-outline" color="text" size="12px" />
  108. <span class="text-provider-close-jobs font9 fontmedium">{{ (p.distance_km ?? '--') + ' km' }}</span>
  109. </div>
  110. </div>
  111. </div>
  112. <div class="column col-3 justify-between text-center items-center">
  113. <span class="text-provider-close-price font12 fontbold">{{ priceByPeriod(p) }}</span>
  114. <div class="full-width">
  115. <q-btn
  116. unelevated rounded no-caps
  117. color="primary"
  118. size="sm"
  119. padding="3px 12px"
  120. :label="$t('search_page.schedule_btn')"
  121. @click="goToScheduling(p)"
  122. />
  123. </div>
  124. </div>
  125. </div>
  126. </div>
  127. </q-card-section>
  128. </q-card>
  129. </div>
  130. </template>
  131. </q-page>
  132. </template>
  133. <script setup>
  134. import { ref, computed, onMounted } from 'vue';
  135. import { useRouter } from 'vue-router';
  136. import { useI18n } from 'vue-i18n';
  137. import { useQuasar } from 'quasar';
  138. import { buscaPrestadores } from 'src/api/dashboard';
  139. import { formatCurrency } from 'src/helpers/utils';
  140. import SearchFilterDialog from 'src/pages/search/components/SearchFilterDialog.vue';
  141. import SchedulingDialog from 'src/pages/search/components/SchedulingDialog.vue';
  142. const { t } = useI18n();
  143. const router = useRouter();
  144. const $q = useQuasar();
  145. const allProviders = ref([]);
  146. const loading = ref(true);
  147. const searchName = ref('');
  148. const activeDate = ref(null);
  149. const activeSort = ref(null);
  150. const currentPeriodType = ref(8);
  151. const periodTypeMap = { 2: 'daily_price_2h', 4: 'daily_price_4h', 6: 'daily_price_6h', 8: 'daily_price_8h' };
  152. const periodLabel = computed(() => {
  153. 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') };
  154. return labels[currentPeriodType.value] ?? '';
  155. });
  156. const priceByPeriod = (p) => {
  157. const key = periodTypeMap[currentPeriodType.value];
  158. return p[key] ? formatCurrency(p[key]) : t('search_page.no_price');
  159. };
  160. // eslint-disable-next-line no-unused-vars
  161. const formatDistance = (distance) => {
  162. if (distance === null || distance === undefined || distance === '') return '—';
  163. const numericDistance = Number(distance);
  164. if (!Number.isFinite(numericDistance)) return '—';
  165. return `${numericDistance.toLocaleString('pt-BR', { maximumFractionDigits: 1 })} km`;
  166. };
  167. const setPeriodTypePrevious = () => {
  168. const prev = currentPeriodType.value - 2;
  169. if (periodTypeMap[prev]) currentPeriodType.value = prev;
  170. };
  171. const setPeriodTypeNext = () => {
  172. const next = currentPeriodType.value + 2;
  173. if (periodTypeMap[next]) currentPeriodType.value = next;
  174. };
  175. const sortedProviders = computed(() => {
  176. const list = [...allProviders.value];
  177. const priceKey = periodTypeMap[currentPeriodType.value];
  178. switch (activeSort.value) {
  179. case 'price_asc':
  180. return list.sort((a, b) => Number(a[priceKey] ?? 0) - Number(b[priceKey] ?? 0));
  181. case 'price_desc':
  182. return list.sort((a, b) => Number(b[priceKey] ?? 0) - Number(a[priceKey] ?? 0));
  183. case 'rating_desc':
  184. return list.sort((a, b) => Number(b.average_rating ?? 0) - Number(a.average_rating ?? 0));
  185. case 'rating_asc':
  186. return list.sort((a, b) => Number(a.average_rating ?? 0) - Number(b.average_rating ?? 0));
  187. case 'reviews_desc':
  188. return list.sort((a, b) => Number(b.total_reviews ?? 0) - Number(a.total_reviews ?? 0));
  189. case 'reviews_asc':
  190. return list.sort((a, b) => Number(a.total_reviews ?? 0) - Number(b.total_reviews ?? 0));
  191. case 'services_desc':
  192. return list.sort((a, b) => Number(b.total_services ?? 0) - Number(a.total_services ?? 0));
  193. case 'services_asc':
  194. return list.sort((a, b) => Number(a.total_services ?? 0) - Number(b.total_services ?? 0));
  195. case 'oldest':
  196. return list.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
  197. case 'newest':
  198. return list.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
  199. default:
  200. return list.sort((a, b) => Number(b.average_rating ?? 0) - Number(a.average_rating ?? 0));
  201. }
  202. });
  203. const loadProviders = async () => {
  204. loading.value = true;
  205. try {
  206. allProviders.value = await buscaPrestadores({
  207. name: searchName.value,
  208. date: activeDate.value ?? '',
  209. }) ?? [];
  210. } catch {
  211. allProviders.value = [];
  212. } finally {
  213. loading.value = false;
  214. }
  215. };
  216. const onNameChange = () => loadProviders();
  217. const openFilterDialog = () => {
  218. $q.dialog({
  219. component: SearchFilterDialog,
  220. componentProps: {
  221. initialSort: activeSort.value,
  222. initialDate: activeDate.value,
  223. },
  224. }).onOk(({ sort, date }) => {
  225. const dateChanged = date !== activeDate.value;
  226. activeSort.value = sort;
  227. activeDate.value = date;
  228. if (dateChanged) loadProviders();
  229. });
  230. };
  231. const goToScheduling = (provider) => {
  232. $q.dialog({
  233. component: SchedulingDialog,
  234. componentProps: { provider },
  235. });
  236. };
  237. const avatarColors = [
  238. { background: '#ffd5df', color: '#932e57' },
  239. { background: '#d7e8ff', color: '#2158a8' },
  240. { background: '#dfd', color: '#2a7a3b' },
  241. { background: '#ffe5cc', color: '#8a4500' },
  242. ];
  243. onMounted(() => loadProviders());
  244. </script>
  245. <style scoped lang="scss">
  246. .shadow-search {
  247. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  248. }
  249. .search-input {
  250. :deep(.q-field__control) {
  251. border-radius: 28px;
  252. }
  253. }
  254. .custom-schedule-card {
  255. border-radius: 12px;
  256. }
  257. .custom-schedule-btn {
  258. flex-shrink: 0;
  259. min-width: 72px;
  260. }
  261. .filter-active {
  262. color: var(--q-primary) !important;
  263. }
  264. .fonte-hint {
  265. line-height: 100%;
  266. letter-spacing: -0.04em;
  267. vertical-align: middle;
  268. }
  269. </style>