Sfoglia il codice sorgente

feat: :sparkles: feat (agendamento-sobre-medida) tela de preenchimento das informações da solicitação

criei a tela onde serão preenchidas as informações para criar a solicitação de agendamento obre medida,WIP

fase:dev | origin:escopo
kayo henrique 3 settimane fa
parent
commit
16c24f060f

+ 6 - 0
src/api/customSchedules.js

@@ -0,0 +1,6 @@
+import api from 'src/api'
+
+export const createCustomSchedule = async (payload) => {
+  const { data } = await api.post('/custom-schedule', payload)
+  return data
+}

+ 20 - 1
src/helpers/utils.js

@@ -296,6 +296,24 @@ const formatAddress = (address) => {
   return parts.join(', ');
 };
 
+const calculateDailyPrices = (dailyPrice8h) => {
+  if (!dailyPrice8h || dailyPrice8h <= 0) {
+    return {
+      daily_price_8h: null,
+      daily_price_6h: null,
+      daily_price_4h: null,
+      daily_price_2h: null,
+    };
+  }
+
+  return {
+    daily_price_8h: dailyPrice8h, 
+    daily_price_6h: dailyPrice8h * 0.85,
+    daily_price_4h: dailyPrice8h * 0.55,
+    daily_price_2h: dailyPrice8h * 0.30,
+  };
+};
+
 export {
   formatDateDMYtoYMD,
   formatDateYMDtoDMY,
@@ -312,5 +330,6 @@ export {
   validateCardNumberLuhn,
   detectCardBrand,
   validateCardExpiration,
-  formatAddress
+  formatAddress,
+  calculateDailyPrices
 };

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

@@ -578,5 +578,48 @@
     "6": "Standard (up to 6h)",
     "8": "Full day (up to 8h)",
     "unknown": "No information"
+  },
+
+  "scheduling_page": {
+    "title": "Scheduling",
+    "about_provider": "About the professional",
+    "schedule_service": "Schedule a service",
+    "reviews_title": "Reviews",
+    "see_all": "see all",
+    "no_reviews": "No reviews found.",
+    "unknown_client": "Client",
+    "select_service": "Select service",
+    "book": "schedule",
+    "no_price": "to be agreed",
+    "time_selection": {
+      "subtitle": "Select the desired start and end time for the service.",
+      "meal_section": "Professional's meal",
+      "meal_offer": "I offer a meal",
+      "meal_no_offer": "I don't offer a meal",
+      "continue": "continue",
+      "pause_note_8h": "Includes a break of up to 1 hour.",
+      "pause_note_6h": "Includes a break of up to 30 minutes.",
+      "pause_note_4h": "Includes a break of up to 10 minutes.",
+      "slot_required": "Select a time slot to continue."
+    },
+    "service_types": {
+      "integral":      { "label": "Full day",     "hours": "up to 8h of service", "description": "Ideal for cleaning with higher demands and larger spaces." },
+      "padrao":        { "label": "Standard",     "hours": "up to 6h of service", "description": "Ideal for residential and commercial cleaning seeking a traditional cleaning routine." },
+      "meio_periodo":  { "label": "Half day",     "hours": "up to 4h of service", "description": "Ideal for smaller spaces, studios or offices." },
+      "diaria_rapida": { "label": "Quick Clean",  "hours": "up to 2h of service", "description": "Ideal for hotel rooms, small spaces or specific services." }
+    },
+    "order_summary": {
+      "title": "Order summary",
+      "info_text": "Send the service request or add more dates to your order.",
+      "info_note": "*You can schedule the same cleaner up to twice a week.",
+      "service_label": "Service:",
+      "time_range": "from {start}h to {end}h",
+      "send_btn": "send request",
+      "add_date_btn": "+ Add date",
+      "remove_confirm_title": "Are you sure you want to remove this time slot from the order?",
+      "remove_confirm_ok": "remove time slot",
+      "remove_confirm_cancel": "cancel",
+      "week_limit_error": "Limit of 2 bookings per week with the same professional reached."
+    }
   }
 }

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

@@ -578,5 +578,48 @@
     "6": "Estándar (hasta 6h)",
     "8": "Día completo (hasta 8h)",
     "unknown": "Sin información"
+  },
+
+  "scheduling_page": {
+    "title": "Agendamiento",
+    "about_provider": "Sobre el profesional",
+    "schedule_service": "Agendar un servicio",
+    "reviews_title": "Reseñas",
+    "see_all": "ver todas",
+    "no_reviews": "No se encontraron reseñas.",
+    "unknown_client": "Cliente",
+    "select_service": "Seleccionar servicio",
+    "book": "agendar",
+    "no_price": "a convenir",
+    "time_selection": {
+      "subtitle": "Seleccione el horario deseado para inicio y fin del servicio.",
+      "meal_section": "Comida del profesional",
+      "meal_offer": "Ofrezco comida",
+      "meal_no_offer": "No ofrezco comida",
+      "continue": "continuar",
+      "pause_note_8h": "Incluye pausa de hasta 1 hora.",
+      "pause_note_6h": "Incluye pausa de hasta 30 minutos.",
+      "pause_note_4h": "Incluye pausa de hasta 10 minutos.",
+      "slot_required": "Seleccione un horario para continuar."
+    },
+    "service_types": {
+      "integral":      { "label": "Integral",        "hours": "hasta 8h de servicio", "description": "Ideal para limpieza con mayores demandas y espacios más amplios." },
+      "padrao":        { "label": "Estándar",        "hours": "hasta 6h de servicio", "description": "Ideal para limpiezas residenciales y comerciales con rutina de limpieza tradicional." },
+      "meio_periodo":  { "label": "Medio tiempo",    "hours": "hasta 4h de servicio", "description": "Ideal para espacios más pequeños, estudios u oficinas." },
+      "diaria_rapida": { "label": "Limpieza Rápida", "hours": "hasta 2h de servicio", "description": "Ideal para habitaciones de hotel, pequeños ambientes o servicios específicos." }
+    },
+    "order_summary": {
+      "title": "Resumen del pedido",
+      "info_text": "Envíe la solicitud del servicio o agregue más fechas a su pedido.",
+      "info_note": "*Puede agendar el mismo profesional hasta dos veces por semana.",
+      "service_label": "Servicio:",
+      "time_range": "de {start}h a {end}h",
+      "send_btn": "enviar solicitud",
+      "add_date_btn": "+ Agregar fecha",
+      "remove_confirm_title": "¿Está seguro de que desea retirar este horario del pedido?",
+      "remove_confirm_ok": "retirar horario",
+      "remove_confirm_cancel": "cancelar",
+      "week_limit_error": "Límite de 2 citas por semana con el mismo profesional alcanzado."
+    }
   }
 }

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

@@ -578,5 +578,49 @@
     "6": "Padrão (até 6h)",
     "8": "Dia completo (até 8h)",
     "unknown": "Sem informação"
+  },
+
+  "scheduling_page": {
+    "title": "Agendamento",
+    "about_provider": "Sobre o profissional",
+    "schedule_service": "Agende um serviço",
+    "reviews_title": "Avaliações",
+    "see_all": "ver todas",
+    "no_reviews": "Nenhuma avaliação encontrada.",
+    "unknown_client": "Cliente",
+    "select_service": "Selecionar serviço",
+    "book": "agendar",
+    "no_price": "a combinar",
+    "time_selection": {
+      "subtitle": "Selecione o horário desejado para início e término do serviço.",
+      "meal_section": "Refeição do profissional",
+      "meal_offer": "Ofereço refeição",
+      "meal_no_offer": "Não ofereço refeição",
+      "continue": "continuar",
+      "pause_note_8h": "Incluso pausa de até 1 hora.",
+      "pause_note_6h": "Incluso pausa de até 30 minutos.",
+      "pause_note_4h": "Incluso pausa de até 10 minutos.",
+      "slot_required": "Selecione um horário para continuar."
+    },
+    "service_types": {
+      "integral":      { "label": "Integral",      "hours": "até 8h de serviço", "description": "Ideal para limpeza com demandas maiores e espaços mais amplos." },
+      "padrao":        { "label": "Padrão",         "hours": "até 6h de serviço", "description": "Ideal para limpezas residenciais e comerciais que buscam uma rotina de limpeza tradicional." },
+      "meio_periodo":  { "label": "Meio período",   "hours": "até 4h de serviço", "description": "Ideal para limpezas de espaços menores, estúdios ou escritórios." },
+      "diaria_rapida": { "label": "Diária Rápida",  "hours": "até 2h de serviço", "description": "Ideal para limpezas de quartos de hotéis, pequenos ambientes ou serviços específicos." }
+    },
+    "order_summary": {
+      "title": "Resumo do pedido",
+      "info_text": "Envie a solicitação do serviço ou adicione mais datas ao seu pedido.",
+      "info_note": "*Você pode agendar o mesmo diarista até duas vezes na semana.",
+      "service_label": "Serviço:",
+      "time_range": "das {start}h às {end}h",
+      "send_btn": "enviar solicitação",
+      "add_date_btn": "+ Adicionar data",
+      "remove_confirm_title": "Tem certeza que deseja retirar esse horário do pedido?",
+      "remove_confirm_ok": "retirar horário",
+      "remove_confirm_cancel": "cancelar",
+      "week_limit_error": "Limite de 2 agendamentos por semana com o mesmo profissional atingido."
+    }
   }
+
 }

+ 598 - 116
src/pages/schedules/SobMedidaPage.vue

@@ -1,154 +1,636 @@
 <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
-
 <template>
-  <q-page class="bg-grey-2 q-pa-md">
+  <q-page class="sob-medida-page">
+    <div class="page-shell">
+      <span class="page-title gradient-diarista">Serviço Sob Medida</span>
 
-    <!-- Título -->
-    <div class="text-center text-primary text-subtitle1 text-weight-bold q-mb-md">
-      Serviço Sob Medida
-    </div>
+      <!-- Seu pedido -->
+      <q-card flat bordered class=" figma-card compact-card">
+        <div class="field-label text-center gradient-diarista">
+  Quantidade de serviço
+</div>
 
-    <!-- Card Pedido -->
-    <q-card flat bordered class="rounded-card q-pa-md q-mb-md">
-
-      <div class="text-primary text-subtitle2 text-weight-bold q-mb-md">
-        Seu pedido
-      </div>
-
-      <!-- Tipo -->
-      <div class="text-primary text-caption text-weight-bold q-mb-sm">
-        Tipo de serviço
-      </div>
-
-      <div class="row q-col-gutter-sm q-mb-md">
-        <div
-          v-for="tipo in tiposServico"
-          :key="tipo"
-          class="col-6"
-        >
-          <q-radio
-            v-model="tipoSelecionado"
-            :val="tipo"
-            :label="tipo"
-            color="purple"
-          />
-        </div>
-      </div>
-
-      <!-- Especialidades -->
-      <div class="text-primary text-caption text-weight-bold q-mb-sm">
-        Especialidade preferencial?
-      </div>
-
-      <div class="row q-col-gutter-sm q-mb-md">
-        <div
-          v-for="item in especialidades"
-          :key="item"
-          class="col-6"
-        >
-          <q-checkbox
-            v-model="especialidadesSelecionadas"
-            :val="item"
-            :label="item"
-            color="purple"
-          />
+<div class="quantity-stepper">
+  <q-btn
+    round
+    flat
+    icon="remove"
+    class="quantity-btn"
+    @click="decreaseQuantity"
+  />
+
+  <span class="quantity-value gradient-diarista">
+    {{ quantity }}
+  </span>
+
+  <q-btn
+    round
+    flat
+    icon="add"
+    class="quantity-btn"
+    @click="increaseQuantity"
+  />
+</div>
+        <div class="card-title gradient-diarista">Seu pedido</div>
+
+        <div class="options-grid">
+          <div
+            v-for="tipo in option"
+            :key="tipo"
+            class="option-col"
+          >
+            <q-radio
+              v-model="selectedOption"
+              :val="tipo"
+              :label="tipo"
+              color="purple"
+              dense
+            />
+          </div>
         </div>
-      </div>
-
-      <!-- Descrição -->
-      <div class="text-primary text-caption text-weight-bold q-mb-sm">
-        Descreva detalhes do pedido
-      </div>
-
-      <q-input
-        v-model="descricao"
-        type="textarea"
-        outlined
-        autogrow
-        placeholder="Olá, desejo profissional dedicado que irá fazer..."
-      />
-
-    </q-card>
-
-    <!-- Faixa de preço -->
-    <div class="text-primary text-subtitle2 text-weight-bold q-mb-sm">
-      Faixa de preço
-    </div>
 
-    <q-card flat bordered class="rounded-card q-pa-md q-mb-md">
+        <div class="field-label text-center gradient-diarista">Tipo de serviço</div>
+<div class="options-grid">
+  <div
+    v-for="tipo in selectedServiceTypes"
+    :key="tipo"
+    class="option-col"
+  >
+    <q-radio
+      v-model="selectedServiceType"
+      :val="tipo"
+      :label="tipo"
+      color="purple"
+      dense
+    />
+  </div>
+</div>
 
-      <q-range
-        v-model="faixaPreco"
-        :min="80"
-        :max="300"
-        color="purple"
-        label
-      />
+<div class="field-label text-center gradient-diarista">Especialidade preferencial?</div>
+<div class="options-grid">
+  <div
+    v-for="item in specialties"
+    :key="item"
+    class="option-col"
+  >
+    <q-checkbox
+      v-model="selectedSpecialties"
+      :val="item"
+      :label="item"
+      color="purple"
+      dense
+    />
+  </div>
+</div>
 
-      <div class="text-caption text-grey-7 text-center q-mt-sm">
-        Selecione a faixa de preço integral para receber propostas de diaristas.
-      </div>
+        i<div class="field-label text-center gradient-diarista">
+          Descreva detalhes do pedido
+          <span class="optional">(opcional)</span>
+        </div>
+
+        <q-input
+          v-model="description"
+          type="textarea"
+          outlined
+          bg-color="white"
+          color="dark"
+          input-class="text-black"
+          class="description-box"
+          placeholder="Olá, desejo profissional dedicado que irá fazer..."
+        />
+      </q-card>
 
-    </q-card>
+      <!-- Faixa -->
+      <div class="section-title gradient-diarista">Faixa de preço por 8 horas</div>
+<q-card flat bordered class="figma-card compact-card">
+  <div class="range-container">
+    <!-- bolha min -->
+    <div
+      class="price-pin"
+      :style="{ left: minPosition + '%' }"
+    >
+      <span>{{ priceRange.min }}</span>
+    </div>
 
-    <!-- Data -->
-    <div class="text-primary text-subtitle2 text-weight-bold q-mb-sm">
-      Data e hora
+    <!-- bolha max -->
+    <div
+      class="price-pin"
+      :style="{ left: maxPosition + '%' }"
+    >
+      <span>{{ priceRange.max }}</span>
     </div>
 
-    <q-card flat bordered class="rounded-card q-pa-md q-mb-md">
-      <q-date v-model="dataSelecionada" minimal color="purple" />
-    </q-card>
-
-    <!-- Botão -->
-    <q-btn
-      label="Continuar"
-      color="primary"
-      class="full-width q-mt-md"
-      unelevated
-      size="lg"
-      @click="continuar"
+    <q-range
+      v-model="priceRange"
+      :min="PRICE_LIMITS.min"
+      :max="PRICE_LIMITS.max"
+      color="secondary"
+      class="price-range"
     />
+  </div>
+
+  <div class="range-helper gradient-diarista">
+    Selecione a faixa de preço integral para receber propostas de diaristas.
+  </div>
+</q-card>
 
+            <!-- Data -->
+      <div class="section-title gradient-diarista">Data e hora</div>
+      <q-card flat bordered class="figma-card date-card">
+        <q-date
+  v-model="selectedDate"
+  minimal
+  color="purple"
+  class="figma-date calendar-custom "
+/>
+      </q-card>
+
+    </div>
   </q-page>
 </template>
 
 <script setup>
-import { ref } from 'vue'
+import { ref, computed, watch } from 'vue'
+import { useQuasar } from 'quasar'
+
+import ServiceSelectionSheet from 'src/pages/search/components/ServiceSelectionSheet.vue'
+import ServiceTimeSelectionDialog from 'src/pages/search/components/ServiceTimeSelectionDialog.vue'
+
+import { createCustomSchedule } from 'src/api/customSchedules'
+import { userStore } from 'src/stores/user'
+import { calculateDailyPrices } from 'src/helpers/utils'
 
-const tiposServico = [
+const $q = useQuasar()
+const user =userStore();
+
+const option = ['Residencial', 'Comercial']
+
+const selectedServiceTypes = [
   'Limpeza',
   'Pré-mudança',
   'Evento',
   'Pós-obra'
 ]
 
-const especialidades = [
+const specialties = [
   'Passar roupa',
   'Limpar vidros',
   'Lavar roupa',
   'Cozinhar'
 ]
 
-const tipoSelecionado = ref('Residencial')
-const especialidadesSelecionadas = ref([])
-const descricao = ref('')
-const faixaPreco = ref({ min: 120, max: 180 })
-const dataSelecionada = ref('2025/08/17')
-
-function continuar() {
-  console.log({
-    tipo: tipoSelecionado.value,
-    especialidades: especialidadesSelecionadas.value,
-    descricao: descricao.value,
-    preco: faixaPreco.value,
-    data: dataSelecionada.value
+const selectedOption = ref('Residencial')
+const selectedServiceType = ref('Limpeza')
+const selectedSpecialties = ref([])
+const description = ref('')
+const priceRange = ref({ min: 150, max: 300 })
+const selectedDate = ref(null)
+
+const PRICE_LIMITS = Object.freeze({
+  min: 100,
+  max: 500
+})
+
+const minPosition = computed(() =>
+  ((priceRange.value.min - PRICE_LIMITS.min) /
+    (PRICE_LIMITS.max - PRICE_LIMITS.min)) * 100
+)
+
+const maxPosition = computed(() =>
+  ((priceRange.value.max - PRICE_LIMITS.min) /
+    (PRICE_LIMITS.max - PRICE_LIMITS.min)) * 100
+)
+
+
+
+function openServiceSelection () {
+  $q.dialog({
+    component: ServiceSelectionSheet,
+    componentProps: {
+      provider: providerMock.value,
+      selectedDate: selectedDate.value
+    }
+  }).onOk((payload) => {
+    if (payload?.serviceType) {
+      openServiceTimeSelection(payload.serviceType)
+    }
+  })
+}
+
+function openServiceTimeSelection (serviceType) {
+  $q.dialog({
+    component: ServiceTimeSelectionDialog,
+    componentProps: {
+      serviceType,
+      provider: calculateDailyPrices(priceRange.value.max * quantity.value),
+      selectedDate: selectedDate.value
+    }
+  }).onOk(async (payloadFinal) => {
+  await saveFinalOrder(payloadFinal)
+})
+}
+
+/**
+ * abre modal somente quando usuário escolher um dia
+ */
+watch(selectedDate, (newDate, previousDate) => {
+  if (!newDate) return
+  if (newDate === previousDate) return
+
+  openServiceSelection()
+})
+
+async function saveFinalOrder (payloadFinal) {
+  const [startHour, endHour] = payloadFinal.slot.split('-')
+
+  const payload = {
+    client_id: user.user.client.id,
+  // address_id: 1,endereço principal do cliente, por enquanto hardcoded
+    // quantity: 1,pegar do tipo de serviço escolhido, por enquanto hardcoded
+    date: payloadFinal.date,
+    period_type: String(payloadFinal.serviceType.hoursCount),
+    start_time: `${startHour}:00:00`,
+    end_time: `${endHour}:00:00`,
+    address_type: selectedOption.value.toLowerCase(),
+    // service_type_id: 1 pega do cadastro do tipo de serviço, por enquanto hardcoded
+    description: description.value,
+    min_price: priceRange.value.min,
+    max_price: priceRange.value.max,
+    offers_meal: payloadFinal.meal === 'offer',
+    speciality_ids: selectedSpecialties.value
+  }
+
+  await createCustomSchedule(payload)
+
+  $q.notify({
+    type: 'positive',
+    message: 'Pedido sob medida salvo com sucesso!'
   })
 }
+
+const quantity = ref(1)
+
+function increaseQuantity () {
+  quantity.value++
+}
+
+function decreaseQuantity () {
+  if (quantity.value > 1) {
+    quantity.value--
+  }
+}
 </script>
 
-<style scoped>
-.rounded-card {
+<style scoped lang="scss">
+.sob-medida-page {
+  background: #f6f5fb;
+  min-height: 100vh;
+  padding: 16px;
+}
+
+.page-shell {
+  max-width: 420px;
+  margin: 0 auto;
+}
+
+.page-title {
+  display: block;
+  text-align: center;
+  font-size: 18px;
+  font-weight: 700;
+  margin: 0 0 20px;
+  width: 100%;
+  margin-top: 16px;
+}
+
+.figma-card {
+  border-radius: 24px;
+  background: #fff;
+  padding: 18px;
+  margin-bottom: 20px;
+  box-shadow: none;
+}
+
+.card-title,
+.section-title {
+  color: #7b61ff;
+  font-size: 16px;
+  font-weight: 700;
+  margin-bottom: 16px;
+}
+
+.section-title {
+  margin-left: 16px;
+}
+
+.field-label {
+  color: #7b61ff;
+  font-size: 13px;
+  font-weight: 600;
+  margin: 18px 0 10px;
+}
+
+.optional {
+  color: #9e9e9e;
+  font-weight: 400;
+}
+
+.options-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 12px 20px;
+}
+
+.option-col {
+  min-width: 0;
+}
+
+.description-box {
+  margin-top: 8px;
+}
+
+.description-box :deep(textarea) {
+  min-height: 90px;
+}
+
+/* CARD */
+.compact-card {
+  border-radius: 32px;
+  padding: 28px 22px;
+  background: #fafafa;
+  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
+}
+
+.date-card {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: fit-content;
+  min-width: 320px;
+  max-width: 360px;
+  margin: 0 auto 20px;
+  padding: 20px;
+  border-radius: 24px;
+  background: #fff;
+}
+
+
+/* container */
+.range-container {
+  position: relative;
+  padding-top: 58px;
+  padding-left: 10px;
+  padding-right: 10px;
+}
+
+/* bolha tipo pin */
+.price-pin {
+  position: absolute;
+  top: 0;
+  width: 46px;
+  height: 46px;
+  background: linear-gradient(180deg, #8b7cff, #6f57db);
+  border-radius: 50% 50% 50% 0;
+  transform: translateX(-50%) rotate(-45deg);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 6px 14px rgba(111, 87, 219, 0.28);
+  z-index: 5;
+}
+
+.price-pin span {
+  transform: rotate(45deg);
+  color: #fff;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+/* RANGE */
+.price-range {
+  padding: 8px 0 0;
+}
+
+/* trilha */
+.price-range :deep(.q-slider__track-container) {
+  height: 6px;
+  border-radius: 30px;
+  background: #ddd7ea;
+}
+
+/* preenchimento */
+.price-range :deep(.q-slider__track) {
+  height: 6px;
+  border-radius: 30px;
+  background: linear-gradient(90deg, #d95cff, #7a5cff);
+}
+
+/* THUMB REAL FIGMA */
+.price-range :deep(.q-slider__thumb) {
+  width: 24px;
+  height: 24px;
+  min-width: 24px;
+  min-height: 24px;
+  border-radius: 50%;
+  background: #7a5cff !important;
+  border: 4px solid #ffffff;
+  box-shadow: none;
+}
+
+/* remove quadrado interno do quasar */
+.price-range :deep(.q-slider__thumb-shape) {
+  display: none;
+}
+
+.price-range :deep(.q-slider__focus-ring) {
+  display: none;
+}
+
+/* texto helper */
+.range-helper {
+  margin-top: 18px;
+  font-size: 12px;
+  line-height: 1.45;
+  text-align: center;
+  color: #6b6b6b;
+}
+
+/* transição suave */
+
+.price-pin,
+.price-range :deep(.q-slider__thumb) {
+  transition:
+    background 0.25s ease,
+    box-shadow 0.25s ease,
+    filter 0.25s ease;
+}
+
+// quantidade 
+.quantity-stepper {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  gap: 18px;
+  margin: 12px 0 20px;
+}
+
+.quantity-btn {
+  background: #f5f0ff;
+  color: #7b61ff;
+  width: 38px;
+  height: 38px;
+}
+
+.quantity-value {
+  min-width: 40px;
+  text-align: center;
+  font-size: 22px;
+  font-weight: 700;
+}
+
+/* hover individual do balão */
+.price-pin:hover {
+  background: linear-gradient(180deg, #7b68ff, #5f46d8);
+  box-shadow: none;
+}
+
+/* clique individual do balão */
+.price-pin:active {
+  background: linear-gradient(180deg, #6d52ff, #5438d6);
+  box-shadow: none;
+}
+
+/* thumb hover individual */
+.price-range :deep(.q-slider__thumb:hover) {
+  background: #6d52ff !important;
+  box-shadow: 0 0 0 18px rgba(109, 82, 255, 0.22);
+}
+
+/* thumb selecionado */
+.price-range :deep(.q-slider__thumb:active) {
+  background: #5e3fff !important;
+  box-shadow: 0 0 0 22px rgba(94, 63, 255, 0.28);
+}
+
+/* trilha mais viva só quando mouse estiver no slider */
+.price-range:hover :deep(.q-slider__track) {
+  filter: brightness(0.95);
+}
+
+.calendar-custom {
   border-radius: 20px;
+  background-color: white !important;
+
+  :deep(.q-date__main) {
+    background-color: white !important;
+  }
+
+  :deep(.q-date__content) {
+    background-color: white !important;
+  }
+
+  :deep(.q-date__calendar) {
+    background-color: white !important;
+  }
+
+  :deep(.q-date__calendar-item--out) {
+    .q-btn__content {
+      color: #CBD5E1 !important;
+    }
+  }
+
+  :deep(.q-date__calendar-days .q-btn__content) {
+    font-family: 'Inter', sans-serif;
+    font-weight: 500;
+    color: #1E293B;
+  }
+
+  :deep(.q-date__calendar-weekdays > div) {
+    color: #6366F1;
+    font-weight: 700;
+    opacity: 0.8;
+  }
+
+  :deep(.q-date__navigation) {
+    .q-btn {
+      color: #1E293B !important;
+    }
+    .q-btn__content {
+      color: #1E293B !important;
+    }
+  }
+
+  :deep(.q-date__nav-btn-month),
+  :deep(.q-date__nav-btn-year) {
+    color: #6366F1 !important;
+    font-weight: 700;
+  }
+
+  :deep(.q-date__event) {
+    bottom: 4px;
+    height: 6px;
+    width: 6px;
+    border-radius: 50%;
+  }
+
+  :deep(.q-date__today) {
+    .q-btn__content {
+      color: #7c4dff !important;
+      background: #7c4dff15;
+      border-radius: 50%;
+    }
+  }
+
+  :deep(.q-date__selected) {
+    .q-btn__content {
+      background: #6366F1 !important;
+      color: white !important;
+      border-radius: 50%;
+      box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
+    }
+  }
+
+  :deep(.q-date__view--months),
+  :deep(.q-date__view--years) {
+    .q-btn {
+      color: #1E293B !important;
+    }
+  }
+}
+
+
+/* 🔥 labels pretas */
+:deep(.q-radio__label),
+:deep(.q-checkbox__label) {
+  color: #000 !important;
+  font-size: 14px;
+  font-weight: 400;
+}
+
+:deep(.q-checkbox__inner),
+:deep(.q-radio__inner) {
+  color: #a78bfa !important;
+}
+
+/* 📱 celular */
+@media (max-width: 480px) {
+  .page-shell {
+    max-width: 100%;
+  }
+
+  .figma-card {
+    padding: 16px;
+  }
+
+  .figma-date {
+    max-width: 100%;
+  }
+}
+
+/* 📲 tablet */
+@media (min-width: 768px) {
+  .page-shell {
+    max-width: 460px;
+  }
 }
 </style>

+ 125 - 0
src/pages/search/components/ServiceSelectionSheet.vue

@@ -0,0 +1,125 @@
+<template>
+  <q-dialog ref="dialogRef" position="bottom" @hide="onDialogHide">
+    <q-card class="bg-surface text-text full-width sheet-card">
+
+      <q-card-section class="row items-center q-pb-none">
+        <div class="text-subtitle1 text-weight-bold text-text">{{ $t('scheduling_page.select_service') }}</div>
+        <q-space />
+        <q-btn flat round dense icon="mdi-close" color="grey-6" @click="onDialogCancel" />
+      </q-card-section>
+
+      <q-separator class="q-mt-sm" />
+
+      <q-card-section class="q-pt-sm q-pb-md">
+        <div
+          v-for="type in serviceTypes"
+          :key="type.key"
+          class="row items-center no-wrap q-py-sm"
+        >
+          <!-- Nome e horas -->
+          <div class="col">
+            <div class="row items-center no-wrap q-gutter-x-xs">
+              <span class="text-body2 text-weight-bold text-text">{{ type.label }}</span>
+              <q-btn
+                flat round dense
+                icon="mdi-information-outline"
+                color="primary"
+                size="xs"
+                @click="openInfo(type)"
+              />
+            </div>
+            <div class="text-caption text-grey-6">{{ type.hours }}</div>
+          </div>
+
+          <!-- Preço -->
+          <div class="text-body2 text-weight-bold text-text q-mx-md" style="white-space: nowrap;">
+            {{ type.price != null ? formatPrice(type.price) : $t('scheduling_page.no_price') }}
+          </div>
+
+          <!-- Botão agendar -->
+          <q-btn
+            unelevated
+            rounded
+            no-caps
+            :label="$t('scheduling_page.book')"
+            :disable="type.price == null"
+            color="secondary"
+            size="sm"
+            style="min-width: 80px;"
+            @click="onDialogOK({ serviceType: type, date: selectedDate, provider })"
+          />
+        </div>
+      </q-card-section>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed } from 'vue';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import ServiceTypeInfoDialog from './ServiceTypeInfoDialog.vue';
+
+const props = defineProps({
+  provider: { type: Object, required: true },
+  selectedDate: { type: String, required: true },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+
+const serviceTypes = computed(() => [
+  {
+    key: 'integral',
+    hoursCount: 8,
+    label: t('scheduling_page.service_types.integral.label'),
+    hours: t('scheduling_page.service_types.integral.hours'),
+    description: t('scheduling_page.service_types.integral.description'),
+    price: props.provider?.daily_price_8h ?? null,
+  },
+  {
+    key: 'padrao',
+    hoursCount: 6,
+    label: t('scheduling_page.service_types.padrao.label'),
+    hours: t('scheduling_page.service_types.padrao.hours'),
+    description: t('scheduling_page.service_types.padrao.description'),
+    price: props.provider?.daily_price_6h ?? null,
+  },
+  {
+    key: 'meio_periodo',
+    hoursCount: 4,
+    label: t('scheduling_page.service_types.meio_periodo.label'),
+    hours: t('scheduling_page.service_types.meio_periodo.hours'),
+    description: t('scheduling_page.service_types.meio_periodo.description'),
+    price: props.provider?.daily_price_4h ?? null,
+  },
+  {
+    key: 'diaria_rapida',
+    hoursCount: 2,
+    label: t('scheduling_page.service_types.diaria_rapida.label'),
+    hours: t('scheduling_page.service_types.diaria_rapida.hours'),
+    description: t('scheduling_page.service_types.diaria_rapida.description'),
+    price: props.provider?.daily_price_2h ?? null,
+  },
+]);
+
+const formatPrice = (value) =>
+  Number(value).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
+
+const openInfo = (type) => {
+  $q.dialog({
+    component: ServiceTypeInfoDialog,
+    componentProps: { serviceType: type },
+  });
+};
+</script>
+
+<style scoped lang="scss">
+.sheet-card {
+  border-radius: 20px 20px 0 0;
+}
+</style>

+ 161 - 0
src/pages/search/components/ServiceTimeSelectionDialog.vue

@@ -0,0 +1,161 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="card-border bg-surface text-text time-card">
+
+      <!-- Fechar -->
+      <q-btn
+        flat round dense
+        icon="mdi-close-circle-outline"
+        color="grey-5"
+        class="absolute-top-right q-mt-sm q-mr-sm"
+        @click="onDialogCancel"
+      />
+
+      <!-- Título -->
+      <q-card-section class="text-center q-pt-lg q-pb-xs">
+        <div class="text-subtitle1 text-weight-bold text-text">
+          {{ serviceType.label }}
+          <span class="text-weight-regular text-grey-6">{{ '(' + serviceType.hoursCount + 'h)' }}</span>
+        </div>
+        <div class="text-caption text-grey-6 q-mt-xs">
+          {{ $t('scheduling_page.time_selection.subtitle') }}
+        </div>
+      </q-card-section>
+
+      <!-- Slots de horário -->
+      <q-card-section class="q-pt-xs">
+        <div class="row q-col-gutter-xs">
+          <div
+            v-for="slot in timeSlots"
+            :key="slot.value"
+            class="col-6"
+          >
+            <q-radio
+              v-model="selectedSlot"
+              :val="slot.value"
+              :label="slot.label"
+              color="primary"
+              keep-color
+              dense
+            />
+          </div>
+        </div>
+      </q-card-section>
+
+      <!-- Refeição (apenas 6h e 8h) -->
+      <template v-if="hasMealSection">
+        <q-separator class="q-mx-md" />
+        <q-card-section class="q-py-sm">
+          <div class="text-body2 text-weight-bold text-text q-mb-sm text-center">
+            {{ $t('scheduling_page.time_selection.meal_section') }}
+          </div>
+          <div class="row justify-center q-gutter-x-xl">
+            <q-radio
+              v-model="selectedMeal"
+              val="offer"
+              :label="$t('scheduling_page.time_selection.meal_offer')"
+              color="primary"
+              keep-color
+              dense
+            />
+            <q-radio
+              v-model="selectedMeal"
+              val="no_offer"
+              :label="$t('scheduling_page.time_selection.meal_no_offer')"
+              color="primary"
+              keep-color
+              dense
+            />
+          </div>
+        </q-card-section>
+      </template>
+
+      <!-- Botão continuar -->
+      <q-card-actions class="q-px-md q-pb-xs q-pt-sm">
+        <q-btn
+          unelevated
+          rounded
+          no-caps
+          :label="$t('scheduling_page.time_selection.continue')"
+          color="secondary"
+          class="full-width"
+          @click="handleContinue"
+        />
+      </q-card-actions>
+
+      <!-- Nota de pausa -->
+      <div v-if="pauseNote" class="text-center text-caption text-primary q-pb-md">
+        {{ pauseNote }}
+      </div>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue';
+import {  useDialogPluginComponent } from 'quasar';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps({
+  serviceType: { type: Object, required: true },
+  provider:    { type: Object, required: true },
+  selectedDate:{ type: String, required: true },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } = useDialogPluginComponent();
+const { t } = useI18n();
+
+const selectedSlot = ref(null);
+const selectedMeal = ref(null);
+
+// Gera todos os slots possíveis de 7h até 20h para a duração dada
+const timeSlots = computed(() => {
+  const h = props.serviceType.hoursCount;
+  const slots = [];
+  for (let start = 7; start + h <= 20; start++) {
+    const end = start + h;
+    slots.push({
+      value: `${start}-${end}`,
+      label: `${start}h às ${end}h`,
+    });
+  }
+  return slots;
+});
+
+const hasMealSection = computed(() =>
+  props.serviceType.hoursCount >= 6
+);
+
+const pauseNote = computed(() => {
+  const map = { 8: t('scheduling_page.time_selection.pause_note_8h'), 6: t('scheduling_page.time_selection.pause_note_6h'), 4: t('scheduling_page.time_selection.pause_note_4h') };
+  return map[props.serviceType.hoursCount] ?? null;
+});
+
+
+// Função que ira pegar os valores para o banco de dados
+function handleContinue(){
+  if(!selectedSlot.value) {
+    return;
+  }
+  if(hasMealSection.value && !selectedMeal.value) {
+    return;
+  }
+  onDialogOK({
+    slot: selectedSlot.value,
+    meal: selectedMeal.value,
+    date: props.selectedDate,
+    serviceType: props.serviceType,
+  })
+};
+</script>
+
+<style scoped lang="scss">
+.time-card {
+  width: min(88vw, 360px);
+}
+
+
+</style>

+ 38 - 0
src/pages/search/components/ServiceTypeInfoDialog.vue

@@ -0,0 +1,38 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="card-border bg-surface text-text" style="width: min(88vw, 360px);">
+
+      <q-card-section class="row items-center q-pb-none">
+        <div class="text-subtitle1 text-weight-bold text-text">{{ serviceType.label }}</div>
+        <q-space />
+        <q-btn flat round dense icon="mdi-close" color="grey-6" @click="onDialogCancel" />
+      </q-card-section>
+
+      <q-card-section class="text-caption text-grey-7 q-pt-xs">
+        {{ serviceType.hours }}
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-section class="text-body2 text-text">
+        {{ serviceType.description }}
+      </q-card-section>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { useDialogPluginComponent } from 'quasar';
+
+defineProps({
+  serviceType: {
+    type: Object,
+    required: true,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+</script>