ソースを参照

refactor: fluxo de cart -> service packages

Gustavo Mantovani 6 日 前
コミット
d1aea4ed6c

+ 0 - 53
src/api/cart.js

@@ -1,53 +0,0 @@
-import api from "src/api";
-
-export const getCarts = async () => {
-  const { data } = await api.get("/cart");
-  return data.payload;
-};
-
-export const getMyCarts = async () => {
-  const { data } = await api.get("/cart/me");
-  return data.payload;
-};
-
-export const getCart = async (cartId) => {
-  const { data } = await api.get(`/cart/${cartId}`);
-  return data.payload;
-};
-
-export const createCart = async (payload) => {
-  const { data } = await api.post("/cart", payload);
-  return data.payload;
-};
-
-export const createMyCart = async (payload) => {
-  const { data } = await api.post("/cart/me", payload);
-  return data.payload;
-};
-
-export const createScheduleCart = createMyCart;
-
-export const updateCart = async (cartId, payload) => {
-  const { data } = await api.put(`/cart/${cartId}`, payload);
-  return data.payload;
-};
-
-export const deleteCart = async (cartId) => {
-  const { data } = await api.delete(`/cart/${cartId}`);
-  return data.payload;
-};
-
-export const createCartItem = async (payload) => {
-  const { data } = await api.post("/cart-item", payload);
-  return data.payload;
-};
-
-export const updateCartItem = async (cartItemId, payload) => {
-  const { data } = await api.put(`/cart-item/${cartItemId}`, payload);
-  return data.payload;
-};
-
-export const deleteCartItem = async (cartItemId) => {
-  const { data } = await api.delete(`/cart-item/${cartItemId}`);
-  return data.payload;
-};

+ 5 - 0
src/api/payment.js

@@ -5,6 +5,11 @@ export const paySchedule = async (scheduleId, payload) => {
   return data.payload;
 };
 
+export const payServicePackage = async (servicePackageId, payload) => {
+  const { data } = await api.post(`/payment/service-package/${servicePackageId}/pay`, payload);
+  return data.payload;
+};
+
 export const getSchedulePixPayment = async (scheduleId) => {
   const { data } = await api.get(`/payment/schedule/${scheduleId}/pix`);
   return data.payload;

+ 53 - 0
src/api/servicePackage.js

@@ -0,0 +1,53 @@
+import api from "src/api";
+
+export const getServicePackages = async () => {
+  const { data } = await api.get("/service-packages");
+  return data.payload;
+};
+
+export const getMyServicePackages = async () => {
+  const { data } = await api.get("/service-packages/me");
+  return data.payload;
+};
+
+export const getServicePackage = async (servicePackageId) => {
+  const { data } = await api.get(`/service-packages/${servicePackageId}`);
+  return data.payload;
+};
+
+export const createServicePackage = async (payload) => {
+  const { data } = await api.post("/service-packages", payload);
+  return data.payload;
+};
+
+export const createMyServicePackage = async (payload) => {
+  const { data } = await api.post("/service-packages/me", payload);
+  return data.payload;
+};
+
+export const createScheduleServicePackage = createMyServicePackage;
+
+export const updateServicePackage = async (servicePackageId, payload) => {
+  const { data } = await api.put(`/service-packages/${servicePackageId}`, payload);
+  return data.payload;
+};
+
+export const deleteServicePackage = async (servicePackageId) => {
+  const { data } = await api.delete(`/service-packages/${servicePackageId}`);
+  return data.payload;
+};
+
+export const createServicePackageItem = async (payload) => {
+  const { data } = await api.post("/service-package-items", payload);
+  return data.payload;
+};
+
+export const updateServicePackageItem = async (servicePackageItemId, payload) => {
+  const { data } = await api.put(`/service-package-items/${servicePackageItemId}`, payload);
+  return data.payload;
+};
+
+export const deleteServicePackageItem = async (servicePackageItemId) => {
+  const { data } = await api.delete(`/service-package-items/${servicePackageItemId}`);
+  return data.payload;
+};

+ 7 - 0
src/api/services.js

@@ -0,0 +1,7 @@
+import api from "src/api";
+
+export const getServicesConfig = async () => {
+  const { data } = await api.get("/services/config");
+
+  return data.payload;
+};

+ 7 - 7
src/components/dashboard/ScheduleAcceptedDialog.vue

@@ -57,7 +57,7 @@
         </div>
 
         <div
-          v-if="hasCartDiscount"
+          v-if="hasServicePackageDiscount"
           class="detail-row"
         >
           <span class="text-primary font14 fontmedium">
@@ -70,7 +70,7 @@
         </div>
 
         <div
-          v-if="!hasCartDiscount"
+          v-if="!hasServicePackageDiscount"
           class="detail-row"
         >
           <span class="text-primary font14 fontmedium">
@@ -83,7 +83,7 @@
         </div>
 
         <div
-          v-if="!hasCartDiscount && pixDiscount > 0"
+          v-if="!hasServicePackageDiscount && pixDiscount > 0"
           class="detail-row discount-row"
         >
           <span class="font13 fontmedium">
@@ -96,7 +96,7 @@
         </div>
 
         <div
-          v-if="!hasCartDiscount"
+          v-if="!hasServicePackageDiscount"
           class="detail-row"
         >
           <span class="text-primary font14 fontmedium">
@@ -142,7 +142,7 @@ import { formatCurrency, getFirstName } from "src/helpers/utils";
 
 import {
   getSchedulePlatformFeeRate,
-  scheduleUsesCartDiscount,
+  scheduleUsesServicePackageDiscount,
 } from "src/helpers/paymentPlatformFees";
 
 import { useDialogPluginComponent, useQuasar } from "quasar";
@@ -176,8 +176,8 @@ const creditCardTotal = computed(() =>
   parseFloat((baseAmount.value + platformFee("credit_card")).toFixed(2)),
 );
 
-const hasCartDiscount = computed(() =>
-  scheduleUsesCartDiscount(props.schedule),
+const hasServicePackageDiscount = computed(() =>
+  scheduleUsesServicePackageDiscount(props.schedule),
 );
 
 const formattedDate = computed(() => {

+ 36 - 0
src/components/layout/ServicePackageMenu.vue

@@ -0,0 +1,36 @@
+<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
+<template>
+  <q-btn
+    class="service-package-button"
+    color="primary"
+    icon="mdi-shopping-outline"
+    round
+    unelevated
+    :to="{ name: 'ScheduleCartPage' }"
+  >
+    <q-badge
+      v-if="servicePackage.items.length"
+      color="secondary"
+      floating
+      rounded
+    >
+      {{ servicePackage.items.length }}
+    </q-badge>
+
+    <q-tooltip>
+      {{ $t("service_package.title") }}
+    </q-tooltip>
+  </q-btn>
+</template>
+
+<script setup>
+import { useServicePackageStore } from "src/stores/servicePackage";
+
+const servicePackage = useServicePackageStore();
+</script>
+
+<style scoped lang="scss">
+.service-package-button {
+  box-shadow: 0 8px 24px rgba(166, 61, 247, 0.28);
+}
+</style>

+ 6 - 4
src/composables/usePaymentPlatformFees.js

@@ -2,9 +2,9 @@ import { getPaymentPlatformFees } from 'src/api/payment'
 import { ref } from 'vue'
 
 const platformFees = ref({
-  pix:                  null,
-  credit_card:          null,
-  cart_min_3_schedules: null,
+  pix:                           null,
+  credit_card:                   null,
+  service_package_min_3_schedules: null,
 })
 
 let loadingPromise = null
@@ -37,7 +37,9 @@ const normalizePlatformFees = (fees) => {
       fees?.credit_card_fee_rate,
       fees?.platform_credit_card_fee_rate,
     )),
-    cart_min_3_schedules: normalizeFee(firstDefined(
+    service_package_min_3_schedules: normalizeFee(firstDefined(
+      fees?.service_package_min_3_schedules,
+      fees?.servicePackageMin3Schedules,
       fees?.cart_min_3_schedules,
       fees?.cartMin3Schedules,
       fees?.cart_min_3_schedules_fee,

+ 11 - 0
src/helpers/avatarColors.js

@@ -0,0 +1,11 @@
+export const avatarColors = [
+  { background: "#ffd5df", color: "#932e57" },
+  { background: "#d7e8ff", color: "#2158a8" },
+  { background: "#dfd", color: "#2a7a3b" },
+  { background: "#ffe5cc", color: "#8a4500" },
+];
+
+export const avatarColorsWithPurple = [
+  ...avatarColors,
+  { background: "#ede0ff", color: "#6200ea" },
+];

+ 4 - 4
src/helpers/paymentPlatformFees.js

@@ -1,11 +1,11 @@
-export const scheduleUsesCartDiscount = (schedule) =>
-  Number(schedule?.cart_items_count ?? schedule?.cartItemsCount ?? schedule?.cart?.items_count ?? 0) >= 3
+export const scheduleUsesServicePackageDiscount = (schedule) =>
+  Number(schedule?.service_package_items_count ?? schedule?.cart_items_count ?? schedule?.cartItemsCount ?? schedule?.cart?.items_count ?? 0) >= 3
 
 export const getSchedulePlatformFeeRate = (schedule, paymentType, platformFees) => {
   const fees = platformFees?.value ?? platformFees ?? {}
 
-  if (scheduleUsesCartDiscount(schedule) && fees.cart_min_3_schedules !== null && fees.cart_min_3_schedules !== undefined) {
-    return fees.cart_min_3_schedules
+  if (scheduleUsesServicePackageDiscount(schedule) && fees.service_package_min_3_schedules !== null && fees.service_package_min_3_schedules !== undefined) {
+    return fees.service_package_min_3_schedules
   }
 
   return fees[paymentType]

+ 9 - 0
src/helpers/profileMedia.js

@@ -0,0 +1,9 @@
+export const getProfileMediaUrl = (resource) =>
+  resource?.profile_media_url ??
+  resource?.profile_media?.url ??
+  resource?.provider_photo ??
+  resource?.client?.profile_media_url ??
+  resource?.client?.profile_media?.url ??
+  resource?.provider?.profile_media_url ??
+  resource?.provider?.profile_media?.url ??
+  null;

+ 25 - 0
src/helpers/provider.js

@@ -0,0 +1,25 @@
+const genderTranslationKeys = {
+  female: "genders.female",
+  male: "genders.male",
+  other: "genders.not_informed",
+  prefer_not_to_say: "genders.not_informed",
+};
+
+export const getProviderGenderLabel = (provider, translate) => {
+  const providerData =
+    provider?.provider ?? provider?.user?.provider ?? provider ?? {};
+  const genderLabel =
+    providerData.gender_label ?? provider?.provider_gender_label;
+  const gender = providerData.gender ?? provider?.provider_gender;
+
+  if (!gender && !genderLabel) return "";
+
+  const translatedGender =
+    typeof translate === "function" && genderTranslationKeys[gender]
+      ? translate(genderTranslationKeys[gender])
+      : genderLabel || gender;
+
+  if (typeof translate !== "function") return translatedGender;
+
+  return translate("common.terms.gender") + ": " + translatedGender;
+};

+ 8 - 7
src/i18n/locales/en.json

@@ -798,7 +798,7 @@
       "no_primary_address": "Please add a primary address in your profile to schedule a service."
     }
   },
-  "schedule_cart": {
+  "service_package": {
     "title": "Orders",
     "empty_title": "You do not have any orders yet",
     "empty_message": "Add time slots from favorite professionals to send a request.",
@@ -825,17 +825,18 @@
     "credit_card_fee": "Card total",
     "credit_card_total": "Card total",
     "pix_discount": "Pix discount: save R$ {value}",
-    "cart_fee_discount": "Orders total discount: R$ {value}",
-    "cart_discount_hint": "Add {count} more time slot(s) to get the orders discount.",
-    "cart_discount_applied": "Discount applied: 3 or more time slots in the orders.",
-    "cart_discount_total": "Total",
+    "discount_saved": "Orders total discount: R$ {value}",
+    "discount_hint": "Add {count} more time slot(s) to get the orders discount.",
+    "discount_applied": "Discount applied: 3 or more time slots in the orders.",
+    "discount_total": "Total",
     "provider": "Professional",
     "address": "Address",
     "quantity": "Quantity",
     "schedule_count": "{count} time slot(s)",
     "submit": "send request",
-    "close_cart": "close orders",
-    "cart_closed_success": "Orders closed. Wait for provider approval.",
+    "close": "close orders",
+    "closed_success": "Orders closed. Wait for provider approval.",
+    "same_provider_required": "All time slots must be from the same provider.",
     "unknown": "No information",
     "multiple_providers": "{count} professionals",
     "loading": "Loading...",

+ 8 - 7
src/i18n/locales/es.json

@@ -794,7 +794,7 @@
       "no_primary_address": "Agregue una dirección principal en su perfil para agendar un servicio."
     }
   },
-  "schedule_cart": {
+  "service_package": {
     "title": "Pedidos",
     "empty_title": "Todavía no tiene pedidos",
     "empty_message": "Agregue horarios de profesionales favoritos para enviar una solicitud.",
@@ -821,17 +821,18 @@
     "credit_card_fee": "Total con tarjeta",
     "credit_card_total": "Total con tarjeta",
     "pix_discount": "Descuento con Pix: ahorre R$ {value}",
-    "cart_fee_discount": "Descuento en el total de los pedidos: R$ {value}",
-    "cart_discount_hint": "Agregue {count} horario(s) más para recibir descuento en los pedidos.",
-    "cart_discount_applied": "Descuento aplicado: 3 o más horarios en los pedidos.",
-    "cart_discount_total": "Total",
+    "discount_saved": "Descuento en el total de los pedidos: R$ {value}",
+    "discount_hint": "Agregue {count} horario(s) más para recibir descuento en los pedidos.",
+    "discount_applied": "Descuento aplicado: 3 o más horarios en los pedidos.",
+    "discount_total": "Total",
     "provider": "Profesional",
     "address": "Dirección",
     "quantity": "Cantidad",
     "schedule_count": "{count} horario(s)",
     "submit": "enviar solicitud",
-    "close_cart": "cerrar pedidos",
-    "cart_closed_success": "Pedidos cerrados. Espere la aprobación de los profesionales.",
+    "close": "cerrar pedidos",
+    "closed_success": "Pedidos cerrados. Espere la aprobación de los profesionales.",
+    "same_provider_required": "Todos los horarios deben ser del mismo profesional.",
     "unknown": "Sin información",
     "multiple_providers": "{count} profesionales",
     "loading": "Cargando...",

+ 8 - 7
src/i18n/locales/pt.json

@@ -806,7 +806,7 @@
       "no_primary_address": "Cadastre um endereço principal no seu perfil para agendar."
     }
   },
-  "schedule_cart": {
+  "service_package": {
     "title": "Pedidos",
     "empty_title": "Você ainda não tem pedidos",
     "empty_message": "Adicione horários de profissionais favoritos para enviar uma solicitação.",
@@ -833,17 +833,18 @@
     "credit_card_fee": "Total no cartão",
     "credit_card_total": "Total no cartão",
     "pix_discount": "Desconto no Pix: economize R$ {value}",
-    "cart_fee_discount": "Desconto no total dos pedidos: R$ {value}",
-    "cart_discount_hint": "Adicione mais {count} horário(s) para receber desconto nos pedidos.",
-    "cart_discount_applied": "Desconto aplicado: 3 ou mais horários nos pedidos.",
-    "cart_discount_total": "Total",
+    "discount_saved": "Desconto no total dos pedidos: R$ {value}",
+    "discount_hint": "Adicione mais {count} horário(s) para receber desconto nos pedidos.",
+    "discount_applied": "Desconto aplicado: 3 ou mais horários nos pedidos.",
+    "discount_total": "Total",
     "provider": "Profissional",
     "address": "Endereço",
     "quantity": "Quantidade",
     "schedule_count": "{count} horário(s)",
     "submit": "enviar solicitação",
-    "close_cart": "fechar pedidos",
-    "cart_closed_success": "Pedidos fechados. Aguarde a aprovação dos profissionais.",
+    "close": "fechar pedidos",
+    "closed_success": "Pedidos fechados. Aguarde a aprovação dos profissionais.",
+    "same_provider_required": "Todos os horários devem ser do mesmo profissional.",
     "unknown": "Sem informação",
     "multiple_providers": "{count} profissionais",
     "loading": "Carregando...",

+ 5 - 5
src/layouts/MainLayout.vue

@@ -54,12 +54,12 @@
             />
 
             <q-badge
-              v-if="item.name === 'ScheduleCartPage' && cartCount"
+              v-if="item.name === 'ScheduleCartPage' && servicePackageCount"
               color="secondary"
               floating
               rounded
             >
-              {{ cartCount }}
+              {{ servicePackageCount }}
             </q-badge>
           </span>
 
@@ -77,7 +77,7 @@ import { computed, useTemplateRef, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { useQuasar } from "quasar";
 import { useRoute } from "vue-router";
-import { useScheduleCartStore } from "src/stores/orders";
+import { useServicePackageStore } from "src/stores/servicePackage";
 
 defineOptions({
   name: "MainLayout",
@@ -88,9 +88,9 @@ const route = useRoute();
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
 const $q = useQuasar();
 
-const cart = useScheduleCartStore();
+const servicePackage = useServicePackageStore();
 
-const cartCount = computed(() => cart.items.length);
+const servicePackageCount = computed(() => servicePackage.items.length);
 
 const navItems = computed(() => [
   { name: "DashboardPage", label: t("nav.home"), icon: "mdi-home-outline" },

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

@@ -0,0 +1,681 @@
+<template>
+  <q-page class="service-package-page bg-page q-pb-xl">
+    <div class="row items-center q-px-md q-pt-md q-pb-sm bg-white shadow-service-package">
+      <q-btn
+        color="primary"
+        dense
+        flat
+        icon="mdi-chevron-left"
+        round
+        @click="router.back()"
+      />
+
+      <div
+        class="col text-center font16 fontbold gradient-diarista text-primary"
+      >
+        {{ $t("service_package.title") }}
+      </div>
+
+      <div style="width: 36px"></div>
+    </div>
+
+    <div
+      v-if="servicePackage.items.length === 0"
+      class="q-px-md q-pt-md"
+    >
+      <q-card
+        :flat="false"
+        class="empty-card bg-surface shadow-card card-border"
+      >
+        <q-card-section class="column items-center text-center q-pa-lg">
+          <q-icon
+            color="grey-5"
+            name="mdi-shopping-outline"
+            size="42px"
+          />
+
+          <span class="font14 fontbold text-text q-mt-sm">
+            {{ $t("service_package.empty_title") }}
+          </span>
+
+          <span class="font12 text-grey-7 q-mt-xs">
+            {{ $t("service_package.empty_message") }}
+          </span>
+
+          <q-btn
+            class="q-mt-md"
+            color="primary"
+            no-caps
+            padding="8px 18px"
+            rounded
+            unelevated
+            :label="$t('service_package.view_favorites')"
+            @click="router.push({ name: 'ProfilePage' })"
+          />
+        </q-card-section>
+      </q-card>
+
+      <q-card
+        v-if="servicePackage.lastCreatedGroup"
+        :flat="false"
+        class="created-card bg-surface shadow-card card-border q-mt-md"
+      >
+        <q-card-section class="q-pa-md">
+          <div class="row items-center no-wrap">
+            <q-icon
+              color="positive"
+              name="mdi-check-circle-outline"
+              size="24px"
+            />
+
+            <div class="column q-ml-sm">
+              <span class="font14 fontbold text-text">
+                {{ $t("service_package.last_request_title") }}
+              </span>
+
+              <span class="font12 text-grey-7">
+                {{
+                  $t("service_package.created_count", {
+                    count: servicePackage.lastCreatedGroup.schedule_ids.length,
+                  })
+                }}
+              </span>
+            </div>
+          </div>
+        </q-card-section>
+      </q-card>
+    </div>
+
+    <template v-else>
+      <div class="q-px-md q-pt-md column q-gutter-y-sm">
+        <div class="service-package-discount-banner row items-center no-wrap q-pa-sm">
+          <q-icon
+            color="positive"
+            name="mdi-sale-outline"
+            size="20px"
+          />
+
+          <span class="font12 fontbold text-positive q-ml-sm">
+            {{ servicePackageDiscountMessage }}
+          </span>
+        </div>
+
+        <q-card
+          v-for="(item, index) in servicePackage.items"
+          :key="item.local_id"
+          :flat="false"
+          class="service-package-item-card bg-surface shadow-card card-border"
+        >
+          <q-card-section class="row no-wrap q-pa-md">
+            <q-avatar
+              color="indigo-1"
+              size="44px"
+              text-color="indigo-4"
+            >
+              <img
+                v-if="getProfileMediaUrl(item)"
+                :src="getProfileMediaUrl(item)"
+                style="object-fit: cover"
+              />
+
+              <span v-else>
+                {{ getProviderInitial(item) }}
+              </span>
+            </q-avatar>
+
+            <div class="column col q-ml-sm">
+              <span class="font14 fontbold text-text">
+                {{ item.provider_name }}
+              </span>
+
+              <span class="font12 text-grey-7">
+                {{ formatDate(item.date) }}
+              </span>
+
+              <span class="font12 text-grey-7">
+                {{
+                  $t("service_package.time_range", {
+                    start: item.start_time,
+                    end: item.end_time,
+                  })
+                }}
+              </span>
+
+              <div class="row items-center q-gutter-x-sm q-mt-xs">
+                <q-chip
+                  class="font10"
+                  color="purple-1"
+                  dense
+                  square
+                  text-color="primary"
+                >
+                  {{
+                    $t("service_package.hours_short", { hours: item.period_type })
+                  }}
+                </q-chip>
+
+                <q-chip
+                  class="font10"
+                  color="grey-2"
+                  dense
+                  square
+                  text-color="grey-8"
+                >
+                  {{
+                    item.offers_meal
+                      ? $t("service_package.with_meal")
+                      : $t("service_package.without_meal")
+                  }}
+                </q-chip>
+              </div>
+
+              <div class="item-prices q-mt-sm">
+                <div
+                  v-if="!hasServicePackageDiscount"
+                  class="row items-center no-wrap"
+                >
+                  <span class="font11 fontbold text-primary">{{
+                    $t("service_package.pix_total")
+                  }}</span>
+
+                  <q-space />
+
+                  <span class="font12 fontbold text-primary">
+                    {{ formatCurrencyValue(getStandardPixItemTotal(item)) }}
+                  </span>
+                </div>
+
+                <div
+                  v-if="!hasServicePackageDiscount"
+                  class="row items-center no-wrap q-mt-xs"
+                >
+                  <span class="font11 text-grey-7">
+                    {{ $t("service_package.credit_card_total") }}
+                  </span>
+
+                  <q-space />
+
+                  <span class="font12 fontbold text-text">
+                    {{ formatCurrencyValue(getCreditCardItemTotal(item)) }}
+                  </span>
+                </div>
+
+                <div
+                  v-if="hasServicePackageDiscount"
+                  class="row items-center no-wrap q-mt-xs"
+                >
+                  <span class="font11 fontbold text-positive">
+                    {{ $t("service_package.discount_total") }}
+                  </span>
+
+                  <q-space />
+
+                  <span class="font12 fontbold text-positive">
+                    {{ formatCurrencyValue(getServicePackageDiscountItemTotal(item)) }}
+                  </span>
+                </div>
+              </div>
+            </div>
+
+            <q-btn
+              color="negative"
+              dense
+              flat
+              icon="mdi-delete-outline"
+              round
+              @click="servicePackage.removeItem(index)"
+            />
+          </q-card-section>
+        </q-card>
+      </div>
+
+      <div class="q-px-md q-pt-md">
+        <q-card
+          :flat="false"
+          class="checkout-card bg-surface shadow-card card-border"
+        >
+          <q-card-section class="q-pa-md">
+            <div class="row items-center no-wrap q-mb-sm">
+              <span class="font14 fontbold text-primary">
+                {{ $t("service_package.summary") }}
+              </span>
+
+              <q-space />
+            </div>
+
+            <div class="summary-row row items-center no-wrap q-py-xs">
+              <span class="font12 text-grey-7">
+                {{ $t("service_package.provider") }}
+              </span>
+
+              <q-space />
+
+              <span class="font12 fontbold text-text text-right">
+                {{ providerName }}
+              </span>
+            </div>
+
+            <div class="summary-row row items-center no-wrap q-py-xs">
+              <span class="font12 text-grey-7">
+                {{ $t("service_package.address") }}
+              </span>
+
+              <q-space />
+
+              <span class="font12 fontbold text-text text-right">
+                {{ primaryAddressLabel }}
+              </span>
+            </div>
+
+            <div class="summary-row row items-center no-wrap q-py-xs">
+              <span class="font12 text-grey-7">
+                {{ $t("service_package.quantity") }}
+              </span>
+
+              <q-space />
+
+              <span class="font12 fontbold text-text">
+                {{
+                  $t("service_package.schedule_count", {
+                    count: servicePackage.items.length,
+                  })
+                }}
+              </span>
+            </div>
+
+            <div
+              v-if="!hasServicePackageDiscount"
+              class="summary-row row items-center no-wrap q-py-xs"
+            >
+              <span class="font12 fontbold text-primary">
+                {{ $t("service_package.pix_total") }}
+              </span>
+
+              <q-space />
+
+              <span class="font14 fontbold text-primary">
+                {{ formatCurrencyValue(pixTotalAmount) }}
+              </span>
+            </div>
+
+            <div
+              v-if="hasServicePackageDiscount"
+              class="summary-row row items-center no-wrap q-py-xs"
+            >
+              <span class="font12 fontbold text-positive">
+                {{ $t("service_package.discount_total") }}
+              </span>
+
+              <q-space />
+
+              <span class="font14 fontbold text-positive">
+                {{ formatCurrencyValue(servicePackageDiscountTotalAmount) }}
+              </span>
+            </div>
+
+            <div
+              v-if="pixDiscountAmount > 0"
+              class="summary-discount row items-center no-wrap q-py-xs q-px-sm q-mt-sm"
+            >
+              <q-icon
+                color="positive"
+                name="mdi-tag-outline"
+                size="18px"
+              />
+
+              <span class="font12 fontbold text-positive q-ml-xs">
+                {{
+                  $t("service_package.discount_saved", {
+                    value: formatPrice(pixDiscountAmount),
+                  })
+                }}
+              </span>
+            </div>
+          </q-card-section>
+
+          <q-card-actions class="q-px-md q-pb-md">
+            <q-btn
+              class="full-width"
+              color="primary"
+              no-caps
+              padding="10px 16px"
+              rounded
+              unelevated
+              :disable="addressLoading || !platformFeesReady || !hasSingleProvider"
+              :label="$t('service_package.close')"
+              :loading="submitting"
+              @click="submitServicePackage"
+            />
+          </q-card-actions>
+        </q-card>
+      </div>
+    </template>
+  </q-page>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { createScheduleServicePackage } from "src/api/servicePackage";
+import { getAddresses } from "src/api/address";
+import { getProfileMediaUrl } from "src/helpers/profileMedia";
+import { useI18n } from "vue-i18n";
+import { usePaymentPlatformFees } from "src/composables/usePaymentPlatformFees";
+import { useQuasar } from "quasar";
+import { useRouter } from "vue-router";
+import { userStore } from "src/stores/user";
+import { useServicePackageStore } from "src/stores/servicePackage";
+
+const { t } = useI18n();
+const router = useRouter();
+const $q = useQuasar();
+const { platformFees, loadPlatformFees } = usePaymentPlatformFees();
+
+const servicePackage = useServicePackageStore();
+const store = userStore();
+
+const addressLoading = ref(false);
+const primaryAddress = ref(null);
+const submitting = ref(false);
+
+const isValidFee = (fee) =>
+  fee !== null && fee !== undefined && Number.isFinite(Number(fee));
+
+const roundMoney = (value) => Number(Number(value ?? 0).toFixed(2));
+
+const roundNullableMoney = (value) => {
+  if (value === null || value === undefined || !Number.isFinite(Number(value)))
+    return null;
+
+  return roundMoney(value);
+};
+
+const sumItems = (callback) => {
+  let total = 0;
+
+  for (const item of servicePackage.items) {
+    const itemTotal = callback(item);
+
+    if (itemTotal === null) return null;
+
+    total += itemTotal;
+  }
+
+  return total;
+};
+
+const getTotalWithFee = (item, fee) => {
+  if (!isValidFee(fee)) return null;
+
+  return roundMoney(Number(item.base_amount ?? 0) * (1 + Number(fee)));
+};
+
+const getServicePackageDiscountItemTotal = (item) =>
+  getTotalWithFee(item, platformFees.value.service_package_min_3_schedules);
+
+const getStandardPixItemTotal = (item) =>
+  getTotalWithFee(item, platformFees.value.pix);
+
+const servicePackageDiscountTotalAmount = computed(() =>
+  roundNullableMoney(sumItems(getServicePackageDiscountItemTotal)),
+);
+
+const hasServicePackageDiscount = computed(() => servicePackage.items.length >= 3);
+
+const missingSchedulesForDiscount = computed(() =>
+  Math.max(3 - servicePackage.items.length, 0),
+);
+
+const servicePackageDiscountMessage = computed(() => {
+  if (hasServicePackageDiscount.value) {
+    return t("service_package.discount_applied");
+  }
+
+  return t("service_package.discount_hint", {
+    count: missingSchedulesForDiscount.value,
+  });
+});
+
+const platformFeesReady = computed(
+  () =>
+    isValidFee(platformFees.value.pix) &&
+    isValidFee(platformFees.value.credit_card) &&
+    (!hasServicePackageDiscount.value ||
+      isValidFee(platformFees.value.service_package_min_3_schedules)),
+);
+
+const hasSingleProvider = computed(() => {
+  const providers = new Set(
+    servicePackage.items.map((item) => item.provider_id).filter(Boolean),
+  );
+
+  return providers.size <= 1;
+});
+
+const pixDiscountAmount = computed(() => {
+  if (!hasServicePackageDiscount.value) return 0;
+
+  return (
+    roundNullableMoney(
+      sumItems((item) => {
+        const standardPixItemTotal = getStandardPixItemTotal(item);
+        const discountedPixItemTotal = getServicePackageDiscountItemTotal(item);
+
+        if (standardPixItemTotal === null || discountedPixItemTotal === null)
+          return null;
+
+        return standardPixItemTotal - discountedPixItemTotal;
+      }),
+    ) ?? 0
+  );
+});
+
+const pixTotalAmount = computed(() =>
+  roundNullableMoney(sumItems(getStandardPixItemTotal)),
+);
+
+const primaryAddressLabel = computed(() => {
+  if (addressLoading.value) return t("service_package.loading");
+  if (!primaryAddress.value) return t("service_package.no_primary_address");
+
+  return [
+    primaryAddress.value.address,
+    primaryAddress.value.number,
+    primaryAddress.value.district,
+  ]
+    .filter(Boolean)
+    .join(", ");
+});
+
+const providerName = computed(() => {
+  const providerNames = [
+    ...new Set(servicePackage.items.map((item) => item.provider_name).filter(Boolean)),
+  ];
+
+  if (providerNames.length === 0) return t("service_package.unknown");
+  if (providerNames.length === 1) return providerNames[0];
+
+  return t("service_package.multiple_providers", { count: providerNames.length });
+});
+
+const normalizeDate = (value) =>
+  String(value ?? "")
+    .replace(/\//g, "-")
+    .slice(0, 10);
+
+const formatDate = (value) => {
+  const parsed = new Date(`${normalizeDate(value)}T12:00:00`);
+
+  return new Intl.DateTimeFormat("pt-BR", {
+    day: "2-digit",
+    month: "2-digit",
+    year: "numeric",
+  }).format(parsed);
+};
+
+const formatPrice = (value) => {
+  if (
+    value === null ||
+    value === undefined ||
+    !Number.isFinite(Number(value))
+  ) {
+    return t("service_package.loading");
+  }
+
+  return Number(value ?? 0).toLocaleString("pt-BR", {
+    minimumFractionDigits: 2,
+    maximumFractionDigits: 2,
+  });
+};
+
+const formatCurrencyValue = (value) => {
+  if (
+    value === null ||
+    value === undefined ||
+    !Number.isFinite(Number(value))
+  ) {
+    return t("service_package.loading");
+  }
+
+  return t("service_package.currency_value", { value: formatPrice(value) });
+};
+
+const getCreditCardItemTotal = (item) =>
+  getTotalWithFee(item, platformFees.value.credit_card);
+
+const getProviderInitial = (item) =>
+  item.provider_name?.slice(0, 1).toUpperCase() ??
+  t("service_package.provider_initial");
+
+const loadPrimaryAddress = async () => {
+  const clientId = store.user?.client_id ?? store.user?.client?.id;
+
+  if (!clientId) return;
+
+  addressLoading.value = true;
+
+  try {
+    const addresses = await getAddresses("client", clientId);
+
+    primaryAddress.value =
+      (addresses ?? []).find((address) => address.is_primary) ?? null;
+  } catch {
+    primaryAddress.value = null;
+  } finally {
+    addressLoading.value = false;
+  }
+};
+
+const submitServicePackage = async () => {
+  if (!servicePackage.items.length || submitting.value) return;
+
+  if (!hasSingleProvider.value) {
+    $q.notify({
+      type: "warning",
+      message: t("service_package.same_provider_required"),
+    });
+
+    return;
+  }
+
+  if (!primaryAddress.value) {
+    $q.notify({
+      type: "warning",
+      message: t("service_package.no_primary_address_notify"),
+    });
+
+    return;
+  }
+
+  const payload = {
+    client_id: store.user.client_id ?? store.user?.client?.id,
+    address_id: primaryAddress.value.id,
+    schedule_type: "default",
+
+    schedules: servicePackage.items.map((item) => ({
+      provider_id: item.provider_id,
+      date: item.date,
+      period_type: item.period_type,
+      start_time: item.start_time,
+      end_time: item.end_time,
+      total_amount: item.base_amount,
+      offers_meal: item.offers_meal,
+    })),
+  };
+
+  submitting.value = true;
+
+  try {
+    const createdServicePackage = await createScheduleServicePackage(payload);
+
+    const createdSchedules = createdServicePackage?.schedules ?? [];
+
+    const scheduleIds =
+      createdServicePackage?.schedule_ids ??
+      createdSchedules.map((schedule) => schedule.id);
+
+    servicePackage.setLastCreatedGroup({
+      local_group_id: createdServicePackage?.id ?? `sp-${Date.now()}`,
+      service_package_id: createdServicePackage?.id ?? null,
+      schedule_ids: scheduleIds,
+      schedules: createdSchedules,
+    });
+
+    servicePackage.clear();
+
+    $q.notify({
+      type: "positive",
+      message: t("service_package.closed_success"),
+    });
+  } catch (error) {
+    const message =
+      error?.response?.data?.message ??
+      error?.message ??
+      t("service_package.submit_error");
+
+    $q.notify({ type: "negative", message });
+  } finally {
+    submitting.value = false;
+  }
+};
+
+onMounted(() => {
+  loadPrimaryAddress();
+  loadPlatformFees().catch(() => {});
+});
+</script>
+
+<style scoped lang="scss">
+.service-package-page {
+  min-height: 100%;
+}
+
+.shadow-service-package {
+  box-shadow: 0 1px 8px rgba(0, 0, 0, 0.1);
+}
+
+.service-package-item-card,
+.checkout-card,
+.empty-card,
+.created-card {
+  border-radius: 8px;
+}
+
+.summary-row {
+  border-top: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.item-prices {
+  max-width: 220px;
+}
+
+.service-package-discount-banner {
+  background: rgba(34, 197, 94, 0.1);
+  border: 1px solid rgba(34, 197, 94, 0.22);
+  border-radius: 8px;
+}
+
+.summary-discount {
+  background: rgba(34, 197, 94, 0.12);
+  border-radius: 8px;
+}
+</style>

+ 13 - 13
src/pages/search/components/SchedulingDialog.vue

@@ -279,9 +279,9 @@ import {
 import { getProviderReceivedReviews } from "src/api/review";
 import { useI18n } from "vue-i18n";
 import { userStore } from "src/stores/user";
-import { useScheduleCartStore } from "src/stores/orders";
+import { useServicePackageStore } from "src/stores/servicePackage";
 
-import OrderSummaryDialog from "./OrderSummaryDialog.vue";
+import ServicePackageSummaryDialog from "./ServicePackageSummaryDialog.vue";
 import ServiceSelectionSheet from "./ServiceSelectionSheet.vue";
 import ServiceTimeSelectionDialog from "./ServiceTimeSelectionDialog.vue";
 
@@ -295,7 +295,7 @@ const { t } = useI18n();
 const $q = useQuasar();
 const { dialogRef } = useDialogPluginComponent();
 
-const scheduleCart = useScheduleCartStore();
+const servicePackage = useServicePackageStore();
 const store = userStore();
 
 const blockedDays = ref([]);
@@ -342,10 +342,10 @@ const getWeekStart = (dateStr) => {
   return d.toISOString().split("T")[0];
 };
 
-const getCartWeekCount = (selectedDate) => {
+const getServicePackageWeekCount = (selectedDate) => {
   const weekStart = getWeekStart(selectedDate);
 
-  return scheduleCart.items.filter(
+  return servicePackage.items.filter(
     (item) =>
       Number(item.provider_id) === Number(props.provider.provider_id) &&
       getWeekStart(item.date) === weekStart,
@@ -369,11 +369,11 @@ const getServerWeekCount = (selectedDate) => {
 };
 
 const wouldExceedWeekLimit = (selectedDate) => {
-  const cartCount = getCartWeekCount(selectedDate);
+  const servicePackageCount = getServicePackageWeekCount(selectedDate);
   const localCount = getLocalWeekCount(selectedDate);
   const serverCount = getServerWeekCount(selectedDate);
 
-  return serverCount + localCount + cartCount >= 2;
+  return serverCount + localCount + servicePackageCount >= 2;
 };
 
 const dateOptions = (d) => {
@@ -502,7 +502,7 @@ const onDateSelected = (val) => {
       init_hour: `${booking.slot.startHour}:00:00`,
     }));
 
-  const cartBookingBlocks = scheduleCart.items
+  const servicePackageBookingBlocks = servicePackage.items
     .filter(
       (item) =>
         Number(item.provider_id) === Number(props.provider.provider_id) &&
@@ -527,7 +527,7 @@ const onDateSelected = (val) => {
     ...workingDayBlocks,
     ...localBookingBlocks,
     ...serverBookingBlocks,
-    ...cartBookingBlocks,
+    ...servicePackageBookingBlocks,
   ];
 
   $q.dialog({
@@ -547,8 +547,8 @@ const onDateSelected = (val) => {
         serviceType,
       },
     }).onOk((booking) => {
-      if (action === "cart") {
-        scheduleCart.addItem({
+      if (action === "servicePackage") {
+        servicePackage.addItem({
           date: normalizeDate(booking.date),
           end_time: formatHour(booking.slot.endHour),
           local_id: `${prov.provider_id}-${booking.date}-${booking.slot.value}-${Date.now()}`,
@@ -568,7 +568,7 @@ const onDateSelected = (val) => {
         });
 
         $q.notify({
-          message: t("schedule_cart.add_success"),
+          message: t("service_package.add_success"),
           type: "positive",
         });
 
@@ -578,7 +578,7 @@ const onDateSelected = (val) => {
       bookings.value.push(booking);
 
       $q.dialog({
-        component: OrderSummaryDialog,
+        component: ServicePackageSummaryDialog,
         componentProps: { initialBooking: booking, provider: props.provider },
       }).onOk(() => {
         dialogRef.value.hide();

+ 7 - 11
src/pages/search/components/OrderSummaryDialog.vue → src/pages/search/components/ServicePackageSummaryDialog.vue

@@ -162,7 +162,7 @@ import {
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 import { userStore } from "src/stores/user";
-import { useScheduleCartStore } from "src/stores/orders";
+import { useServicePackageStore } from "src/stores/servicePackage";
 
 import ServiceSelectionSheet from "./ServiceSelectionSheet.vue";
 import ServiceTimeSelectionDialog from "./ServiceTimeSelectionDialog.vue";
@@ -179,7 +179,7 @@ const router = useRouter();
 const $q = useQuasar();
 const { dialogRef, onDialogOK } = useDialogPluginComponent();
 
-const scheduleCart = useScheduleCartStore();
+const servicePackage = useServicePackageStore();
 const store = userStore();
 
 const addDateValue = ref(null);
@@ -224,10 +224,10 @@ const getWeekStart = (dateStr) => {
   return d.toISOString().slice(0, 10);
 };
 
-const getCartWeekCount = (newDateStr) => {
+const getServicePackageWeekCount = (newDateStr) => {
   const newWeek = getWeekStart(newDateStr);
 
-  return scheduleCart.items.filter(
+  return servicePackage.items.filter(
     (item) =>
       Number(item.provider_id) === Number(props.provider.provider_id) &&
       getWeekStart(item.date) === newWeek,
@@ -254,7 +254,7 @@ const wouldExceedWeekLimit = (newDateStr) => {
   const count =
     getServerWeekCount(newDateStr) +
     getLocalWeekCount(newDateStr) +
-    getCartWeekCount(newDateStr);
+    getServicePackageWeekCount(newDateStr);
 
   return count >= 2;
 };
@@ -394,8 +394,6 @@ const onAddDateSelected = (val) => {
     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) => ({
@@ -412,7 +410,7 @@ const onAddDateSelected = (val) => {
       end_hour: schedule.end_time,
     }));
 
-  const cartBookingBlocks = scheduleCart.items
+  const servicePackageBookingBlocks = servicePackage.items
     .filter(
       (item) =>
         Number(item.provider_id) === Number(props.provider.provider_id) &&
@@ -428,11 +426,9 @@ const onAddDateSelected = (val) => {
     ...workingDayBlocks,
     ...existingBookingBlocks,
     ...serverBookingBlocks,
-    ...cartBookingBlocks,
+    ...servicePackageBookingBlocks,
   ];
 
-  //
-
   $q.dialog({
     component: ServiceSelectionSheet,
     componentProps: {

+ 1 - 7
src/pages/search/components/ServiceSelectionSheet.vue

@@ -54,10 +54,6 @@
             </div>
           </div>
 
-          <!-- <div class="font16 fontbold text-text q-mx-md" style="white-space: nowrap;">
-            {{ type.price != null ? formatPrice(type.price) : $t('scheduling_page.no_price') }}
-          </div> -->
-
           <div class="row no-wrap q-gutter-x-xs">
             <q-btn
               color="secondary"
@@ -67,7 +63,7 @@
               size="sm"
               unelevated
               :label="$t('common.actions.select')"
-              @click="onDialogOK({ action: 'cart', serviceType: type, date: selectedDate, provider })"
+              @click="onDialogOK({ action: 'servicePackage', serviceType: type, date: selectedDate, provider })"
             />
           </div>
         </div>
@@ -166,8 +162,6 @@ const availableServiceTypes = computed(() =>
   ].filter((type) => hasValidSlots(type.hoursCount)),
 );
 
-// const formatPrice = (value) =>Number(value).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
-
 const openInfo = (type) => {
   $q.dialog({
     component: ServiceTypeInfoDialog,

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

@@ -0,0 +1,21 @@
+export default [
+  {
+    path: "pedidos",
+    name: "ScheduleCartPage",
+    component: () => import("src/pages/orders/ServicePackagePage.vue"),
+    meta: {
+      title: "service_package.title",
+      requireAuth: true,
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "ui.navigation.dashboard",
+        },
+        {
+          name: "ScheduleCartPage",
+          title: "service_package.title",
+        },
+      ],
+    },
+  },
+];

+ 33 - 0
src/stores/servicePackage.js

@@ -0,0 +1,33 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+
+export const useServicePackageStore = defineStore("servicePackage", () => {
+  const items = ref([]);
+
+  const lastCreatedGroup = ref(null);
+
+  const addItem = (item) => {
+    items.value.push(item);
+  };
+
+  const removeItem = (index) => {
+    items.value.splice(index, 1);
+  };
+
+  const clear = () => {
+    items.value = [];
+  };
+
+  const setLastCreatedGroup = (group) => {
+    lastCreatedGroup.value = group;
+  };
+
+  return {
+    items,
+    lastCreatedGroup,
+    addItem,
+    removeItem,
+    clear,
+    setLastCreatedGroup,
+  };
+});