2026-06-12 20:16:14 +03:30
|
|
|
|
<template>
|
|
|
|
|
|
<div class="min-w-0 max-w-full space-y-6 overflow-x-hidden pb-24 lg:pb-0">
|
|
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
|
<h1 class="text-xl font-bold text-text lg:text-2xl">رزرو خدمات</h1>
|
|
|
|
|
|
<RouterLink
|
|
|
|
|
|
:to="homeTo"
|
|
|
|
|
|
class="inline-flex items-center gap-2 text-sm text-muted transition hover:text-brand-600"
|
|
|
|
|
|
>
|
|
|
|
|
|
<AppIcon name="home" size="sm" />
|
|
|
|
|
|
بازگشت به صفحه اصلی
|
|
|
|
|
|
</RouterLink>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<BookingSuccessView
|
|
|
|
|
|
v-if="phase === 'success' && successDetails"
|
|
|
|
|
|
v-bind="successDetails"
|
|
|
|
|
|
@view-bookings="emit('view-bookings')"
|
|
|
|
|
|
@add-to-calendar="downloadCalendar"
|
|
|
|
|
|
@go-home="emit('go-home')"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<BookingStepper :current="currentStep" />
|
|
|
|
|
|
<p class="text-center text-sm font-semibold text-brand-600 lg:hidden">
|
|
|
|
|
|
{{ currentStepLabel }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
<BookingServiceClassStep
|
|
|
|
|
|
v-if="currentStep === 1"
|
|
|
|
|
|
v-model="selectedClassId"
|
|
|
|
|
|
:classes="serviceClasses"
|
|
|
|
|
|
:loading="loadingClasses"
|
|
|
|
|
|
:error="classesError"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else-if="currentStep === 2" class="space-y-4">
|
|
|
|
|
|
<p class="text-sm text-muted">
|
|
|
|
|
|
میتوانید یک یا چند خدمت انتخاب کنید. برای لغو انتخاب، دوباره روی همان خدمت بزنید.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<ServiceGridSkeleton v-if="loadingServices" />
|
|
|
|
|
|
<p v-else-if="servicesError" class="text-sm text-red-600">{{ servicesError }}</p>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-4 xl:grid-cols-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="service in services"
|
|
|
|
|
|
:key="service.id"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="relative rounded-2xl border p-4 text-right transition"
|
|
|
|
|
|
:class="
|
|
|
|
|
|
isServiceSelected(service.id)
|
|
|
|
|
|
? 'border-brand-500 bg-brand-50 ring-1 ring-brand-500/30 dark:bg-brand-500/10'
|
|
|
|
|
|
: 'border-border bg-white hover:border-brand-500/40 dark:bg-surface'
|
|
|
|
|
|
"
|
|
|
|
|
|
:disabled="!service.isActive"
|
|
|
|
|
|
@click="toggleService(service.id)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="isServiceSelected(service.id)"
|
|
|
|
|
|
class="absolute left-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white"
|
|
|
|
|
|
>
|
|
|
|
|
|
<AppIcon name="circle-check" size="sm" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<p class="font-semibold text-text">
|
|
|
|
|
|
{{ service.serviceTemplate?.title ?? 'خدمت' }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="mt-2 text-sm text-muted">
|
|
|
|
|
|
مدت زمان: {{ durationFor(service) }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p class="mt-2 text-sm font-bold text-brand-600">
|
|
|
|
|
|
{{ priceFor(service) }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="selectedServices.length"
|
|
|
|
|
|
class="flex items-center justify-between rounded-xl border border-brand-500/30 bg-brand-50 px-4 py-3 text-sm dark:bg-brand-500/10"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="text-text">
|
|
|
|
|
|
{{ selectedServices.length }} خدمت انتخاب شده
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="font-bold text-brand-700 dark:text-brand-500">
|
|
|
|
|
|
جمع: {{ formatPriceToman(selectedPrice) }} تومان
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="auth.isLoggedIn"
|
|
|
|
|
|
class="grid min-w-0 grid-cols-1 items-start gap-4 lg:grid-cols-3 lg:gap-6"
|
|
|
|
|
|
>
|
|
|
|
|
|
<BookingOrderSummary
|
|
|
|
|
|
class="order-2 min-w-0 w-full lg:order-1 lg:col-span-1 lg:sticky lg:top-24"
|
|
|
|
|
|
:items="summaryItems"
|
|
|
|
|
|
:class-title="selectedClassTitle"
|
|
|
|
|
|
:total-price="selectedPrice"
|
|
|
|
|
|
:total-duration-minutes="selectedDurationMinutes"
|
|
|
|
|
|
:image-url="coverImageUrl"
|
|
|
|
|
|
:earliest-slot="earliestSlotLabel"
|
|
|
|
|
|
@select-earliest="applyEarliestSlot"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="order-1 min-w-0 w-full space-y-4 lg:order-2 lg:col-span-2">
|
|
|
|
|
|
<DesktopBookingDateTime
|
|
|
|
|
|
v-if="currentStep === 3"
|
|
|
|
|
|
ref="dateTimeRef"
|
|
|
|
|
|
v-model="scheduledAt"
|
|
|
|
|
|
:business-id="business.id"
|
|
|
|
|
|
:business-service-ids="selectedServiceIds"
|
|
|
|
|
|
:service-class-id="selectedClassId || null"
|
|
|
|
|
|
:duration-minutes="selectedDurationMinutes"
|
|
|
|
|
|
:earliest-slot-iso="earliestSlotIso"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="currentStep === 4"
|
|
|
|
|
|
class="rounded-2xl border border-border bg-white p-5 shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface"
|
|
|
|
|
|
>
|
|
|
|
|
|
<h3 class="mb-4 font-semibold text-text">اطلاعات تکمیلی</h3>
|
|
|
|
|
|
<AppTextarea
|
|
|
|
|
|
v-model="note"
|
|
|
|
|
|
label="یادداشت (اختیاری)"
|
|
|
|
|
|
placeholder="در صورت نیاز توضیحی بنویسید..."
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p class="mt-4 text-sm text-muted">
|
|
|
|
|
|
پرداخت در محل انجام میشود. نیازی به پرداخت آنلاین نیست.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else-if="currentStep === 5"
|
|
|
|
|
|
class="rounded-2xl border border-border bg-white p-5 shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface"
|
|
|
|
|
|
>
|
|
|
|
|
|
<h3 class="mb-4 font-semibold text-text">بررسی نهایی</h3>
|
|
|
|
|
|
<dl class="space-y-3 text-sm">
|
|
|
|
|
|
<div v-if="selectedClassTitle" class="flex justify-between gap-4">
|
|
|
|
|
|
<dt class="text-muted">کلاس</dt>
|
|
|
|
|
|
<dd class="font-medium text-text">{{ selectedClassTitle }}</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex justify-between gap-4">
|
|
|
|
|
|
<dt class="shrink-0 text-muted">خدمات</dt>
|
|
|
|
|
|
<dd class="max-w-[60%] text-left font-medium text-text">
|
|
|
|
|
|
{{ selectedServiceTitle }}
|
|
|
|
|
|
</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex justify-between gap-4">
|
|
|
|
|
|
<dt class="text-muted">تاریخ و ساعت</dt>
|
|
|
|
|
|
<dd class="font-medium text-text">{{ scheduledLabel }}</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="flex justify-between gap-4">
|
|
|
|
|
|
<dt class="text-muted">مبلغ</dt>
|
|
|
|
|
|
<dd class="font-medium text-text">
|
|
|
|
|
|
{{ formatPriceToman(selectedPrice) }} تومان
|
|
|
|
|
|
</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="note.trim()" class="flex justify-between gap-4">
|
|
|
|
|
|
<dt class="text-muted">یادداشت</dt>
|
|
|
|
|
|
<dd class="max-w-[60%] text-left font-medium text-text">{{ note }}</dd>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</dl>
|
|
|
|
|
|
<p v-if="bookingError" class="mt-4 text-sm text-red-600">{{ bookingError }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="hidden flex-wrap items-center justify-between gap-3 lg:flex"
|
|
|
|
|
|
>
|
|
|
|
|
|
<AppButton variant="ghost" class="!w-auto" @click="goBack">
|
|
|
|
|
|
مرحله قبل
|
|
|
|
|
|
</AppButton>
|
|
|
|
|
|
<AppButton
|
|
|
|
|
|
class="!w-auto min-w-[140px]"
|
|
|
|
|
|
:disabled="!canProceed"
|
|
|
|
|
|
:loading="bookingLoading && currentStep === 5"
|
|
|
|
|
|
@click="goNext"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ nextButtonLabel }}
|
|
|
|
|
|
</AppButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="showMobileActions"
|
|
|
|
|
|
class="fixed inset-x-0 bottom-16 z-20 border-t border-border bg-surface/95 backdrop-blur lg:hidden"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="mx-auto flex w-full max-w-app gap-2 px-4 py-3">
|
|
|
|
|
|
<AppButton
|
|
|
|
|
|
v-if="currentStep > 1"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
class="!w-auto shrink-0 px-4"
|
|
|
|
|
|
@click="goBack"
|
|
|
|
|
|
>
|
|
|
|
|
|
قبل
|
|
|
|
|
|
</AppButton>
|
|
|
|
|
|
<AppButton
|
|
|
|
|
|
class="flex-1"
|
|
|
|
|
|
:disabled="!canProceed"
|
|
|
|
|
|
:loading="bookingLoading && currentStep === 5"
|
|
|
|
|
|
@click="goNext"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ nextButtonLabel }}
|
|
|
|
|
|
</AppButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="!auth.isLoggedIn && currentStep > 2"
|
|
|
|
|
|
class="rounded-2xl border border-brand-500/30 bg-brand-50 p-5 dark:bg-brand-500/10"
|
|
|
|
|
|
>
|
|
|
|
|
|
<p class="text-sm text-text">برای ادامه رزرو، ابتدا وارد حساب کاربری شوید.</p>
|
|
|
|
|
|
<AppButton class="mt-4 !w-auto" @click="emit('login')">ورود / ثبتنام</AppButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
|
|
|
|
import { createBooking, createBookingBatch, fetchAvailableSlots } from '@/api/bookings';
|
|
|
|
|
|
import { fetchBusinessServiceClasses } from '@/api/business-service-classes';
|
|
|
|
|
|
import { ApiError } from '@/api/client';
|
|
|
|
|
|
import type { Booking, Business, BusinessService, BusinessServiceClass } from '@/api/types';
|
|
|
|
|
|
import BookingOrderSummary from '@/components/booking/BookingOrderSummary.vue';
|
|
|
|
|
|
import BookingServiceClassStep from '@/components/booking/BookingServiceClassStep.vue';
|
|
|
|
|
|
import BookingStepper from '@/components/booking/BookingStepper.vue';
|
|
|
|
|
|
import BookingSuccessView from '@/components/booking/BookingSuccessView.vue';
|
|
|
|
|
|
import DesktopBookingDateTime from '@/components/booking/DesktopBookingDateTime.vue';
|
|
|
|
|
|
import AppButton from '@/components/ui/AppButton.vue';
|
|
|
|
|
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
|
|
|
|
|
import AppTextarea from '@/components/ui/AppTextarea.vue';
|
|
|
|
|
|
import ServiceGridSkeleton from '@/components/ui/ServiceGridSkeleton.vue';
|
|
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
|
|
|
|
|
import { formatPriceToman } from '@/utils/format';
|
|
|
|
|
|
import {
|
|
|
|
|
|
formatJalaliDateLong,
|
|
|
|
|
|
formatJalaliDateShort,
|
|
|
|
|
|
formatJalaliDateTime,
|
|
|
|
|
|
generateJalaliDateRange,
|
|
|
|
|
|
gregorianToJalaliParts,
|
|
|
|
|
|
isoToJalaliParts,
|
|
|
|
|
|
isSameJalaliDay,
|
|
|
|
|
|
jalaliToGregorianYmd,
|
|
|
|
|
|
firstAllowedJalaliDate,
|
|
|
|
|
|
persianWeekdayName,
|
|
|
|
|
|
jalaliPartsToDate,
|
|
|
|
|
|
} from '@/utils/jalali';
|
|
|
|
|
|
import { businessCoverUrl } from '@/utils/media';
|
|
|
|
|
|
import {
|
|
|
|
|
|
resolveServicePrice,
|
|
|
|
|
|
serviceDurationLabel,
|
|
|
|
|
|
servicePriceLabel,
|
|
|
|
|
|
sumServicesDurationMinutes,
|
|
|
|
|
|
sumServicesPrice,
|
|
|
|
|
|
} from '@/utils/service-pricing';
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
business: Business;
|
|
|
|
|
|
services: BusinessService[];
|
|
|
|
|
|
loadingServices: boolean;
|
|
|
|
|
|
servicesError: string;
|
|
|
|
|
|
homeTo: string | { name: string };
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
login: [];
|
|
|
|
|
|
'view-bookings': [];
|
|
|
|
|
|
'go-home': [];
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
|
|
|
|
|
|
const currentStep = ref(1);
|
|
|
|
|
|
const phase = ref<'booking' | 'success'>('booking');
|
|
|
|
|
|
const selectedClassId = ref('');
|
|
|
|
|
|
const selectedServiceIds = ref<string[]>([]);
|
|
|
|
|
|
const scheduledAt = ref('');
|
|
|
|
|
|
const note = ref('');
|
|
|
|
|
|
const bookingLoading = ref(false);
|
|
|
|
|
|
const bookingError = ref('');
|
|
|
|
|
|
const createdBooking = ref<Booking | null>(null);
|
|
|
|
|
|
const earliestSlotIso = ref<string | null>(null);
|
|
|
|
|
|
const dateTimeRef = ref<InstanceType<typeof DesktopBookingDateTime> | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const serviceClasses = ref<BusinessServiceClass[]>([]);
|
|
|
|
|
|
const loadingClasses = ref(false);
|
|
|
|
|
|
const classesError = ref('');
|
|
|
|
|
|
|
|
|
|
|
|
const classStepRequired = computed(
|
|
|
|
|
|
() => Boolean(props.business.serviceClassesEnabled) && serviceClasses.value.length > 0,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-13 10:19:31 +03:30
|
|
|
|
const classesEnabled = computed(() => Boolean(props.business.serviceClassesEnabled));
|
|
|
|
|
|
|
2026-06-12 20:16:14 +03:30
|
|
|
|
const perClass = computed(
|
2026-06-13 10:19:31 +03:30
|
|
|
|
() => classesEnabled.value && Boolean(selectedClassId.value),
|
2026-06-12 20:16:14 +03:30
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedServices = computed(() =>
|
|
|
|
|
|
props.services.filter((service) => selectedServiceIds.value.includes(service.id)),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedClass = computed(
|
|
|
|
|
|
() => serviceClasses.value.find((c) => c.id === selectedClassId.value) ?? null,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedClassTitle = computed(() => selectedClass.value?.title ?? null);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedServiceTitle = computed(() => {
|
|
|
|
|
|
if (!selectedServices.value.length) return 'خدمت';
|
|
|
|
|
|
return selectedServices.value
|
|
|
|
|
|
.map((service) => service.serviceTemplate?.title ?? 'خدمت')
|
|
|
|
|
|
.join('، ');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const selectedPrice = computed(() => {
|
|
|
|
|
|
if (!selectedServices.value.length) return '0';
|
|
|
|
|
|
return String(
|
|
|
|
|
|
sumServicesPrice(
|
|
|
|
|
|
selectedServices.value,
|
|
|
|
|
|
perClass.value,
|
|
|
|
|
|
selectedClassId.value || undefined,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const selectedDurationMinutes = computed(() => {
|
|
|
|
|
|
if (!selectedServices.value.length) return null;
|
|
|
|
|
|
const total = sumServicesDurationMinutes(
|
|
|
|
|
|
selectedServices.value,
|
|
|
|
|
|
perClass.value,
|
|
|
|
|
|
selectedClassId.value || undefined,
|
|
|
|
|
|
);
|
|
|
|
|
|
return total > 0 ? total : null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const selectedDurationLabel = computed(() => {
|
|
|
|
|
|
if (!selectedDurationMinutes.value) return null;
|
|
|
|
|
|
return `${selectedDurationMinutes.value} دقیقه`;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const summaryItems = computed(() =>
|
|
|
|
|
|
selectedServices.value.map((service) => ({
|
|
|
|
|
|
id: service.id,
|
|
|
|
|
|
title: service.serviceTemplate?.title ?? 'خدمت',
|
|
|
|
|
|
price: resolveServicePrice(
|
|
|
|
|
|
service,
|
|
|
|
|
|
perClass.value,
|
|
|
|
|
|
selectedClassId.value || undefined,
|
|
|
|
|
|
),
|
|
|
|
|
|
durationLabel: serviceDurationLabel(
|
|
|
|
|
|
service,
|
2026-06-13 10:19:31 +03:30
|
|
|
|
classesEnabled.value,
|
2026-06-12 20:16:14 +03:30
|
|
|
|
serviceClasses.value,
|
|
|
|
|
|
selectedClassId.value || undefined,
|
|
|
|
|
|
),
|
|
|
|
|
|
})),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const coverImageUrl = computed(() => businessCoverUrl(props.business.images));
|
|
|
|
|
|
|
|
|
|
|
|
const scheduledLabel = computed(() => {
|
|
|
|
|
|
const parsed = isoToJalaliParts(scheduledAt.value);
|
|
|
|
|
|
if (!parsed) return '—';
|
|
|
|
|
|
return formatJalaliDateTime(parsed);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const earliestSlotLabel = computed(() => {
|
|
|
|
|
|
if (!earliestSlotIso.value) return null;
|
|
|
|
|
|
const parsed = isoToJalaliParts(earliestSlotIso.value);
|
|
|
|
|
|
if (!parsed) return null;
|
|
|
|
|
|
const today = gregorianToJalaliParts(new Date());
|
|
|
|
|
|
const date = jalaliPartsToDate({
|
|
|
|
|
|
jy: parsed.jy,
|
|
|
|
|
|
jm: parsed.jm,
|
|
|
|
|
|
jd: parsed.jd,
|
|
|
|
|
|
hour: 0,
|
|
|
|
|
|
minute: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
const prefix = isSameJalaliDay(parsed, today) ? 'امروز' : persianWeekdayName(date);
|
|
|
|
|
|
return {
|
|
|
|
|
|
dateLabel: `${prefix} — ${formatJalaliDateShort(parsed)}`,
|
|
|
|
|
|
timeLabel: `${String(parsed.hour).padStart(2, '0')}:${String(parsed.minute).padStart(2, '0')}`,
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const successDetails = computed(() => {
|
|
|
|
|
|
if (!createdBooking.value) return null;
|
|
|
|
|
|
const parsed = isoToJalaliParts(createdBooking.value.scheduledAt);
|
|
|
|
|
|
const serviceTitle = selectedClassTitle.value
|
|
|
|
|
|
? `${selectedServiceTitle.value} · ${selectedClassTitle.value}`
|
|
|
|
|
|
: selectedServiceTitle.value;
|
|
|
|
|
|
return {
|
|
|
|
|
|
serviceTitle,
|
|
|
|
|
|
dateLabel: parsed ? formatJalaliDateLong(parsed) : '—',
|
|
|
|
|
|
timeLabel: parsed
|
|
|
|
|
|
? `${String(parsed.hour).padStart(2, '0')}:${String(parsed.minute).padStart(2, '0')}`
|
|
|
|
|
|
: '—',
|
|
|
|
|
|
durationLabel: selectedDurationLabel.value ?? '—',
|
|
|
|
|
|
address: props.business.address,
|
|
|
|
|
|
bookingRef: createdBooking.value.id.slice(0, 8).toUpperCase(),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const stepLabels = [
|
|
|
|
|
|
'کلاس خدمات',
|
|
|
|
|
|
'انتخاب خدمات',
|
|
|
|
|
|
'تاریخ و ساعت',
|
|
|
|
|
|
'اطلاعات و پرداخت',
|
|
|
|
|
|
'تایید نهایی',
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
const currentStepLabel = computed(() => stepLabels[currentStep.value - 1] ?? '');
|
|
|
|
|
|
|
|
|
|
|
|
const nextButtonLabel = computed(() =>
|
|
|
|
|
|
currentStep.value === 5 ? 'ثبت نهایی رزرو' : 'مرحله بعد',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const showMobileActions = computed(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
phase.value === 'booking' &&
|
|
|
|
|
|
(currentStep.value <= 2 || auth.isLoggedIn) &&
|
|
|
|
|
|
!(currentStep.value > 2 && !auth.isLoggedIn),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const canProceed = computed(() => {
|
|
|
|
|
|
if (currentStep.value === 1) {
|
|
|
|
|
|
return classStepRequired.value ? Boolean(selectedClassId.value) : true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (currentStep.value === 2) return selectedServiceIds.value.length > 0;
|
|
|
|
|
|
if (currentStep.value === 3) return Boolean(scheduledAt.value);
|
|
|
|
|
|
if (currentStep.value === 4) return true;
|
|
|
|
|
|
if (currentStep.value === 5) {
|
|
|
|
|
|
return Boolean(
|
|
|
|
|
|
scheduledAt.value &&
|
|
|
|
|
|
selectedServiceIds.value.length > 0 &&
|
|
|
|
|
|
(!classStepRequired.value || selectedClassId.value),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function durationFor(service: BusinessService) {
|
|
|
|
|
|
return serviceDurationLabel(
|
|
|
|
|
|
service,
|
2026-06-13 10:19:31 +03:30
|
|
|
|
classesEnabled.value,
|
2026-06-12 20:16:14 +03:30
|
|
|
|
serviceClasses.value,
|
|
|
|
|
|
selectedClassId.value || undefined,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function priceFor(service: BusinessService) {
|
|
|
|
|
|
return servicePriceLabel(
|
|
|
|
|
|
service,
|
2026-06-13 10:19:31 +03:30
|
|
|
|
classesEnabled.value,
|
2026-06-12 20:16:14 +03:30
|
|
|
|
serviceClasses.value,
|
|
|
|
|
|
selectedClassId.value || undefined,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isServiceSelected(serviceId: string) {
|
|
|
|
|
|
return selectedServiceIds.value.includes(serviceId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleService(serviceId: string) {
|
|
|
|
|
|
if (isServiceSelected(serviceId)) {
|
|
|
|
|
|
selectedServiceIds.value = selectedServiceIds.value.filter((id) => id !== serviceId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
selectedServiceIds.value = [...selectedServiceIds.value, serviceId];
|
|
|
|
|
|
}
|
|
|
|
|
|
scheduledAt.value = '';
|
|
|
|
|
|
bookingError.value = '';
|
|
|
|
|
|
void loadEarliestSlot();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadClasses() {
|
|
|
|
|
|
loadingClasses.value = true;
|
|
|
|
|
|
classesError.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
serviceClasses.value = await fetchBusinessServiceClasses(props.business.id, {
|
|
|
|
|
|
activeOnly: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (serviceClasses.value.length === 1) {
|
|
|
|
|
|
selectedClassId.value = serviceClasses.value[0].id;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
classesError.value =
|
|
|
|
|
|
e instanceof ApiError ? e.message : 'بارگذاری کلاسهای خدمات ناموفق بود';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingClasses.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadEarliestSlot() {
|
|
|
|
|
|
earliestSlotIso.value = null;
|
|
|
|
|
|
if (!selectedServiceIds.value.length) return;
|
|
|
|
|
|
const days = generateJalaliDateRange(firstAllowedJalaliDate(), 7);
|
|
|
|
|
|
for (const day of days) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetchAvailableSlots(
|
|
|
|
|
|
props.business.id,
|
|
|
|
|
|
jalaliToGregorianYmd(day),
|
|
|
|
|
|
{
|
|
|
|
|
|
businessServiceIds: selectedServiceIds.value,
|
|
|
|
|
|
serviceClassId: selectedClassId.value || undefined,
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
if (res.slots.length) {
|
|
|
|
|
|
earliestSlotIso.value = res.slots[0];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* try next day */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyEarliestSlot() {
|
|
|
|
|
|
if (!earliestSlotIso.value) return;
|
|
|
|
|
|
scheduledAt.value = earliestSlotIso.value;
|
|
|
|
|
|
dateTimeRef.value?.selectDayAndSlot(earliestSlotIso.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function goBack() {
|
|
|
|
|
|
if (currentStep.value > 1) {
|
|
|
|
|
|
currentStep.value -= 1;
|
|
|
|
|
|
bookingError.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function goNext() {
|
|
|
|
|
|
if (currentStep.value === 2 && !auth.isLoggedIn) {
|
|
|
|
|
|
emit('login');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (currentStep.value < 5) {
|
|
|
|
|
|
currentStep.value += 1;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await submitBooking();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function submitBooking() {
|
|
|
|
|
|
if (!auth.user || !selectedServiceIds.value.length || !scheduledAt.value) {
|
|
|
|
|
|
bookingError.value = 'لطفاً خدمت و زمان را انتخاب کنید.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (classStepRequired.value && !selectedClassId.value) {
|
|
|
|
|
|
bookingError.value = 'لطفاً کلاس خدمات را انتخاب کنید.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
bookingLoading.value = true;
|
|
|
|
|
|
bookingError.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
customerId: auth.user.id,
|
|
|
|
|
|
businessId: props.business.id,
|
|
|
|
|
|
serviceClassId: selectedClassId.value || null,
|
|
|
|
|
|
scheduledAt: scheduledAt.value,
|
|
|
|
|
|
note: note.value.trim() || null,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (selectedServiceIds.value.length === 1) {
|
|
|
|
|
|
createdBooking.value = await createBooking({
|
|
|
|
|
|
...payload,
|
|
|
|
|
|
businessServiceId: selectedServiceIds.value[0],
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const bookings = await createBookingBatch({
|
|
|
|
|
|
...payload,
|
|
|
|
|
|
items: selectedServiceIds.value.map((businessServiceId) => ({
|
|
|
|
|
|
businessServiceId,
|
|
|
|
|
|
})),
|
|
|
|
|
|
});
|
|
|
|
|
|
createdBooking.value = bookings[0] ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
phase.value = 'success';
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
bookingError.value = e instanceof ApiError ? e.message : 'ثبت رزرو ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
bookingLoading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function downloadCalendar() {
|
|
|
|
|
|
if (!createdBooking.value) return;
|
|
|
|
|
|
const start = new Date(createdBooking.value.scheduledAt);
|
|
|
|
|
|
const end = new Date(start.getTime() + (selectedDurationMinutes.value ?? 60) * 60_000);
|
|
|
|
|
|
const formatIcs = (d: Date) =>
|
|
|
|
|
|
d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
|
|
|
|
const ics = [
|
|
|
|
|
|
'BEGIN:VCALENDAR',
|
|
|
|
|
|
'VERSION:2.0',
|
|
|
|
|
|
'BEGIN:VEVENT',
|
|
|
|
|
|
`DTSTART:${formatIcs(start)}`,
|
|
|
|
|
|
`DTEND:${formatIcs(end)}`,
|
|
|
|
|
|
`SUMMARY:${selectedServiceTitle.value}`,
|
|
|
|
|
|
`LOCATION:${props.business.address ?? ''}`,
|
|
|
|
|
|
'END:VEVENT',
|
|
|
|
|
|
'END:VCALENDAR',
|
|
|
|
|
|
].join('\r\n');
|
|
|
|
|
|
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
|
link.href = url;
|
|
|
|
|
|
link.download = 'booking.ics';
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(selectedClassId, () => {
|
|
|
|
|
|
selectedServiceIds.value = [];
|
|
|
|
|
|
scheduledAt.value = '';
|
|
|
|
|
|
earliestSlotIso.value = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
selectedServiceIds,
|
|
|
|
|
|
() => {
|
|
|
|
|
|
void loadEarliestSlot();
|
|
|
|
|
|
},
|
|
|
|
|
|
{ deep: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void loadClasses();
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|