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
+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,