SearchPage.vue 13 KB

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