Browse Source

feat: ✨ feat (fluxo agendamento) pacote de agendamento e carrinho

alterado funcionamento do pacote de agendamentos e carrinho para so permitir mais de um agendamento no carrinho se for do mesmo prestador + bloco novo na dashboard para indicar que ha agendamentos sendo solicitados para o prestador + animacoes no bloco 'realize pagamento' e 'solicitando agendamento'

fase:dev | origin:escopo
Gustavo Zanatta 4 hours ago
parent
commit
5a1c5f423e

+ 1 - 1
quasar.config.js

@@ -181,7 +181,7 @@ export default defineConfig((ctx) => {
       // directives: [],
 
       // Quasar plugins
-      plugins: ["Dialog", "Notify", "Loading", "Cookies", "Dark"],
+      plugins: ["Dialog", "Notify", "Loading", "Cookies", "Dark", "LocalStorage"],
     },
 
     // animations: 'all', // --- includes all animations

+ 145 - 29
src/components/dashboard/DashboardPendingSchedules.vue

@@ -1,7 +1,11 @@
 <template>
   <div class="q-mx-md q-mb-md">
     <div class="scroll-wrapper">
-      <div class="scroll-track">
+      <div
+        ref="trackRef"
+        class="scroll-track"
+        @scroll="onTrackScroll"
+      >
         <q-card
           v-for="item in data"
           :key="item.id"
@@ -9,7 +13,7 @@
           class="pending-card card-border shadow-card bg-surface"
           @click="seeDetails(item)"
         >
-          <q-card-section class="q-pa-md">
+          <q-card-section class="card-body q-pa-md">
             <div class="row no-wrap items-start q-mb-sm">
               <q-avatar
                 class="q-mr-sm flex-shrink-0"
@@ -61,7 +65,7 @@
                     size="13px"
                   />
 
-                  <span class="font9 fontmedium text-grey-5">
+                  <span class="font9 fontmedium text-grey-5 meta-text ellipsis">
                     {{ displayTime(item) }}
                   </span>
                 </div>
@@ -95,11 +99,11 @@
             <div class="progress-track q-mb-md">
               <div
                 class="progress-fill"
-                :style="{ width: progressPercent(item.status) + '%' }"
+                :class="progressClass"
               />
             </div>
 
-            <div class="row items-center no-wrap">
+            <div class="card-footer row items-center no-wrap">
               <q-btn
                 v-if="type !== 'servicePackage'"
                 class="q-mr-sm flex-shrink-0"
@@ -109,7 +113,7 @@
                 no-caps
                 size="xs"
                 :label="$t('dashboard_client.pending_schedules.cancel_btn')"
-                @click="$emit('cancel', item)"
+                @click.stop="$emit('cancel', item)"
               />
 
               <q-space />
@@ -133,21 +137,49 @@
         </q-card>
       </div>
     </div>
+
+    <div
+      v-if="data.length > 1"
+      class="row justify-center q-gutter-x-xs q-mt-sm"
+    >
+      <span
+        v-for="(item, index) in data"
+        :key="`dot-${item.id}`"
+        class="carousel-dot"
+        :class="{ 'carousel-dot--active': index === activeIndex }"
+      />
+    </div>
   </div>
 </template>
 
 <script setup>
-import { computed } from "vue";
+import { computed, ref } from "vue";
 import { avatarColors } from "src/helpers/avatarColors";
 import { getFirstName } from "src/helpers/utils";
+import { useI18n } from "vue-i18n";
 
 const props = defineProps({
   data: { type: Array, default: () => [] },
   type: { type: String, default: 'schedule' },
+
+  progressClass: { type: String, default: '' },
 });
 
 const emit = defineEmits(["view-details", "cancel"]);
 
+const { t } = useI18n();
+
+const trackRef = ref(null);
+const activeIndex = ref(0);
+
+const onTrackScroll = () => {
+  const track = trackRef.value;
+
+  if (!track || !track.clientWidth) return;
+
+  activeIndex.value = Math.round(track.scrollLeft / track.clientWidth);
+};
+
 const isServicePackage = computed(() => props.type === 'servicePackage');
 
 const providerPhoto = (item) => {
@@ -180,24 +212,26 @@ const displayAddress = (item) => {
   ].filter(Boolean).join(', ') || '—';
 };
 
+const scheduleCountLabel = (count) =>
+  t("dashboard_client.pending_schedules.schedule_count", { count });
+
 const displayTime = (item) => {
   if (isServicePackage.value) {
-    return item.schedules?.length
-      ? `${item.schedules.length} agendamentos`
-      : '—';
+    return item.schedules?.length ? scheduleCountLabel(item.schedules.length) : "—";
   }
-  return item.time_since_request;
-};
 
-const statusProgressMap = {
-  pending: 20,
-  accepted: 40,
-  paid: 60,
-  started: 80,
-  finished: 100,
-};
+  if (!item.time_since_request) return "—";
 
-const progressPercent = (status) => statusProgressMap[status] ?? 20;
+  const timeAgo = t("dashboard_client.pending_schedules.time_ago", {
+    time: item.time_since_request,
+  });
+
+  const packageCount = Number(item.service_package_items_count ?? 0);
+
+  return packageCount > 1
+    ? `${timeAgo} · ${scheduleCountLabel(packageCount)}`
+    : timeAgo;
+};
 
 const seeDetails = (item) => {
   if (isServicePackage.value || item.status === "accepted") {
@@ -216,26 +250,55 @@ const seeDetails = (item) => {
   gap: 12px;
   overflow-x: auto;
   overscroll-behavior-x: contain;
-  scroll-snap-type: x proximity;
+  scroll-snap-type: x mandatory;
   padding-bottom: 8px;
   &::-webkit-scrollbar {
     display: none;
   }
-  &::after {
-    content: "";
-    flex: 0 0 1px;
-  }
+}
+
+.carousel-dot {
+  width: 6px;
+  height: 6px;
+  border-radius: 50%;
+  background: #cbd5e1;
+  transition: background 0.3s ease, width 0.3s ease;
+}
+
+.carousel-dot--active {
+  width: 16px;
+  border-radius: 999px;
+  background: #8b5cf6;
 }
 .pending-card {
-  min-width: 80%;
-  scroll-snap-align: start;
+  flex: 0 0 100%;
+  width: 100%;
+  min-height: 146px;
+  display: flex;
+  scroll-snap-align: center;
+}
+
+.card-body {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  width: 100%;
 }
 
 .clock-badge-col {
-  min-width: 52px;
+  flex: 0 0 68px;
+  width: 68px;
   align-items: center;
 }
 
+.meta-text {
+  white-space: nowrap;
+}
+
+.card-footer {
+  min-height: 24px;
+}
+
 .clock-badge {
   width: 24px;
   height: 24px;
@@ -253,6 +316,7 @@ const seeDetails = (item) => {
 .progress-track {
   width: 100%;
   height: 6px;
+  margin-top: auto;
   background: #e2e8f0;
   border-radius: 999px;
   overflow: hidden;
@@ -261,6 +325,7 @@ const seeDetails = (item) => {
 .progress-fill {
   position: relative;
   height: 100%;
+  width: 0;
   border-radius: inherit;
 
   background: linear-gradient(
@@ -270,7 +335,6 @@ const seeDetails = (item) => {
     #d94cff
   );
 
-  transition: width .4s ease;
   overflow: hidden;
 }
 
@@ -302,4 +366,56 @@ const seeDetails = (item) => {
     left: 135%;
   }
 }
+
+.progress-fill--loop {
+  background: #ec4899;
+  animation: progressLoopFill 3s ease-in-out infinite;
+}
+
+.progress-fill--loop::after {
+  display: none;
+}
+
+@keyframes progressLoopFill {
+  0% {
+    width: 0;
+  }
+
+  80% {
+    width: 100%;
+  }
+
+  100% {
+    width: 100%;
+  }
+}
+
+.progress-fill--urgent {
+  width: 100%;
+  animation: progressUrgentPulse 1.2s ease-in-out infinite;
+}
+
+@keyframes progressUrgentPulse {
+  0%,
+  100% {
+    opacity: 1;
+  }
+
+  50% {
+    opacity: 0.2;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .progress-fill,
+  .progress-fill::after {
+    animation: none;
+  }
+
+  .progress-fill--loop,
+  .progress-fill--urgent {
+    width: 100%;
+    opacity: 1;
+  }
+}
 </style>

+ 68 - 25
src/components/dashboard/ScheduleAcceptedDialog.vue

@@ -34,25 +34,48 @@
       <q-separator />
 
       <q-card-section class="q-py-md q-px-lg">
+        <div
+          v-if="activeSchedules.length > 1"
+          class="font13 fontbold text-primary q-mb-xs"
+        >
+          {{ $t("dashboard_client.pending_schedules.detail_schedules_title") }}
+        </div>
+
+        <div
+          v-for="schedule in activeSchedules"
+          :key="schedule.id"
+          class="detail-row schedule-row"
+        >
+          <span class="detail-value font13">
+            {{ formatScheduleDate(schedule) }}
+          </span>
+
+          <span class="detail-valued text-text font13">
+            {{ schedule.start_time?.slice(0, 5) }}
+            {{ $t("dashboard_client.next_schedules.to") }}
+            {{ schedule.end_time?.slice(0, 5) }}
+          </span>
+        </div>
+
+        <q-separator class="q-my-sm" />
+
         <div class="detail-row">
           <span class="text-primary font14 fontmedium">
-            {{ $t("dashboard_client.pending_schedules.detail_date") }}
+            {{ $t("dashboard_client.pending_schedules.detail_value") }}
           </span>
 
-          <span class="detail-value">
-            {{ displayDate }}
+          <span class="detail-value font14">
+            {{ formatCurrency(baseAmount) }}
           </span>
         </div>
 
         <div class="detail-row">
-          <span class="text-primary font14 fontmedium">
-            {{ $t("dashboard_client.pending_schedules.detail_time") }}
+          <span class="text-primary font13 fontmedium">
+            {{ $t("dashboard_client.pending_schedules.detail_service_fee_pix") }}
           </span>
 
-          <span class="detail-valued text-text">
-            {{ displayStartTime }}
-            {{ $t("dashboard_client.next_schedules.to") }}
-            {{ displayEndTime }}
+          <span class="detail-value font13">
+            {{ formatCurrency(platformFee("pix")) }}
           </span>
         </div>
 
@@ -81,6 +104,20 @@
           </span>
         </div>
 
+        <div class="detail-row">
+          <span class="text-primary font13 fontmedium">
+            {{
+              $t(
+                "dashboard_client.pending_schedules.detail_service_fee_credit_card",
+              )
+            }}
+          </span>
+
+          <span class="detail-value font13">
+            {{ formatCurrency(platformFee("credit_card")) }}
+          </span>
+        </div>
+
         <div class="detail-row">
           <span class="text-primary font14 fontmedium">
             {{
@@ -155,15 +192,20 @@ const displayProviderName = computed(() => {
   return item.value.provider?.user?.name ?? item.value.provider_name ?? '';
 });
 
+const activeSchedules = computed(() =>
+  (item.value.schedules ?? []).filter(
+    (schedule) => !['cancelled', 'rejected'].includes(schedule.status),
+  ),
+);
+
 const displayDistrict = computed(() => {
-  return item.value.schedules?.[0]?.address?.district ?? item.value.address?.district ?? '';
+  return activeSchedules.value[0]?.address?.district ?? item.value.address?.district ?? '';
 });
 
-const displayDate = computed(() => {
-  const src = item.value.schedules?.[0] ?? item.value;
-  if (!src) return '';
-  if (src.formatted_date) return src.formatted_date;
-  const raw = String(src.date || '');
+const formatScheduleDate = (schedule) => {
+  if (!schedule) return '';
+  if (schedule.formatted_date) return schedule.formatted_date;
+  const raw = String(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]);
@@ -172,21 +214,22 @@ const displayDate = computed(() => {
     month: 'long',
     year: 'numeric',
   });
-});
-
-const displayStartTime = computed(() => {
-  return item.value.schedules?.[0]?.start_time?.slice(0, 5) ?? '';
-});
-
-const displayEndTime = computed(() => {
-  return item.value.schedules?.[0]?.end_time?.slice(0, 5) ?? '';
-});
+};
 
 const avatarStyle = computed(
   () => avatarColors[item.value.id % avatarColors.length],
 );
 
-const baseAmount = computed(() => Number(item.value.total_amount) || 0);
+const baseAmount = computed(() => {
+  const total = Number(item.value.total_amount);
+
+  if (Number.isFinite(total) && total > 0) return total;
+
+  return activeSchedules.value.reduce(
+    (sum, schedule) => sum + (Number(schedule.total_amount) || 0),
+    0,
+  );
+});
 
 const creditCardTotal = computed(() =>
   parseFloat((baseAmount.value + platformFee("credit_card")).toFixed(2)),

+ 29 - 1
src/components/dashboard/ScheduleCancelDialog.vue

@@ -12,6 +12,15 @@
         </div>
       </q-card-section>
 
+      <q-card-section v-if="packageWarning" class="q-pt-none q-pb-sm q-px-lg">
+        <div class="package-warning-box row no-wrap q-gutter-x-sm q-pa-sm">
+          <q-icon name="mdi-package-variant" color="negative" size="22px" class="q-mt-xs flex-shrink-0" />
+          <span class="font13 fontbold text-negative">
+            {{ packageWarning }}
+          </span>
+        </div>
+      </q-card-section>
+
       <q-card-section class="q-pt-none q-pb-sm q-px-lg">
         <div class="font14 fontbold text-grey-8 q-mb-xs text-center">
           {{ $t('provider.dashboard.cancel_schedule.reason_label') }}
@@ -76,7 +85,7 @@
 </template>
 
 <script setup>
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
 import { useDialogPluginComponent, useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 import { cancelSchedule } from 'src/api/schedule'
@@ -95,6 +104,20 @@ const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent()
 const cancelText = ref('')
 const loading = ref(false)
 
+const packageWarning = computed(() => {
+  if (!props.schedule?.service_package_id) return null
+
+  const count = props.schedule?.service_package_items_count
+
+  if (count === undefined || count === null) {
+    return t('provider.dashboard.cancel_schedule.package_warning_generic')
+  }
+
+  return Number(count) > 1
+    ? t('provider.dashboard.cancel_schedule.package_warning', { count: Number(count) })
+    : null
+})
+
 const confirmCancel = async () => {
   if (!cancelText.value || cancelText.value.trim().length < 5) return
   loading.value = true
@@ -126,6 +149,11 @@ const confirmCancel = async () => {
   border-radius: 10px;
 }
 
+.package-warning-box {
+  background: #fdecec;
+  border-radius: 10px;
+}
+
 .btn-action {
   min-width: 110px;
 }

+ 11 - 1
src/i18n/locales/en.json

@@ -514,6 +514,8 @@
     "pending_schedules": {
       "title": "Awaiting",
       "requesting_with": "Requesting booking with",
+      "time_ago": "{time} ago",
+      "schedule_count": "{count} bookings",
       "pay_to_provider": "Make payment to",
       "no_provider": "Provider not defined",
       "cancel_btn": "cancel",
@@ -529,6 +531,9 @@
       "detail_time": "Time:",
       "detail_value": "Amount:",
       "detail_service_fee": "Total:",
+      "detail_schedules_title": "Dates and times",
+      "detail_service_fee_pix": "Service fee (Pix):",
+      "detail_service_fee_credit_card": "Service fee (card):",
       "detail_total": "Total:",
       "detail_package_total": "Package total (Pix):",
       "detail_pix_total": "Pix total:",
@@ -765,6 +770,7 @@
       "slot_required": "Select a time slot to continue."
     },
     "price_up_to": "up to {value}",
+    "cart": "cart",
     "service_types": {
       "integral": {
         "label": "Full day",
@@ -810,6 +816,8 @@
     "empty_message": "Add time slots from favorite professionals to send a request.",
     "add_to_cart": "Add to orders",
     "add_success": "Time slot added to orders.",
+    "add_more": "add another time slot",
+    "other_provider_blocked": "Your order already has time slots with another professional. Finish or empty the current order before booking with another professional.",
     "local_cart_title": "Orders",
     "orders_title": "Orders",
     "no_orders": "No orders created.",
@@ -919,7 +927,9 @@
         "warning_fee": "Cancellations after this period generate a 50% refund as a compensation fee to the professional.",
         "btn_cancel": "cancel",
         "btn_back": "go back",
-        "btn_keep": "Cancel the service"
+        "btn_keep": "Cancel the service",
+        "package_warning": "This booking is part of a package with {count} bookings. Cancelling it will cancel ALL upcoming bookings in the package.",
+        "package_warning_generic": "This booking is part of a package. Cancelling it will cancel ALL upcoming bookings in the package."
       }
     }
   },

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

@@ -511,6 +511,8 @@
     "pending_schedules": {
       "title": "En espera de confirmación",
       "requesting_with": "Solicitando reserva con",
+      "time_ago": "hace {time}",
+      "schedule_count": "{count} reservas",
       "pay_to_provider": "Realizar pago a",
       "no_provider": "Proveedor no definido",
       "cancel_btn": "cancelar",
@@ -526,6 +528,9 @@
       "detail_time": "Horario:",
       "detail_value": "Valor:",
       "detail_service_fee": "Total:",
+      "detail_schedules_title": "Fechas y horarios",
+      "detail_service_fee_pix": "Tarifa de servicio (Pix):",
+      "detail_service_fee_credit_card": "Tarifa de servicio (tarjeta):",
       "detail_total": "Total:",
       "detail_package_total": "Total del paquete (Pix):",
       "detail_pix_total": "Total en Pix:",
@@ -762,6 +767,7 @@
       "slot_required": "Seleccione un horario para continuar."
     },
     "price_up_to": "hasta {value}",
+    "cart": "carrinho",
     "service_types": {
       "integral": {
         "label": "Integral",
@@ -807,6 +813,8 @@
     "empty_message": "Agregue horarios de profesionales favoritos para enviar una solicitud.",
     "add_to_cart": "Agregar a pedidos",
     "add_success": "Horario agregado a pedidos.",
+    "add_more": "agregar otro horario",
+    "other_provider_blocked": "Tu pedido ya tiene horarios con otro profesional. Finaliza o vacía el pedido actual antes de reservar con otro profesional.",
     "local_cart_title": "Pedidos",
     "orders_title": "Pedidos",
     "no_orders": "Ningún pedido creado.",
@@ -916,7 +924,9 @@
         "warning_fee": "Las cancelaciones después de este período generan un reembolso del 50% del valor como compensación al profesional.",
         "btn_cancel": "cancelar",
         "btn_back": "volver",
-        "btn_keep": "Cancelar el servicio"
+        "btn_keep": "Cancelar el servicio",
+        "package_warning": "Esta reserva forma parte de un paquete con {count} reservas. Al cancelarla, se cancelarán TODAS las reservas futuras del paquete.",
+        "package_warning_generic": "Esta reserva forma parte de un paquete. Al cancelarla, se cancelarán TODAS las reservas futuras del paquete."
       }
     }
   },

+ 12 - 2
src/i18n/locales/pt.json

@@ -513,7 +513,9 @@
     },
     "pending_schedules": {
       "title": "Aguardando confirmação",
-      "requesting_with": "Solicitando serviço com",
+      "requesting_with": "Solicitando agendamento com",
+      "time_ago": "{time} atrás",
+      "schedule_count": "{count} agendamentos",
       "pay_to_provider": "Realize o pagamento para",
       "no_provider": "Prestador não definido",
       "cancel_btn": "cancelar",
@@ -529,6 +531,9 @@
       "detail_time": "Horário:",
       "detail_value": "Valor:",
       "detail_service_fee": "Total:",
+      "detail_schedules_title": "Datas e horários",
+      "detail_service_fee_pix": "Taxa de serviço (Pix):",
+      "detail_service_fee_credit_card": "Taxa de serviço (cartão):",
       "detail_total": "Total:",
       "detail_package_total": "Total do pacote (Pix):",
       "detail_pix_total": "Total no Pix:",
@@ -773,6 +778,7 @@
       "slot_required": "Selecione um horário para continuar."
     },
     "price_up_to": "até {value}",
+    "cart": "carrinho",
     "service_types": {
       "integral": {
         "label": "Integral",
@@ -818,6 +824,8 @@
     "empty_message": "Adicione horários de profissionais favoritos para enviar uma solicitação.",
     "add_to_cart": "Adicionar aos pedidos",
     "add_success": "Horário adicionado aos pedidos.",
+    "add_more": "adicionar mais um horário",
+    "other_provider_blocked": "Seu pedido já possui horários com outro profissional. Finalize ou esvazie o pedido atual antes de agendar com outro profissional.",
     "local_cart_title": "Pedidos",
     "orders_title": "Pedidos",
     "no_orders": "Nenhum pedido criado.",
@@ -927,7 +935,9 @@
         "warning_fee": "Cancelamentos após este período geram reembolso de 50% do valor, como taxa de compensação ao profissional.",
         "btn_cancel": "cancelar",
         "btn_back": "Manter o pedido",
-        "btn_keep": "Cancelar"
+        "btn_keep": "Cancelar",
+        "package_warning": "Este agendamento faz parte de um pacote com {count} agendamentos. Ao cancelar, TODOS os agendamentos futuros do pacote serão cancelados.",
+        "package_warning_generic": "Este agendamento faz parte de um pacote. Ao cancelar, TODOS os agendamentos futuros do pacote serão cancelados."
       }
     }
   },

+ 65 - 2
src/pages/dashboard/DashboardPage.vue

@@ -18,8 +18,15 @@
           v-if="pendingServicePackages.length > 0"
           :data="pendingServicePackages"
           :type="'servicePackage'"
+          progress-class="progress-fill--urgent"
           @view-details="openServicePackagePaymentDialog"
         />
+        <DashboardPendingSchedules
+          v-if="pendingRequests.length > 0"
+          :data="pendingRequests"
+          progress-class="progress-fill--loop"
+          @cancel="openCancelRequestDialog"
+        />
         <DashboardTodaySchedules v-if="todaySchedules.length > 0" :data="todaySchedules" @rate="openRatingDialog" />
         <DashboardScrollAreaSchedules />
         <DashboardClientProposals v-if="clientProposals.length > 0" :data="clientProposals" @refresh-data="reloadDashboard" />
@@ -40,6 +47,7 @@ import DashboardRegistrationIncomplete from 'src/components/dashboard/DashboardR
 import DashboardPaymentIncomplete from 'src/components/dashboard/DashboardPaymentIncomplete.vue';
 import DashboardPendingSchedules from 'src/components/dashboard/DashboardPendingSchedules.vue';
 import ScheduleAcceptedDialog from 'src/components/dashboard/ScheduleAcceptedDialog.vue';
+import ScheduleCancelDialog from 'src/components/dashboard/ScheduleCancelDialog.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';
@@ -51,7 +59,7 @@ import DashboardPendingCustomSchedules from 'src/pages/dashboard/components/Dash
 import DashboardClientProposals from 'src/pages/dashboard/components/DashboardClientProposals.vue';
 import { useRouter } from 'vue-router'
 import { onMounted, ref, computed } from 'vue';
-import { useQuasar } from 'quasar';
+import { LocalStorage, useQuasar } from 'quasar';
 import { dadosDashboard } from 'src/api/dashboard';
 import { userStore } from 'src/stores/user';
 import NextSchedulesDetailsDialog from 'src/components/dashboard/NextSchedulesDetailsDialog.vue';
@@ -78,6 +86,30 @@ const loading = ref(true);
 const showSuccessModal = ref(router.currentRoute.value.fullPath.includes('showSuccessModal'));
 const registrationComplete = computed(() => store.user?.registration_complete ?? true);
 
+const pendingRequests = computed(() => {
+  const seenPackages = new Set();
+
+  return pendingSchedules.value.filter((schedule) => {
+    if (schedule.status !== 'pending') return false;
+
+    if (!schedule.service_package_id) return true;
+
+    if (seenPackages.has(schedule.service_package_id)) return false;
+
+    seenPackages.add(schedule.service_package_id);
+
+    return true;
+  });
+});
+
+const openCancelRequestDialog = (schedule) => {
+  $q.dialog({
+    component: ScheduleCancelDialog,
+    componentProps: { schedule },
+  }).onDismiss(() => {
+    reloadDashboard(false);
+  });
+};
 
 const openServicePackagePaymentDialog = (servicePackage) => {
   $q.dialog({
@@ -88,6 +120,35 @@ const openServicePackagePaymentDialog = (servicePackage) => {
   });
 };
 
+const AUTO_SHOWN_PACKAGES_KEY = "sp_payment_popup_shown_ids";
+let autoOpeningPackageDialog = false;
+
+const maybeAutoOpenServicePackagePayment = () => {
+  if (autoOpeningPackageDialog) return;
+
+  const shownIds = LocalStorage.getItem(AUTO_SHOWN_PACKAGES_KEY) ?? [];
+
+  const packageToShow = pendingServicePackages.value.find(
+    (servicePackage) => !shownIds.includes(servicePackage.id),
+  );
+
+  if (!packageToShow) return;
+
+  autoOpeningPackageDialog = true;
+  LocalStorage.set(AUTO_SHOWN_PACKAGES_KEY, [...shownIds, packageToShow.id]);
+
+  $q.dialog({
+    component: ScheduleAcceptedDialog,
+    componentProps: { servicePackage: packageToShow },
+  })
+    .onOk(() => {
+      reloadDashboard();
+    })
+    .onDismiss(() => {
+      autoOpeningPackageDialog = false;
+    });
+};
+
 const reloadDashboard = async (showLoader = true) => {
   if (showLoader) loading.value = true;
   const response = await dadosDashboard();
@@ -108,7 +169,7 @@ const reloadDashboard = async (showLoader = true) => {
   }
   if( showSuccessModal.value == true) {
     $q.dialog({
-       component: FinalSuccesModal   
+       component: FinalSuccesModal
     }).onDismiss(() => {
       showSuccessModal.value = false;
       router.replace({ path: router.currentRoute.value.path, query: {} });
@@ -116,6 +177,8 @@ const reloadDashboard = async (showLoader = true) => {
   }
 
   loading.value = false;
+
+  maybeAutoOpenServicePackagePayment();
 };
 
 const onRefresh = async (done) => {

+ 30 - 0
src/pages/orders/ServicePackagePage.vue

@@ -95,6 +95,18 @@
           </span>
         </div>
 
+        <q-btn
+          class="full-width"
+          color="primary"
+          icon="mdi-plus"
+          no-caps
+          outline
+          padding="8px 16px"
+          rounded
+          :label="$t('service_package.add_more')"
+          @click="openAddMoreDialog"
+        />
+
         <q-card
           v-for="(item, index) in servicePackage.items"
           :key="item.local_id"
@@ -293,6 +305,7 @@
 
 <script setup>
 import { computed, onMounted, ref } from "vue";
+import SchedulingDialog from "src/pages/search/components/SchedulingDialog.vue";
 import { createScheduleServicePackage } from "src/api/servicePackage";
 import { getAddresses } from "src/api/address";
 import { getProfileMediaUrl } from "src/helpers/profileMedia";
@@ -412,6 +425,23 @@ const hasSingleProvider = computed(() => {
   return providers.size <= 1;
 });
 
+const openAddMoreDialog = () => {
+  const firstItem = servicePackage.items[0];
+
+  if (!firstItem) return;
+
+  const provider = servicePackage.provider ?? {
+    provider_id: firstItem.provider_id,
+    provider_name: firstItem.provider_name,
+    profile_media_url: firstItem.provider_photo,
+  };
+
+  $q.dialog({
+    component: SchedulingDialog,
+    componentProps: { provider },
+  });
+};
+
 const primaryAddressLabel = computed(() => {
   if (addressLoading.value) return t("service_package.loading");
   if (!primaryAddress.value) return t("service_package.no_primary_address");

+ 2 - 1
src/pages/schedules/SobMedidaPage.vue

@@ -270,7 +270,8 @@ const openServiceSelection = () => {
     component: ServiceSelectionSheet,
     componentProps: {
       provider: providerPrices.value,
-      selectedDate: selectedDate.value
+      selectedDate: selectedDate.value,
+      scheduleType: 'custom'
     }
   }).onOk((payload) => {
     if (payload?.serviceType) {

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

@@ -347,6 +347,11 @@ const getWeekStart = (dateStr) => {
   return d.toISOString().split("T")[0];
 };
 
+const cartHasAnotherProvider = () =>
+  servicePackage.items.length > 0 &&
+  Number(servicePackage.items[0].provider_id) !==
+    Number(props.provider.provider_id);
+
 const getServicePackageWeekCount = (selectedDate) => {
   const weekStart = getWeekStart(selectedDate);
 
@@ -541,6 +546,7 @@ const onDateSelected = (val) => {
       partialBlocks,
       provider: props.provider,
       selectedDate: val,
+      scheduleType: 'default'
     },
   }).onOk(({ action, serviceType, date: date_, provider: prov }) => {
     $q.dialog({
@@ -553,6 +559,15 @@ const onDateSelected = (val) => {
       },
     }).onOk((booking) => {
       if (action === "servicePackage") {
+        if (cartHasAnotherProvider()) {
+          $q.notify({
+            message: t("service_package.other_provider_blocked"),
+            type: "warning",
+          });
+
+          return;
+        }
+
         servicePackage.addItem({
           date: normalizeDate(booking.date),
           end_time: formatHour(booking.slot.endHour),
@@ -572,6 +587,10 @@ const onDateSelected = (val) => {
             booking.serviceType.basePrice ?? booking.serviceType.price,
         });
 
+        if (!servicePackage.provider) {
+          servicePackage.setProvider({ ...props.provider });
+        }
+
         $q.notify({
           message: t("service_package.add_success"),
           type: "positive",

+ 9 - 3
src/pages/search/components/ServiceSelectionSheet.vue

@@ -58,7 +58,12 @@
               v-if="type.price"
                class="service-price"
             >
-              {{ $t('scheduling_page.price_up_to', { value: formatCurrency(type.price) }) }}
+              <div v-if="props.scheduleType == 'custom'">
+                {{ $t('scheduling_page.price_up_to', { value: formatCurrency(type.price) }) }}
+              </div>
+              <div v-else>
+                {{ formatCurrency(type.price) }}
+              </div>
             </div>
 
           <div class="row no-wrap q-gutter-x-xs">
@@ -69,7 +74,7 @@
               rounded
               size="sm"
               unelevated
-              :label="$t('common.actions.select')"
+              :label="$t('scheduling_page.cart')"
               @click="onDialogOK({ action: 'servicePackage', serviceType: type, date: selectedDate, provider })"
             />
           </div>
@@ -93,6 +98,7 @@ const props = defineProps({
   partialBlocks: { default: () => [], required: false, type: Array },
   provider: { required: true, type: Object },
   selectedDate: { required: true, type: String },
+  scheduleType: { required: false, type: String, default: "default" },
 });
 
 const $q = useQuasar();
@@ -188,7 +194,7 @@ const openInfo = (type) => {
   margin: 0 20px;
   text-align: right;
 
-  font-size: 18px;
+  font-size: 14px;
   font-weight: 700;
   color: var(--q-text);
 }

+ 51 - 2
src/stores/servicePackage.js

@@ -1,21 +1,68 @@
 import { defineStore } from "pinia";
-import { ref } from "vue";
+import { LocalStorage, date } from "quasar";
+import { ref, watch } from "vue";
+
+const STORAGE_KEY = "service_package_cart";
+
+const loadPersistedCart = () => {
+  const persisted = LocalStorage.getItem(STORAGE_KEY);
+
+  if (!persisted || !Array.isArray(persisted.items)) {
+    return { items: [], provider: null };
+  }
+
+  const today = date.formatDate(Date.now(), "YYYY-MM-DD");
+
+  const items = persisted.items.filter(
+    (item) => item && item.date && item.date >= today,
+  );
+
+  return {
+    items,
+    provider: items.length ? (persisted.provider ?? null) : null,
+  };
+};
 
 export const useServicePackageStore = defineStore("servicePackage", () => {
-  const items = ref([]);
+  const persisted = loadPersistedCart();
+
+  const items = ref(persisted.items);
+
+  const provider = ref(persisted.provider);
 
   const lastCreatedGroup = ref(null);
 
+  watch(
+    [items, provider],
+    () => {
+      LocalStorage.set(STORAGE_KEY, {
+        items: items.value,
+        provider: provider.value,
+      });
+    },
+    { deep: true },
+  );
+
   const addItem = (item) => {
     items.value.push(item);
   };
 
   const removeItem = (index) => {
     items.value.splice(index, 1);
+
+    if (!items.value.length) {
+      provider.value = null;
+    }
+  };
+
+  const setProvider = (value) => {
+    provider.value = value ? { ...value } : null;
   };
 
   const clear = () => {
     items.value = [];
+    provider.value = null;
+    LocalStorage.remove(STORAGE_KEY);
   };
 
   const setLastCreatedGroup = (group) => {
@@ -24,9 +71,11 @@ export const useServicePackageStore = defineStore("servicePackage", () => {
 
   return {
     items,
+    provider,
     lastCreatedGroup,
     addItem,
     removeItem,
+    setProvider,
     clear,
     setLastCreatedGroup,
   };