| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682 |
- <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
- class="header-back-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>
|