Files
webMontime/src/components/booking/DesktopBookingFlow.vue
T

627 lines
20 KiB
Vue
Raw Normal View History

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>