SearchPage.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
  2. <template>
  3. <q-page class="bg-page">
  4. <div
  5. class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-search"
  6. >
  7. <q-btn
  8. color="primary"
  9. dense
  10. flat
  11. icon="mdi-chevron-left"
  12. round
  13. @click="router.back()"
  14. />
  15. <div
  16. class="col text-center font16 fontbold gradient-diarista text-primary"
  17. >
  18. {{ $t("search_page.title") }}
  19. </div>
  20. <div style="width: 36px" />
  21. </div>
  22. <div class="q-px-md q-pt-md q-pb-sm">
  23. <q-card
  24. class="custom-schedule-card bg-surface shadow-card q-pa-sm"
  25. :flat="false"
  26. >
  27. <q-card-section class="row items-center no-wrap q-pa-sm q-gutter-x-sm">
  28. <span class="col text-text font12 fontmedium fonte-hint">
  29. {{ $t("search_page.custom_schedule_description") }}
  30. </span>
  31. <q-btn
  32. class="font12 fontmedium custom-schedule-btn card-border"
  33. color="secondary"
  34. no-caps
  35. padding="8px 16px"
  36. unelevated
  37. @click="router.push({ name: 'SobMedidaPage' })"
  38. >
  39. <template #default>
  40. <div class="column items-center q-gutter-y-xs">
  41. <q-icon
  42. name="mdi-scissors-cutting"
  43. size="16px"
  44. />
  45. <span>{{ $t("search_page.custom_schedule_btn") }}</span>
  46. </div>
  47. </template>
  48. </q-btn>
  49. </q-card-section>
  50. </q-card>
  51. </div>
  52. <div class="row items-center q-px-md q-py-md q-gutter-x-sm">
  53. <q-input
  54. v-model="searchName"
  55. class="col bg-white search-input"
  56. clearable
  57. debounce="400"
  58. dense
  59. input-class="text-text"
  60. outlined
  61. rounded
  62. :placeholder="$t('search_page.search_placeholder')"
  63. @update:model-value="onNameChange"
  64. >
  65. <template #append>
  66. <q-icon
  67. color="grey-5"
  68. name="mdi-magnify"
  69. />
  70. </template>
  71. </q-input>
  72. <q-btn
  73. :class="{ 'filter-active': activeSort || activeDate }"
  74. color="grey-6"
  75. dense
  76. flat
  77. icon="mdi-tune-variant"
  78. round
  79. size="md"
  80. @click="openFilterDialog"
  81. />
  82. </div>
  83. <div class="row items-center justify-between no-wrap q-px-md q-pb-sm">
  84. <div class="dashboard-section-title font16 fontbold gradient-diarista">
  85. {{ $t("search_page.choose_provider") }}
  86. </div>
  87. <div class="row items-center no-wrap text-text">
  88. <q-btn
  89. color="text"
  90. dense
  91. flat
  92. icon="mdi-chevron-left"
  93. round
  94. size="sm"
  95. @click="setPeriodTypePrevious"
  96. />
  97. <span class="font10 fontbold">{{ periodLabel }}</span>
  98. <q-btn
  99. color="text"
  100. dense
  101. flat
  102. icon="mdi-chevron-right"
  103. round
  104. size="sm"
  105. @click="setPeriodTypeNext"
  106. />
  107. </div>
  108. </div>
  109. <div
  110. v-if="loading"
  111. class="row items-center justify-center q-py-xl"
  112. >
  113. <q-spinner-dots
  114. color="primary"
  115. size="40px"
  116. />
  117. </div>
  118. <template v-else>
  119. <div
  120. v-if="sortedProviders.length === 0"
  121. class="text-center text-grey-6 q-px-md q-py-lg text-body2"
  122. >
  123. {{ $t("search_page.no_results") }}
  124. </div>
  125. <div
  126. v-else
  127. class="column q-px-md q-pb-xl"
  128. >
  129. <q-card
  130. v-for="p in sortedProviders"
  131. :key="p.provider_id"
  132. :flat="false"
  133. class="card-border bg-page text-text q-mb-sm"
  134. >
  135. <q-card-section class="row no-wrap q-pa-sm">
  136. <div class="row no-wrap full-width">
  137. <div class="col-2">
  138. <q-avatar
  139. :style="
  140. p.profile_media_url
  141. ? {}
  142. : avatarColors[p.provider_id % avatarColors.length]
  143. "
  144. >
  145. <img
  146. v-if="p.profile_media_url"
  147. :src="p.profile_media_url"
  148. style="object-fit: cover; width: 100%; height: 100%"
  149. />
  150. <span v-else>{{
  151. getFirstName(p.provider_name)?.slice(0, 1).toUpperCase() ??
  152. "—"
  153. }}</span>
  154. </q-avatar>
  155. </div>
  156. <div class="col-10 row">
  157. <div class="column col-9 justify-between">
  158. <span class="text-provider-close-nam font12 fontbold">
  159. {{
  160. getFirstName(p.provider_name) || "Prestador"
  161. }}
  162. </span>
  163. <span
  164. v-if="getProviderGenderLabel(p, t)"
  165. class="font9 fontmedium text-grey-7"
  166. >
  167. {{ getProviderGenderLabel(p, t) }}
  168. </span>
  169. <span
  170. class="text-provider-close-region font9 fontbold text-grey-7"
  171. >
  172. {{ p.district }}
  173. </span>
  174. <div class="row items-center justify-between q-pr-lg">
  175. <div class="row items-center">
  176. <q-icon
  177. color="warning"
  178. name="mdi-star"
  179. size="12px"
  180. />
  181. <span class="text-provider-close-rating font9 fontmedium">
  182. {{
  183. p.average_rating != null
  184. ? Number(p.average_rating).toFixed(1) +
  185. " (" +
  186. (p.total_reviews ?? 0) +
  187. ")"
  188. : "(" + (p.total_reviews ?? 0) + ")"
  189. }}
  190. </span>
  191. </div>
  192. <div class="row items-center">
  193. <q-icon
  194. color="secondary"
  195. name="mdi-broom"
  196. size="12px"
  197. />
  198. <span class="text-provider-close-jobs font9 fontmedium">
  199. {{ p.total_services ?? 0 }}
  200. </span>
  201. </div>
  202. <div class="row items-center">
  203. <q-icon
  204. color="text"
  205. name="mdi-map-marker-outline"
  206. size="12px"
  207. />
  208. <span class="text-provider-close-jobs font9 fontmedium">
  209. {{ (p.distance_km ?? "--") + " km" }}
  210. </span>
  211. </div>
  212. </div>
  213. </div>
  214. <div
  215. class="column col-3 justify-between text-center items-center"
  216. >
  217. <span class="text-provider-close-price font12 fontbold">
  218. {{ priceByPeriod(p) }}
  219. </span>
  220. <div class="full-width">
  221. <q-btn
  222. color="primary"
  223. no-caps
  224. padding="3px 12px"
  225. rounded
  226. size="sm"
  227. unelevated
  228. :label="$t('search_page.schedule_btn')"
  229. @click="goToScheduling(p)"
  230. />
  231. </div>
  232. </div>
  233. </div>
  234. </div>
  235. </q-card-section>
  236. </q-card>
  237. </div>
  238. </template>
  239. </q-page>
  240. </template>
  241. <script setup>
  242. import { avatarColors } from "src/helpers/avatarColors";
  243. import { buscaPrestadores } from "src/api/dashboard";
  244. import { computed, onMounted, ref } from "vue";
  245. import { formatCurrency, getFirstName } from "src/helpers/utils";
  246. import { getProviderGenderLabel } from "src/helpers/provider";
  247. import { useI18n } from "vue-i18n";
  248. import { useQuasar } from "quasar";
  249. import { useRouter } from "vue-router";
  250. import SchedulingDialog from "src/pages/search/components/SchedulingDialog.vue";
  251. import SearchFilterDialog from "src/pages/search/components/SearchFilterDialog.vue";
  252. const { t } = useI18n();
  253. const router = useRouter();
  254. const $q = useQuasar();
  255. const activeDate = ref(null);
  256. const activeSort = ref(null);
  257. const allProviders = ref([]);
  258. const currentPeriodType = ref(8);
  259. const loading = ref(true);
  260. const searchName = ref("");
  261. const periodTypeMap = {
  262. 2: "daily_price_2h",
  263. 4: "daily_price_4h",
  264. 6: "daily_price_6h",
  265. 8: "daily_price_8h",
  266. };
  267. const periodLabel = computed(() => {
  268. const labels = {
  269. 8: t("search_page.until_8h"),
  270. 6: t("search_page.until_6h"),
  271. 4: t("search_page.until_4h"),
  272. 2: t("search_page.until_2h"),
  273. };
  274. return labels[currentPeriodType.value] ?? "";
  275. });
  276. const sortedProviders = computed(() => {
  277. const list = [...allProviders.value];
  278. const priceKey = periodTypeMap[currentPeriodType.value];
  279. switch (activeSort.value) {
  280. case "price_asc":
  281. return list.sort(
  282. (a, b) => Number(a[priceKey] ?? 0) - Number(b[priceKey] ?? 0),
  283. );
  284. case "price_desc":
  285. return list.sort(
  286. (a, b) => Number(b[priceKey] ?? 0) - Number(a[priceKey] ?? 0),
  287. );
  288. case "rating_desc":
  289. return list.sort(
  290. (a, b) => Number(b.average_rating ?? 0) - Number(a.average_rating ?? 0),
  291. );
  292. case "rating_asc":
  293. return list.sort(
  294. (a, b) => Number(a.average_rating ?? 0) - Number(b.average_rating ?? 0),
  295. );
  296. case "reviews_desc":
  297. return list.sort(
  298. (a, b) => Number(b.total_reviews ?? 0) - Number(a.total_reviews ?? 0),
  299. );
  300. case "reviews_asc":
  301. return list.sort(
  302. (a, b) => Number(a.total_reviews ?? 0) - Number(b.total_reviews ?? 0),
  303. );
  304. case "services_desc":
  305. return list.sort(
  306. (a, b) => Number(b.total_services ?? 0) - Number(a.total_services ?? 0),
  307. );
  308. case "services_asc":
  309. return list.sort(
  310. (a, b) => Number(a.total_services ?? 0) - Number(b.total_services ?? 0),
  311. );
  312. case "oldest":
  313. return list.sort(
  314. (a, b) => new Date(a.created_at) - new Date(b.created_at),
  315. );
  316. case "newest":
  317. return list.sort(
  318. (a, b) => new Date(b.created_at) - new Date(a.created_at),
  319. );
  320. default:
  321. return list.sort(
  322. (a, b) => Number(b.average_rating ?? 0) - Number(a.average_rating ?? 0),
  323. );
  324. }
  325. });
  326. // eslint-disable-next-line no-unused-vars
  327. const formatDistance = (distance) => {
  328. if (distance === null || distance === undefined || distance === "")
  329. return "—";
  330. const numericDistance = Number(distance);
  331. if (!Number.isFinite(numericDistance)) return "—";
  332. return `${numericDistance.toLocaleString("pt-BR", { maximumFractionDigits: 1 })} km`;
  333. };
  334. const goToScheduling = (provider) => {
  335. $q.dialog({
  336. component: SchedulingDialog,
  337. componentProps: { provider },
  338. });
  339. };
  340. const loadProviders = async () => {
  341. loading.value = true;
  342. try {
  343. allProviders.value =
  344. (await buscaPrestadores({
  345. date: activeDate.value ?? "",
  346. name: searchName.value,
  347. })) ?? [];
  348. } catch {
  349. allProviders.value = [];
  350. } finally {
  351. loading.value = false;
  352. }
  353. };
  354. const onNameChange = () => {
  355. loadProviders();
  356. };
  357. const openFilterDialog = () => {
  358. $q.dialog({
  359. component: SearchFilterDialog,
  360. componentProps: {
  361. initialDate: activeDate.value,
  362. initialSort: activeSort.value,
  363. },
  364. }).onOk(({ date, sort }) => {
  365. const dateChanged = date !== activeDate.value;
  366. activeDate.value = date;
  367. activeSort.value = sort;
  368. if (dateChanged) loadProviders();
  369. });
  370. };
  371. const priceByPeriod = (provider) => {
  372. const key = periodTypeMap[currentPeriodType.value];
  373. return provider[key]
  374. ? formatCurrency(provider[key])
  375. : t("search_page.no_price");
  376. };
  377. const setPeriodTypeNext = () => {
  378. const next = currentPeriodType.value + 2;
  379. if (periodTypeMap[next]) currentPeriodType.value = next;
  380. };
  381. const setPeriodTypePrevious = () => {
  382. const previous = currentPeriodType.value - 2;
  383. if (periodTypeMap[previous]) currentPeriodType.value = previous;
  384. };
  385. onMounted(loadProviders);
  386. </script>
  387. <style scoped lang="scss">
  388. .shadow-search {
  389. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  390. }
  391. .search-input {
  392. :deep(.q-field__control) {
  393. border-radius: 28px;
  394. }
  395. }
  396. .custom-schedule-card {
  397. border-radius: 12px;
  398. }
  399. .custom-schedule-btn {
  400. flex-shrink: 0;
  401. min-width: 72px;
  402. }
  403. .filter-active {
  404. color: var(--q-primary) !important;
  405. }
  406. .fonte-hint {
  407. line-height: 100%;
  408. letter-spacing: -0.04em;
  409. vertical-align: middle;
  410. }
  411. </style>