Files
webMontime/src/components/booking/DesktopBookingFlow.vue
T
2026-06-13 10:19:31 +03:30

627 lines
20 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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,
);
const classesEnabled = computed(() => Boolean(props.business.serviceClassesEnabled));
const perClass = computed(
() => classesEnabled.value && Boolean(selectedClassId.value),
);
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,
classesEnabled.value,
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,
classesEnabled.value,
serviceClasses.value,
selectedClassId.value || undefined,
);
}
function priceFor(service: BusinessService) {
return servicePriceLabel(
service,
classesEnabled.value,
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>