Add service listing and customer booking flow in app

This commit is contained in:
2026-05-25 17:49:48 +03:30
parent bdad9ee073
commit 0b2defd50c
6 changed files with 477 additions and 13 deletions
+176 -3
View File
@@ -1,12 +1,185 @@
<template>
<WebAppLayout>
<h1 class="mb-4 text-xl font-bold text-text">رزروهای من</h1>
<div class="rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted">
به‌زودی
<h1 class="mb-1 text-xl font-bold text-text">
{{ auth.isBusinessOwner ? 'رزروهای کسب‌وکار' : 'رزروهای من' }}
</h1>
<p class="mb-4 text-sm text-muted">
{{
auth.isBusinessOwner
? 'درخواست‌های مشتریان برای خدمات شما'
: 'وضعیت رزروهای ثبت‌شده'
}}
</p>
<p
v-if="error"
class="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
>
{{ error }}
</p>
<div v-if="loading" class="py-8 text-center text-sm text-muted">در حال بارگذاری…</div>
<div
v-else-if="!bookings.length"
class="rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
>
{{
auth.isBusinessOwner
? 'هنوز رزروی برای کسب‌وکار شما ثبت نشده است.'
: 'هنوز رزروی ندارید. از صفحه کسب‌وکارها یک خدمت را رزرو کنید.'
}}
<RouterLink
v-if="auth.isCustomer"
to="/app"
class="mt-3 inline-block text-brand-600 dark:text-brand-500"
>
مشاهده کسب‌وکارها
</RouterLink>
</div>
<ul v-else class="space-y-3">
<li
v-for="b in bookings"
:key="b.id"
class="rounded-2xl border border-border bg-surface-2 p-4"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="font-semibold text-text">
{{ b.business?.title ?? 'کسب‌وکار' }}
</p>
<p class="mt-0.5 text-sm text-muted">
{{ serviceTitle(b) }}
</p>
<p class="mt-1 text-xs text-muted">
{{ formatDate(b.scheduledAt) }}
</p>
<p
v-if="auth.isBusinessOwner && b.customer?.mobile"
class="mt-1 text-xs text-muted"
dir="ltr"
>
{{ b.customer.fullName ? `${b.customer.fullName} · ` : '' }}{{ b.customer.mobile }}
</p>
<p v-if="b.note" class="mt-2 text-xs text-muted">{{ b.note }}</p>
<span
class="mt-2 inline-block rounded-lg px-2 py-0.5 text-xs"
:class="statusClass(b.status)"
>
{{ bookingStatusLabel(b.status) }}
</span>
</div>
</div>
<div
v-if="canActOn(b)"
class="mt-3 flex flex-wrap gap-2 border-t border-border pt-3"
>
<AppButton
v-if="auth.isBusinessOwner && b.status === 'pending'"
class="!w-auto flex-1 px-3"
:loading="actingId === b.id"
@click="onConfirm(b)"
>
تأیید
</AppButton>
<AppButton
v-if="canCancel(b)"
variant="danger"
class="!w-auto flex-1 px-3"
:loading="actingId === b.id"
@click="onCancel(b)"
>
لغو
</AppButton>
</div>
</li>
</ul>
</WebAppLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { cancelBooking, confirmBooking, fetchBookings } from '@/api/bookings';
import { ApiError } from '@/api/client';
import type { Booking, BookingStatus } from '@/api/types';
import AppButton from '@/components/ui/AppButton.vue';
import WebAppLayout from '@/layouts/WebAppLayout.vue';
import { useAuthStore } from '@/stores/auth';
import { bookingStatusLabel, formatDate } from '@/utils/format';
const auth = useAuthStore();
const bookings = ref<Booking[]>([]);
const loading = ref(true);
const error = ref('');
const actingId = ref<string | null>(null);
function serviceTitle(b: Booking): string {
return b.businessService?.serviceTemplate?.title ?? 'خدمت';
}
function statusClass(status: BookingStatus): string {
if (status === 'confirmed' || status === 'completed') {
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
}
if (status === 'pending') {
return 'bg-amber-500/15 text-amber-700 dark:text-amber-400';
}
if (status === 'cancelled') {
return 'bg-red-500/15 text-red-600 dark:text-red-400';
}
return 'bg-surface-3 text-muted';
}
function canCancel(b: Booking): boolean {
return b.status === 'pending' || b.status === 'confirmed';
}
function canActOn(b: Booking): boolean {
if (b.status === 'cancelled' || b.status === 'completed') return false;
if (auth.isBusinessOwner && b.status === 'pending') return true;
return canCancel(b);
}
async function load() {
loading.value = true;
error.value = '';
try {
bookings.value = await fetchBookings();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'بارگذاری رزروها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function onConfirm(b: Booking) {
actingId.value = b.id;
error.value = '';
try {
await confirmBooking(b.id);
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'تأیید رزرو ناموفق بود.';
} finally {
actingId.value = null;
}
}
async function onCancel(b: Booking) {
if (!confirm('این رزرو لغو شود؟')) return;
actingId.value = b.id;
error.value = '';
try {
await cancelBooking(b.id);
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'لغو رزرو ناموفق بود.';
} finally {
actingId.value = null;
}
}
onMounted(load);
</script>
+169 -10
View File
@@ -17,6 +17,13 @@
بازگشت
</button>
<div
v-if="!business.isActive"
class="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-300"
>
این کسب‌وکار فعلاً غیرفعال است و امکان رزرو ندارد.
</div>
<div>
<span
v-if="business.category"
@@ -44,11 +51,20 @@
<div>
<h2 class="mb-3 font-semibold text-text">خدمات</h2>
<div v-if="loadingServices" class="text-sm text-muted">در حال بارگذاری…</div>
<p
v-else-if="servicesError"
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
>
{{ servicesError }}
</p>
<div
v-else-if="!services.length"
class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted"
>
خدمتی ثبت نشده است.
هنوز خدمتی برای این کسب‌وکار ثبت نشده است.
<span v-if="auth.isBusinessOwner" class="mt-2 block">
از بخش «کسب‌وکار» در منو، خدمات و قیمت را اضافه کنید.
</span>
</div>
<ul v-else class="space-y-3">
<li
@@ -75,56 +91,199 @@
)
}}</span>
</div>
<AppButton class="mt-3" variant="primary" @click="onBook(svc)">
رزرو (به‌زودی)
<AppButton
v-if="canBook"
class="mt-3"
variant="primary"
@click="openBooking(svc)"
>
رزرو
</AppButton>
</li>
</ul>
</div>
</section>
<AppModal
:open="bookingOpen"
title="رزرو خدمت"
:subtitle="bookingService ? serviceLabel(bookingService) : ''"
@close="closeBooking"
>
<form v-if="bookingService && business" class="space-y-4" @submit.prevent="onSubmitBooking">
<p
v-if="bookingError"
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
>
{{ bookingError }}
</p>
<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 class="mt-2 text-muted">مبلغ تقریبی</p>
<p class="font-medium text-text">
{{ formatPriceToman(bookingService.price) }} تومان
</p>
</div>
<div class="space-y-1.5">
<label for="scheduledAt" class="block text-sm font-medium text-muted">
زمان مراجعه
</label>
<input
id="scheduledAt"
v-model="bookingForm.scheduledAt"
type="datetime-local"
required
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
/>
<p class="text-xs text-muted">حداقل یک ساعت بعد از الان</p>
</div>
<div class="space-y-1.5">
<label for="bookingNote" class="block text-sm font-medium text-muted">
یادداشت (اختیاری)
</label>
<textarea
id="bookingNote"
v-model="bookingForm.note"
rows="2"
maxlength="500"
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 focus:ring-2 focus:ring-brand-500/30"
placeholder="توضیح کوتاه برای کسب‌وکار"
/>
</div>
<AppButton type="submit" :loading="bookingSaving">ثبت رزرو</AppButton>
</form>
</AppModal>
</WebAppLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { createBooking } from '@/api/bookings';
import { fetchBusinessServices } from '@/api/business-services';
import { fetchBusiness } from '@/api/businesses';
import { ApiError } from '@/api/client';
import type { Business, BusinessService } from '@/api/types';
import AppButton from '@/components/ui/AppButton.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import AppModal from '@/components/ui/AppModal.vue';
import WebAppLayout from '@/layouts/WebAppLayout.vue';
import { formatDuration, formatPriceToman } from '@/utils/format';
import { useAuthStore } from '@/stores/auth';
import {
defaultBookingDateTimeLocal,
formatDuration,
formatPriceToman,
} from '@/utils/format';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const business = ref<Business | null>(null);
const services = ref<BusinessService[]>([]);
const loading = ref(true);
const loadingServices = ref(true);
const error = ref('');
const servicesError = ref('');
const bookingOpen = ref(false);
const bookingService = ref<BusinessService | null>(null);
const bookingSaving = ref(false);
const bookingError = ref('');
const bookingForm = ref({
scheduledAt: defaultBookingDateTimeLocal(),
note: '',
});
const canBook = computed(
() =>
auth.isCustomer &&
business.value?.isActive === true &&
services.value.length > 0,
);
function serviceLabel(svc: BusinessService): string {
return svc.serviceTemplate?.title ?? 'خدمت';
}
async function loadServices(businessId: string) {
loadingServices.value = true;
servicesError.value = '';
try {
services.value = await fetchBusinessServices(businessId, { activeOnly: true });
} catch (e) {
services.value = [];
servicesError.value =
e instanceof ApiError ? e.message : 'بارگذاری خدمات ناموفق بود.';
} finally {
loadingServices.value = false;
}
}
async function load() {
const id = String(route.params.id);
loading.value = true;
loadingServices.value = true;
error.value = '';
try {
business.value = await fetchBusiness(id);
services.value = await fetchBusinessServices(id, { activeOnly: true });
await loadServices(id);
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'بارگذاری اطلاعات ناموفق بود.';
} finally {
loading.value = false;
loadingServices.value = false;
}
}
function onBook(_svc: BusinessService) {
// فاز بعدی: فرم رزرو
function openBooking(svc: BusinessService) {
if (!auth.user) return;
bookingService.value = svc;
bookingForm.value = {
scheduledAt: defaultBookingDateTimeLocal(),
note: '',
};
bookingError.value = '';
bookingOpen.value = true;
}
function closeBooking() {
bookingOpen.value = false;
bookingService.value = null;
bookingError.value = '';
}
async function onSubmitBooking() {
if (!auth.user || !business.value || !bookingService.value) return;
const scheduled = new Date(bookingForm.value.scheduledAt);
if (Number.isNaN(scheduled.getTime())) {
bookingError.value = 'زمان انتخاب‌شده معتبر نیست.';
return;
}
bookingSaving.value = true;
bookingError.value = '';
try {
await createBooking({
customerId: auth.user.id,
businessId: business.value.id,
businessServiceId: bookingService.value.id,
scheduledAt: scheduled.toISOString(),
note: bookingForm.value.note.trim() || null,
});
closeBooking();
router.push({ name: 'app-bookings' });
} catch (e) {
bookingError.value =
e instanceof ApiError ? e.message : 'ثبت رزرو ناموفق بود.';
} finally {
bookingSaving.value = false;
}
}
onMounted(load);