add cancellationReason
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user