Преглед изворни кода

Merge branch 'feature/diariaapp-gus-agendamentos-apps' of Softpar/sfp_front_vue_diarista_cliente into development

aprovado
zntt пре 2 недеља
родитељ
комит
178779adfe

+ 8 - 0
src/api/dashboard.js

@@ -3,4 +3,12 @@ import api from "src/api";
 export const dadosDashboard = async () => {
   const { data } = await api.get("/dados-dashboard-cliente");
   return data.payload;
+}
+
+export const buscaPrestadores = async ({ name = '', date = '' } = {}) => {
+  const params = {};
+  if (name) params.name = name;
+  if (date) params.date = date;
+  const { data } = await api.get('/prestadores-busca', { params });
+  return data.payload;
 }

+ 11 - 0
src/api/providerAvailability.js

@@ -0,0 +1,11 @@
+import api from 'src/api'
+
+export const getProviderWorkingDays = async (providerId) => {
+  const { data } = await api.get(`/provider/working-days/${providerId}`)
+  return data.payload
+}
+
+export const getProviderBlockedDays = async (providerId) => {
+  const { data } = await api.get(`/provider/blocked-days/${providerId}`)
+  return data.payload
+}

+ 6 - 0
src/api/review.js

@@ -0,0 +1,6 @@
+import api from 'src/api'
+
+export const getProviderReceivedReviews = async (providerId) => {
+  const { data } = await api.get(`/reviews/provider/${providerId}/received`)
+  return data.payload
+}

+ 11 - 0
src/api/schedule.js

@@ -0,0 +1,11 @@
+import api from 'src/api';
+
+export const createSchedule = async (scheduleData) => {
+  const { data } = await api.post('/schedule', scheduleData);
+  return data.payload;
+};
+
+export const updateScheduleStatus = async (id, status) => {
+  const { data } = await api.patch(`/schedule/${id}/status`, { status });
+  return data.payload;
+};

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

@@ -108,7 +108,7 @@ const addressIcon = (type) => type === 'home' ? 'mdi-home-outline' : 'mdi-office
 const addressLabel = (type) => {
   if (type === 'home') return t('address.types.commercial.home');
   if (type === 'apartment') return t('dashboard_client.next_schedules.place_apartment');
-  if (type === 'commercial') return t('address.types.commercial');
+  if (type === 'commercial') return t('address.types.commercial.commercial');
   return t('dashboard_client.next_schedules.place_unknown');
 };
 

+ 140 - 0
src/components/dashboard/DashboardPendingSchedules.vue

@@ -0,0 +1,140 @@
+<template>
+  <div class="q-mx-md q-mb-md">
+    <div class="scroll-wrapper">
+      <div class="scroll-track">
+        <q-card
+          v-for="item in data"
+          :key="item.id"
+          class="pending-card card-border shadow-card bg-surface"
+          :flat="false"
+          :class="{ 'cursor-pointer': item.status === 'accepted' }"
+          @click="item.status === 'accepted' && emit('view-details', item)"
+        >
+          <q-card-section class="q-pa-md">
+
+            <div class="row no-wrap items-start q-mb-sm">
+              <q-avatar size="40px" :style="avatarColors[item.id % avatarColors.length]" class="text-weight-bold q-mr-sm flex-shrink-0">
+                {{ item.provider_name?.slice(0, 2).toUpperCase() ?? '??' }}
+              </q-avatar>
+
+              <div class="col column no-wrap overflow-hidden">
+                <span class="text-body2 text-text">
+                  {{ $t('dashboard_client.pending_schedules.requesting_with') }}
+                  <span class="text-weight-bold">{{ item.provider_name ?? '—' }}</span>
+                </span>
+                <div class="row items-center q-mt-xs">
+                  <q-icon name="mdi-clock-outline" size="13px" color="grey-5" class="q-mr-xs" />
+                  <span class="text-caption text-grey-5">{{ item.time_since_request }}</span>
+                </div>
+              </div>
+
+              <div class="clock-badge-col q-ml-sm flex-shrink-0 column items-center">
+                <div class="clock-badge">
+                  <q-icon name="mdi-clock-outline" size="18px" color="white" />
+                </div>
+                <span class="text-caption text-primary text-weight-bold q-mt-xs badge-status-text">
+                  {{ $t(`dashboard_client.pending_schedules.status.${item.status ?? 'pending'}`) }}
+                </span>
+              </div>
+            </div>
+
+            <div class="progress-track q-mb-md">
+              <div class="progress-fill" :style="{ width: progressPercent(item.status) + '%' }" />
+            </div>
+
+            <div class="row items-center no-wrap">
+              <q-btn
+                flat no-caps dense
+                :label="$t('dashboard_client.pending_schedules.cancel_btn')"
+                color="primary"
+                size="sm"
+                class="q-mr-sm flex-shrink-0"
+              />
+              <q-space />
+              <q-icon name="mdi-map-marker-outline" size="13px" color="grey-6" class="q-mr-xs flex-shrink-0" />
+              <span class="text-caption text-grey-6 col ellipsis text-right">
+                {{ [item.address?.address, item.address?.number, item.address?.district].filter(Boolean).join(', ') || '—' }}
+              </span>
+            </div>
+          </q-card-section>
+        </q-card>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
+const statusProgressMap = {
+  pending:  20,
+  accepted: 40,
+  paid:     60,
+  started:  80,
+  finished: 100,
+};
+
+const progressPercent = (status) => statusProgressMap[status] ?? 20;
+
+defineProps({ data: { type: Array, default: () => [] } });
+const emit = defineEmits(['view-details']);
+</script>
+
+<style scoped lang="scss">
+.scroll-wrapper { overflow: hidden; }
+.scroll-track {
+  display: flex;
+  flex-direction: row;
+  gap: 12px;
+  overflow-x: auto;
+  overscroll-behavior-x: contain;
+  scroll-snap-type: x proximity;
+  padding-bottom: 8px;
+  &::-webkit-scrollbar { display: none; }
+  &::after { content: ''; flex: 0 0 1px; }
+}
+.pending-card {
+  min-width: 80%;
+  scroll-snap-align: start;
+}
+
+.clock-badge-col {
+  min-width: 52px;
+  align-items: center;
+}
+
+.clock-badge {
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #8B5CF6, #EC4899);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.badge-status-text {
+  font-size: 10px;
+  white-space: nowrap;
+}
+
+.progress-track {
+  width: 100%;
+  height: 5px;
+  background: #E2E8F0;
+  border-radius: 3px;
+  overflow: hidden;
+}
+
+.progress-fill {
+  height: 100%;
+  border-radius: 3px;
+  background: linear-gradient(90deg, #8B5CF6, #EC4899);
+  transition: width 0.4s ease;
+}
+</style>

+ 12 - 3
src/components/dashboard/DashboardScrollAreaSchedules.vue

@@ -1,7 +1,13 @@
 <template>
   <section class="promo-scroll-wrapper q-ma-md">
     <div class="promo-scroll">
-      <div v-for="card in cards" :key="card.id" class="promo-card">
+      <div
+        v-for="card in cards"
+        :key="card.id"
+        class="promo-card"
+        :class="{ 'cursor-pointer': card.route }"
+        @click="card.route && router.push({ name: card.route })"
+      >
         <img :src="card.image" :alt="card.alt" class="promo-card__img" />
       </div>
     </div>
@@ -9,12 +15,15 @@
 </template>
 
 <script setup>
+import { useRouter } from 'vue-router';
 import Banner1 from 'src/assets/banner_1.svg';
 import Banner2 from 'src/assets/banner_2.svg';
 
+const router = useRouter();
+
 const cards = [
-  { id: 1, image: Banner1, alt: 'Diária sob medida' },
-  { id: 2, image: Banner2, alt: 'Escolha profissionais' },
+  { id: 1, image: Banner1, alt: 'Diária sob medida', route: null },
+  { id: 2, image: Banner2, alt: 'Escolha profissionais', route: 'SearchPage' },
 ];
 </script>
 

+ 175 - 0
src/components/dashboard/ScheduleAcceptedDialog.vue

@@ -0,0 +1,175 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="accepted-dialog-card bg-surface" :flat="false">
+
+      <q-card-section class="column items-center q-pt-lg q-pb-sm">
+        <q-avatar size="80px" :style="avatarStyle" class="text-weight-bold text-h5 q-mb-sm">
+          {{ schedule.provider_name?.slice(0, 2).toUpperCase() ?? '??' }}
+        </q-avatar>
+
+        <div class="text-h6 text-weight-bold provider-name">{{ schedule.provider_name }}</div>
+        <div class="text-caption text-text">{{ schedule.provider_district || '' }}</div>
+      </q-card-section>
+
+      <q-card-section class="text-center q-pt-xs q-pb-md">
+        <div class="accepted-title text-weight-bold">
+          {{ $t('dashboard_client.pending_schedules.accepted_title') }}
+        </div>
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-section class="q-py-md q-px-lg">
+        <div class="detail-row">
+          <span class="detail-label">{{ $t('dashboard_client.pending_schedules.detail_date') }}</span>
+          <span class="detail-value">{{ formattedDate }}</span>
+        </div>
+        <div class="detail-row">
+          <span class="detail-label">{{ $t('dashboard_client.pending_schedules.detail_time') }}</span>
+          <span class="detail-value text-weight-bold">
+            {{ schedule.start_time?.slice(0, 5) }} {{ $t('dashboard_client.next_schedules.to') }} {{ schedule.end_time?.slice(0, 5) }}
+          </span>
+        </div>
+        <div class="detail-row">
+          <span class="detail-label">{{ $t('dashboard_client.pending_schedules.detail_value') }}</span>
+          <span class="detail-value">{{ formatCurrency(schedule.total_amount) }}</span>
+        </div>
+        <div class="detail-row">
+          <span class="detail-label">{{ $t('dashboard_client.pending_schedules.detail_service_fee') }}</span>
+          <span class="detail-value">{{ formatCurrency(serviceFee) }}</span>
+        </div>
+
+        <q-separator class="q-my-sm" />
+
+        <div class="detail-row">
+          <span class="detail-label text-weight-bold text-text">{{ $t('dashboard_client.pending_schedules.detail_total') }}</span>
+          <span class="total-value">{{ formatCurrency(total) }}</span>
+        </div>
+      </q-card-section>
+
+      <q-card-section class="q-pt-none q-pb-lg q-px-lg column q-gutter-y-sm">
+        <q-btn
+          unelevated
+          rounded
+          no-caps
+          class="payment-btn full-width"
+          :label="$t('dashboard_client.pending_schedules.btn_payment')"
+          @click="onGoToPayment"
+        />
+        <q-btn
+          flat
+          no-caps
+          color="grey-6"
+          class="full-width"
+          :label="$t('dashboard_client.pending_schedules.btn_cancel')"
+          @click="onDialogHide"
+        />
+      </q-card-section>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { formatCurrency } from 'src/helpers/utils'
+import SchedulePaymentDialog from './SchedulePaymentDialog.vue'
+
+const props = defineProps({
+  schedule: {
+    type: Object,
+    required: true
+  }
+})
+
+defineEmits([...useDialogPluginComponent.emits])
+
+const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent()
+
+const $q = useQuasar()
+
+const SERVICE_FEE_RATE = 0.10
+
+const serviceFee = computed(() => Number(props.schedule.total_amount) * SERVICE_FEE_RATE)
+const total = computed(() => Number(props.schedule.total_amount) + serviceFee.value)
+
+const onGoToPayment = () => {
+  $q.dialog({
+    component: SchedulePaymentDialog,
+    componentProps: {
+      schedule: props.schedule,
+      total: total.value,
+    },
+  }).onOk(() => {
+    onDialogOK()
+  })
+}
+
+const formattedDate = computed(() => {
+  if (props.schedule.formatted_date) return props.schedule.formatted_date
+  const raw = String(props.schedule.date || '')
+  const m = raw.match(/^(\d{4})-(\d{2})-(\d{2})/)
+  if (!m) return raw
+  const d = new Date(+m[1], +m[2] - 1, +m[3])
+  return d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' })
+})
+
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+]
+const avatarStyle = computed(() => avatarColors[props.schedule.id % avatarColors.length])
+</script>
+
+<style scoped lang="scss">
+.accepted-dialog-card {
+  width: 320px;
+  max-width: 92vw;
+  border-radius: 20px !important;
+  overflow: hidden;
+}
+
+.accepted-title {
+  font-size: 22px;
+  line-height: 1.3;
+  color: #8B5CF6;
+}
+
+.provider-name {
+  color: #8B5CF6;
+}
+
+.detail-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 4px 0;
+}
+
+.detail-label {
+  font-size: 13px;
+  color: #8a8a9a;
+}
+
+.detail-value {
+  font-size: 13px;
+  color: #3a3a4a;
+}
+
+.total-value {
+  font-size: 18px;
+  font-weight: 700;
+  color: #3a3a4a;
+}
+
+.payment-btn {
+  background: linear-gradient(90deg, #8B5CF6, #EC4899);
+  color: white;
+  font-weight: 700;
+  font-size: 15px;
+  height: 48px;
+}
+</style>

+ 304 - 0
src/components/dashboard/SchedulePaymentDialog.vue

@@ -0,0 +1,304 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down" @hide="onDialogHide">
+    <div class="bg-page full-height column">
+
+      <div class="row items-center q-px-md q-pt-md q-pb-sm bg-surface shadow-header">
+        <q-btn icon="mdi-chevron-left" flat round dense color="primary" @click="onDialogCancel" />
+        <q-space />
+        <span class="text-subtitle1 text-weight-bold text-primary">
+          {{ $t('payment.title') }}
+        </span>
+        <q-space />
+        <div style="width: 32px" />
+      </div>
+
+      <div class="col overflow-auto q-px-md q-pt-lg q-pb-xl">
+
+        <div class="section-label q-mb-sm">{{ $t('payment.schedule_address') }}</div>
+        <div class="address-box row items-center no-wrap q-mb-lg">
+          <div class="col">
+            <div class="address-type-label">{{ addressTypeLabel }}</div>
+            <div class="address-full-text text-grey-7">{{ addressFullText }}</div>
+          </div>
+          <q-icon name="mdi-chevron-down" color="grey-5" size="22px" />
+        </div>
+
+        <div class="section-label q-mb-sm">{{ $t('payment.pay_with') }}</div>
+        <div class="row q-gutter-sm q-mb-sm">
+
+          <div
+            class="payment-option-card col column items-center justify-center q-pa-md cursor-pointer"
+            :class="{ 'payment-option-selected': selectedMethod === 'pix' }"
+            @click="selectedMethod = 'pix'"
+          >
+            <span class="payment-option-title">{{ $t('payment.pix') }}</span>
+            <q-icon name="mdi-cash-fast" size="32px" color="teal" class="q-mt-xs" />
+          </div>
+
+          <div
+            class="payment-option-card col column items-center justify-center q-pa-md cursor-pointer"
+            :class="{ 'payment-option-selected': selectedMethod === 'new_card' }"
+            @click="openAddCard"
+          >
+            <span class="payment-option-title">{{ $t('payment.add_card') }}</span>
+            <q-icon name="mdi-plus-circle-outline" size="22px" color="grey-5" class="q-mt-xs" />
+            <span class="payment-option-sub">{{ $t('payment.credit_debit') }}</span>
+          </div>
+
+        </div>
+
+        <div v-if="loadingCards" class="flex flex-center q-py-md">
+          <q-spinner color="primary" size="2em" />
+        </div>
+
+        <div v-else-if="paymentMethods.length > 0" class="column q-gutter-y-sm q-mb-lg">
+          <div
+            v-for="card in paymentMethods"
+            :key="card.id"
+            class="saved-card-box row items-center no-wrap q-pa-md cursor-pointer"
+            :class="{ 'payment-option-selected': selectedMethod === `card_${card.id}` }"
+            @click="selectedMethod = `card_${card.id}`"
+          >
+            <div class="col column">
+              <span class="card-titular-label">{{ $t('payment.card_holder') }}</span>
+              <span class="card-holder-name">{{ card.holder_name }}</span>
+            </div>
+            <div class="column items-end">
+              <span class="card-brand-text">{{ brandDisplay(card.brand) }}</span>
+              <span class="card-last-four">{{ '**** **** **** ' + card.last_four_digits }}</span>
+              <span class="card-expiry-text">{{ card.expiration }}</span>
+            </div>
+          </div>
+        </div>
+
+        <q-separator class="q-my-lg" />
+
+        <div class="row items-center q-mb-lg">
+          <q-checkbox v-model="agreedToTerms" color="primary" keep-color />
+          <span class="terms-text">
+            {{ $t('payment.agree_prefix') }}
+            <span class="text-primary text-weight-bold cursor-pointer text-underline">{{ $t('payment.terms_link') }}</span>
+          </span>
+        </div>
+
+        <q-btn
+          unelevated
+          rounded
+          no-caps
+          color="primary"
+          class="full-width confirm-btn"
+          :label="$t('payment.confirm_btn')"
+          :disable="!canConfirm"
+          @click="onConfirm"
+        />
+
+      </div>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import { userStore } from 'src/stores/user'
+import { getClientPaymentMethods } from 'src/api/clientPaymentMethod'
+import ProfilePaymentAddDialog from 'src/components/profile/ProfilePaymentAddDialog.vue'
+import SchedulePaymentPixDialog from './SchedulePaymentPixDialog.vue'
+import SchedulePaymentProcessingDialog from './SchedulePaymentProcessingDialog.vue'
+
+const props = defineProps({
+  schedule: {
+    type: Object,
+    required: true,
+  },
+  total: {
+    type: Number,
+    required: true,
+  },
+})
+
+defineEmits([...useDialogPluginComponent.emits])
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+const { t } = useI18n()
+const store = userStore()
+
+const selectedMethod = ref(null)
+const agreedToTerms = ref(false)
+const paymentMethods = ref([])
+const loadingCards = ref(false)
+
+const addressTypeLabel = computed(() => {
+  const type = props.schedule.address?.address_type
+  if (!type) return ''
+  return t(`profile.address.type.${type}`, type)
+})
+
+const addressFullText = computed(() => {
+  const a = props.schedule.address
+  if (!a) return ''
+  const parts = [a.address, a.number, a.district].filter(Boolean)
+  return parts.join(', ')
+})
+
+const canConfirm = computed(() => selectedMethod.value !== null && agreedToTerms.value)
+
+const brandDisplay = (brand) => {
+  if (!brand) return ''
+  const map = { visa: 'VISA', mastercard: 'Mastercard', elo: 'Elo', hipercard: 'Hipercard', diners: 'Diners', discover: 'Discover' }
+  return map[brand] ?? brand.toUpperCase()
+}
+
+const loadCards = async () => {
+  loadingCards.value = true
+  try {
+    paymentMethods.value = await getClientPaymentMethods(store.user?.client_id)
+  } catch (e) {
+    console.error(e)
+  } finally {
+    loadingCards.value = false
+  }
+}
+
+const openAddCard = () => {
+  $q.dialog({
+    component: ProfilePaymentAddDialog,
+    componentProps: { clientId: store.user?.client_id },
+  }).onOk(() => {
+    loadCards()
+  })
+}
+
+const onConfirm = () => {
+  if (selectedMethod.value === 'pix') {
+    $q.dialog({
+      component: SchedulePaymentPixDialog,
+      componentProps: { schedule: props.schedule, total: props.total },
+    }).onOk(() => {
+      onDialogOK()
+    })
+  } else {
+    $q.dialog({
+      component: SchedulePaymentProcessingDialog,
+      componentProps: { schedule: props.schedule },
+    }).onOk(() => {
+      onDialogOK()
+    })
+  }
+}
+
+onMounted(() => {
+  loadCards()
+})
+</script>
+
+<style scoped lang="scss">
+.shadow-header {
+  box-shadow: 0px 1px 8px rgba(0, 0, 0, 0.1);
+}
+
+.section-label {
+  font-size: 15px;
+  font-weight: 700;
+  color: #3a3a4a;
+}
+
+.address-box {
+  border: 1px solid #e0e0e0;
+  border-radius: 10px;
+  padding: 12px 16px;
+  background: #fff;
+}
+
+.address-type-label {
+  font-size: 14px;
+  font-weight: 700;
+  color: #3a3a4a;
+}
+
+.address-full-text {
+  font-size: 12px;
+  line-height: 1.4;
+  margin-top: 2px;
+}
+
+.payment-option-card {
+  border: 1.5px solid #e0e0e0;
+  border-radius: 12px;
+  background: #fff;
+  min-height: 90px;
+  text-align: center;
+  transition: border-color 0.2s;
+}
+
+.payment-option-selected {
+  border-color: #22c55e !important;
+  box-shadow: 0 0 0 1px #22c55e;
+}
+
+.payment-option-title {
+  font-size: 15px;
+  font-weight: 700;
+  color: #3a3a4a;
+}
+
+.payment-option-sub {
+  font-size: 11px;
+  color: #9a9aaa;
+  margin-top: 2px;
+}
+
+.saved-card-box {
+  border: 1.5px solid #e0e0e0;
+  border-radius: 12px;
+  background: #fff;
+  transition: border-color 0.2s;
+}
+
+.card-titular-label {
+  font-size: 11px;
+  color: #9a9aaa;
+}
+
+.card-holder-name {
+  font-size: 14px;
+  font-weight: 600;
+  color: #3a3a4a;
+}
+
+.card-brand-text {
+  font-size: 14px;
+  font-weight: 700;
+  color: #3a3a4a;
+  text-align: right;
+}
+
+.card-last-four {
+  font-size: 13px;
+  color: #6a6a7a;
+  letter-spacing: 1px;
+}
+
+.card-expiry-text {
+  font-size: 12px;
+  color: #9a9aaa;
+}
+
+.terms-text {
+  font-size: 13px;
+  color: #5a5a6a;
+  line-height: 1.4;
+}
+
+.text-underline {
+  text-decoration: underline;
+}
+
+.confirm-btn {
+  font-size: 16px;
+  font-weight: 700;
+  height: 52px;
+}
+</style>

+ 273 - 0
src/components/dashboard/SchedulePaymentPixDialog.vue

@@ -0,0 +1,273 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down" @hide="onDialogHide">
+    <div class="bg-page full-height column">
+
+      <div class="row items-center q-px-md q-pt-md q-pb-sm bg-surface shadow-header">
+        <q-btn icon="mdi-chevron-left" flat round dense color="primary" @click="onDialogCancel" />
+        <q-space />
+        <span class="text-subtitle1 text-weight-bold text-primary">
+          {{ $t('payment.pix_title') }}
+        </span>
+        <q-space />
+        <div style="width: 32px" />
+      </div>
+
+      <div v-if="success" class="col column items-center justify-center q-px-xl">
+        <q-btn
+          flat round icon="mdi-close" color="grey-5"
+          class="self-end q-mb-md"
+          @click="onDialogOK"
+        />
+        <div class="success-icon-wrapper q-mb-lg">
+          <q-icon name="mdi-check-circle" size="100px" color="primary" />
+        </div>
+        <div class="success-title text-primary text-weight-bold text-center q-mb-sm">
+          {{ $t('payment.success_title') }}
+        </div>
+        <i18n-t keypath="payment.success_message" tag="div" class="success-message text-grey-6 text-center">
+          <template #nextServices>
+            <strong class="text-text">{{ $t('payment.success_next_services') }}</strong>
+          </template>
+          <template #agenda>
+            <strong class="text-text">{{ $t('payment.success_agenda') }}</strong>
+          </template>
+        </i18n-t>
+      </div>
+
+      <div v-else class="col scroll q-px-lg q-pt-lg q-pb-xl column">
+
+        <div class="row items-center justify-between q-mb-sm">
+          <span class="pix-label">{{ $t('payment.pix_total') }}</span>
+          <span class="pix-value text-primary">{{ formatCurrency(total) }}</span>
+        </div>
+        <q-separator />
+
+        <div class="row items-center justify-between q-mt-sm q-mb-lg">
+          <span class="pix-label">{{ $t('payment.pix_expires') }}</span>
+          <span class="pix-value text-primary">{{ countdown }}</span>
+        </div>
+
+        <div class="flex flex-center q-mb-md">
+          <q-icon name="mdi-cash-fast" size="48px" color="teal" />
+        </div>
+
+        <div class="flex flex-center q-mb-md">
+          <div class="qrcode-wrapper">
+            <svg viewBox="0 0 200 200" width="180" height="180" xmlns="http://www.w3.org/2000/svg">
+              <rect x="10" y="10" width="60" height="60" rx="4" fill="none" stroke="#000" stroke-width="6"/>
+              <rect x="22" y="22" width="36" height="36" rx="2" fill="#000"/>
+              <rect x="130" y="10" width="60" height="60" rx="4" fill="none" stroke="#000" stroke-width="6"/>
+              <rect x="142" y="22" width="36" height="36" rx="2" fill="#000"/>
+              <rect x="10" y="130" width="60" height="60" rx="4" fill="none" stroke="#000" stroke-width="6"/>
+              <rect x="22" y="142" width="36" height="36" rx="2" fill="#000"/>
+              <g fill="#000">
+                <rect x="85" y="10" width="8" height="8"/>
+                <rect x="100" y="10" width="8" height="8"/>
+                <rect x="85" y="24" width="8" height="8"/>
+                <rect x="108" y="24" width="8" height="8"/>
+                <rect x="85" y="38" width="16" height="8"/>
+                <rect x="85" y="52" width="8" height="8"/>
+                <rect x="100" y="52" width="16" height="8"/>
+                <rect x="10" y="85" width="8" height="8"/>
+                <rect x="24" y="85" width="16" height="8"/>
+                <rect x="48" y="85" width="8" height="8"/>
+                <rect x="62" y="85" width="8" height="8"/>
+                <rect x="10" y="99" width="16" height="8"/>
+                <rect x="34" y="99" width="8" height="8"/>
+                <rect x="54" y="99" width="16" height="8"/>
+                <rect x="10" y="113" width="8" height="8"/>
+                <rect x="26" y="113" width="16" height="8"/>
+                <rect x="56" y="113" width="8" height="8"/>
+                <rect x="85" y="85" width="8" height="8"/>
+                <rect x="100" y="85" width="16" height="8"/>
+                <rect x="124" y="85" width="8" height="8"/>
+                <rect x="140" y="85" width="16" height="8"/>
+                <rect x="166" y="85" width="8" height="8"/>
+                <rect x="85" y="100" width="16" height="8"/>
+                <rect x="108" y="100" width="8" height="8"/>
+                <rect x="130" y="100" width="16" height="8"/>
+                <rect x="154" y="100" width="8" height="8"/>
+                <rect x="170" y="100" width="8" height="8"/>
+                <rect x="85" y="115" width="8" height="8"/>
+                <rect x="100" y="115" width="8" height="8"/>
+                <rect x="116" y="115" width="16" height="8"/>
+                <rect x="140" y="115" width="8" height="8"/>
+                <rect x="156" y="115" width="16" height="8"/>
+                <rect x="85" y="130" width="16" height="8"/>
+                <rect x="108" y="130" width="8" height="8"/>
+                <rect x="124" y="130" width="16" height="8"/>
+                <rect x="148" y="130" width="8" height="8"/>
+                <rect x="164" y="130" width="8" height="8"/>
+                <rect x="85" y="144" width="8" height="8"/>
+                <rect x="100" y="144" width="16" height="8"/>
+                <rect x="124" y="144" width="8" height="8"/>
+                <rect x="140" y="144" width="16" height="8"/>
+                <rect x="166" y="144" width="8" height="8"/>
+                <rect x="85" y="158" width="16" height="8"/>
+                <rect x="110" y="158" width="8" height="8"/>
+                <rect x="126" y="158" width="8" height="8"/>
+                <rect x="144" y="158" width="8" height="8"/>
+                <rect x="160" y="158" width="16" height="8"/>
+                <rect x="85" y="172" width="8" height="8"/>
+                <rect x="100" y="172" width="8" height="8"/>
+                <rect x="116" y="172" width="16" height="8"/>
+                <rect x="142" y="172" width="8" height="8"/>
+                <rect x="158" y="172" width="8" height="8"/>
+                <rect x="174" y="172" width="8" height="8"/>
+              </g>
+            </svg>
+          </div>
+        </div>
+
+        <div class="pix-code-text q-mb-md">{{ pixCode }}</div>
+
+        <q-btn
+          unelevated
+          rounded
+          no-caps
+          color="primary"
+          class="full-width copy-btn q-mb-lg"
+          :label="$t('payment.pix_copy_btn')"
+          :loading="processing"
+          @click="copyCode"
+        />
+
+        <p class="pix-instructions-text q-mb-sm">
+          <strong>{{ $t('payment.pix_instructions') }}</strong>
+        </p>
+        <p class="pix-instructions-text text-grey-6">
+          {{ $t('payment.pix_email_note') }}
+        </p>
+
+      </div>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onMounted, onUnmounted } from 'vue'
+import { useDialogPluginComponent, useQuasar, copyToClipboard } from 'quasar'
+import { formatCurrency } from 'src/helpers/utils'
+import { updateScheduleStatus } from 'src/api/schedule'
+
+const props = defineProps({
+  schedule: { type: Object, required: true },
+  total: { type: Number, required: true },
+})
+
+defineEmits([...useDialogPluginComponent.emits])
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+const pixCode = `00020126580014br.gov.bcb.pix0136sfp-diaria-${props.schedule.id}-${props.schedule.provider_id}5204000053039865406${(props.total * 100).toFixed(0).padStart(8, '0')}5802BR5913Diaria App6009SAO PAULO62070503***6304ABCD`
+
+const success = ref(false)
+const processing = ref(false)
+
+const copyCode = async () => {
+  try {
+    await copyToClipboard(pixCode)
+    $q.notify({ type: 'positive', message: 'Código copiado!' })
+  } catch {
+    $q.notify({ type: 'negative', message: 'Erro ao copiar.' })
+    return
+  }
+
+  processing.value = true
+  try {
+    await updateScheduleStatus(props.schedule.id, 'paid')
+  } catch (e) {
+    console.error('Erro ao atualizar status:', e)
+  } finally {
+    processing.value = false
+  }
+  success.value = true
+  setTimeout(() => onDialogOK(), 3000)
+}
+
+const totalSeconds = ref(20 * 60)
+const countdown = ref('')
+let timer = null
+
+const updateCountdown = () => {
+  const m = Math.floor(totalSeconds.value / 60)
+  const s = totalSeconds.value % 60
+  countdown.value = `${m} min, ${String(s).padStart(2, '0')} seg`
+  if (totalSeconds.value > 0) totalSeconds.value--
+}
+
+onMounted(() => {
+  updateCountdown()
+  timer = setInterval(updateCountdown, 1000)
+})
+
+onUnmounted(() => {
+  clearInterval(timer)
+})
+</script>
+
+<style scoped lang="scss">
+.shadow-header {
+  box-shadow: 0px 1px 8px rgba(0, 0, 0, 0.1);
+}
+
+.pix-label {
+  font-size: 15px;
+  color: #3a3a4a;
+  font-weight: 500;
+}
+
+.pix-value {
+  font-size: 15px;
+  font-weight: 700;
+}
+
+.qrcode-wrapper {
+  background: #fff;
+  border: 1px solid #e0e0e0;
+  border-radius: 12px;
+  padding: 12px;
+}
+
+.pix-code-text {
+  font-size: 11px;
+  color: #5a5a6a;
+  text-align: center;
+  word-break: break-all;
+  line-height: 1.5;
+  background: #f5f5f8;
+  border-radius: 8px;
+  padding: 10px;
+}
+
+.copy-btn {
+  font-size: 16px;
+  font-weight: 700;
+  height: 52px;
+}
+
+.pix-instructions-text {
+  font-size: 13px;
+  line-height: 1.5;
+  color: #3a3a4a;
+}
+
+.success-icon-wrapper {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.success-title {
+  font-size: 24px;
+  line-height: 1.3;
+  max-width: 280px;
+}
+
+.success-message {
+  font-size: 14px;
+  line-height: 1.6;
+  max-width: 280px;
+}
+</style>

+ 105 - 0
src/components/dashboard/SchedulePaymentProcessingDialog.vue

@@ -0,0 +1,105 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="fade" transition-hide="fade" @hide="onDialogHide">
+    <div class="bg-surface full-height column items-center justify-center q-px-xl">
+
+      <template v-if="!success">
+        <q-spinner-oval color="primary" size="72px" class="q-mb-lg" />
+        <div class="processing-title text-primary text-weight-bold text-center q-mb-sm">
+          {{ $t('payment.processing_title') }}
+        </div>
+        <div class="processing-message text-grey-6 text-center">
+          {{ $t('payment.processing_message') }}
+        </div>
+      </template>
+
+      <template v-else>
+        <q-btn
+          flat
+          round
+          icon="mdi-close"
+          color="grey-5"
+          class="self-end q-mb-md"
+          @click="onDialogOK"
+        />
+        <img
+          src="/logo_diaria_branco.svg"
+          alt="mascote"
+          class="success-mascot q-mb-lg"
+          style="display:none"
+        />
+        <div class="success-icon-wrapper q-mb-lg">
+          <q-icon name="mdi-check-circle" size="100px" color="primary" />
+        </div>
+        <div class="success-title text-primary text-weight-bold text-center q-mb-sm">
+          {{ $t('payment.success_title') }}
+        </div>
+        <i18n-t keypath="payment.success_message" tag="div" class="success-message text-grey-6 text-center">
+          <template #nextServices>
+            <strong class="text-text">{{ $t('payment.success_next_services') }}</strong>
+          </template>
+          <template #agenda>
+            <strong class="text-text">{{ $t('payment.success_agenda') }}</strong>
+          </template>
+        </i18n-t>
+      </template>
+
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { useDialogPluginComponent } from 'quasar'
+import { updateScheduleStatus } from 'src/api/schedule'
+
+const props = defineProps({
+  schedule: { type: Object, required: true },
+})
+
+defineEmits([...useDialogPluginComponent.emits])
+
+const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent()
+
+const success = ref(false)
+
+onMounted(async () => {
+  await new Promise(resolve => setTimeout(resolve, 4000))
+  try {
+    await updateScheduleStatus(props.schedule.id, 'paid')
+  } catch (e) {
+    console.error('Erro ao atualizar status:', e)
+  }
+  success.value = true
+})
+</script>
+
+<style scoped lang="scss">
+.processing-title {
+  font-size: 22px;
+  line-height: 1.3;
+}
+
+.processing-message {
+  font-size: 15px;
+  line-height: 1.5;
+  max-width: 280px;
+}
+
+.success-icon-wrapper {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.success-title {
+  font-size: 24px;
+  line-height: 1.3;
+  max-width: 280px;
+}
+
+.success-message {
+  font-size: 14px;
+  line-height: 1.6;
+  max-width: 280px;
+}
+</style>

+ 85 - 1
src/components/defaults/DefaultInputDatePicker.vue

@@ -18,7 +18,7 @@
         >
           <q-popup-proxy cover transition-show="scale" transition-hide="scale">
             <template v-if="!time">
-              <q-date v-model="date" mask="YYYY-MM-DD">
+              <q-date v-model="date" mask="YYYY-MM-DD" color="primary" class="bg-surface text-text calendar-custom">
                 <div class="row items-center justify-end">
                   <q-btn v-close-popup label="Close" color="primary" flat />
                 </div>
@@ -160,3 +160,87 @@ watch(
   { immediate: true },
 );
 </script>
+
+<style scoped lang="scss">
+
+.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;
+    }
+  }
+}
+</style>

+ 82 - 0
src/css/app.scss

@@ -324,4 +324,86 @@ box-shadow: 1px 4px 4px 0px rgba(0,0,0,0.2);
   font-size: 20px;
   font-weight: 600;
   line-height: 1.05;
+}
+
+// customizando calendario das agendas para padrao do figma
+.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;
+    }
+  }
 }

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

@@ -315,6 +315,53 @@
       "detractors": "Detractors"
     }
   },
+  "search_filter": {
+    "title": "Filters",
+    "sort_by": "Sort by:",
+    "filter_by": "Filter by:",
+    "availability": "Availability",
+    "availability_placeholder": "Ex. 01/10/2025",
+    "apply": "Filter",
+    "clear": "Clear",
+    "sort": {
+      "price_asc": "Lowest price",
+      "price_desc": "Highest price",
+      "distance_asc": "Closest (coming soon)",
+      "distance_desc": "Farthest (coming soon)",
+      "reviews_desc": "Most reviews",
+      "reviews_asc": "Fewer reviews",
+      "rating_desc": "Most stars",
+      "rating_asc": "Fewer stars",
+      "services_desc": "Most services hired",
+      "oldest": "Oldest on platform",
+      "higher": "higher",
+      "lower": "lower",
+      "oldest_asc": "oldest",
+      "oldest_desc": "newest"
+    },
+    "groups": {
+      "price": "Price",
+      "distance": "Distance",
+      "reviews": "Reviews",
+      "rating": "Stars",
+      "services": "Services hired",
+      "oldest": "Time on platform"
+    }
+  },
+  "search_page": {
+    "title": "Search",
+    "search_placeholder": "Name",
+    "custom_schedule_btn": "custom",
+    "custom_schedule_description": "If you prefer, select the details and we will find the ideal cleaner for you.",
+    "choose_provider": "Choose a cleaner",
+    "schedule_btn": "schedule",
+    "until_8h": "Up to 8h",
+    "until_6h": "Up to 6h",
+    "until_4h": "Up to 4h",
+    "until_2h": "Up to 2h",
+    "no_price": "to negotiate",
+    "no_results": "No cleaners found for this search."
+  },
   "dashboard_client": {
     "header": {
       "rating": "Rating",
@@ -359,6 +406,27 @@
       "until_2h": "Up to 2h",
       "place_home": "Home",
       "no_price": "to arrange"
+    },
+    "pending_schedules": {
+      "title": "Awaiting",
+      "requesting_with": "Requesting booking with",
+      "no_provider": "Provider not defined",
+      "cancel_btn": "cancel",
+      "status": {
+        "pending": "Awaiting",
+        "accepted": "Accepted",
+        "paid": "Paid",
+        "started": "In progress",
+        "finished": "Completed"
+      },
+      "accepted_title": "Accepted to do your daily!",
+      "detail_date": "Date:",
+      "detail_time": "Time:",
+      "detail_value": "Amount:",
+      "detail_service_fee": "Service fee:",
+      "detail_total": "Total:",
+      "btn_payment": "go to payment",
+      "btn_cancel": "Cancel request"
     }
   },
   "profile": {
@@ -373,6 +441,10 @@
     "phone": "Phone",
     "placeholder_phone": "(11) 99999-9999",
     "update": "Update",
+    "language": "Language",
+    "lang_pt": "PT-br",
+    "lang_en": "EN-us",
+    "lang_es": "ES-es",
     "payments": {
       "title": "Payments",
       "description": "My cards",
@@ -526,11 +598,103 @@
       }
     }
   },
+  "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",
+    "no_slots_available": "No time slots available for this day.",
+    "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.",
+      "submit_success": "Request sent successfully!",
+      "submit_error": "Could not send the request. Please try again.",
+      "no_primary_address": "Please add a primary address in your profile to schedule a service."
+    }
+  },
   "period_types": {
     "2": "Quick (up to 2h)",
     "4": "Medium (up to 4h)",
     "6": "Standard (up to 6h)",
     "8": "Full day (up to 8h)",
     "unknown": "No information"
+  },
+  "payment": {
+    "title": "Payment",
+    "schedule_address": "Schedule to address",
+    "pay_with": "Pay with",
+    "add_card": "Add card",
+    "credit_debit": "Credit or debit",
+    "card_holder": "Holder",
+    "agree_prefix": "I agree with the",
+    "terms_link": "terms and conditions",
+    "confirm_btn": "confirm payment",
+    "pix_title": "PIX Payment",
+    "pix_total": "Total payment",
+    "pix_expires": "Pay within",
+    "pix_copy_btn": "copy pix code",
+    "pix_instructions": "Copy the Pix code above, open your bank app, choose to pay with Pix, paste the code and complete the payment. Your payment will be approved in seconds.",
+    "pix_email_note": "You will also receive instructions by email. Then just wait for confirmation from the chosen housekeeper.",
+    "processing_title": "Awaiting payment",
+    "processing_message": "We are processing your payment, please wait a moment...",
+    "success_title": "Service scheduled successfully!",
+    "success_message": "You can view your bookings in {nextServices} and in your {agenda}.",
+    "success_next_services": "upcoming services",
+    "success_agenda": "calendar",
+    "pix": "PIX"
+  },
+  "nav": {
+    "home": "Home",
+    "search": "Search",
+    "agenda": "Schedule",
+    "profile": "Profile"
   }
 }

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

@@ -315,6 +315,53 @@
       "detractors": "Detractores"
     }
   },
+  "search_filter": {
+    "title": "Filtros",
+    "sort_by": "Ordenar por:",
+    "filter_by": "Filtrar por:",
+    "availability": "Disponibilidad",
+    "availability_placeholder": "Ej. 10/01/2025",
+    "apply": "Filtrar",
+    "clear": "Limpiar",
+    "sort": {
+      "price_asc": "Menor precio",
+      "price_desc": "Mayor precio",
+      "distance_asc": "Más cercanos (próximamente)",
+      "distance_desc": "Más lejanos (próximamente)",
+      "reviews_desc": "Más valoraciones",
+      "reviews_asc": "Menos valoraciones",
+      "rating_desc": "Más estrellas",
+      "rating_asc": "Menos estrellas",
+      "services_desc": "Más servicios contratados",
+      "oldest": "Más antiguo en la plataforma",
+      "higher": "mayor",
+      "lower": "menor",
+      "oldest_asc": "más antiguo",
+      "oldest_desc": "más reciente"
+    },
+    "groups": {
+      "price": "Precio",
+      "distance": "Distancia",
+      "reviews": "Valoraciones",
+      "rating": "Estrellas",
+      "services": "Servicios contratados",
+      "oldest": "Tiempo en la plataforma"
+    }
+  },
+  "search_page": {
+    "title": "Búsqueda",
+    "search_placeholder": "Nombre",
+    "custom_schedule_btn": "a medida",
+    "custom_schedule_description": "Si prefieres, selecciona los detalles y encontraremos el limpiador ideal para ti.",
+    "choose_provider": "Elige un limpiador",
+    "schedule_btn": "agendar",
+    "until_8h": "Hasta 8h",
+    "until_6h": "Hasta 6h",
+    "until_4h": "Hasta 4h",
+    "until_2h": "Hasta 2h",
+    "no_price": "a combinar",
+    "no_results": "No se encontraron limpiadores para esta búsqueda."
+  },
   "dashboard_client": {
     "header": {
       "rating": "Calificación",
@@ -359,6 +406,27 @@
       "until_2h": "Hasta 2h",
       "place_home": "Casa",
       "no_price": "a convenir"
+    },
+    "pending_schedules": {
+      "title": "En espera de confirmación",
+      "requesting_with": "Solicitando reserva con",
+      "no_provider": "Proveedor no definido",
+      "cancel_btn": "cancelar",
+      "status": {
+        "pending": "En espera de confirmación",
+        "accepted": "Aceptado",
+        "paid": "Pagado",
+        "started": "En curso",
+        "finished": "Completado"
+      },
+      "accepted_title": "¡Aceptó realizar su jornada!",
+      "detail_date": "Fecha:",
+      "detail_time": "Horario:",
+      "detail_value": "Valor:",
+      "detail_service_fee": "Tasa de servicio:",
+      "detail_total": "Total:",
+      "btn_payment": "ir al pago",
+      "btn_cancel": "Cancelar pedido"
     }
   },
   "profile": {
@@ -373,6 +441,10 @@
     "phone": "Teléfono",
     "placeholder_phone": "(11) 99999-9999",
     "update": "Actualizar",
+    "language": "Idioma",
+    "lang_pt": "PT-br",
+    "lang_en": "EN-us",
+    "lang_es": "ES-es",
     "payments": {
       "title": "Pagos",
       "description": "Mis tarjetas",
@@ -526,11 +598,103 @@
       }
     }
   },
+  "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",
+    "no_slots_available": "No hay horarios disponibles para este día.",
+    "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.",
+      "submit_success": "¡Solicitud enviada con éxito!",
+      "submit_error": "No se pudo enviar la solicitud. Inténtelo de nuevo.",
+      "no_primary_address": "Agregue una dirección principal en su perfil para agendar un servicio."
+    }
+  },
   "period_types": {
     "2": "Rápido (hasta 2h)",
     "4": "Medio (hasta 4h)",
     "6": "Estándar (hasta 6h)",
     "8": "Día completo (hasta 8h)",
     "unknown": "Sin información"
+  },
+  "payment": {
+    "title": "Pago",
+    "schedule_address": "Agendar para la dirección",
+    "pay_with": "Pagar con",
+    "add_card": "Agregar tarjeta",
+    "credit_debit": "Crédito o débito",
+    "card_holder": "Titular",
+    "agree_prefix": "Acepto los",
+    "terms_link": "términos y condiciones",
+    "confirm_btn": "confirmar pago",
+    "pix_title": "Pago con PIX",
+    "pix_total": "Pago total",
+    "pix_expires": "Pagar en hasta",
+    "pix_copy_btn": "copiar código pix",
+    "pix_instructions": "Copie el código Pix de arriba, abra la aplicación de su banco, elija pagar con Pix, pegue el código y finalice el pago. Su pago será aprobado en segundos.",
+    "pix_email_note": "También recibirá instrucciones por correo electrónico. Luego solo espere la confirmación de la empleada doméstica elegida.",
+    "processing_title": "Esperando pago",
+    "processing_message": "Estamos procesando su pago, por favor espere un momento...",
+    "success_title": "¡Servicio programado con éxito!",
+    "success_message": "Puede ver sus reservas en {nextServices} y en su {agenda}.",
+    "success_next_services": "próximos servicios",
+    "success_agenda": "agenda",
+    "pix": "PIX"
+  },
+  "nav": {
+    "home": "Inicio",
+    "search": "Buscar",
+    "agenda": "Agenda",
+    "profile": "Perfil"
   }
 }

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

@@ -315,6 +315,53 @@
       "detractors": "Detratores"
     }
   },
+  "search_filter": {
+    "title": "Filtros",
+    "sort_by": "Ordenar por:",
+    "filter_by": "Filtrar por:",
+    "availability": "Disponibilidade",
+    "availability_placeholder": "Ex. 10/01/2025",
+    "apply": "Filtrar",
+    "clear": "Limpar",
+    "sort": {
+      "price_asc": "Menor preço",
+      "price_desc": "Maior preço",
+      "distance_asc": "Mais próximos (em breve)",
+      "distance_desc": "Mais distantes (em breve)",
+      "reviews_desc": "Mais avaliações",
+      "reviews_asc": "Menos avaliações",
+      "rating_desc": "Mais estrelas",
+      "rating_asc": "Menos estrelas",
+      "services_desc": "Mais serviços contratados",
+      "oldest": "Mais antigo na plataforma",
+      "higher": "maior",
+      "lower": "menor",
+      "oldest_asc": "mais antigo",
+      "oldest_desc": "mais recente"
+    },
+    "groups": {
+      "price": "Preço",
+      "distance": "Distância",
+      "reviews": "Avaliações",
+      "rating": "Estrelas",
+      "services": "Serviços contratados",
+      "oldest": "Tempo na plataforma"
+    }
+  },
+  "search_page": {
+    "title": "Busca",
+    "search_placeholder": "Nome",
+    "custom_schedule_btn": "sob medida",
+    "custom_schedule_description": "Se preferir, selecione os detalhes e encontraremos um diarista ideal para você.",
+    "choose_provider": "Escolha um diarista",
+    "schedule_btn": "agendar",
+    "until_8h": "Até 8h",
+    "until_6h": "Até 6h",
+    "until_4h": "Até 4h",
+    "until_2h": "Até 2h",
+    "no_price": "a combinar",
+    "no_results": "Nenhum diarista encontrado para essa busca."
+  },
   "dashboard_client": {
     "header": {
       "rating": "Avaliação",
@@ -359,6 +406,27 @@
       "until_2h": "Até 2h",
       "place_home": "Casa",
       "no_price": "a combinar"
+    },
+    "pending_schedules": {
+      "title": "Aguardando confirmação",
+      "requesting_with": "Solicitando agendamento com",
+      "no_provider": "Prestador não definido",
+      "cancel_btn": "cancelar",
+      "status": {
+        "pending": "Aguardando",
+        "accepted": "Aceito",
+        "paid": "Pago",
+        "started": "Em andamento",
+        "finished": "Concluído"
+      },
+      "accepted_title": "Aceitou realizar sua diária!",
+      "detail_date": "Data:",
+      "detail_time": "Horário:",
+      "detail_value": "Valor:",
+      "detail_service_fee": "Taxa de serviço:",
+      "detail_total": "Total:",
+      "btn_payment": "ir para o pagamento",
+      "btn_cancel": "Cancelar pedido"
     }
   },
   "profile": {
@@ -373,6 +441,10 @@
     "phone": "Telefone",
     "placeholder_phone": "(11) 99999-9999",
     "update": "Atualizar",
+    "language": "Idioma",
+    "lang_pt": "PT-br",
+    "lang_en": "EN-us",
+    "lang_es": "ES-es",
     "payments": {
       "title": "Pagamentos",
       "description": "Meus cartões",
@@ -526,11 +598,103 @@
       }
     }
   },
+  "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",
+    "no_slots_available": "Não há horários disponíveis para este dia.",
+    "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.",
+      "submit_success": "Solicitação enviada com sucesso!",
+      "submit_error": "Não foi possível enviar a solicitação. Tente novamente.",
+      "no_primary_address": "Cadastre um endereço principal no seu perfil para agendar."
+    }
+  },
   "period_types": {
     "2": "Rápida (até 2h)",
     "4": "Média (até 4h)",
     "6": "Padrão (até 6h)",
     "8": "Dia completo (até 8h)",
     "unknown": "Sem informação"
+  },
+  "payment": {
+    "title": "Pagamento",
+    "schedule_address": "Agendar para o endereço",
+    "pay_with": "Pagar com",
+    "add_card": "Adicionar cartão",
+    "credit_debit": "Crédito ou débito",
+    "card_holder": "Titular",
+    "agree_prefix": "Concordo com os",
+    "terms_link": "termos e condições",
+    "confirm_btn": "confirmar pagamento",
+    "pix_title": "Pagamento com PIX",
+    "pix_total": "Pagamento total",
+    "pix_expires": "Pagar em até",
+    "pix_copy_btn": "copiar código pix",
+    "pix_instructions": "Copie o código Pix acima, acesse o app do seu banco, escolha pagar com Pix, cole o código e finalize o pagamento. Seu pagamento será aprovado em alguns segundos.",
+    "pix_email_note": "Você também receberá as instruções no seu email. Depois, é só aguardar a confirmação do diarista escolhido.",
+    "processing_title": "Aguardando pagamento",
+    "processing_message": "Estamos processando seu pagamento, aguarde um momento...",
+    "success_title": "Diária agendada com sucesso!",
+    "success_message": "Você pode visualizar seus agendamentos em {nextServices} e em sua {agenda}.",
+    "success_next_services": "próximos serviços",
+    "success_agenda": "agenda",
+    "pix": "PIX"
+  },
+  "nav": {
+    "home": "Início",
+    "search": "Busca",
+    "agenda": "Agenda",
+    "profile": "Perfil"
   }
 }

+ 8 - 6
src/layouts/MainLayout.vue

@@ -50,6 +50,7 @@
 import { computed, useTemplateRef, watch } from "vue";
 import { useQuasar } from "quasar";
 import { useRoute } from "vue-router";
+import { useI18n } from "vue-i18n";
 
 defineOptions({
   name: "MainLayout",
@@ -58,31 +59,32 @@ defineOptions({
 const $q = useQuasar();
 const route = useRoute();
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
+const { t } = useI18n();
 
 let oldValue = route.path;
 
-const navItems = [
+const navItems = computed(() => [
   {
     name: "DashboardPage",
-    label: "Início",
+    label: t('nav.home'),
     icon: "mdi-home-outline",
   },
   {
     name: "SearchPage",
-    label: "Busca",
+    label: t('nav.search'),
     icon: "mdi-magnify",
   },
   {
     name: "AgendaPage",
-    label: "Agenda",
+    label: t('nav.agenda'),
     icon: "mdi-calendar-blank-outline",
   },
   {
     name: "ProfilePage",
-    label: "Perfil",
+    label: t('nav.profile'),
     icon: "mdi-account-circle-outline",
   },
-];
+]);
 
 const isNavItemActive = (item) => route.name === item.name;
 

+ 32 - 6
src/pages/dashboard/DashboardPage.vue

@@ -8,6 +8,11 @@
     <template v-else>
       <DashboardHeaderBar :data="headerBar" />
       <DashboardSummaryInfos :data="summaryInfos" />
+      <DashboardPendingSchedules
+        v-if="pendingSchedules.length > 0"
+        :data="pendingSchedules"
+        @view-details="openAcceptedDialog"
+      />
       <DashboardScrollAreaSchedules />
       <DashboardNextSchedules v-if="nextSchedules.length > 0" :data="nextSchedules" />
       <DashboardLastDoneSchedules v-if="lastDoneSchedules.length > 0" :data="lastDoneSchedules" />
@@ -20,32 +25,53 @@
 <script setup>
 import DashboardHeaderBar from 'src/components/dashboard/DashboardHeaderBar.vue';
 import DashboardSummaryInfos from 'src/components/dashboard/DashboardSummaryInfos.vue';
+import DashboardPendingSchedules from 'src/components/dashboard/DashboardPendingSchedules.vue';
+import ScheduleAcceptedDialog from 'src/components/dashboard/ScheduleAcceptedDialog.vue';
 import DashboardScrollAreaSchedules from 'src/components/dashboard/DashboardScrollAreaSchedules.vue';
 import DashboardNextSchedules from 'src/components/dashboard/DashboardNextSchedules.vue';
 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 { onMounted, ref } from 'vue';
+import { useQuasar } from 'quasar';
 import { dadosDashboard } from 'src/api/dashboard';
 
 const headerBar = ref({});
 const summaryInfos = ref({});
+const pendingSchedules = ref([]);
 const nextSchedules = ref([]);
 const lastDoneSchedules = ref([]);
 const favoriteProviders = ref([]);
 const providersClose = ref([]);
+const $q = useQuasar();
 const loading = ref(true);
-onMounted( async () => {
+
+const openAcceptedDialog = (schedule) => {
+  $q.dialog({
+    component: ScheduleAcceptedDialog,
+    componentProps: { schedule }
+  }).onOk(() => {
+    reloadDashboard();
+  });
+};
+
+const reloadDashboard = async () => {
+  loading.value = true;
   const response = await dadosDashboard();
-  if(response) {
+  if (response) {
     headerBar.value = response.headerBar;
     summaryInfos.value = response.summaryInfos;
-    nextSchedules.value = response.nextSchedules;
-    lastDoneSchedules.value = response.lastDoneSchedules;
-    favoriteProviders.value = response.favoriteProviders;
-    providersClose.value = response.providersClose;
+    pendingSchedules.value = response.pendingSchedules ?? [];
+    nextSchedules.value = response.nextSchedules ?? [];
+    lastDoneSchedules.value = response.lastDoneSchedules ?? [];
+    favoriteProviders.value = response.favoriteProviders ?? [];
+    providersClose.value = response.providersClose ?? [];
   }
   loading.value = false;
+};
+
+onMounted(async () => {
+  await reloadDashboard();
 });
 </script>
 

+ 26 - 1
src/pages/profile/ProfileEditDialog.vue

@@ -57,6 +57,15 @@
                 :placeholder="$t('profile.placeholder_phone')"
               />
             </div>
+
+            <div>
+              <div class="text-weight-bold text-grey-8 q-mb-sm">{{ $t('profile.language') }}</div>
+              <div class="row">
+                <q-radio v-model="selectedLocale" val="pt" :label="$t('profile.lang_pt')" color="primary" class="text-text col-4" keep-color @update:model-value="onLocaleChange" />
+                <q-radio v-model="selectedLocale" val="en" :label="$t('profile.lang_en')" color="primary" class="text-text col-4" keep-color @update:model-value="onLocaleChange" />
+                <q-radio v-model="selectedLocale" val="es" :label="$t('profile.lang_es')" color="primary" class="text-text col-4" keep-color @update:model-value="onLocaleChange" />
+              </div>
+            </div>
           </div>
 
           <q-space/>
@@ -83,9 +92,10 @@
 
 <script setup>
 import { ref, onMounted } from 'vue';
-import { useDialogPluginComponent } from 'quasar';
+import { useDialogPluginComponent, Cookies } from 'quasar';
 import { updateUser } from 'src/api/user';
 import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
+import { i18n } from 'src/boot/i18n';
 
 const props = defineProps({
   userData: {
@@ -101,6 +111,21 @@ const loading = ref(false);
 const submitting = ref(false);
 const userId = ref(null);
 
+const normalizeLocale = (loc) => {
+  if (!loc) return 'pt'
+  const l = String(loc).toLowerCase()
+  if (l.startsWith('pt')) return 'pt'
+  if (l.startsWith('en')) return 'en'
+  if (l.startsWith('es')) return 'es'
+  return 'pt'
+}
+const selectedLocale = ref(normalizeLocale(i18n.global.locale.value ?? i18n.global.locale))
+
+const onLocaleChange = (val) => {
+  i18n.global.locale.value = val   // troca em tempo real (composition API mode)
+  Cookies.set('locale', val, { expires: 365, path: '/' })
+}
+
 const { form, hasUpdatedFields, setUpdateFormAsOriginal } = useFormUpdateTracker({
   name: '',
   email: '',

+ 332 - 0
src/pages/scheduling/SchedulingPage.vue

@@ -0,0 +1,332 @@
+<template>
+  <q-page class="bg-page">
+
+    <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-header">
+      <q-btn flat round dense icon="mdi-chevron-left" color="primary" @click="router.back()" />
+      <div class="col text-center text-subtitle1 text-weight-bold text-primary">
+        {{ $t('scheduling_page.title') }}
+      </div>
+      <div style="width: 36px" />
+    </div>
+
+    <div class="col overflow-auto q-pb-xl">
+
+      <div class="q-px-md q-pt-md">
+        <q-card class="card-border bg-white" flat>
+          <q-card-section class="q-pa-md">
+            <div class="text-subtitle2 text-weight-bold text-primary q-mb-sm">
+              {{ $t('scheduling_page.about_provider') }}
+            </div>
+            <div class="row items-center no-wrap q-gutter-x-md">
+              <q-avatar :style="avatarStyle" size="52px" class="text-weight-bold text-body1">
+                {{ provider?.provider_name?.slice(0, 1).toUpperCase() ?? '—' }}
+              </q-avatar>
+              <div class="col">
+                <div class="text-weight-bold text-text">{{ provider?.provider_name ?? '—' }}</div>
+                <div class="text-caption text-grey-6">{{ provider?.city ?? provider?.district ?? '—' }}</div>
+                <div class="row items-center q-gutter-x-sm q-mt-xs">
+                  <div class="row items-center">
+                    <q-icon name="mdi-star" color="warning" size="14px" />
+                    <span class="text-caption text-weight-medium q-ml-xs">
+                      {{ provider?.average_rating != null ? Number(provider.average_rating).toFixed(1) : '' }}
+                      <span class="text-grey-5">{{ '(' + (provider?.total_reviews ?? 0) + ')' }}</span>
+                    </span>
+                  </div>
+                  <div class="row items-center">
+                    <q-icon name="mdi-broom" color="secondary" size="14px" />
+                    <span class="text-caption q-ml-xs">{{ provider?.total_services ?? 0 }}</span>
+                  </div>
+                </div>
+              </div>
+              <div class="column items-center q-gutter-y-xs">
+                <q-btn flat round dense icon="mdi-heart-outline" color="pink-4" size="sm" />
+                <q-btn flat round dense icon="mdi-information-outline" color="grey-5" size="sm" />
+              </div>
+            </div>
+          </q-card-section>
+        </q-card>
+      </div>
+
+      <div class="q-px-md q-pt-md">
+        <div class="text-h6 text-weight-bold gradient-diarista q-mb-xs">
+          {{ $t('scheduling_page.schedule_service') }}
+        </div>
+
+        <div v-if="loadingAvailability" class="row items-center justify-center q-py-lg">
+          <q-spinner-dots color="primary" size="36px" />
+        </div>
+
+        <div v-else class="shadow-card q-mb-md" style="border-radius: 20px; overflow: hidden;">
+          <q-date
+            v-model="selectedDate"
+            square
+            class="full-width calendar-custom text-text"
+            :first-day-of-week="0"
+            :events="availableDatesForCalendar"
+            event-color="positive"
+            :options="dateOptions"
+            minimal
+          />
+        </div>
+      </div>
+
+      <div class="q-px-md q-pt-sm">
+        <div class="row items-center justify-between q-mb-sm">
+          <div class="text-h6 text-weight-bold gradient-diarista">
+            {{ $t('scheduling_page.reviews_title') }}
+          </div>
+          <span class="text-caption text-primary cursor-pointer">
+            {{ $t('scheduling_page.see_all') }}
+          </span>
+        </div>
+
+        <div v-if="loadingReviews" class="row items-center justify-center q-py-lg">
+          <q-spinner-dots color="primary" size="36px" />
+        </div>
+
+        <div v-else-if="reviews.length === 0" class="text-center text-grey-6 text-body2 q-py-md">
+          {{ $t('scheduling_page.no_reviews') }}
+        </div>
+
+        <div v-else class="row no-wrap scroll-reviews q-pb-sm" style="overflow-x: auto;">
+          <q-card
+            v-for="review in reviews"
+            :key="review.id"
+            class="review-card card-border bg-white q-mr-sm flex-shrink-0"
+            flat
+            style="min-width: 220px; max-width: 240px;"
+          >
+            <q-card-section class="q-pa-sm">
+              <div class="row items-center no-wrap q-gutter-x-sm q-mb-xs">
+                <q-avatar size="32px" :style="clientAvatarStyle(review)" class="text-weight-bold text-caption">
+                  {{ review.schedule?.client?.name?.slice(0, 1).toUpperCase() ?? '?' }}
+                </q-avatar>
+                <div class="col text-weight-medium text-text text-caption ellipsis">
+                  {{ review.schedule?.client?.name ?? $t('scheduling_page.unknown_client') }}
+                </div>
+              </div>
+              <div class="row items-center q-mb-xs">
+                <q-icon
+                  v-for="s in 5"
+                  :key="s"
+                  :name="s <= review.stars ? 'mdi-star' : 'mdi-star-outline'"
+                  color="warning"
+                  size="14px"
+                />
+              </div>
+              <div class="text-caption text-text review-comment ellipsis-2-lines">
+                {{ review.comment ?? '' }}
+              </div>
+            </q-card-section>
+          </q-card>
+        </div>
+      </div>
+
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue';
+import { useRouter } from 'vue-router';
+import { date } from 'quasar';
+import { getProviderWorkingDays, getProviderBlockedDays } from 'src/api/providerAvailability';
+import { getProviderReceivedReviews } from 'src/api/review';
+
+const router = useRouter();
+
+const provider = ref(history.state?.provider ?? null);
+
+const selectedDate = ref(null);
+const workingDays = ref([]);
+const blockedDays = ref([]);
+const loadingAvailability = ref(true);
+
+const reviews = ref([]);
+const loadingReviews = ref(true);
+
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
+const avatarStyle = computed(() => {
+  const idx = (provider.value?.provider_id ?? 0) % avatarColors.length;
+  return avatarColors[idx];
+});
+
+const clientAvatarStyle = (review) => {
+  const idx = (review.id ?? 0) % avatarColors.length;
+  return avatarColors[idx];
+};
+
+const availableWeekDays = computed(() =>
+  [...new Set(workingDays.value.map((wd) => wd.day))]
+);
+
+const blockedDateSet = computed(() =>
+  new Set(blockedDays.value.map((bd) => bd.date))
+);
+
+const availableDatesForCalendar = computed(() => {
+  const result = [];
+  const today = new Date();
+  const end = date.addToDate(today, { months: 3 });
+  let current = new Date(today);
+  while (current <= end) {
+    const dayOfWeek = current.getDay();
+    const dateStr = date.formatDate(current, 'YYYY-MM-DD');
+    const isWorkingDay = availableWeekDays.value.includes(dayOfWeek);
+    const isBlocked = blockedDateSet.value.has(dateStr);
+    if (isWorkingDay && !isBlocked) {
+      result.push(date.formatDate(current, 'YYYY/MM/DD'));
+    }
+    current = date.addToDate(current, { days: 1 });
+  }
+  return result;
+});
+
+const dateOptions = (d) => {
+  const today = date.formatDate(new Date(), 'YYYY/MM/DD');
+  if (d < today) return false;
+  const raw = d.replace(/\//g, '-');
+  const dayOfWeek = new Date(d.replace(/\//g, '-')).getDay();
+  const isWorkingDay = availableWeekDays.value.includes(dayOfWeek);
+  const isBlocked = blockedDateSet.value.has(raw);
+  return isWorkingDay && !isBlocked;
+};
+
+const loadAvailability = async () => {
+  if (!provider.value?.provider_id) return;
+  loadingAvailability.value = true;
+  try {
+    const [wd, bd] = await Promise.all([
+      getProviderWorkingDays(provider.value.provider_id),
+      getProviderBlockedDays(provider.value.provider_id),
+    ]);
+    workingDays.value = wd ?? [];
+    blockedDays.value = bd ?? [];
+  } catch {
+    workingDays.value = [];
+    blockedDays.value = [];
+  } finally {
+    loadingAvailability.value = false;
+  }
+};
+
+const loadReviews = async () => {
+  if (!provider.value?.provider_id) return;
+  loadingReviews.value = true;
+  try {
+    const all = await getProviderReceivedReviews(provider.value.provider_id);
+    reviews.value = (all ?? []).slice(0, 10);
+  } catch {
+    reviews.value = [];
+  } finally {
+    loadingReviews.value = false;
+  }
+};
+
+onMounted(async () => {
+  await Promise.all([loadAvailability(), loadReviews()]);
+});
+</script>
+
+<style scoped lang="scss">
+.shadow-header {
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+
+.review-card {
+  border-radius: 12px;
+}
+
+.review-comment {
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+.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;
+    }
+  }
+}
+</style>

+ 280 - 42
src/pages/search/SearchPage.vue

@@ -1,52 +1,290 @@
 <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
 <template>
-  <section class="mobile-placeholder">
-    <div class="mobile-placeholder__badge">
-      <q-icon name="mdi-magnify" />
+  <q-page class="bg-page">
+
+    <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-search">
+      <q-btn flat round dense icon="mdi-chevron-left" color="primary" @click="router.back()" />
+      <div class="col text-center text-subtitle1 text-weight-bold text-primary">
+        {{ $t('search_page.title') }}
+      </div>
+      <div style="width: 36px" />
+    </div>
+
+    <div class="q-px-md q-pt-md q-pb-sm">
+      <q-card class="custom-schedule-card bg-surface shadow-card q-pa-sm" :flat="false">
+        <q-card-section class="row items-center no-wrap q-pa-sm q-gutter-x-sm">
+          <span class="col text-text fonte-hint">
+            {{ $t('search_page.custom_schedule_description') }}
+          </span>
+          <q-btn
+            color="secondary"
+            no-caps
+            unelevated
+            padding="8px 16px"
+            class="text-weight-bold custom-schedule-btn card-border"
+          >
+            <template #default>
+              <div class="column items-center q-gutter-y-xs">
+                <q-icon name="mdi-scissors-cutting" size="16px" />
+                <span>{{ $t('search_page.custom_schedule_btn') }}</span>
+              </div>
+            </template>
+          </q-btn>
+        </q-card-section>
+      </q-card>
+    </div>
+
+    <div class="row items-center q-px-md q-py-md q-gutter-x-sm">
+      <q-input
+        v-model="searchName"
+        :placeholder="$t('search_page.search_placeholder')"
+        outlined
+        rounded
+        dense
+        clearable
+        debounce="400"
+        class="col bg-white search-input"
+        input-class="text-text"
+        @update:model-value="onNameChange"
+      >
+        <template #append>
+          <q-icon name="mdi-magnify" color="grey-5" />
+        </template>
+      </q-input>
+      <q-btn
+        flat round dense
+        icon="mdi-tune-variant"
+        color="grey-6"
+        size="md"
+        :class="{ 'filter-active': activeSort || activeDate }"
+        @click="openFilterDialog"
+      />
+    </div>
+
+    <div class="row items-center justify-between no-wrap q-px-md q-pb-sm">
+      <div class="dashboard-section-title gradient-diarista">{{ $t('search_page.choose_provider') }}</div>
+      <div class="row items-center no-wrap text-text">
+        <q-btn flat dense round icon="mdi-chevron-left" color="text" size="sm" @click="setPeriodTypePrevious" />
+        <span class="text-caption text-weight-medium">{{ periodLabel }}</span>
+        <q-btn flat dense round icon="mdi-chevron-right" color="text" size="sm" @click="setPeriodTypeNext" />
+      </div>
+    </div>
+
+    <div v-if="loading" class="row items-center justify-center q-py-xl">
+      <q-spinner-dots color="primary" size="40px" />
     </div>
-    <h1 class="mobile-placeholder__title">Busca</h1>
-    <p class="mobile-placeholder__description">
-      Área reservada para a busca de diárias e oportunidades próximas.
-    </p>
-  </section>
+
+    <template v-else>
+      <div v-if="sortedProviders.length === 0" class="text-center text-grey-6 q-px-md q-py-lg text-body2">
+        {{ $t('search_page.no_results') }}
+      </div>
+
+      <div v-else class="column q-px-md q-pb-xl">
+        <q-card
+          v-for="p in sortedProviders"
+          :key="p.provider_id"
+          class="card-border bg-page text-text q-mb-sm"
+          :flat="false"
+        >
+          <q-card-section class="row no-wrap q-pa-sm">
+            <div class="row no-wrap full-width">
+              <div class="col-2">
+                <q-avatar :style="avatarColors[p.provider_id % avatarColors.length]" class="text-weight-bold">
+                  {{ p.provider_name?.slice(0,1).toUpperCase() ?? '—' }}
+                </q-avatar>
+              </div>
+
+              <div class="col-10 row">
+                <div class="column col-9 justify-between">
+                  <span class="text-provider-close-name">{{ p.provider_name ?? 'Prestador' }}</span>
+                  <span class="text-provider-close-region">{{ p.district }}</span>
+                  <div class="row items-center justify-between q-pr-lg">
+                    <div class="row items-center">
+                      <q-icon name="mdi-star" color="warning" size="16px" />
+                      <span class="text-provider-close-rating">
+                        {{ p.average_rating != null ? (Number(p.average_rating).toFixed(1) + ' (' + (p.total_reviews ?? 0) + ')') : ('(' + (p.total_reviews ?? 0) + ')') }}
+                      </span>
+                    </div>
+                    <div class="row items-center">
+                      <q-icon name="mdi-broom" color="secondary" size="16px" />
+                      <span class="text-provider-close-jobs">{{ p.total_services ?? 0 }}</span>
+                    </div>
+                    <div class="row items-center">
+                      <q-icon name="mdi-map-marker-outline" color="text" size="16px" />
+                      <span class="text-provider-close-jobs">{{ 0 + ' km' }}</span>
+                    </div>
+                  </div>
+                </div>
+
+                <div class="column col-3 justify-between text-center items-center">
+                  <span class="text-provider-close-price">{{ priceByPeriod(p) }}</span>
+                  <div class="full-width">
+                    <q-btn
+                      unelevated rounded no-caps
+                      color="primary"
+                      size="sm"
+                      padding="3px 12px"
+                      :label="$t('search_page.schedule_btn')"
+                      @click="goToScheduling(p)"
+                    />
+                  </div>
+                </div>
+              </div>
+            </div>
+          </q-card-section>
+        </q-card>
+      </div>
+    </template>
+
+  </q-page>
 </template>
 
-<style scoped>
-.mobile-placeholder {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  min-height: calc(100dvh - 240px);
-  padding: 32px 20px;
-  text-align: center;
-}
+<script setup>
+import { ref, computed, onMounted } from 'vue';
+import { useRouter } from 'vue-router';
+import { useI18n } from 'vue-i18n';
+import { useQuasar } from 'quasar';
+import { buscaPrestadores } from 'src/api/dashboard';
+import { formatCurrency } from 'src/helpers/utils';
+import SearchFilterDialog from 'src/pages/search/components/SearchFilterDialog.vue';
+import SchedulingDialog from 'src/pages/search/components/SchedulingDialog.vue';
 
-.mobile-placeholder__badge {
-  display: grid;
-  place-items: center;
-  width: 88px;
-  height: 88px;
-  border-radius: 28px;
-  margin-bottom: 20px;
-  background: linear-gradient(180deg, rgba(255, 0, 234, 0.14), rgba(107, 17, 203, 0.08));
-  color: #ff00ea;
-  font-size: 44px;
-}
+const { t } = useI18n();
+const router = useRouter();
+const $q = useQuasar();
 
-.mobile-placeholder__title {
-  margin: 0 0 8px;
-  font-size: 28px;
-  font-weight: 700;
-  line-height: 1.1;
-  color: #4d4d4d;
-}
+const allProviders = ref([]);
+const loading      = ref(true);
+const searchName   = ref('');
+const activeDate   = ref(null);
+const activeSort   = ref(null);
+
+const currentPeriodType = ref(8);
+const periodTypeMap = { 2: 'daily_price_2h', 4: 'daily_price_4h', 6: 'daily_price_6h', 8: 'daily_price_8h' };
 
-.mobile-placeholder__description {
-  max-width: 280px;
-  margin: 0;
-  font-size: 16px;
-  line-height: 1.5;
-  color: #8d8d8d;
+const periodLabel = computed(() => {
+  const labels = { 8: t('search_page.until_8h'), 6: t('search_page.until_6h'), 4: t('search_page.until_4h'), 2: t('search_page.until_2h') };
+  return labels[currentPeriodType.value] ?? '';
+});
+
+const priceByPeriod = (p) => {
+  const key = periodTypeMap[currentPeriodType.value];
+  return p[key] ? formatCurrency(p[key]) : t('search_page.no_price');
+};
+
+const setPeriodTypePrevious = () => {
+  const prev = currentPeriodType.value - 2;
+  if (periodTypeMap[prev]) currentPeriodType.value = prev;
+};
+
+const setPeriodTypeNext = () => {
+  const next = currentPeriodType.value + 2;
+  if (periodTypeMap[next]) currentPeriodType.value = next;
+};
+
+const sortedProviders = computed(() => {
+  const list = [...allProviders.value];
+  const priceKey = periodTypeMap[currentPeriodType.value];
+
+  switch (activeSort.value) {
+    case 'price_asc':
+      return list.sort((a, b) => Number(a[priceKey] ?? 0) - Number(b[priceKey] ?? 0));
+    case 'price_desc':
+      return list.sort((a, b) => Number(b[priceKey] ?? 0) - Number(a[priceKey] ?? 0));
+    case 'rating_desc':
+      return list.sort((a, b) => Number(b.average_rating ?? 0) - Number(a.average_rating ?? 0));
+    case 'rating_asc':
+      return list.sort((a, b) => Number(a.average_rating ?? 0) - Number(b.average_rating ?? 0));
+    case 'reviews_desc':
+      return list.sort((a, b) => Number(b.total_reviews ?? 0) - Number(a.total_reviews ?? 0));
+    case 'reviews_asc':
+      return list.sort((a, b) => Number(a.total_reviews ?? 0) - Number(b.total_reviews ?? 0));
+    case 'services_desc':
+      return list.sort((a, b) => Number(b.total_services ?? 0) - Number(a.total_services ?? 0));
+    case 'services_asc':
+      return list.sort((a, b) => Number(a.total_services ?? 0) - Number(b.total_services ?? 0));
+    case 'oldest':
+      return list.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+    case 'newest':
+      return list.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
+    default:
+      return list.sort((a, b) => Number(b.average_rating ?? 0) - Number(a.average_rating ?? 0));
+  }
+});
+
+const loadProviders = async () => {
+  loading.value = true;
+  try {
+    allProviders.value = await buscaPrestadores({
+      name: searchName.value,
+      date: activeDate.value ?? '',
+    }) ?? [];
+  } catch {
+    allProviders.value = [];
+  } finally {
+    loading.value = false;
+  }
+};
+
+const onNameChange = () => loadProviders();
+
+const openFilterDialog = () => {
+  $q.dialog({
+    component: SearchFilterDialog,
+    componentProps: {
+      initialSort: activeSort.value,
+      initialDate: activeDate.value,
+    },
+  }).onOk(({ sort, date }) => {
+    const dateChanged = date !== activeDate.value;
+    activeSort.value = sort;
+    activeDate.value = date;
+    if (dateChanged) loadProviders();
+  });
+};
+
+const goToScheduling = (provider) => {
+  $q.dialog({
+    component: SchedulingDialog,
+    componentProps: { provider },
+  });
+};
+
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
+onMounted(() => loadProviders());
+</script>
+
+<style scoped lang="scss">
+.shadow-search {
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+.search-input {
+  :deep(.q-field__control) {
+    border-radius: 28px;
+  }
+}
+.custom-schedule-card {
+  border-radius: 12px;
+}
+.custom-schedule-btn {
+  flex-shrink: 0;
+  min-width: 72px;
+}
+.filter-active {
+  color: var(--q-primary) !important;
+}
+.fonte-hint {
+  font-family: Inter;
+  font-weight: 500;
+  font-size: 14px;
+  line-height: 100%;
+  letter-spacing: -0.04em;
+  vertical-align: middle;
 }
 </style>

+ 366 - 0
src/pages/search/components/OrderSummaryDialog.vue

@@ -0,0 +1,366 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down">
+    <div class="dialog-root">
+
+      <div class="dialog-header row items-center q-px-md q-pt-md q-pb-sm bg-white">
+        <q-btn v-close-popup flat round dense icon="mdi-chevron-left" color="primary" />
+        <div class="col text-center text-subtitle1 text-weight-bold text-primary">
+          {{ $t('scheduling_page.title') }}
+        </div>
+        <div style="width: 36px" />
+      </div>
+
+      <div class="dialog-body">
+
+        <div class="q-px-md q-pt-md">
+          <div class="info-banner card-border q-pa-md">
+            <div class="text-body2 text-weight-medium text-primary">
+              {{ $t('scheduling_page.order_summary.info_text') }}
+            </div>
+            <div class="text-caption text-primary q-mt-xs" style="opacity: 0.75;">
+              {{ $t('scheduling_page.order_summary.info_note') }}
+            </div>
+          </div>
+        </div>
+
+        <div class="q-px-md q-pt-md">
+          <div class="text-h6 text-weight-bold gradient-diarista q-mb-sm">
+            {{ $t('scheduling_page.order_summary.title') }}
+          </div>
+
+          <q-card
+            v-for="(booking, idx) in bookings"
+            :key="idx"
+            :flat="false"
+            class="card-border bg-surface q-mb-sm shadow-card"
+          >
+            <q-card-section class="q-pa-md row items-center no-wrap">
+              <div class="col">
+                <div class="text-body2 text-text">
+                  <span class="text-weight-bold">{{ $t('scheduling_page.order_summary.service_label') }}</span>
+                  <span class="text-weight-bold">{{ ` ${booking.serviceType.label} (${booking.serviceType.hours})` }}</span>
+                </div>
+                <div class="text-body2 text-weight-bold text-text">{{ formatDate(booking.date) }}</div>
+                <div class="text-body2 text-text">
+                  {{ $t('scheduling_page.order_summary.time_range', { start: booking.slot.startHour, end: booking.slot.endHour }) }}
+                </div>
+              </div>
+              <q-btn
+                flat round dense
+                icon="mdi-minus-circle-outline"
+                color="grey-5"
+                @click="confirmRemove(idx)"
+              />
+            </q-card-section>
+          </q-card>
+
+          <q-btn
+            unelevated rounded no-caps
+            :label="$t('scheduling_page.order_summary.send_btn')"
+            color="secondary"
+            class="full-width q-mt-sm"
+            @click="submitOrder"
+          />
+
+          <q-btn
+            outline rounded no-caps
+            :label="$t('scheduling_page.order_summary.add_date_btn')"
+            color="primary"
+            class="full-width q-mt-xs"
+            :disable="showCalendar"
+            @click="showCalendar = true"
+          />
+        </div>
+
+        <div v-if="showCalendar" class="q-px-md q-pt-lg q-pb-xl">
+          <div v-if="loadingAvailability" class="row items-center justify-center q-py-lg">
+            <q-spinner-dots color="primary" size="36px" />
+          </div>
+          <div v-else class="calendar-wrapper shadow-card">
+            <q-date
+              v-model="addDateValue"
+              square
+              class="full-width"
+              :first-day-of-week="0"
+              :options="dateOptions"
+              minimal
+              @update:model-value="onAddDateSelected"
+            />
+          </div>
+        </div>
+      </div>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
+import { date } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import { getProviderWorkingDays, getProviderBlockedDays } from 'src/api/providerAvailability';
+import { getAddresses } from 'src/api/address';
+import { createSchedule } from 'src/api/schedule';
+import { userStore } from 'src/stores/user';
+import ServiceSelectionSheet from './ServiceSelectionSheet.vue';
+import ServiceTimeSelectionDialog from './ServiceTimeSelectionDialog.vue';
+
+const props = defineProps({
+  provider:       { type: Object, required: true },
+  initialBooking: { type: Object, required: true },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t, locale } = useI18n();
+const store = userStore();
+
+const bookings = ref([props.initialBooking]);
+const submitting = ref(false);
+const primaryAddress = ref(null);
+
+const showCalendar = ref(false);
+const addDateValue = ref(null);
+const loadingAvailability = ref(false);
+const workingDays = ref([]);
+const blockedDays = ref([]);
+
+const getWeekStart = (dateStr) => {
+  const d = new Date(dateStr.replace(/\//g, '-') + 'T12:00:00');
+  d.setDate(d.getDate() - d.getDay());
+  return d.toISOString().slice(0, 10);
+};
+
+const wouldExceedWeekLimit = (newDateStr) => {
+  const newWeek = getWeekStart(newDateStr);
+  const count = bookings.value.filter(b => getWeekStart(b.date) === newWeek).length;
+  return count >= 2;
+};
+
+const availableWeekDays = computed(() =>
+  [...new Set(workingDays.value.map(wd => wd.day))]
+);
+
+const blockedDateSet = computed(() =>
+  new Set(blockedDays.value.filter(bd => bd.period === 'all').map(bd => bd.date))
+);
+
+const dateOptions = (d) => {
+  const today = date.formatDate(new Date(), 'YYYY/MM/DD');
+  if (d < today) return false;
+  if (wouldExceedWeekLimit(d)) return false;
+  const raw = d.replace(/\//g, '-');
+  const parsed = new Date(`${raw}T12:00:00`);
+  const dayOfWeek = parsed.getDay();
+  const isWorking = availableWeekDays.value.includes(dayOfWeek);
+  const isBlocked = blockedDateSet.value.has(raw);
+  return isWorking && !isBlocked;
+};
+
+const loadAvailability = async () => {
+  loadingAvailability.value = true;
+  try {
+    const [wd, bd] = await Promise.all([
+      getProviderWorkingDays(props.provider.provider_id),
+      getProviderBlockedDays(props.provider.provider_id),
+    ]);
+    workingDays.value = wd ?? [];
+    blockedDays.value = bd ?? [];
+  } catch {
+    workingDays.value = [];
+    blockedDays.value = [];
+  } finally {
+    loadingAvailability.value = false;
+  }
+};
+
+const loadPrimaryAddress = async () => {
+  try {
+    const clientId = store.user?.client_id;
+    if (!clientId) return;
+    const addresses = await getAddresses('client', clientId);
+    primaryAddress.value = (addresses ?? []).find(a => a.is_primary) ?? null;
+  } catch {
+    primaryAddress.value = null;
+  }
+};
+
+onMounted(() => Promise.all([loadAvailability(), loadPrimaryAddress()]));
+
+const formatHour = (h) => `${String(h).padStart(2, '0')}:00`;
+
+const normalizeDate = (d) => d.replace(/\//g, '-');
+
+const onAddDateSelected = (val) => {
+  if (!val) return;
+  addDateValue.value = null;
+  const valFormatted = val.replace(/\//g, '-');
+
+  const blocksOfDate = blockedDays.value.filter(
+    bd => bd.date === valFormatted && bd.period !== 'all'
+  );
+
+  const dayOfWeek = new Date(`${valFormatted}T12:00:00`).getDay();
+  const dayPeriods = workingDays.value
+    .filter(wd => wd.day === dayOfWeek)
+    .map(wd => wd.period);
+
+  const workingDayBlocks = [];
+  if (!dayPeriods.includes('afternoon')) {
+    workingDayBlocks.push({ init_hour: '14:00:00', end_hour: '20:00:00' });
+  }
+  if (!dayPeriods.includes('morning')) {
+    workingDayBlocks.push({ init_hour: '7:00:00', end_hour: '13:00:00' });
+  }
+
+  const existingBookingBlocks = bookings.value
+    .filter(b => b.date.replace(/\//g, '-') === valFormatted)
+    .map(b => ({
+      init_hour: `${b.slot.startHour}:00:00`,
+      end_hour:  `${b.slot.endHour}:00:00`,
+    }));
+
+  const partialBlocks = [...blocksOfDate, ...workingDayBlocks, ...existingBookingBlocks];
+
+  $q.dialog({
+    component: ServiceSelectionSheet,
+    componentProps: { provider: props.provider, selectedDate: val, partialBlocks },
+  }).onOk(({ serviceType, date: date_, provider: prov }) => {
+    $q.dialog({
+      component: ServiceTimeSelectionDialog,
+      componentProps: { serviceType, selectedDate: date_, provider: prov, partialBlocks },
+    }).onOk((booking) => {
+      if (wouldExceedWeekLimit(booking.date)) {
+        $q.notify({ type: 'negative', message: t('scheduling_page.order_summary.week_limit_error') });
+        return;
+      }
+      bookings.value.push(booking);
+      showCalendar.value = false;
+    });
+  });
+};
+
+const confirmRemove = (idx) => {
+  $q.dialog({
+    title: t('scheduling_page.order_summary.remove_confirm_title'),
+    cancel: { label: t('scheduling_page.order_summary.remove_confirm_cancel'), flat: true, color: 'grey-6' },
+    ok:     { label: t('scheduling_page.order_summary.remove_confirm_ok'), unelevated: true, color: 'primary', rounded: true, noCaps: true },
+    persistent: true,
+  }).onOk(() => {
+    bookings.value.splice(idx, 1);
+  });
+};
+
+const formatDate = (dateStr) => {
+  const d = new Date(normalizeDate(dateStr) + 'T12:00:00');
+  const localeMap = { pt: 'pt-BR', en: 'en-US', es: 'es-ES' };
+  const loc = localeMap[locale.value] ?? 'pt-BR';
+  const weekday = new Intl.DateTimeFormat(loc, { weekday: 'long' }).format(d);
+  const dateFormatted = new Intl.DateTimeFormat(loc, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d);
+  return `${weekday.charAt(0).toUpperCase() + weekday.slice(1)}, ${dateFormatted}`;
+};
+
+const submitOrder = async () => {
+  if (!primaryAddress.value) {
+    $q.notify({ type: 'warning', message: t('scheduling_page.order_summary.no_primary_address') });
+    return;
+  }
+
+  const payload = {
+    client_id:     store.user.client_id,
+    provider_id:   props.provider.provider_id,
+    address_id:    primaryAddress.value.id,
+    schedule_type: 'default',
+    schedules: bookings.value.map(b => ({
+      date:         normalizeDate(b.date),
+      period_type:  b.serviceType.hoursCount,
+      start_time:   formatHour(b.slot.startHour),
+      end_time:     formatHour(b.slot.endHour),
+      total_amount: b.serviceType.price,
+      offers_meal:  b.meal === 'offer' ? true : b.meal === 'no_offer' ? false : null,
+    })),
+  };
+
+  submitting.value = true;
+  try {
+    await createSchedule(payload);
+    $q.notify({ type: 'positive', message: t('scheduling_page.order_summary.submit_success') });
+    dialogRef.value.hide();
+  } catch (err) {
+    const msg = err?.response?.data?.message
+      ?? err?.message
+      ?? t('scheduling_page.order_summary.submit_error');
+    $q.notify({ type: 'negative', message: msg });
+  } finally {
+    submitting.value = false;
+  }
+};
+</script>
+
+<style scoped lang="scss">
+.dialog-root {
+  width: 100vw;
+  max-width: 100vw;
+  height: 100vh;
+  max-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background: #F9FAFB;
+}
+
+.dialog-header {
+  flex-shrink: 0;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+
+.dialog-body {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  overflow-x: clip;
+}
+
+.info-banner {
+  background: rgba(139, 92, 246, 0.08);
+  border: 1px solid rgba(139, 92, 246, 0.2);
+}
+
+.calendar-wrapper {
+  border-radius: 20px;
+  overflow: hidden;
+  background: white;
+
+  :deep(.q-date) { background: white; width: 100%; }
+  :deep(.q-date__main),
+  :deep(.q-date__content),
+  :deep(.q-date__calendar) { background: white !important; }
+
+  :deep(.q-date__calendar-item .q-btn) {
+    font-size: 13px !important;
+    min-width: 0 !important;
+    padding: 6px 2px !important;
+  }
+  :deep(.q-date__calendar-item .q-btn.disabled),
+  :deep(.q-date__calendar-item .q-btn[disabled]) { opacity: 1 !important; }
+  :deep(.q-date__calendar-item .q-btn.disabled .q-btn__content),
+  :deep(.q-date__calendar-item .q-btn[disabled] .q-btn__content) { color: #CBD5E1 !important; }
+
+  :deep(.q-date__calendar-days .q-btn__content) { 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: #6366F1 !important; }
+    .q-btn__content { color: #6366F1 !important; }
+  }
+  :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);
+  }
+}
+</style>

+ 392 - 0
src/pages/search/components/SchedulingDialog.vue

@@ -0,0 +1,392 @@
+<template>
+  <q-dialog ref="dialogRef" persistent maximized transition-show="slide-up" transition-hide="slide-down">
+    <div class="dialog-root">
+
+      <div class="dialog-header row items-center q-px-md q-pt-md q-pb-sm bg-white">
+        <q-btn v-close-popup flat round dense icon="mdi-chevron-left" color="primary" />
+        <div class="col text-center text-subtitle1 text-weight-bold text-primary gradient-diarista q-mb-xs">
+          {{ $t('scheduling_page.title') }}
+        </div>
+        <div style="width: 36px" />
+      </div>
+
+      <div class="dialog-body">
+
+        <div class="q-px-md q-pt-md">
+          <div class="text-h6 text-weight-bold gradient-diarista q-mb-xs">
+            {{ $t('scheduling_page.about_provider') }}
+          </div>
+          <q-card class="card-border shadow-card bg-surface text-text" :flat="false">
+            <q-card-section class="q-pa-md">
+              <div class="row items-center no-wrap q-gutter-x-md">
+                <q-avatar :style="avatarStyle" size="52px" class="text-weight-bold text-body1">
+                  {{ provider?.provider_name?.slice(0, 1).toUpperCase() ?? '—' }}
+                </q-avatar>
+                <div class="col min-width-0">
+                  <div class="text-weight-bold text-text">{{ provider?.provider_name ?? '—' }}</div>
+                  <div class="text-caption text-grey-6">{{ provider?.district ?? '—' }}</div>
+                  <div class="row items-center q-gutter-x-md q-mt-xs">
+                    <div class="row items-center">
+                      <q-icon name="mdi-star" color="warning" size="14px" />
+                      <span class="text-caption text-weight-medium q-ml-xs">
+                        {{ (provider?.average_rating != null ? Number(provider.average_rating).toFixed(1) : '') + ' (' + (provider?.total_reviews ?? 0) + ')' }}
+                      </span>
+                    </div>
+                    <div class="row items-center">
+                      <q-icon name="mdi-broom" color="secondary" size="14px" />
+                      <span class="text-caption q-ml-xs">{{ provider?.total_services ?? 0 }}</span>
+                    </div>
+                  </div>
+                </div>
+                <div class="column items-center q-gutter-y-xs">
+                  <q-btn flat round dense icon="mdi-heart-outline" color="pink-4" size="sm" />
+                  <q-btn flat round dense icon="mdi-information-outline" color="grey-5" size="sm" />
+                </div>
+              </div>
+            </q-card-section>
+          </q-card>
+        </div>
+
+        <div class="q-px-md q-pt-lg">
+          <div class="text-h6 text-weight-bold gradient-diarista q-mb-xs">
+            {{ $t('scheduling_page.schedule_service') }}
+          </div>
+
+          <div v-if="loadingAvailability" class="row items-center justify-center q-py-lg">
+            <q-spinner-dots color="primary" size="36px" />
+          </div>
+
+          <div v-else class="calendar-wrapper shadow-card q-mb-md">
+            <q-date
+              v-model="selectedDate"
+              square
+              class="full-width"
+              :first-day-of-week="0"
+              :options="dateOptions"
+              minimal
+              @update:model-value="onDateSelected"
+            />
+          </div>
+        </div>
+
+        <div class="q-px-md q-pt-sm q-pb-xl">
+          <div class="row items-center justify-between q-mb-sm">
+            <div class="text-h6 text-weight-bold gradient-diarista">
+              {{ $t('scheduling_page.reviews_title') }}
+            </div>
+            <span class="text-caption text-primary cursor-pointer">
+              {{ $t('scheduling_page.see_all') }}
+            </span>
+          </div>
+
+          <div v-if="loadingReviews" class="row items-center justify-center q-py-md">
+            <q-spinner-dots color="primary" size="36px" />
+          </div>
+
+          <div v-else-if="reviews.length === 0" class="text-center text-grey-6 text-body2 q-py-md">
+            {{ $t('scheduling_page.no_reviews') }}
+          </div>
+
+          <div v-else class="reviews-scroll">
+            <q-card
+              v-for="review in reviews"
+              :key="review.id"
+              class="review-card card-border bg-white q-mr-sm shadow-card"
+              :flat="false"
+            >
+              <q-card-section class="q-pa-sm">
+                <div class="row items-center no-wrap q-gutter-x-sm q-mb-xs">
+                  <q-avatar size="32px" :style="clientAvatarStyle(review)" class="text-weight-bold text-caption">
+                    {{ review.schedule?.client?.name?.slice(0, 1).toUpperCase() ?? '?' }}
+                  </q-avatar>
+                  <div class="col text-weight-medium text-text text-caption ellipsis">
+                    {{ review.schedule?.client?.name ?? $t('scheduling_page.unknown_client') }}
+                  </div>
+                </div>
+                <div class="row items-center q-mb-xs">
+                  <q-icon
+                    v-for="s in 5"
+                    :key="s"
+                    :name="s <= review.stars ? 'mdi-star' : 'mdi-star-outline'"
+                    color="warning"
+                    size="14px"
+                  />
+                </div>
+                <div class="text-caption text-text review-comment">
+                  {{ review.comment ?? '' }}
+                </div>
+              </q-card-section>
+            </q-card>
+          </div>
+        </div>
+      </div>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue';
+import { useDialogPluginComponent, useQuasar } from 'quasar';
+import { date } from 'quasar';
+import { getProviderWorkingDays, getProviderBlockedDays } from 'src/api/providerAvailability';
+import { getProviderReceivedReviews } from 'src/api/review';
+import ServiceSelectionSheet from './ServiceSelectionSheet.vue';
+import ServiceTimeSelectionDialog from './ServiceTimeSelectionDialog.vue';
+import OrderSummaryDialog from './OrderSummaryDialog.vue';
+
+const props = defineProps({
+  provider: {
+    type: Object,
+    required: true,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef } = useDialogPluginComponent();
+const $q = useQuasar();
+
+const onDateSelected = (val) => {
+  if (!val) return;
+  selectedDate.value = null;
+  const valFormatted = val.replace(/\//g, '-');
+
+  const blocksOfDate = blockedDays.value.filter(
+    (bd) => bd.date === valFormatted && bd.period !== 'all'
+  );
+
+  const dayOfWeek = new Date(`${valFormatted}T12:00:00`).getDay();
+  const dayPeriods = workingDays.value
+    .filter((wd) => wd.day === dayOfWeek)
+    .map((wd) => wd.period);
+
+  const workingDayBlocks = [];
+  if (!dayPeriods.includes('afternoon')) {
+    workingDayBlocks.push({ init_hour: '14:00:00', end_hour: '20:00:00' });
+  }
+  if (!dayPeriods.includes('morning')) {
+    workingDayBlocks.push({ init_hour: '7:00:00', end_hour: '13:00:00' });
+  }
+
+  const partialBlocks = [...blocksOfDate, ...workingDayBlocks];
+
+  $q.dialog({
+    component: ServiceSelectionSheet,
+    componentProps: { provider: props.provider, selectedDate: val, partialBlocks },
+  }).onOk(({ serviceType, date: date_, provider: prov }) => {
+    $q.dialog({
+      component: ServiceTimeSelectionDialog,
+      componentProps: { serviceType, selectedDate: date_, provider: prov, partialBlocks },
+    }).onOk((booking) => {
+      $q.dialog({
+        component: OrderSummaryDialog,
+        componentProps: { provider: props.provider, initialBooking: booking },
+      });
+    });
+  });
+};
+
+const selectedDate = ref(null);
+const workingDays = ref([]);
+const blockedDays = ref([]);
+const loadingAvailability = ref(true);
+
+const reviews = ref([]);
+const loadingReviews = ref(true);
+
+const avatarColors = [
+  { background: '#ffd5df', color: '#932e57' },
+  { background: '#d7e8ff', color: '#2158a8' },
+  { background: '#dfd',    color: '#2a7a3b' },
+  { background: '#ffe5cc', color: '#8a4500' },
+];
+
+const avatarStyle = computed(() => {
+  const idx = (props.provider?.provider_id ?? 0) % avatarColors.length;
+  return avatarColors[idx];
+});
+
+const clientAvatarStyle = (review) => {
+  const idx = (review.id ?? 0) % avatarColors.length;
+  return avatarColors[idx];
+};
+
+const availableWeekDays = computed(() =>
+  [...new Set(workingDays.value.map((wd) => wd.day))]
+);
+
+const blockedDateSet = computed(() =>
+  new Set(blockedDays.value.map((bd) => bd.date && bd.period == 'all'))
+);
+
+const dateOptions = (d) => {
+  const today = date.formatDate(new Date(), 'YYYY/MM/DD');
+  if (d < today) return false;
+  const raw = d.replace(/\//g, '-');
+  const parsed = new Date(`${raw}T12:00:00`);
+  const dayOfWeek = parsed.getDay();
+  const isWorkingDay = availableWeekDays.value.includes(dayOfWeek);
+  const isBlocked = blockedDateSet.value.has(raw);
+  return isWorkingDay && !isBlocked;
+};
+
+const loadAvailability = async () => {
+  loadingAvailability.value = true;
+  try {
+    const [wd, bd] = await Promise.all([
+      getProviderWorkingDays(props.provider.provider_id),
+      getProviderBlockedDays(props.provider.provider_id),
+    ]);
+    workingDays.value = wd ?? [];
+    blockedDays.value = bd ?? [];
+  } catch {
+    workingDays.value = [];
+    blockedDays.value = [];
+  } finally {
+    loadingAvailability.value = false;
+  }
+};
+
+const loadReviews = async () => {
+  loadingReviews.value = true;
+  try {
+    const all = await getProviderReceivedReviews(props.provider.provider_id);
+    reviews.value = (all ?? []).slice(0, 10);
+  } catch {
+    reviews.value = [];
+  } finally {
+    loadingReviews.value = false;
+  }
+};
+
+onMounted(() => {
+  Promise.all([loadAvailability(), loadReviews()]);
+});
+</script>
+
+<style scoped lang="scss">
+.dialog-root {
+  width: 100vw;
+  max-width: 100vw;
+  height: 100vh;
+  max-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background: #F9FAFB;
+}
+
+.dialog-header {
+  flex-shrink: 0;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+
+.dialog-body {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  overflow-x: clip;
+}
+
+.calendar-wrapper {
+  border-radius: 20px;
+  overflow: hidden;
+  background: white;
+
+  :deep(.q-date) {
+    background: white;
+    width: 100%;
+  }
+
+  :deep(.q-date__calendar-days .q-btn__content) {
+    color: #1E293B !important;
+  }
+
+  // dias desabilitados: visíveis mas opacos
+  :deep(.q-date__calendar-days .q-btn.disabled .q-btn__content),
+  :deep(.q-date__calendar-days .q-btn[disabled] .q-btn__content) {
+    color: #000000 !important;
+    opacity: 1 !important;
+  }
+
+  // o Quasar aplica opacity no elemento .q-btn quando disabled — reseta
+  :deep(.q-date__calendar-days .q-btn.disabled),
+  :deep(.q-date__calendar-days .q-btn[disabled]) {
+    opacity: 1 !important;
+  }
+
+  // cabeçalho dos dias (dom, seg, ter, qua...)
+  :deep(.q-date__calendar-weekdays > div) {
+    color: #6366F1;
+    font-weight: 700;
+    opacity: 0.8;
+  }
+
+  :deep(.q-date__navigation) {
+    .q-btn { color: #6366F1 !important; }
+    .q-btn__content { color: #6366F1 !important; }
+  }
+
+  :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),
+  :deep(.q-date__calendar-item .q-btn.q-date__selected .q-btn__content) {
+    background: #6366F1 !important;
+    color: #ffffff !important;
+    border-radius: 50%;
+    box-shadow: 0 4px 10px rgba(99, 102, 241, 0.4);
+  }
+
+  :deep(.q-date__view--months .q-btn),
+  :deep(.q-date__view--years .q-btn) {
+    color: #6366F1 !important;
+  }
+
+  :deep(.q-date__calendar-item--out) {
+    color: #b9b9b9 !important;
+    opacity: 0.8 !important;
+  }
+}
+
+// Reviews scroll horizontal
+.reviews-scroll {
+  display: flex;
+  flex-direction: row;
+  overflow-x: auto;
+  -webkit-overflow-scrolling: touch;
+  scrollbar-width: none;
+  padding-bottom: 8px;
+  &::-webkit-scrollbar { display: none; }
+}
+
+.review-card {
+  flex-shrink: 0;
+  min-width: 220px;
+  max-width: 240px;
+  border-radius: 12px;
+  margin-right: 8px;
+}
+
+.review-comment {
+  display: -webkit-box;
+  -webkit-line-clamp: 3;
+  line-clamp: 3;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+.min-width-0 {
+  min-width: 0;
+}
+</style>

+ 208 - 0
src/pages/search/components/SearchFilterDialog.vue

@@ -0,0 +1,208 @@
+<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
+<template>
+  <q-dialog ref="dialogRef" position="standard" @hide="onDialogHide">
+    <q-card class="filter-card bg-surface text-text">
+
+      <q-card-section class="row items-center q-pb-none">
+        <span class="text-subtitle1 text-weight-bold text-text">{{ $t('search_filter.title') }}</span>
+        <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-none scroll filter-body">
+
+        <div class="text-caption text-weight-bold text-grey-7 q-mb-sm">{{ $t('search_filter.sort_by') }}</div>
+
+        <div v-for="group in sortGroups" :key="group.key" class="q-mb-md">
+          <div class="text-body2 text-weight-medium text-text q-mb-xs">{{ group.label }}</div>
+          <div class="row q-gutter-x-lg">
+            <q-radio
+              v-for="opt in group.options"
+              :key="opt.value"
+              v-model="localSort"
+              :val="opt.value"
+              :label="opt.label"
+              :disable="opt.disable ?? false"
+              color="primary"
+              keep-color
+              dense
+            />
+          </div>
+        </div>
+
+        <q-separator class="q-my-sm" />
+
+        <div class="text-caption text-weight-bold text-grey-7 q-mb-xs">{{ $t('search_filter.filter_by') }}</div>
+        <div class="text-body2 text-weight-medium text-text q-mb-xs">{{ $t('search_filter.availability') }}</div>
+        <DefaultInputDatePicker
+          v-model:untreated-date="localDate"
+          :label="$t('search_filter.availability_placeholder')"
+          dense
+          class="date-picker-primary"
+          input-class="text-text"
+        />
+      </q-card-section>
+
+      <q-separator />
+      <q-card-actions class="q-px-md q-py-md row q-gutter-x-sm">
+        <q-btn
+          outline
+          color="grey-6"
+          :label="$t('search_filter.clear')"
+          rounded
+          no-caps
+          class="col"
+          @click="clearFilters"
+        />
+        <q-btn
+          color="primary-button"
+          :label="$t('search_filter.apply')"
+          rounded
+          no-caps
+          unelevated
+          class="col"
+          @click="applyFilters"
+        />
+      </q-card-actions>
+
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from 'vue';
+import { useDialogPluginComponent } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import DefaultInputDatePicker from 'src/components/defaults/DefaultInputDatePicker.vue';
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { initialSort, initialDate } = defineProps({
+  initialSort: { type: String, default: null },
+  initialDate: { type: String, default: null },
+});
+
+const { t } = useI18n();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const localSort = ref(initialSort ?? null);
+const localDate = ref(initialDate ?? null);
+
+const sortGroups = computed(() => [
+  {
+    key: 'price',
+    label: t('search_filter.groups.price'),
+    options: [
+      { value: 'price_desc', label: t('search_filter.sort.higher') },
+      { value: 'price_asc',  label: t('search_filter.sort.lower') },
+    ],
+  },
+  {
+    key: 'distance',
+    label: t('search_filter.groups.distance'),
+    options: [
+      { value: 'distance_desc', label: t('search_filter.sort.higher'), disable: true },
+      { value: 'distance_asc',  label: t('search_filter.sort.lower'),  disable: true },
+    ],
+  },
+  {
+    key: 'reviews',
+    label: t('search_filter.groups.reviews'),
+    options: [
+      { value: 'reviews_desc', label: t('search_filter.sort.higher') },
+      { value: 'reviews_asc',  label: t('search_filter.sort.lower') },
+    ],
+  },
+  {
+    key: 'rating',
+    label: t('search_filter.groups.rating'),
+    options: [
+      { value: 'rating_desc', label: t('search_filter.sort.higher') },
+      { value: 'rating_asc',  label: t('search_filter.sort.lower') },
+    ],
+  },
+  {
+    key: 'services',
+    label: t('search_filter.groups.services'),
+    options: [
+      { value: 'services_desc', label: t('search_filter.sort.higher') },
+      { value: 'services_asc',  label: t('search_filter.sort.lower') },
+    ],
+  },
+  {
+    key: 'oldest',
+    label: t('search_filter.groups.oldest'),
+    options: [
+      { value: 'oldest',  label: t('search_filter.sort.oldest_asc') },
+      { value: 'newest',  label: t('search_filter.sort.oldest_desc') },
+    ],
+  },
+]);
+
+const clearFilters = () => {
+  localSort.value = null;
+  localDate.value = null;
+  onDialogOK({ sort: null, date: null });
+};
+
+const applyFilters = () => {
+  onDialogOK({ sort: localSort.value, date: localDate.value });
+};
+</script>
+
+<style scoped lang="scss">
+.filter-card {
+  border-radius: 16px;
+  width: min(92vw, 420px);
+}
+.filter-body {
+  max-height: 60dvh;
+  overflow-y: auto;
+}
+.date-picker-primary {
+  :deep(.q-field__label) {
+    color: var(--q-primary);
+  }
+  :deep(.q-field__native),
+  :deep(.q-field__input) {
+    color: var(--q-text, #555555) !important;
+  }
+  :deep(.q-icon) {
+    color: var(--q-primary);
+  }
+}
+</style>
+
+<style lang="scss">
+.q-popup-proxy {
+  .q-date {
+    background: var(--q-surface, #ffffff) !important;
+    color: var(--q-text, #555555) !important;
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
+
+    .q-date__header {
+      background: var(--q-primary);
+      color: #fff;
+    }
+
+    .q-date__content {
+      color: var(--q-text, #555555) !important;
+    }
+
+    .q-date__calendar-item .q-btn {
+      color: var(--q-text, #555555) !important;
+    }
+
+    .q-date__navigation .q-btn {
+      color: var(--q-text, #555555) !important;
+    }
+
+    .q-date__calendar-weekdays > div {
+      color: var(--q-text, #555555) !important;
+      opacity: 0.6;
+    }
+  }
+}
+</style>

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

@@ -0,0 +1,143 @@
+<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-if="availableServiceTypes.length === 0" class="text-center text-grey-6 text-body2 q-py-md">
+          {{ $t('scheduling_page.no_slots_available') }}
+        </div>
+
+        <div
+          v-for="type in availableServiceTypes"
+          :key="type.key"
+          class="row items-center no-wrap q-py-sm"
+        >
+          <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>
+
+          <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>
+
+          <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 },
+  partialBlocks: { type: Array, required: false, default: () => [] },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const $q = useQuasar();
+const { t } = useI18n();
+
+const slotConflicts = (slotStart, slotEnd, blocks) =>
+  blocks.some(b => {
+    const blockStart = parseInt(b.init_hour);
+    const blockEnd   = parseInt(b.end_hour);
+    return slotEnd >= blockStart && slotStart <= blockEnd;
+  });
+
+const hasValidSlots = (hoursCount) => {
+  for (let s = 7; s + hoursCount <= 20; s++) {
+    if (!slotConflicts(s, s + hoursCount, props.partialBlocks)) return true;
+  }
+  return false;
+};
+
+const availableServiceTypes = 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,
+    },
+  ].filter(type => hasValidSlots(type.hoursCount))
+);
+
+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>

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

@@ -0,0 +1,157 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="card-border bg-surface text-text time-card">
+
+      <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"
+      />
+
+      <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>
+
+      <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>
+
+      <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>
+
+      <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')"
+          :disable="!selectedSlot"
+          color="secondary"
+          class="full-width"
+          @click="handleContinue"
+        />
+      </q-card-actions>
+
+      <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 },
+  partialBlocks: { type: Array,  required: false, default: () => [] },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const { t } = useI18n();
+
+const selectedSlot = ref(null);
+const selectedMeal = ref(null);
+
+const handleContinue = () => {
+  const slotObj = timeSlots.value.find(s => s.value === selectedSlot.value);
+  onDialogOK({
+    serviceType: props.serviceType,
+    date: props.selectedDate,
+    slot: { value: selectedSlot.value, startHour: slotObj.startHour, endHour: slotObj.endHour },
+    meal: selectedMeal.value,
+  });
+};
+
+const slotConflicts = (slotStart, slotEnd, blocks) =>
+  blocks.some(b => {
+    const blockStart = parseInt(b.init_hour);
+    const blockEnd   = parseInt(b.end_hour);
+    return slotEnd >= blockStart && slotStart <= blockEnd;
+  });
+
+const timeSlots = computed(() => {
+  const h = props.serviceType.hoursCount;
+  const slots = [];
+  for (let start = 7; start + h <= 20; start++) {
+    const end = start + h;
+    if (slotConflicts(start, end, props.partialBlocks)) continue;
+    slots.push({
+      value: `${start}-${end}`,
+      startHour: start,
+      endHour: 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;
+});
+</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>