add cancellationReason

This commit is contained in:
2026-06-13 13:43:40 +03:30
parent 945e0ec3b9
commit 3c2db09447
12 changed files with 447 additions and 102 deletions
+8 -2
View File
@@ -46,8 +46,14 @@ export function createBookingBatch(payload: CreateBookingBatchPayload) {
});
}
export function cancelBooking(id: string) {
return api<Booking>(`/bookings/${id}/cancel`, { method: 'POST' });
export function cancelBooking(
id: string,
payload?: { cancellationReason?: string },
) {
return api<Booking>(`/bookings/${id}/cancel`, {
method: 'POST',
body: JSON.stringify(payload ?? {}),
});
}
export function confirmBooking(id: string) {
+1
View File
@@ -97,6 +97,7 @@ export type Booking = {
scheduledAt: string;
status: BookingStatus;
note?: string | null;
cancellationReason?: string | null;
business?: Business;
businessService?: BusinessService;
serviceClass?: BusinessServiceClass;
+12
View File
@@ -55,6 +55,13 @@
<p v-if="primary.note" class="mt-2 line-clamp-2 text-xs text-muted">
{{ primary.note }}
</p>
<p
v-if="cancellationReason"
class="mt-2 rounded-lg bg-red-500/10 px-2 py-1.5 text-xs leading-6 text-red-700 dark:text-red-400"
>
<span class="font-medium">علت لغو:</span>
{{ cancellationReason }}
</p>
</div>
<div
@@ -123,6 +130,11 @@ const statusLabel = computed(() => {
return bookingStatusLabel(groupStatus.value);
});
const cancellationReason = computed(() => {
const booking = props.bookings.find((item) => item.cancellationReason?.trim());
return booking?.cancellationReason?.trim() ?? null;
});
function statusClassFor(status: BookingStatus | 'mixed') {
if (status === 'confirmed' || status === 'completed') {
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
+182 -38
View File
@@ -1,44 +1,94 @@
<template>
<div class="relative -mx-4 overflow-hidden">
<div
class="relative overflow-hidden select-none"
:class="variant === 'default' ? '-mx-4' : ''"
@wheel="onWheel"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<div
v-if="images.length"
ref="trackRef"
class="flex snap-x snap-mandatory overflow-x-auto scroll-smooth"
dir="ltr"
@scroll.passive="updateActiveIndex"
class="overflow-hidden"
:class="[frameClass, frameExtraClass]"
>
<img
v-for="(src, index) in images"
:key="`${src}-${index}`"
:src="src"
:alt="`${title} — تصویر ${index + 1}`"
class="aspect-[4/3] w-full shrink-0 snap-center object-cover"
loading="lazy"
/>
<div
class="flex"
dir="ltr"
:class="isDragging ? '' : 'transition-transform duration-300 ease-out'"
:style="trackStyle"
>
<img
v-for="(src, index) in images"
:key="`${src}-${index}`"
:src="src"
:alt="`${title} — تصویر ${index + 1}`"
class="w-full shrink-0 object-cover"
:class="imageClass"
draggable="false"
loading="lazy"
/>
</div>
</div>
<div
v-else
class="flex aspect-[4/3] items-center justify-center bg-surface-3 text-muted/40"
class="flex items-center justify-center bg-surface-3 text-muted/40"
:class="[frameClass, frameExtraClass]"
>
<AppIcon name="store" size="lg" />
<AppIcon
:name="variant === 'card' || variant === 'hero' ? 'car' : 'store'"
size="lg"
:class="
variant === 'card' || variant === 'hero'
? 'h-16 w-16 text-brand-600/25 dark:text-brand-500/30'
: ''
"
/>
</div>
<template v-if="images.length > 1">
<div
class="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center gap-1.5"
<button
type="button"
class="absolute left-2 top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white shadow-sm backdrop-blur-sm transition hover:bg-black/70 disabled:pointer-events-none disabled:opacity-35"
:disabled="activeIndex === 0"
aria-label="تصویر قبلی"
@click="goPrev"
>
<span
<AppIcon name="chevron-left" size="sm" />
</button>
<button
type="button"
class="absolute right-2 top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white shadow-sm backdrop-blur-sm transition hover:bg-black/70 disabled:pointer-events-none disabled:opacity-35"
:disabled="activeIndex === images.length - 1"
aria-label="تصویر بعدی"
@click="goNext"
>
<AppIcon name="chevron-right" size="sm" />
</button>
<div class="absolute inset-x-0 bottom-3 z-10 flex justify-center gap-1.5">
<button
v-for="(_, index) in images"
:key="index"
class="h-1.5 rounded-full transition-all"
type="button"
class="rounded-full transition-all"
:class="
index === activeIndex
? 'w-4 bg-white shadow-sm'
: 'w-1.5 bg-white/60'
? 'h-2 w-5 bg-white shadow-sm'
: 'h-2 w-2 bg-white/55 hover:bg-white/80'
"
:aria-label="`تصویر ${index + 1}`"
:aria-current="index === activeIndex ? 'true' : undefined"
@click="goTo(index)"
/>
</div>
<div class="sr-only" aria-live="polite">
تصویر {{ activeIndex + 1 }} از {{ images.length }}
</div>
</template>
</div>
</template>
@@ -49,37 +99,131 @@ import AppIcon from '@/components/ui/AppIcon.vue';
import { businessGalleryUrls } from '@/utils/media';
import type { BusinessImage } from '@/api/types';
const props = defineProps<{
images?: BusinessImage[] | null;
title?: string;
}>();
const SWIPE_THRESHOLD_PX = 48;
const WHEEL_COOLDOWN_MS = 380;
const props = withDefaults(
defineProps<{
images?: BusinessImage[] | null;
title?: string;
variant?: 'default' | 'card' | 'hero';
frameExtraClass?: string;
}>(),
{ variant: 'default', title: '', frameExtraClass: '' },
);
const trackRef = ref<HTMLElement | null>(null);
const activeIndex = ref(0);
const isDragging = ref(false);
const touchStartX = ref(0);
const touchDeltaX = ref(0);
const wheelLocked = ref(false);
const images = computed(() => businessGalleryUrls(props.images));
function updateActiveIndex() {
const track = trackRef.value;
if (!track || !images.value.length) {
return;
const frameClass = computed(() => {
if (props.variant === 'card') {
return 'h-52 bg-gradient-to-br from-brand-50 via-slate-100 to-brand-100 dark:from-brand-500/10 dark:via-surface-3 dark:to-surface-2';
}
const width = track.clientWidth;
if (!width) {
return;
if (props.variant === 'hero') {
return 'h-[320px] bg-slate-200 lg:h-[390px]';
}
return 'aspect-[4/3]';
});
const imageClass = computed(() => {
if (props.variant === 'card') {
return 'h-52';
}
if (props.variant === 'hero') {
return 'h-[320px] lg:h-[390px]';
}
return 'aspect-[4/3]';
});
const trackStyle = computed(() => {
const base = `translateX(calc(-${activeIndex.value * 100}% + ${isDragging.value ? touchDeltaX.value : 0}px))`;
return { transform: base };
});
function goTo(index: number) {
activeIndex.value = Math.min(
images.value.length - 1,
Math.max(0, Math.round(track.scrollLeft / width)),
Math.max(0, index),
);
}
function goPrev() {
goTo(activeIndex.value - 1);
}
function goNext() {
goTo(activeIndex.value + 1);
}
function onWheel(event: WheelEvent) {
if (images.value.length <= 1) return;
const delta =
Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY;
if (Math.abs(delta) < 10) return;
event.preventDefault();
if (wheelLocked.value) return;
wheelLocked.value = true;
if (delta > 0) {
goNext();
} else {
goPrev();
}
window.setTimeout(() => {
wheelLocked.value = false;
}, WHEEL_COOLDOWN_MS);
}
function onTouchStart(event: TouchEvent) {
if (images.value.length <= 1) return;
touchStartX.value = event.touches[0]?.clientX ?? 0;
touchDeltaX.value = 0;
isDragging.value = true;
}
function onTouchMove(event: TouchEvent) {
if (!isDragging.value || images.value.length <= 1) return;
const currentX = event.touches[0]?.clientX ?? touchStartX.value;
let delta = currentX - touchStartX.value;
if (activeIndex.value === 0 && delta > 0) {
delta *= 0.35;
} else if (activeIndex.value === images.value.length - 1 && delta < 0) {
delta *= 0.35;
}
touchDeltaX.value = delta;
}
function onTouchEnd() {
if (!isDragging.value) return;
if (touchDeltaX.value <= -SWIPE_THRESHOLD_PX) {
goNext();
} else if (touchDeltaX.value >= SWIPE_THRESHOLD_PX) {
goPrev();
}
isDragging.value = false;
touchDeltaX.value = 0;
}
watch(images, () => {
activeIndex.value = 0;
if (trackRef.value) {
trackRef.value.scrollLeft = 0;
}
isDragging.value = false;
touchDeltaX.value = 0;
});
</script>
+9 -33
View File
@@ -70,35 +70,25 @@
</div>
</div>
<div class="relative">
<div class="landing-hero-blob relative overflow-hidden bg-slate-200 shadow-[0_20px_50px_-12px_rgba(0,0,0,0.18)]">
<img
v-if="heroImage && !heroImageFailed"
:key="heroImage"
:src="heroImage"
:alt="heroTitle"
class="h-[320px] w-full object-cover lg:h-[390px]"
@error="onHeroImageError"
/>
<div
v-else
class="flex h-[320px] w-full items-center justify-center bg-gradient-to-br from-brand-50 via-slate-100 to-brand-100 lg:h-[390px]"
>
<AppIcon name="car" size="xl" class="h-20 w-20 text-brand-600/30" />
</div>
</div>
<div class="relative shadow-[0_20px_50px_-12px_rgba(0,0,0,0.18)]">
<BusinessImageGallery
:images="singleBusiness?.images"
:title="heroTitle"
variant="hero"
frame-extra-class="landing-hero-blob bg-slate-200"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import { useTenantStore } from '@/stores/tenant';
import type { AppIconName } from '@/types/icons';
import { businessCoverUrl } from '@/utils/media';
const props = withDefaults(
defineProps<{
@@ -115,29 +105,15 @@ const emit = defineEmits<{
const tenant = useTenantStore();
const { singleBusiness } = storeToRefs(tenant);
const heroImageFailed = ref(false);
const heroTitle = computed(() => singleBusiness.value?.title ?? '');
const heroSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
const heroDescription = computed(() => singleBusiness.value?.description?.trim() ?? '');
const heroImage = computed(() =>
businessCoverUrl(singleBusiness.value?.images),
);
watch(heroImage, () => {
heroImageFailed.value = false;
});
const highlights: { label: string; icon: AppIconName }[] = [
{ label: 'رضایت مشتریان', icon: 'shield' },
{ label: 'تجهیزات مدرن', icon: 'settings' },
{ label: 'مواد شوینده با کیفیت', icon: 'droplet' },
];
function onHeroImageError() {
heroImageFailed.value = true;
}
</script>
@@ -17,19 +17,11 @@
<div
class="overflow-hidden rounded-2xl bg-slate-100 shadow-[0_8px_30px_rgba(15,23,42,0.12)] dark:bg-surface-2 dark:shadow-black/30"
>
<img
v-if="heroImage && !heroImageFailed"
:src="heroImage"
:alt="businessTitle"
class="h-52 w-full object-cover"
@error="heroImageFailed = true"
<BusinessImageGallery
:images="singleBusiness?.images"
:title="businessTitle"
variant="card"
/>
<div
v-else
class="flex h-52 w-full items-center justify-center bg-gradient-to-br from-brand-50 via-slate-100 to-brand-100 dark:from-brand-500/10 dark:via-surface-3 dark:to-surface-2"
>
<AppIcon name="car" size="xl" class="h-16 w-16 text-brand-600/25 dark:text-brand-500/30" />
</div>
</div>
</section>
@@ -119,14 +111,14 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
import HomeServicesSection from '@/components/home/HomeServicesSection.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import { useTenantStore } from '@/stores/tenant';
import type { AppIconName } from '@/types/icons';
import type { LandingServiceItem } from '@/types/landing';
import { businessFirstImageUrl } from '@/utils/media';
const props = defineProps<{
services: LandingServiceItem[];
@@ -142,20 +134,10 @@ const emit = defineEmits<{
const tenant = useTenantStore();
const { singleBusiness } = storeToRefs(tenant);
const heroImageFailed = ref(false);
const businessTitle = computed(() => singleBusiness.value?.title ?? '');
const businessSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
const aboutText = computed(() => singleBusiness.value?.description?.trim() ?? '');
const heroImage = computed(() =>
businessFirstImageUrl(singleBusiness.value?.images),
);
watch(heroImage, () => {
heroImageFailed.value = false;
});
const quickActions: {
id: string;
label: string;
@@ -109,7 +109,7 @@
<!-- حالت جداگانه با تب -->
<div v-else class="space-y-3">
<div
class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin"
class="flex gap-1 overflow-x-auto overscroll-x-contain pb-1 [-webkit-overflow-scrolling:touch]"
role="tablist"
>
<button
+4
View File
@@ -28,6 +28,8 @@ import {
Camera,
Car,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
CircleCheck,
Clock,
@@ -83,6 +85,8 @@ const iconMap: Record<AppIconName, Component> = {
'circle-check': CircleCheck,
search: Search,
'chevron-down': ChevronDown,
'chevron-left': ChevronLeft,
'chevron-right': ChevronRight,
'chevron-up': ChevronUp,
clock: Clock,
pencil: Pencil,
+24
View File
@@ -26,6 +26,13 @@
html {
@apply antialiased font-sans;
font-family: IRANYekanX, ui-sans-serif, system-ui, sans-serif;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
}
html::-webkit-scrollbar {
display: none;
}
body,
@@ -38,6 +45,23 @@
body {
@apply bg-surface-2 text-text;
scrollbar-width: none;
-ms-overflow-style: none;
}
body::-webkit-scrollbar {
display: none;
}
* {
scrollbar-width: none;
-ms-overflow-style: none;
}
*::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
#app {
+2
View File
@@ -18,6 +18,8 @@ export type AppIconName =
| 'search'
| 'chevron-down'
| 'chevron-up'
| 'chevron-left'
| 'chevron-right'
| 'clock'
| 'pencil'
| 'trash'
+22
View File
@@ -41,6 +41,28 @@ export function groupBookingStatus(
return 'mixed';
}
export type OwnerBookingTab = 'pending' | 'confirmed' | 'cancelled';
export function entryMatchesOwnerTab(
bookings: Booking[],
tab: OwnerBookingTab,
): boolean {
const status = groupBookingStatus(bookings);
if (tab === 'pending') {
return (
status === 'pending' ||
(status === 'mixed' && bookings.some((booking) => booking.status === 'pending'))
);
}
if (tab === 'confirmed') {
return status === 'confirmed' || status === 'completed';
}
return status === 'cancelled';
}
export function serviceTitleForBooking(booking: Booking): string {
return booking.businessService?.serviceTemplate?.title ?? 'خدمت';
}
+176 -4
View File
@@ -9,6 +9,35 @@
}}
</p>
<div
v-if="auth.isBusinessOwner"
class="mb-4 flex gap-1 overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]"
role="tablist"
>
<button
v-for="tab in ownerTabs"
:key="tab.key"
type="button"
role="tab"
class="shrink-0 rounded-xl px-4 py-2.5 text-sm font-medium transition"
:class="
activeOwnerTab === tab.key
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
: 'text-muted hover:bg-surface-3'
"
:aria-selected="activeOwnerTab === tab.key"
@click="activeOwnerTab = tab.key"
>
{{ tab.label }}
<span
v-if="tabCount(tab.key)"
class="ms-1.5 inline-flex min-w-[1.25rem] items-center justify-center rounded-full bg-surface-3 px-1.5 text-[11px] font-semibold text-muted"
>
{{ tabCount(tab.key) }}
</span>
</button>
</div>
<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"
@@ -23,10 +52,10 @@
/>
<div
v-else-if="!bookingEntries.length"
v-else-if="!visibleEntries.length"
class="rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
>
هنوز رزروی ندارید.
{{ emptyMessage }}
<RouterLink to="/app" class="mt-3 block text-brand-600 dark:text-brand-500">
بازگشت به خانه
</RouterLink>
@@ -37,7 +66,7 @@
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<div
v-for="entry in bookingEntries"
v-for="entry in visibleEntries"
:key="entry.key"
class="flex h-full flex-col rounded-2xl border border-border bg-surface-2 p-4"
>
@@ -49,10 +78,55 @@
:can-confirm="canConfirmEntry(entry.bookings)"
:can-cancel="canCancelEntry(entry.bookings)"
@confirm="onConfirm"
@cancel="onCancel"
@cancel="onCancelRequest"
/>
</div>
</div>
<AppModal
:open="cancelModalOpen"
title="لغو رزرو"
subtitle="علت لغو را برای مشتری بنویسید."
@close="closeCancelModal"
>
<form class="space-y-4" @submit.prevent="submitCancel">
<AppTextarea
id="cancelReason"
v-model="cancelReason"
label="علت لغو"
rows="4"
placeholder="مثلاً: ظرفیت این بازه تکمیل شده است."
required
/>
<p
v-if="cancelModalError"
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
>
{{ cancelModalError }}
</p>
<div class="flex gap-2">
<AppButton
type="submit"
variant="danger"
class="flex-1"
:loading="cancelModalSubmitting"
>
تأیید لغو
</AppButton>
<AppButton
type="button"
variant="ghost"
class="!w-auto px-4"
:disabled="cancelModalSubmitting"
@click="closeCancelModal"
>
انصراف
</AppButton>
</div>
</form>
</AppModal>
</ResponsiveAppLayout>
</template>
@@ -62,12 +136,17 @@ import { cancelBooking, confirmBooking, fetchBookings } from '@/api/bookings';
import { ApiError } from '@/api/client';
import type { Booking } from '@/api/types';
import BookingItem from '@/components/booking/BookingItem.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppModal from '@/components/ui/AppModal.vue';
import AppTextarea from '@/components/ui/AppTextarea.vue';
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
import { useAuthStore } from '@/stores/auth';
import {
entryMatchesOwnerTab,
groupBookingsForDisplay,
type BookingListEntry,
type OwnerBookingTab,
} from '@/utils/booking-groups';
const auth = useAuthStore();
@@ -75,9 +154,50 @@ const bookings = ref<Booking[]>([]);
const loading = ref(true);
const error = ref('');
const actingKey = ref<string | null>(null);
const activeOwnerTab = ref<OwnerBookingTab>('pending');
const cancelModalOpen = ref(false);
const cancelModalSubmitting = ref(false);
const cancelModalError = ref('');
const cancelReason = ref('');
const pendingCancelEntry = ref<Booking[] | null>(null);
const ownerTabs: { key: OwnerBookingTab; label: string }[] = [
{ key: 'pending', label: 'در انتظار تأیید' },
{ key: 'confirmed', label: 'تأیید شده' },
{ key: 'cancelled', label: 'لغو شده' },
];
const bookingEntries = computed(() => groupBookingsForDisplay(bookings.value));
const visibleEntries = computed(() => {
if (!auth.isBusinessOwner) {
return bookingEntries.value;
}
return bookingEntries.value.filter((entry) =>
entryMatchesOwnerTab(entry.bookings, activeOwnerTab.value),
);
});
const emptyMessage = computed(() => {
if (!auth.isBusinessOwner) {
return 'هنوز رزروی ندارید.';
}
if (activeOwnerTab.value === 'pending') {
return 'رزروی در انتظار تأیید نیست.';
}
if (activeOwnerTab.value === 'confirmed') {
return 'رزرو تأییدشده‌ای نیست.';
}
return 'رزرو لغوشده‌ای نیست.';
});
function tabCount(tab: OwnerBookingTab) {
return bookingEntries.value.filter((entry) =>
entryMatchesOwnerTab(entry.bookings, tab),
).length;
}
function canConfirm(booking: Booking) {
return auth.isBusinessOwner && booking.status === 'pending';
}
@@ -136,6 +256,58 @@ async function onConfirm(entry: Booking[]) {
}
}
function onCancelRequest(entry: Booking[]) {
if (auth.isBusinessOwner) {
pendingCancelEntry.value = entry;
cancelReason.value = '';
cancelModalError.value = '';
cancelModalOpen.value = true;
return;
}
void onCancel(entry);
}
function closeCancelModal() {
if (cancelModalSubmitting.value) return;
cancelModalOpen.value = false;
pendingCancelEntry.value = null;
cancelReason.value = '';
cancelModalError.value = '';
}
async function submitCancel() {
const entry = pendingCancelEntry.value;
const reason = cancelReason.value.trim();
if (!entry?.length) return;
if (!reason) {
cancelModalError.value = 'علت لغو را وارد کنید.';
return;
}
cancelModalSubmitting.value = true;
cancelModalError.value = '';
actingKey.value = entry[0]?.bookingGroupId ?? entry[0]?.id ?? null;
try {
const updated = await Promise.all(
entry
.filter((booking) => canCancel(booking))
.map((booking) =>
cancelBooking(booking.id, { cancellationReason: reason }),
),
);
applyUpdatedBookings(updated);
closeCancelModal();
} catch (e) {
cancelModalError.value =
e instanceof ApiError ? e.message : 'لغو رزرو ناموفق بود.';
} finally {
cancelModalSubmitting.value = false;
actingKey.value = null;
}
}
async function onCancel(entry: Booking[]) {
const cancellable = entry.filter((booking) => canCancel(booking));
if (!cancellable.length) return;