627 lines
20 KiB
Vue
627 lines
20 KiB
Vue
<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>
|