Add service listing and customer booking flow in app
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { api } from './client';
|
||||
import type { Booking, BookingStatus } from './types';
|
||||
|
||||
export type CreateBookingPayload = {
|
||||
customerId: string;
|
||||
businessId: string;
|
||||
businessServiceId: string;
|
||||
scheduledAt: string;
|
||||
note?: string | null;
|
||||
};
|
||||
|
||||
export function fetchBookings(params?: {
|
||||
businessId?: string;
|
||||
status?: BookingStatus;
|
||||
}) {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.businessId) search.set('businessId', params.businessId);
|
||||
if (params?.status) search.set('status', params.status);
|
||||
const query = search.toString();
|
||||
return api<Booking[]>(`/bookings${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export function createBooking(payload: CreateBookingPayload) {
|
||||
return api<Booking>('/bookings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelBooking(id: string) {
|
||||
return api<Booking>(`/bookings/${id}/cancel`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function confirmBooking(id: string) {
|
||||
return api<Booking>(`/bookings/${id}/confirm`, { method: 'POST' });
|
||||
}
|
||||
@@ -82,3 +82,28 @@ export type BusinessService = {
|
||||
isActive: boolean;
|
||||
serviceTemplate?: ServiceTemplate;
|
||||
};
|
||||
|
||||
export type BookingStatus =
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
| 'cancelled'
|
||||
| 'completed';
|
||||
|
||||
export type Booking = {
|
||||
id: string;
|
||||
customerId: string;
|
||||
businessId: string;
|
||||
businessServiceId: string;
|
||||
scheduledAt: string;
|
||||
status: BookingStatus;
|
||||
note: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
customer?: {
|
||||
id: string;
|
||||
mobile: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
business?: Business;
|
||||
businessService?: BusinessService;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-0 sm:items-center sm:p-4"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="max-h-[90dvh] w-full max-w-app overflow-y-auto rounded-t-2xl border border-border bg-surface p-4 shadow-xl sm:rounded-2xl"
|
||||
role="dialog"
|
||||
>
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 v-if="title" class="text-lg font-bold text-text">{{ title }}</h2>
|
||||
<p v-if="subtitle" class="mt-0.5 text-sm text-muted">{{ subtitle }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-2 py-1 text-muted hover:bg-surface-3"
|
||||
aria-label="بستن"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<AppIcon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
open: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
</script>
|
||||
@@ -7,6 +7,35 @@ export function formatPriceToman(price: string): string {
|
||||
return new Intl.NumberFormat('fa-IR').format(Math.round(value));
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | Date): string {
|
||||
if (!value) return '—';
|
||||
const date = typeof value === 'string' ? new Date(value) : value;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function bookingStatusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'در انتظار تأیید',
|
||||
confirmed: 'تأیید شده',
|
||||
completed: 'انجام شده',
|
||||
cancelled: 'لغو شده',
|
||||
};
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
/** Default value for datetime-local (at least ~90 min ahead). */
|
||||
export function defaultBookingDateTimeLocal(): string {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() + 90);
|
||||
d.setSeconds(0, 0);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function formatDuration(minutes: number | null, fallback?: number): string {
|
||||
const m = minutes ?? fallback;
|
||||
if (!m) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user