SobMedidaPage.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680
  1. <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
  2. <template>
  3. <q-page class="sob-medida-page">
  4. <div class="page-shell">
  5. <span class="page-title gradient-diarista">Serviço Sob Medida</span>
  6. <q-card flat bordered class="figma-card compact-card">
  7. <div class="card-title text-center gradient-diarista">
  8. Seu pedido
  9. </div>
  10. <div class="field-label text-center gradient-diarista">
  11. Quantidade de serviço
  12. </div>
  13. <div class="quantity-stepper">
  14. <q-btn
  15. round
  16. flat
  17. icon="remove"
  18. class="quantity-btn"
  19. @click="decreaseQuantity"
  20. />
  21. <span class="quantity-value gradient-diarista">
  22. {{ quantity }}
  23. </span>
  24. <q-btn
  25. round
  26. flat
  27. icon="add"
  28. class="quantity-btn"
  29. @click="increaseQuantity"
  30. />
  31. </div>
  32. <div class="options-grid gradient-diarista">
  33. <div
  34. v-for="option in addressTypesOptions"
  35. :key="option.value"
  36. class="option-col"
  37. >
  38. <q-radio
  39. v-model="selectedOption"
  40. :val="option.value"
  41. :label="option.label"
  42. color="purple"
  43. dense
  44. />
  45. </div>
  46. </div>
  47. <div class="field-label text-center gradient-diarista">
  48. Tipo de serviço
  49. </div>
  50. <div class="service-type-inline">
  51. <div
  52. v-for="serviceType in serviceTypes"
  53. :key="serviceType.id"
  54. class="option-col"
  55. >
  56. <q-radio
  57. v-model="selectedServiceType"
  58. :val="serviceType.id"
  59. :label="serviceType.description"
  60. color="purple"
  61. dense
  62. />
  63. </div>
  64. </div>
  65. <div class="field-label text-center gradient-diarista">Especialidade preferencial?</div>
  66. <div class="options-grid">
  67. <div class="options-grid">
  68. <div
  69. v-for="item in specialties"
  70. :key="item.id"
  71. class="option-col"
  72. >
  73. <q-checkbox
  74. v-model="selectedSpecialties"
  75. :val="item.id"
  76. :label="item.description"
  77. color="purple"
  78. dense
  79. />
  80. </div>
  81. </div>
  82. </div>
  83. <div class="field-label text-center gradient-diarista">
  84. Descreva detalhes do pedido
  85. <span class="optional">(opcional)</span>
  86. </div>
  87. <q-input
  88. v-model="description"
  89. type="textarea"
  90. outlined
  91. bg-color="white"
  92. color="dark"
  93. input-class="text-black"
  94. class="description-box"
  95. placeholder="Olá, desejo profissional dedicado que irá fazer..."
  96. />
  97. </q-card>
  98. <!-- Faixa -->
  99. <div class="section-title gradient-diarista">Faixa de preço por 8 horas</div>
  100. <q-card flat bordered class="figma-card compact-card">
  101. <div class="range-container">
  102. <!-- bolha min -->
  103. <div
  104. class="price-pin"
  105. :style="{ left: minPosition + '%' }"
  106. >
  107. <span>{{ priceRange.min }}</span>
  108. </div>
  109. <!-- bolha max -->
  110. <div
  111. class="price-pin"
  112. :style="{ left: maxPosition + '%' }"
  113. >
  114. <span>{{ priceRange.max }}</span>
  115. </div>
  116. <q-range
  117. v-model="priceRange"
  118. :min="PRICE_LIMITS.min"
  119. :max="PRICE_LIMITS.max"
  120. color="secondary"
  121. class="price-range"
  122. />
  123. </div>
  124. <div class="range-helper gradient-diarista">
  125. Selecione a faixa de preço integral para receber propostas de diaristas.
  126. </div>
  127. </q-card>
  128. <!-- Data -->
  129. <div class="section-title gradient-diarista">Data e hora</div>
  130. <q-card flat bordered class="figma-card date-card">
  131. <q-date
  132. v-model="selectedDate"
  133. minimal
  134. color="purple"
  135. class="figma-date calendar-custom "
  136. />
  137. </q-card>
  138. </div>
  139. </q-page>
  140. </template>
  141. <script setup>
  142. import { ref, computed, watch, onMounted } from 'vue'
  143. import { useQuasar } from 'quasar'
  144. import ServiceSelectionSheet from 'src/pages/search/components/ServiceSelectionSheet.vue'
  145. import ServiceTimeSelectionDialog from 'src/pages/search/components/ServiceTimeSelectionDialog.vue'
  146. import { createCustomSchedule } from 'src/api/customSchedules'
  147. import { getPrimaryAddress } from 'src/api/address'
  148. import { getPublicServiceTypes } from 'src/api/serviceTypes'
  149. import { getPublicSpecialties } from 'src/api/specialties'
  150. import { userStore } from 'src/stores/user'
  151. import { calculateDailyPrices } from 'src/helpers/utils'
  152. const $q = useQuasar()
  153. const user = userStore()
  154. const addressTypesOptions = computed(() => [
  155. { label: 'Residencial', value: 'home' },
  156. { label: 'Comercial', value: 'commercial' }
  157. ])
  158. // listas vindas da API
  159. const serviceTypes = ref([])
  160. const specialties = ref([])
  161. // seleções do usuário
  162. const selectedServiceType = ref(null)
  163. const selectedSpecialties = ref([])
  164. const selectedOption = ref('home')
  165. // campos do formulário
  166. const address = ref([])
  167. const description = ref('')
  168. const selectedDate = ref(null)
  169. const quantity = ref(1)
  170. const PRICE_LIMITS = Object.freeze({
  171. min: 100,
  172. max: 500
  173. })
  174. const priceRange = ref({
  175. min: 150,
  176. max: 300
  177. })
  178. const minPosition = computed(() =>
  179. ((priceRange.value.min - PRICE_LIMITS.min) /
  180. (PRICE_LIMITS.max - PRICE_LIMITS.min)) * 100
  181. )
  182. const maxPosition = computed(() =>
  183. ((priceRange.value.max - PRICE_LIMITS.min) /
  184. (PRICE_LIMITS.max - PRICE_LIMITS.min)) * 100
  185. )
  186. onMounted(async () => {
  187. const { data } = await getPrimaryAddress(user.user.client.id, 'client')
  188. address.value = data.payload
  189. const publicServiceTypes = await getPublicServiceTypes()
  190. serviceTypes.value = publicServiceTypes
  191. const publicSpecialties = await getPublicSpecialties()
  192. specialties.value = publicSpecialties
  193. })
  194. watch(selectedDate, (newDate, oldDate) => {
  195. if (!newDate || newDate === oldDate) return
  196. openServiceSelection()
  197. })
  198. function openServiceSelection () {
  199. $q.dialog({
  200. component: ServiceSelectionSheet,
  201. componentProps: {
  202. provider: calculateDailyPrices(priceRange.value.max * quantity.value),
  203. selectedDate: selectedDate.value
  204. }
  205. }).onOk((payload) => {
  206. if (payload?.serviceType) {
  207. openServiceTimeSelection(payload.serviceType)
  208. }
  209. })
  210. }
  211. function openServiceTimeSelection (serviceType) {
  212. $q.dialog({
  213. component: ServiceTimeSelectionDialog,
  214. componentProps: {
  215. serviceType,
  216. provider: calculateDailyPrices(priceRange.value.max * quantity.value),
  217. selectedDate: selectedDate.value
  218. }
  219. }).onOk(saveFinalOrder)
  220. }
  221. async function saveFinalOrder (payloadFinal) {
  222. let [startHour, endHour] = payloadFinal.slot.split('-')
  223. if(startHour < 10) {
  224. startHour = '0' + startHour
  225. }
  226. if(endHour < 10) {
  227. endHour = '0' + endHour
  228. }
  229. const payload = {
  230. client_id: user.user.client.id,
  231. address_id: address.value?.id,
  232. quantity: quantity.value,
  233. date: payloadFinal.date,
  234. period_type: String(payloadFinal.serviceType.hoursCount),
  235. start_time: `${startHour}:00`,
  236. end_time: `${endHour}:00`,
  237. address_type: selectedOption.value.toLowerCase(),
  238. service_type_id: selectedServiceType.value,
  239. description: description.value,
  240. min_price: priceRange.value.min,
  241. max_price: priceRange.value.max,
  242. offers_meal: payloadFinal.meal === 'offer',
  243. speciality_ids: selectedSpecialties.value
  244. }
  245. await createCustomSchedule(payload)
  246. $q.notify({
  247. type: 'positive',
  248. message: 'Pedido sob medida salvo com sucesso!'
  249. })
  250. }
  251. function increaseQuantity () {
  252. quantity.value++
  253. }
  254. function decreaseQuantity () {
  255. if (quantity.value > 1) quantity.value--
  256. }
  257. </script>
  258. <style scoped lang="scss">
  259. .sob-medida-page {
  260. background: #f6f5fb;
  261. min-height: 100vh;
  262. padding: 16px;
  263. }
  264. .page-shell {
  265. max-width: 420px;
  266. margin: 0 auto;
  267. }
  268. .page-title {
  269. display: block;
  270. text-align: center;
  271. font-size: 18px;
  272. font-weight: 700;
  273. margin: 0 0 20px;
  274. width: 100%;
  275. margin-top: 16px;
  276. }
  277. .figma-card {
  278. border-radius: 24px;
  279. background: #fff;
  280. padding: 18px;
  281. margin-bottom: 20px;
  282. box-shadow: none;
  283. }
  284. .card-title,
  285. .section-title {
  286. color: #7b61ff;
  287. font-size: 16px;
  288. font-weight: 700;
  289. margin-bottom: 16px;
  290. }
  291. .options-grid {
  292. display: grid;
  293. grid-template-columns: repeat(2, 1fr);
  294. gap: 12px 20px;
  295. justify-items: center;
  296. margin-bottom: 12px;
  297. :deep(.q-radio__label),
  298. :deep(.q-checkbox__label) {
  299. color: #000 !important;
  300. font-size: 14px;
  301. font-weight: 400;
  302. }
  303. :deep(.q-checkbox__inner),
  304. :deep(.q-radio__inner) {
  305. color: #a78bfa !important;
  306. }
  307. }
  308. .section-title {
  309. margin-left: 16px;
  310. }
  311. .field-label {
  312. color: #7b61ff;
  313. font-size: 13px;
  314. font-weight: 600;
  315. margin: 18px 0 10px;
  316. }
  317. .optional {
  318. color: #9e9e9e;
  319. font-weight: 400;
  320. }
  321. .option-col {
  322. min-width: 0;
  323. }
  324. .description-box {
  325. margin-top: 8px;
  326. }
  327. .description-box :deep(textarea) {
  328. min-height: 90px;
  329. resize: none;
  330. }
  331. /* CARD */
  332. .compact-card {
  333. border-radius: 32px;
  334. padding: 28px 22px;
  335. background: #fafafa;
  336. box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
  337. }
  338. .date-card {
  339. display: flex;
  340. justify-content: center;
  341. align-items: center;
  342. width: fit-content;
  343. min-width: 320px;
  344. max-width: 360px;
  345. margin: 0 auto 20px;
  346. padding: 20px;
  347. border-radius: 24px;
  348. background: #fff;
  349. }
  350. /* container */
  351. .range-container {
  352. position: relative;
  353. padding-top: 58px;
  354. padding-left: 10px;
  355. padding-right: 10px;
  356. }
  357. /* bolha tipo pin */
  358. .price-pin {
  359. position: absolute;
  360. top: 0;
  361. width: 46px;
  362. height: 46px;
  363. background: linear-gradient(180deg, #8b7cff, #6f57db);
  364. border-radius: 50% 50% 50% 0;
  365. transform: translateX(-50%) rotate(-45deg);
  366. display: flex;
  367. align-items: center;
  368. justify-content: center;
  369. box-shadow: 0 6px 14px rgba(111, 87, 219, 0.28);
  370. z-index: 5;
  371. }
  372. .price-pin span {
  373. transform: rotate(45deg);
  374. color: #fff;
  375. font-size: 13px;
  376. font-weight: 700;
  377. }
  378. /* RANGE */
  379. .price-range {
  380. padding: 8px 0 0;
  381. }
  382. /* trilha */
  383. .price-range :deep(.q-slider__track-container) {
  384. height: 6px;
  385. border-radius: 30px;
  386. background: #ddd7ea;
  387. }
  388. /* preenchimento */
  389. .price-range :deep(.q-slider__track) {
  390. height: 6px;
  391. border-radius: 30px;
  392. background: linear-gradient(90deg, #d95cff, #7a5cff);
  393. }
  394. /* THUMB REAL FIGMA */
  395. .price-range :deep(.q-slider__thumb) {
  396. width: 24px;
  397. height: 24px;
  398. min-width: 24px;
  399. min-height: 24px;
  400. border-radius: 50%;
  401. background: #7a5cff !important;
  402. border: 4px solid #ffffff;
  403. box-shadow: none;
  404. }
  405. /* remove quadrado interno do quasar */
  406. .price-range :deep(.q-slider__thumb-shape) {
  407. display: none;
  408. }
  409. .price-range :deep(.q-slider__focus-ring) {
  410. display: none;
  411. }
  412. /* texto helper */
  413. .range-helper {
  414. margin-top: 18px;
  415. font-size: 12px;
  416. line-height: 1.45;
  417. text-align: center;
  418. color: #6b6b6b;
  419. }
  420. /* transição suave */
  421. .price-pin,
  422. .price-range :deep(.q-slider__thumb) {
  423. transition:
  424. background 0.25s ease,
  425. box-shadow 0.25s ease,
  426. filter 0.25s ease;
  427. }
  428. // quantidade
  429. .quantity-stepper {
  430. display: flex;
  431. justify-content: center;
  432. align-items: center;
  433. gap: 18px;
  434. margin: 12px 0 20px;
  435. }
  436. .quantity-btn {
  437. background: #f5f0ff;
  438. color: #7b61ff;
  439. width: 38px;
  440. height: 38px;
  441. }
  442. .quantity-value {
  443. min-width: 40px;
  444. text-align: center;
  445. font-size: 22px;
  446. font-weight: 700;
  447. }
  448. .service-type-inline {
  449. display: grid;
  450. grid-template-columns: repeat(2, 160px);
  451. gap: 12px 24px;
  452. justify-content: center;
  453. margin: 0 auto 16px;
  454. }
  455. /* hover individual do balão */
  456. .price-pin:hover {
  457. background: linear-gradient(180deg, #7b68ff, #5f46d8);
  458. box-shadow: none;
  459. }
  460. /* clique individual do balão */
  461. .price-pin:active {
  462. background: linear-gradient(180deg, #6d52ff, #5438d6);
  463. box-shadow: none;
  464. }
  465. /* thumb hover individual */
  466. .price-range :deep(.q-slider__thumb:hover) {
  467. background: #6d52ff !important;
  468. box-shadow: 0 0 0 18px rgba(109, 82, 255, 0.22);
  469. }
  470. /* thumb selecionado */
  471. .price-range :deep(.q-slider__thumb:active) {
  472. background: #5e3fff !important;
  473. box-shadow: 0 0 0 22px rgba(94, 63, 255, 0.28);
  474. }
  475. /* trilha mais viva só quando mouse estiver no slider */
  476. .price-range:hover :deep(.q-slider__track) {
  477. filter: brightness(0.95);
  478. }
  479. .calendar-custom {
  480. border-radius: 20px;
  481. background-color: white !important;
  482. :deep(.q-date__main) {
  483. background-color: white !important;
  484. }
  485. :deep(.q-date__content) {
  486. background-color: white !important;
  487. }
  488. :deep(.q-date__calendar) {
  489. background-color: white !important;
  490. }
  491. :deep(.q-date__calendar-item--out) {
  492. .q-btn__content {
  493. color: #CBD5E1 !important;
  494. }
  495. }
  496. :deep(.q-date__calendar-days .q-btn__content) {
  497. font-family: 'Inter', sans-serif;
  498. font-weight: 500;
  499. color: #1E293B;
  500. }
  501. :deep(.q-date__calendar-weekdays > div) {
  502. color: #6366F1;
  503. font-weight: 700;
  504. opacity: 0.8;
  505. }
  506. :deep(.q-date__navigation) {
  507. .q-btn {
  508. color: #1E293B !important;
  509. }
  510. .q-btn__content {
  511. color: #1E293B !important;
  512. }
  513. }
  514. :deep(.q-date__nav-btn-month),
  515. :deep(.q-date__nav-btn-year) {
  516. color: #6366F1 !important;
  517. font-weight: 700;
  518. }
  519. :deep(.q-date__event) {
  520. bottom: 4px;
  521. height: 6px;
  522. width: 6px;
  523. border-radius: 50%;
  524. }
  525. :deep(.q-date__today) {
  526. .q-btn__content {
  527. color: #7c4dff !important;
  528. background: #7c4dff15;
  529. border-radius: 50%;
  530. }
  531. }
  532. :deep(.q-date__selected) {
  533. .q-btn__content {
  534. background: #6366F1 !important;
  535. color: white !important;
  536. border-radius: 50%;
  537. box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
  538. }
  539. }
  540. :deep(.q-date__view--months),
  541. :deep(.q-date__view--years) {
  542. .q-btn {
  543. color: #1E293B !important;
  544. }
  545. }
  546. }
  547. /* 🔥 labels pretas */
  548. :deep(.q-radio__label),
  549. :deep(.q-checkbox__label) {
  550. color: #000 !important;
  551. font-size: 14px;
  552. font-weight: 400;
  553. }
  554. :deep(.q-checkbox__inner),
  555. :deep(.q-radio__inner) {
  556. color: #a78bfa !important;
  557. }
  558. /* 📱 celular */
  559. @media (max-width: 480px) {
  560. .page-shell {
  561. max-width: 100%;
  562. }
  563. .figma-card {
  564. padding: 16px;
  565. }
  566. .figma-date {
  567. max-width: 100%;
  568. }
  569. }
  570. /* 📲 tablet */
  571. @media (min-width: 768px) {
  572. .page-shell {
  573. max-width: 460px;
  574. }
  575. }
  576. </style>