Procházet zdrojové kódy

Merge remote-tracking branch 'origin/feature/diariaapp-kay-agendamentos-sob-medida-apps' into development

Gustavo Zanatta před 2 týdny
rodič
revize
988b3fda8b

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 6 - 0
public/diarinho-2.svg


+ 3 - 0
public/star.svg

@@ -0,0 +1,3 @@
+<svg width="30" height="29" viewBox="0 0 30 29" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M14.8114 0L19.3696 9.23966L29.6213 10.6954L22.2142 17.9095L23.9235 28.0979L14.8114 23.2897L5.63581 28.0993L7.40711 17.911L0 10.6969L10.2517 9.24109L14.8114 0Z" fill="#C67FFA"/>
+</svg>

+ 3 - 0
public/star1.svg

@@ -0,0 +1,3 @@
+<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.4057 0L9.6848 4.62019L14.8128 5.34808L11.1092 8.95477L11.9639 14.0493L7.4057 11.6448L2.81755 14.0493L3.70357 8.95477L0 5.34808L5.12801 4.62019L7.4057 0Z" fill="#C67FFA"/>
+</svg>

+ 3 - 0
public/star2.svg

@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M5.87014 0L7.67634 3.66296L11.7403 4.23986L8.80521 7.09945L9.48255 11.1385L5.87014 9.23183L2.23282 11.1385L2.93507 7.09945L0 4.23986L4.06396 3.66296L5.87014 0Z" fill="#C67FFA"/>
+</svg>

+ 8 - 0
src/api/address.js

@@ -60,3 +60,11 @@ export const searchAddressByCEP = async (cep) => {
     return null;
   }
 };
+
+export function getClientAddresses (clientId, source) {
+  return api.get(`/addresses?source=${source}&source_id=${clientId}`)
+}
+
+export function getPrimaryAddress (clientId, source) {
+  return api.get(`/addresses-primary?source=${source}&source_id=${clientId}`)
+}

+ 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
+}

+ 6 - 0
src/api/serviceTypes.js

@@ -0,0 +1,6 @@
+import api from 'src/api'
+
+export const getPublicServiceTypes = async () => {
+  const { data } = await api.get('/service-types')
+  return data.payload
+}

+ 6 - 0
src/api/specialties.js

@@ -0,0 +1,6 @@
+import api from 'src/api'
+
+export const getPublicSpecialties = async () => {
+  const { data } = await api.get('/specialities')
+  return data.payload
+}

+ 1 - 1
src/components/dashboard/DashboardScrollAreaSchedules.vue

@@ -22,7 +22,7 @@ import Banner2 from 'src/assets/banner_2.svg';
 const router = useRouter();
 
 const cards = [
-  { id: 1, image: Banner1, alt: 'Diária sob medida', route: null },
+  { id: 1, image: Banner1, alt: 'Diária sob medida', route: 'SobMedidaPage' },
   { id: 2, image: Banner2, alt: 'Escolha profissionais', route: 'SearchPage' },
 ];
 </script>

+ 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
 };

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

@@ -362,6 +362,32 @@
     "no_price": "to negotiate",
     "no_results": "No cleaners found for this search."
   },
+  "custom_schedule": {
+    "success_title_line1": "SOB MEDIDA",
+    "success_title_line2": "SOLICITADO!",
+    "success_subtitle_before": "Em breve ",
+    "success_subtitle_highlight": "diaristas enviarão propostas",
+    "success_subtitle_after": " para atender ao seu serviço.",
+    "close": "Fechar",
+    "star_alt": "estrela",
+    "mascot_alt": "Mascote Sob Medida"
+  },
+  "sob_medida": {
+  "page_title": "Custom Service",
+  "your_order": "Your request",
+  "quantity_service": "Service quantity",
+  "service_type": "Service type",
+  "preferred_specialty": "Preferred specialty?",
+  "description_label": "Describe request details",
+  "optional": "(optional)",
+  "description_placeholder": "Hello, I would like a dedicated professional who will...",
+  "price_range_title": "Price range for 8 hours",
+  "price_range_helper": "Select the full price range to receive housekeeper proposals.",
+  "date_and_time": "Date and time",
+  "success_message": "Custom request saved successfully!",
+  "residential": "Residential",
+  "commercial": "Commercial"
+},
   "dashboard_client": {
     "header": {
       "rating": "Rating",
@@ -696,5 +722,48 @@
     "search": "Search",
     "agenda": "Schedule",
     "profile": "Profile"
+  },
+
+  "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."
+    }
   }
 }

+ 85 - 1
src/i18n/locales/es.json

@@ -362,6 +362,32 @@
     "no_price": "a combinar",
     "no_results": "No se encontraron limpiadores para esta búsqueda."
   },
+  "custom_schedule": {
+    "success_title_line1": "SOB MEDIDA",
+    "success_title_line2": "SOLICITADO!",
+    "success_subtitle_before": "Em breve ",
+    "success_subtitle_highlight": "diaristas enviarão propostas",
+    "success_subtitle_after": " para atender ao seu serviço.",
+    "close": "Fechar",
+    "star_alt": "estrela",
+    "mascot_alt": "Mascote Sob Medida"
+  },
+  "sob_medida": {
+    "page_title": "Servicio Personalizado",
+    "your_order": "Tu solicitud",
+    "quantity_service": "Cantidad de servicio",
+    "service_type": "Tipo de servicio",
+    "preferred_specialty": "¿Especialidad preferida?",
+    "description_label": "Describe los detalles del servicio",
+    "optional": "(opcional)",
+    "description_placeholder": "Hola, deseo un profesional dedicado que hará...",
+    "price_range_title": "Rango de precio por 8 horas",
+    "price_range_helper": "Selecciona el rango completo de precio para recibir propuestas de profesionales.",
+    "date_and_time": "Fecha y hora",
+    "success_message": "¡Solicitud personalizada guardada con éxito!",
+    "residential": "Residencial",
+    "commercial": "Comercial"
+  },
   "dashboard_client": {
     "header": {
       "rating": "Calificación",
@@ -696,5 +722,63 @@
     "search": "Buscar",
     "agenda": "Agenda",
     "profile": "Perfil"
+  },
+  "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."
+    }
   }
-}
+}

+ 90 - 1
src/i18n/locales/pt.json

@@ -362,11 +362,42 @@
     "no_price": "a combinar",
     "no_results": "Nenhum diarista encontrado para essa busca."
   },
+  "custom_schedule": {
+    "success_title_line1": "SOB MEDIDA",
+    "success_title_line2": "SOLICITADO!",
+    "success_subtitle_before": "Em breve ",
+    "success_subtitle_highlight": "diaristas enviarão propostas",
+    "success_subtitle_after": " para atender ao seu serviço.",
+    "close": "Fechar",
+    "star_alt": "estrela",
+    "mascot_alt": "Mascote Sob Medida"
+  },
+  "sob_medida": {
+    "page_title": "Serviço Sob Medida",
+    "your_order": "Seu pedido",
+    "quantity_service": "Quantidade de serviço",
+    "service_type": "Tipo de serviço",
+    "preferred_specialty": "Especialidade preferencial?",
+    "description_label": "Descreva detalhes do pedido",
+    "optional": "(opcional)",
+    "description_placeholder": "Olá, desejo profissional dedicado que irá fazer...",
+    "price_range_title": "Faixa de preço por 8 horas",
+    "price_range_helper": "Selecione a faixa de preço integral para receber propostas de diaristas.",
+    "date_and_time": "Data e hora",
+    "success_message": "Pedido sob medida salvo com sucesso!",
+    "residential": "Residencial",
+    "commercial": "Comercial"
+  },
   "dashboard_client": {
     "header": {
       "rating": "Avaliação",
       "services": "Serviços"
     },
+    "dashboard_pending_custom_schedules": {
+      "pending_request_title": "Solicitando propostas de diaristas",
+      "pending_request_time": "15 min atrás",
+      "waiting_status": "Aguardando"
+    },
     "summary": {
       "welcome": "Bem-vindo (a),",
       "my_schedules": "Minhas diárias"
@@ -696,5 +727,63 @@
     "search": "Busca",
     "agenda": "Agenda",
     "profile": "Perfil"
+  },
+  "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."
+    }
   }
-}
+}

+ 16 - 0
src/pages/dashboard/DashboardPage.vue

@@ -14,6 +14,7 @@
         @view-details="openAcceptedDialog"
       />
       <DashboardScrollAreaSchedules />
+      <DashboardPendingCustomSchedules />
       <DashboardNextSchedules v-if="nextSchedules.length > 0" :data="nextSchedules" />
       <DashboardLastDoneSchedules v-if="lastDoneSchedules.length > 0" :data="lastDoneSchedules" />
       <DashboardFavoriteProviders v-if="favoriteProviders.length > 0" :data="favoriteProviders" />
@@ -32,10 +33,14 @@ import DashboardNextSchedules from 'src/components/dashboard/DashboardNextSchedu
 import DashboardLastDoneSchedules from 'src/components/dashboard/DashboardLastDoneSchedules.vue';
 import DashboardFavoriteProviders from 'src/components/dashboard/DashboardFavoriteProviders.vue';
 import DashboardProvidersClose from 'src/components/dashboard/DashboardProvidersClose.vue';
+import FinalSuccesModal from '../schedules/components/FinalSuccesModal.vue';
+import DashboardPendingCustomSchedules from 'src/pages/dashboard/components/DashboardPendingCustomSchedules.vue';
+import { useRouter } from 'vue-router'
 import { onMounted, ref } from 'vue';
 import { useQuasar } from 'quasar';
 import { dadosDashboard } from 'src/api/dashboard';
 
+const router = useRouter()
 const headerBar = ref({});
 const summaryInfos = ref({});
 const pendingSchedules = ref([]);
@@ -46,6 +51,8 @@ const providersClose = ref([]);
 const $q = useQuasar();
 const loading = ref(true);
 
+const showSuccessModal = ref(router.currentRoute.value.fullPath.includes('showSuccessModal') || false);
+
 const openAcceptedDialog = (schedule) => {
   $q.dialog({
     component: ScheduleAcceptedDialog,
@@ -67,6 +74,15 @@ const reloadDashboard = async () => {
     favoriteProviders.value = response.favoriteProviders ?? [];
     providersClose.value = response.providersClose ?? [];
   }
+  if( showSuccessModal.value ) {
+    $q.dialog({
+       component: FinalSuccesModal   
+       })
+
+    showSuccessModal.value = false;
+  }
+
+
   loading.value = false;
 };
 

+ 94 - 0
src/pages/dashboard/components/DashboardPendingCustomSchedules.vue

@@ -0,0 +1,94 @@
+<template>
+  <q-card flat bordered class="pending-request-card">
+    <div class="request-wrapper">
+      <!-- informações -->
+      <div class="request-info">
+        <div class="request-title">
+          {{ $t('dashboard_client.dashboard_pending_custom_schedules.pending_request_title') }}
+        </div>
+
+        <div class="request-time">
+          <q-icon name="schedule" size="14px" />
+          <span>{{ $t('dashboard_client.dashboard_pending_custom_schedules.pending_request_time') }}</span>
+        </div>
+      </div>
+
+      <!-- status -->
+      <div class="request-status">
+        <q-icon
+          name="hourglass_empty"
+          size="22px"
+          color="secondary"
+        />
+        <span>{{ $t('dashboard_client.dashboard_pending_custom_schedules.waiting_status') }}</span>
+      </div>
+    </div>
+
+    <!-- barra -->
+    <div class="progress-track">
+      <div class="progress-fill" />
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+</script>
+
+<style scoped lang="scss">
+.pending-request-card {
+  border-radius: 14px;
+  padding: 12px;
+  background: white;
+  margin: 12px 16px;
+}
+
+.request-wrapper {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.request-info {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.request-title {
+  font-size: 14px;
+  font-weight: 500;
+  color: #4b4b4b;
+  line-height: 1.2;
+}
+
+.request-time {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  color: #999;
+  font-size: 12px;
+}
+
+.request-status {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  color: #9b5cff;
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.progress-track {
+  margin-top: 10px;
+  height: 4px;
+  border-radius: 999px;
+  background: #ece6f8;
+  overflow: hidden;
+}
+
+.progress-fill {
+  width: 35%;
+  height: 100%;
+  background: linear-gradient(90deg, #ff72df, #8f5cff);
+}
+</style>

+ 722 - 0
src/pages/schedules/SobMedidaPage.vue

@@ -0,0 +1,722 @@
+<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
+<template>
+  <q-page class="sob-medida-page">
+    <div class="page-shell">
+      <span class="page-title gradient-diarista">
+        {{ $t('sob_medida.page_title') }}
+      </span>
+
+      <!-- CARD PEDIDO -->
+      <q-card flat bordered class="figma-card compact-card">
+        <div class="card-title text-center gradient-diarista">
+          {{ $t('sob_medida.your_order') }}
+        </div>
+
+        <!-- quantidade -->
+        <div class="field-label text-center gradient-diarista">
+          {{ $t('sob_medida.quantity_service') }}
+        </div>
+
+        <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>
+
+        <!-- endereço -->
+        <div class="options-grid gradient-diarista">
+          <div
+            v-for="option in addressTypesOptions"
+            :key="option.value"
+            class="option-col"
+          >
+            <q-radio
+              v-model="selectedOption"
+              :val="option.value"
+              :label="option.label"
+              color="purple"
+              dense
+            />
+          </div>
+        </div>
+
+        <!-- tipo serviço -->
+        <div class="field-label text-center gradient-diarista">
+          {{ $t('sob_medida.service_type') }}
+        </div>
+
+        <div class="service-type-inline">
+          <div
+            v-for="serviceType in serviceTypes"
+            :key="serviceType.id"
+            class="option-col"
+          >
+            <q-radio
+              v-model="selectedServiceType"
+              :val="serviceType.id"
+              :label="serviceType.description"
+              color="purple"
+              dense
+            />
+          </div>
+        </div>
+
+        <!-- especialidades -->
+        <div class="field-label text-center gradient-diarista">
+          {{ $t('sob_medida.preferred_specialty') }}
+        </div>
+
+        <div class="options-grid specialties-grid">
+          <div
+            v-for="item in specialties"
+            :key="item.id"
+            class="specialty-col"
+          >
+            <q-checkbox
+              v-model="selectedSpecialties"
+              :val="item.id"
+              :label="item.description"
+              color="purple"
+              dense
+            />
+          </div>
+        </div>
+
+        <!-- descrição -->
+        <div class="field-label text-center gradient-diarista">
+          {{ $t('sob_medida.description_label') }}
+          <span class="optional">
+            {{ $t('sob_medida.optional') }}
+          </span>
+        </div>
+
+        <q-input
+          v-model="description"
+          type="textarea"
+          outlined
+          bg-color="white"
+          color="dark"
+          input-class="text-black"
+          class="description-box"
+          :placeholder="$t('sob_medida.description_placeholder')"
+        />
+      </q-card>
+
+      <!-- FAIXA -->
+      <div class="section-title gradient-diarista">
+        {{ $t('sob_medida.price_range_title') }}
+      </div>
+
+      <q-card flat bordered class="figma-card compact-card">
+        <div class="range-container">
+          <div
+            class="price-pin"
+            :style="{ left: minPosition + '%' }"
+          >
+            <span>{{ priceRange.min }}</span>
+          </div>
+
+          <div
+            class="price-pin"
+            :style="{ left: maxPosition + '%' }"
+          >
+            <span>{{ priceRange.max }}</span>
+          </div>
+
+          <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">
+          {{ $t('sob_medida.price_range_helper') }}
+        </div>
+      </q-card>
+
+      <!-- DATA -->
+      <div class="section-title gradient-diarista">
+        {{ $t('sob_medida.date_and_time') }}
+      </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>
+// IMPORTS
+import { ref, computed, watch, onMounted } from 'vue'
+import { useQuasar } from 'quasar'
+import { useRouter } from 'vue-router'
+
+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 { getPrimaryAddress } from 'src/api/address'
+import { getPublicServiceTypes } from 'src/api/serviceTypes'
+import { getPublicSpecialties } from 'src/api/specialties'
+import {useI18n} from 'vue-i18n'
+import { userStore } from 'src/stores/user'
+import { calculateDailyPrices } from 'src/helpers/utils'
+
+// SETUP
+const $q = useQuasar()
+const router = useRouter()
+const user = userStore()
+const { t } = useI18n()
+
+// REFS
+const serviceTypes = ref([])
+const specialties = ref([])
+const address = ref(null)
+
+const selectedServiceType = ref(null)
+const selectedSpecialties = ref([])
+const selectedOption = ref('home')
+const description = ref('')
+const selectedDate = ref(null)
+const quantity = ref(1)
+
+const PRICE_LIMITS = Object.freeze({
+  min: 100,
+  max: 500
+})
+
+const priceRange = ref({
+  min: 150,
+  max: 300
+})
+
+// COMPUTED
+const addressTypesOptions = computed(() => [
+  { label: t('sob_medida.residential'), value: 'home' },
+  { label: t('sob_medida.commercial'), value: 'commercial' }
+])
+
+const providerPrices = computed(() =>
+  calculateDailyPrices(priceRange.value.max * quantity.value)
+)
+
+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
+)
+
+// FUNCTIONS
+const openServiceSelection = () => {
+  $q.dialog({
+    component: ServiceSelectionSheet,
+    componentProps: {
+      provider: providerPrices.value,
+      selectedDate: selectedDate.value
+    }
+  }).onOk((payload) => {
+    if (payload?.serviceType) {
+      openServiceTimeSelection(payload.serviceType)
+    }
+  })
+}
+
+const openServiceTimeSelection = (serviceType) => {
+  $q.dialog({
+    component: ServiceTimeSelectionDialog,
+    componentProps: {
+      serviceType,
+      provider: providerPrices.value,
+      selectedDate: selectedDate.value
+    }
+  }).onOk(saveFinalOrder)
+}
+
+const saveFinalOrder = async (payloadFinal) => {
+  let [startHour, endHour] = payloadFinal.slot.split('-')
+
+  startHour = String(startHour).padStart(2, '0')
+  endHour = String(endHour).padStart(2, '0')
+
+  const payload = {
+    client_id: user.user.client.id,
+    address_id: address.value?.id,
+    quantity: quantity.value,
+    date: payloadFinal.date,
+    period_type: String(payloadFinal.serviceType.hoursCount),
+    start_time: `${startHour}:00`,
+    end_time: `${endHour}:00`,
+    address_type: selectedOption.value,
+    service_type_id: selectedServiceType.value,
+    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: t('sob_medida.success_message')
+  })
+
+  router.push('/#showSuccessModal')
+}
+
+const increaseQuantity = () => {
+  quantity.value++
+}
+
+const decreaseQuantity = () => {
+  if (quantity.value > 1) quantity.value--
+}
+
+// WATCH
+watch(selectedDate, (newDate, oldDate) => {
+  if (!newDate || newDate === oldDate) return
+  openServiceSelection()
+})
+
+// LIFECYCLE
+onMounted(async () => {
+  const { data } = await getPrimaryAddress(user.user.client.id, 'client')
+  address.value = data.payload
+
+  serviceTypes.value = await getPublicServiceTypes()
+  specialties.value = await getPublicSpecialties()
+})
+</script>
+
+<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;
+}
+
+.options-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 12px 20px;
+  justify-items: center;
+  margin-bottom: 12px;
+
+  :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;
+  }
+}
+
+.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;
+}
+
+
+
+.option-col {
+  min-width: 0;
+}
+
+.description-box {
+  margin-top: 8px;
+}
+
+.description-box :deep(textarea) {
+  min-height: 90px;
+  resize: none;
+}
+
+/* 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;
+}
+
+.service-type-inline {
+  display: grid;
+  grid-template-columns: repeat(2, 160px);
+  gap: 12px 24px;
+  justify-content: center;
+  margin: 0 auto 16px;
+}
+
+
+.specialties-grid {
+  justify-items: center;
+  gap: 10px 18px;
+  margin: 0 auto 16px;
+}
+
+.specialty-col {
+  min-width: 0;
+  display: flex;
+  justify-content: center;
+}
+
+.specialties-grid :deep(.q-checkbox) {
+  justify-content: flex-start;
+}
+
+.specialties-grid :deep(.q-checkbox__label) {
+  color: #000 !important;
+  font-size: 13px;
+  line-height: 1.2;
+  white-space: normal;
+}
+
+/* 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>

+ 113 - 0
src/pages/schedules/components/FinalSuccesModal.vue

@@ -0,0 +1,113 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="handleClose">
+    <q-card class="success-card bg-white">
+      <!-- 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="handleClose"
+      />
+
+      <q-card-section class="text-center q-pt-lg q-pb-md">
+        <!-- estrelas -->
+        <div class="stars-wrapper">
+          <img src="/public/star1.svg" class="star-small" alt="estrela" />
+          <img src="/public/star.svg" class="star-big" alt="estrela" />
+          <img src="/public/star2.svg" class="star-small" alt="estrela" />
+        </div>
+
+        <!-- mascote -->
+        <img
+          src="/public/diarinho-2.svg"
+          alt="Mascote Sob Medida"
+          class="success-image"
+        />
+
+        <!-- titulo -->
+        <div class="success-title gradient-diarista q-mt-md">
+          {{ $t('custom_schedule.success_title_line1') }}<br>
+          {{ $t('custom_schedule.success_title_line2') }}
+        </div>
+
+        <!-- subtitulo -->
+        <div class="success-subtitle q-mt-md">
+          {{ $t('custom_schedule.success_subtitle_before') }}
+          <strong>{{ $t('custom_schedule.success_subtitle_highlight') }}</strong>
+          {{ $t('custom_schedule.success_subtitle_after') }}
+        </div>
+      </q-card-section>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { useDialogPluginComponent } from 'quasar'
+
+defineEmits([...useDialogPluginComponent.emits])
+
+const {
+  dialogRef,
+  onDialogOK
+} = useDialogPluginComponent()
+
+function handleClose () {
+  onDialogOK()
+}
+</script>
+
+<style scoped lang="scss">
+.success-card {
+  width: 300px;
+  border-radius: 30px;
+  padding: 14px 12px 18px;
+  background: white !important;
+}
+
+.stars-wrapper {
+  display: flex;
+  justify-content: center;
+  align-items: flex-end;
+  gap: 4px;
+  margin-top: 2px;
+  margin-bottom: 10px;
+}
+
+.star-small {
+  width: 16px;
+  height: 16px;
+  object-fit: contain;
+}
+
+.star-big {
+  width: 28px;
+  height: 28px;
+  object-fit: contain;
+}
+
+.success-image {
+  width: 100px;
+  display: block;
+  margin: 0 auto;
+}
+
+.success-title {
+  font-size: 20px;
+  font-weight: 800;
+  line-height: 1.08;
+  text-align: center;
+  margin-top: 8px;
+}
+
+.success-subtitle {
+  font-size: 14px;
+  line-height: 1.35;
+  color: #666;
+  text-align: center;
+  padding: 0 24px;
+  margin-top: 14px;
+}
+</style>

+ 1 - 0
src/pages/search/SearchPage.vue

@@ -22,6 +22,7 @@
             unelevated
             padding="8px 16px"
             class="text-weight-bold custom-schedule-btn card-border"
+            @click="router.push({ name: 'SobMedidaPage' })"
           >
             <template #default>
               <div class="column items-center q-gutter-y-xs">

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

@@ -34,5 +34,42 @@ defineProps({
 
 defineEmits([...useDialogPluginComponent.emits]);
 
+const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+</script><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>

+ 21 - 0
src/router/routes/appointments.route.js

@@ -0,0 +1,21 @@
+export default [
+  {
+    path: "sob-medida",
+    name: "SobMedidaPage",
+    component: () => import("src/pages/schedules/SobMedidaPage.vue"),
+    meta: {
+      title: "Serviço Sob Medida",
+      requireAuth: true,
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "ui.navigation.dashboard",
+        },
+        {
+          name: "SobMedidaPage",
+          title: "Serviço Sob Medida",
+        },
+      ],
+    },
+  },
+];

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů