landing desktop
This commit is contained in:
@@ -0,0 +1,624 @@
|
||||
<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 perClass = computed(
|
||||
() => Boolean(props.business.serviceClassesEnabled) && 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,
|
||||
perClass.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,
|
||||
perClass.value,
|
||||
serviceClasses.value,
|
||||
selectedClassId.value || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function priceFor(service: BusinessService) {
|
||||
return servicePriceLabel(
|
||||
service,
|
||||
perClass.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>
|
||||
Reference in New Issue
Block a user