update rezervasion

This commit is contained in:
2026-05-31 22:20:56 +03:30
parent 5eb5bd233c
commit 3f3572dc89
2 changed files with 185 additions and 120 deletions
+13
View File
@@ -33,6 +33,13 @@ export function serviceHasActiveClassPrices(
);
}
export function isServiceBookableForClass(
service: BusinessService,
classId: string,
): boolean {
return service.isActive && !!getClassPriceForService(service, classId);
}
export function isServiceBookable(
service: BusinessService,
classesEnabled: boolean,
@@ -47,7 +54,13 @@ export function servicePriceLabel(
service: BusinessService,
classesEnabled: boolean,
classes: BusinessServiceClass[],
classId?: string | null,
): string {
if (classId) {
const price = resolvedServicePrice(service, classId);
return price ? `${formatPriceToman(price)} تومان` : 'قیمت تعیین نشده';
}
if (!usesServiceClasses(classesEnabled, classes)) {
return `${formatPriceToman(service.price)} تومان`;
}
+172 -120
View File
@@ -60,88 +60,140 @@
>
{{ servicesError }}
</p>
<div
v-else-if="!bookableServices.length"
class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted"
>
{{
services.length
? 'خدمات این کسب‌وکار هنوز برای رزرو آماده نیست.'
: 'هنوز خدمتی برای این کسب‌وکار ثبت نشده است.'
}}
<span v-if="auth.isBusinessOwner" class="mt-2 block">
از بخش «کسب‌وکار» در منو، خدمات و قیمت را اضافه کنید.
</span>
</div>
<p v-else-if="canBook" class="mb-3 text-xs text-muted">
خدمات مورد نظر را انتخاب کنید، سپس زمان مراجعه را تعیین کنید.
</p>
<ul v-if="bookableServices.length" class="grid grid-cols-2 gap-3">
<li
v-for="svc in bookableServices"
:key="svc.id"
class="flex flex-col items-center rounded-2xl border p-3 text-center transition"
:class="
canBook && isServiceSelected(svc.id)
? 'border-brand-500 bg-brand-500/10'
: 'border-border bg-surface-2'
"
<template v-else>
<div
v-if="requiresServiceClass && canBook"
class="mb-4 space-y-2 rounded-2xl border border-border bg-surface-2 p-4"
>
<label
v-if="canBook"
class="flex w-full cursor-pointer flex-col items-center"
>
<input
type="checkbox"
class="sr-only"
:checked="isServiceSelected(svc.id)"
@change="toggleService(svc.id)"
/>
<span
class="mb-2 flex h-5 w-5 items-center justify-center rounded-md border text-xs"
<p class="text-sm font-medium text-text">۱. کلاس خودرو را انتخاب کنید</p>
<p class="text-xs text-muted">
قیمت و مدت هر خدمت بر اساس کلاس انتخاب‌شده نمایش داده می‌شود.
</p>
<div class="grid grid-cols-2 gap-2">
<button
v-for="cls in activeClasses"
:key="cls.id"
type="button"
class="rounded-xl border px-3 py-3 text-center text-sm transition"
:class="
isServiceSelected(svc.id)
? 'border-brand-500 bg-brand-500 text-white'
: 'border-border bg-surface'
selectedClassId === cls.id
? 'border-brand-500 bg-brand-500/15 text-brand-700 dark:text-brand-500'
: 'border-border bg-surface text-text hover:border-brand-500/40'
"
@click="selectClass(cls.id)"
>
<span class="font-medium">{{ cls.title }}</span>
<span v-if="cls.description" class="mt-1 block text-xs text-muted">
{{ cls.description }}
</span>
</button>
</div>
</div>
<div
v-if="!bookableServices.length"
class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted"
>
{{
services.length
? 'خدمات این کسب‌وکار هنوز برای رزرو آماده نیست.'
: 'هنوز خدمتی برای این کسب‌وکار ثبت نشده است.'
}}
<span v-if="auth.isBusinessOwner" class="mt-2 block">
از بخش «کسب‌وکار» در منو، خدمات و قیمت را اضافه کنید.
</span>
</div>
<p
v-else-if="requiresServiceClass && canBook && !selectedClassId"
class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted"
>
برای دیدن خدمات و قیمت‌ها، ابتدا کلاس خودرو را انتخاب کنید.
</p>
<div
v-else-if="requiresServiceClass && selectedClassId && !displayedServices.length"
class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted"
>
برای این کلاس هنوز خدمتی با قیمت ثبت نشده است.
</div>
<template v-else-if="displayedServices.length">
<p v-if="canBook" class="mb-3 text-xs text-muted">
{{
requiresServiceClass
? '۲. خدمات مورد نظر را انتخاب کنید، سپس زمان مراجعه را تعیین کنید.'
: 'خدمات مورد نظر را انتخاب کنید، سپس زمان مراجعه را تعیین کنید.'
}}
</p>
<ul class="grid grid-cols-2 gap-3">
<li
v-for="svc in displayedServices"
:key="svc.id"
class="flex flex-col items-center rounded-2xl border p-3 text-center transition"
:class="
canBook && isServiceSelected(svc.id)
? 'border-brand-500 bg-brand-500/10'
: 'border-border bg-surface-2'
"
>
{{ isServiceSelected(svc.id) ? '✓' : '' }}
</span>
<p class="font-medium text-text">
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
</p>
<p
v-if="svc.serviceTemplate?.description"
class="mt-1 line-clamp-2 text-xs text-muted"
>
{{ svc.serviceTemplate.description }}
</p>
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
<p>{{ servicePriceLabel(svc, classesEnabled, serviceClasses) }}</p>
<p>{{ serviceDurationLabel(svc, classesEnabled, serviceClasses) }}</p>
</div>
</label>
<template v-else>
<p class="font-medium text-text">
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
</p>
<p
v-if="svc.serviceTemplate?.description"
class="mt-1 line-clamp-2 text-xs text-muted"
>
{{ svc.serviceTemplate.description }}
</p>
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
<p>{{ servicePriceLabel(svc, classesEnabled, serviceClasses) }}</p>
<p>{{ serviceDurationLabel(svc, classesEnabled, serviceClasses) }}</p>
</div>
</template>
</li>
</ul>
<label
v-if="canBook"
class="flex w-full cursor-pointer flex-col items-center"
>
<input
type="checkbox"
class="sr-only"
:checked="isServiceSelected(svc.id)"
@change="toggleService(svc.id)"
/>
<span
class="mb-2 flex h-5 w-5 items-center justify-center rounded-md border text-xs"
:class="
isServiceSelected(svc.id)
? 'border-brand-500 bg-brand-500 text-white'
: 'border-border bg-surface'
"
>
{{ isServiceSelected(svc.id) ? '✓' : '' }}
</span>
<p class="font-medium text-text">
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
</p>
<p
v-if="svc.serviceTemplate?.description"
class="mt-1 line-clamp-2 text-xs text-muted"
>
{{ svc.serviceTemplate.description }}
</p>
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
<p>{{ servicePriceLabel(svc, classesEnabled, serviceClasses, selectedClassId || null) }}</p>
<p>{{ serviceDurationLabel(svc, classesEnabled, serviceClasses, selectedClassId || null) }}</p>
</div>
</label>
<template v-else>
<p class="font-medium text-text">
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
</p>
<p
v-if="svc.serviceTemplate?.description"
class="mt-1 line-clamp-2 text-xs text-muted"
>
{{ svc.serviceTemplate.description }}
</p>
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
<p>{{ servicePriceLabel(svc, classesEnabled, serviceClasses, selectedClassId || null) }}</p>
<p>{{ serviceDurationLabel(svc, classesEnabled, serviceClasses, selectedClassId || null) }}</p>
</div>
</template>
</li>
</ul>
</template>
</template>
</div>
<div
v-if="canBook && selectedServices.length"
v-if="canBook && selectedServices.length && canProceedToBooking"
class="sticky bottom-2 z-10 rounded-2xl border border-brand-500/40 bg-surface/95 p-4 shadow-lg backdrop-blur"
>
<div class="flex items-center justify-between gap-3">
@@ -177,6 +229,8 @@
<div class="rounded-xl border border-border bg-surface-2 p-3 text-sm">
<p class="text-muted">کسب‌وکار</p>
<p class="font-medium text-text">{{ business.title }}</p>
<p v-if="selectedClass" class="mt-2 text-muted">کلاس</p>
<p v-if="selectedClass" class="font-medium text-text">{{ selectedClass.title }}</p>
<ul class="mt-3 space-y-2 border-t border-border pt-3">
<li
v-for="svc in selectedServices"
@@ -186,8 +240,8 @@
<span class="text-text">{{ svc.serviceTemplate?.title ?? 'خدمت' }}</span>
<span class="shrink-0 text-muted">
{{
requiresServiceClass && bookingForm.serviceClassId
? `${formatPriceToman(resolvedServicePrice(svc, bookingForm.serviceClassId) ?? svc.price)} تومان`
requiresServiceClass && selectedClassId
? `${formatPriceToman(resolvedServicePrice(svc, selectedClassId) ?? svc.price)} تومان`
: `${formatPriceToman(svc.price)} تومان`
}}
</span>
@@ -199,43 +253,13 @@
<p class="font-medium text-text">{{ totalDurationLabel }}</p>
</div>
<div v-if="requiresServiceClass" class="space-y-1.5">
<label for="bookingClass" class="block text-sm font-medium text-muted">
کلاس خودرو / دسته
</label>
<select
id="bookingClass"
v-model="bookingForm.serviceClassId"
required
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-sm text-text outline-none focus:border-brand-500"
@change="bookingForm.scheduledAt = ''"
>
<option value="" disabled>انتخاب کنید</option>
<option
v-for="cls in activeClasses"
:key="cls.id"
:value="cls.id"
:disabled="!selectedServices.every((svc) => getClassPriceForService(svc, cls.id))"
>
{{ cls.title }}
</option>
</select>
</div>
<BookingSlotPicker
v-if="!requiresServiceClass || bookingForm.serviceClassId"
v-model="bookingForm.scheduledAt"
label="تاریخ و ساعت مراجعه (شمسی)"
:business-id="business.id"
:business-service-ids="selectedServiceIds"
:service-class-id="bookingForm.serviceClassId || null"
:service-class-id="selectedClassId || null"
/>
<p
v-else
class="rounded-xl border border-dashed border-border px-4 py-3 text-center text-sm text-muted"
>
ابتدا کلاس خودرو را انتخاب کنید.
</p>
<div class="space-y-1.5">
<label for="bookingNote" class="block text-sm font-medium text-muted">
@@ -276,8 +300,8 @@ import { useAuthStore } from '@/stores/auth';
import { formatPriceToman } from '@/utils/format';
import {
activeServiceClasses,
getClassPriceForService,
isServiceBookable,
isServiceBookableForClass,
resolvedServicePrice,
serviceDurationLabel,
servicePriceLabel,
@@ -299,11 +323,11 @@ const error = ref('');
const servicesError = ref('');
const bookingOpen = ref(false);
const selectedClassId = ref('');
const selectedServiceIds = ref<string[]>([]);
const bookingSaving = ref(false);
const bookingError = ref('');
const bookingForm = ref({
serviceClassId: '',
scheduledAt: '',
note: '',
});
@@ -321,8 +345,28 @@ const bookableServices = computed(() =>
),
);
const displayedServices = computed(() => {
if (!requiresServiceClass.value) {
return bookableServices.value;
}
if (!selectedClassId.value) {
return [];
}
return bookableServices.value.filter((svc) =>
isServiceBookableForClass(svc, selectedClassId.value),
);
});
const selectedClass = computed(() =>
activeClasses.value.find((cls) => cls.id === selectedClassId.value) ?? null,
);
const canProceedToBooking = computed(
() => !requiresServiceClass.value || !!selectedClassId.value,
);
const selectedServices = computed(() =>
bookableServices.value.filter((svc) => selectedServiceIds.value.includes(svc.id)),
displayedServices.value.filter((svc) => selectedServiceIds.value.includes(svc.id)),
);
const totalDurationLabel = computed(() => {
@@ -330,21 +374,18 @@ const totalDurationLabel = computed(() => {
selectedServices.value,
classesEnabled.value,
serviceClasses.value,
bookingForm.value.serviceClassId || null,
selectedClassId.value || null,
);
return `${minutes} دقیقه`;
});
const bookingPriceLabel = computed(() => {
if (!selectedServices.value.length) return '—';
if (requiresServiceClass.value && !bookingForm.value.serviceClassId) {
return 'کلاس را انتخاب کنید';
}
const total = totalPriceForServices(
selectedServices.value,
classesEnabled.value,
serviceClasses.value,
bookingForm.value.serviceClassId || null,
selectedClassId.value || null,
);
return total != null ? `${formatPriceToman(String(total))} تومان` : '—';
});
@@ -363,7 +404,18 @@ function isServiceSelected(id: string): boolean {
return selectedServiceIds.value.includes(id);
}
function selectClass(classId: string) {
if (selectedClassId.value === classId) {
return;
}
selectedClassId.value = classId;
selectedServiceIds.value = [];
}
function toggleService(id: string) {
if (requiresServiceClass.value && !selectedClassId.value) {
return;
}
if (isServiceSelected(id)) {
selectedServiceIds.value = selectedServiceIds.value.filter((item) => item !== id);
} else {
@@ -372,9 +424,8 @@ function toggleService(id: string) {
}
function openBooking() {
if (!auth.user || !selectedServices.value.length) return;
if (!auth.user || !selectedServices.value.length || !canProceedToBooking.value) return;
bookingForm.value = {
serviceClassId: '',
scheduledAt: '',
note: '',
};
@@ -442,7 +493,7 @@ async function onSubmitBooking() {
bookingError.value = 'لطفاً یک ساعت از لیست انتخاب کنید.';
return;
}
if (requiresServiceClass.value && !bookingForm.value.serviceClassId) {
if (requiresServiceClass.value && !selectedClassId.value) {
bookingError.value = 'لطفاً کلاس خودرو را انتخاب کنید.';
return;
}
@@ -456,11 +507,12 @@ async function onSubmitBooking() {
items: selectedServices.value.map((svc) => ({
businessServiceId: svc.id,
})),
serviceClassId: bookingForm.value.serviceClassId || null,
serviceClassId: selectedClassId.value || null,
scheduledAt: scheduled.toISOString(),
note: bookingForm.value.note.trim() || null,
});
selectedServiceIds.value = [];
selectedClassId.value = '';
closeBooking();
router.push({ name: 'app-bookings' });
} catch (e) {