tipsEnabled
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
<ProfileModal />
|
<ProfileModal />
|
||||||
|
<BookingReviewHost />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import BookingReviewHost from '@/components/booking/BookingReviewHost.vue';
|
||||||
import ProfileModal from '@/components/profile/ProfileModal.vue';
|
import ProfileModal from '@/components/profile/ProfileModal.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -86,6 +86,37 @@ export function initiateBookingPaymentByToken(token: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PendingBookingReview = {
|
||||||
|
bookingId: string;
|
||||||
|
businessId: string;
|
||||||
|
businessTitle: string;
|
||||||
|
serviceTitle: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
tipsEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchPendingReviews() {
|
||||||
|
return api<PendingBookingReview[]>('/bookings/pending-reviews');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitBookingReview(bookingId: string, rating: number) {
|
||||||
|
return api<{ id: string; rating: number }>(`/bookings/${bookingId}/review`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ rating }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initiateBookingTip(bookingId: string, amountToman: number) {
|
||||||
|
return api<PaymentInitResult>(`/bookings/${bookingId}/tip/init`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ amountToman }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeBooking(id: string) {
|
||||||
|
return api<Booking>(`/bookings/${id}/complete`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAvailableSlots(
|
export function fetchAvailableSlots(
|
||||||
businessId: string,
|
businessId: string,
|
||||||
dateYmd: string,
|
dateYmd: string,
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export type Business = {
|
|||||||
slotStepMinutes?: number;
|
slotStepMinutes?: number;
|
||||||
serviceClassesEnabled?: boolean;
|
serviceClassesEnabled?: boolean;
|
||||||
autoApproveBookings?: boolean;
|
autoApproveBookings?: boolean;
|
||||||
|
tipsEnabled?: boolean;
|
||||||
|
averageRating?: number | null;
|
||||||
|
reviewCount?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
@@ -53,6 +56,7 @@ export type UpdateBusinessPayload = {
|
|||||||
slotStepMinutes?: number;
|
slotStepMinutes?: number;
|
||||||
serviceClassesEnabled?: boolean;
|
serviceClassesEnabled?: boolean;
|
||||||
autoApproveBookings?: boolean;
|
autoApproveBookings?: boolean;
|
||||||
|
tipsEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceTemplate = {
|
export type ServiceTemplate = {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="canConfirm || canCancel || canPay"
|
v-if="canConfirm || canCancel || canPay || canComplete"
|
||||||
class="mt-auto flex flex-wrap gap-2 border-t border-border pt-3"
|
class="mt-auto flex flex-wrap gap-2 border-t border-border pt-3"
|
||||||
>
|
>
|
||||||
<AppButton
|
<AppButton
|
||||||
@@ -76,6 +76,15 @@
|
|||||||
>
|
>
|
||||||
پرداخت
|
پرداخت
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
<AppButton
|
||||||
|
v-if="canComplete"
|
||||||
|
variant="outline"
|
||||||
|
class="!w-auto flex-1 px-3"
|
||||||
|
:loading="acting"
|
||||||
|
@click="emit('complete', bookings)"
|
||||||
|
>
|
||||||
|
انجام شد
|
||||||
|
</AppButton>
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="canConfirm"
|
v-if="canConfirm"
|
||||||
class="!w-auto flex-1 px-3"
|
class="!w-auto flex-1 px-3"
|
||||||
@@ -115,12 +124,14 @@ const props = defineProps<{
|
|||||||
canConfirm: boolean;
|
canConfirm: boolean;
|
||||||
canCancel: boolean;
|
canCancel: boolean;
|
||||||
canPay: boolean;
|
canPay: boolean;
|
||||||
|
canComplete: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
confirm: [bookings: Booking[]];
|
confirm: [bookings: Booking[]];
|
||||||
cancel: [bookings: Booking[]];
|
cancel: [bookings: Booking[]];
|
||||||
pay: [bookings: Booking[]];
|
pay: [bookings: Booking[]];
|
||||||
|
complete: [bookings: Booking[]];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const primary = computed(() => props.bookings[0]!);
|
const primary = computed(() => props.bookings[0]!);
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<AppModal
|
||||||
|
:open="reviewOpen"
|
||||||
|
:title="`امتیاز به ${activeReview?.businessTitle ?? 'کسبوکار'}`"
|
||||||
|
subtitle="از ۱ تا ۵ ستاره — تجربه خود را با ما به اشتراک بگذارید"
|
||||||
|
@close="dismissReview"
|
||||||
|
>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<p class="text-center text-sm text-muted">
|
||||||
|
خدمت: {{ activeReview?.serviceTitle ?? '—' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<StarRating v-model="rating" />
|
||||||
|
|
||||||
|
<template v-if="activeReview?.tipsEnabled">
|
||||||
|
<div class="rounded-2xl border border-amber-500/25 bg-amber-50/80 p-4 dark:bg-amber-500/10">
|
||||||
|
<h3 class="text-sm font-bold text-text">حسن انجام کار</h3>
|
||||||
|
<p class="mt-2 text-sm leading-7 text-muted">
|
||||||
|
در صورتی که از خدمات ارائهشده توسط
|
||||||
|
<span class="font-semibold text-text">{{ activeReview?.businessTitle }}</span>
|
||||||
|
رضایت دارید، میتوانید مبلغی را بهعنوان قدردانی اختصاص دهید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<AppButton
|
||||||
|
class="flex-1"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="!rating || submitting"
|
||||||
|
@click="submitWithoutTip"
|
||||||
|
>
|
||||||
|
تمایل ندارم
|
||||||
|
</AppButton>
|
||||||
|
<AppButton
|
||||||
|
class="flex-1"
|
||||||
|
:disabled="!rating || submitting"
|
||||||
|
@click="openTipModal"
|
||||||
|
>
|
||||||
|
تمایل دارم
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||||
|
<AppButton
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!rating || submitting"
|
||||||
|
:loading="submitting"
|
||||||
|
@click="submitWithoutTip"
|
||||||
|
>
|
||||||
|
ثبت امتیاز
|
||||||
|
</AppButton>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</AppModal>
|
||||||
|
|
||||||
|
<AppModal
|
||||||
|
:open="tipOpen"
|
||||||
|
title="حسن انجام کار"
|
||||||
|
:subtitle="`مبلغ قدردانی برای ${activeReview?.businessTitle ?? 'کسبوکار'}`"
|
||||||
|
@close="closeTipModal"
|
||||||
|
>
|
||||||
|
<form class="space-y-4" @submit.prevent="submitTip">
|
||||||
|
<AppInput
|
||||||
|
id="tipAmount"
|
||||||
|
v-model="tipAmountInput"
|
||||||
|
label="مبلغ (تومان)"
|
||||||
|
inputmode="numeric"
|
||||||
|
ltr
|
||||||
|
placeholder="مثلاً ۵۰٬۰۰۰"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<p v-if="tipError" class="text-sm text-red-600">{{ tipError }}</p>
|
||||||
|
<AppButton type="submit" class="w-full" :loading="tipSubmitting" :disabled="!tipAmountToman">
|
||||||
|
پرداخت
|
||||||
|
</AppButton>
|
||||||
|
</form>
|
||||||
|
</AppModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
fetchPendingReviews,
|
||||||
|
initiateBookingTip,
|
||||||
|
submitBookingReview,
|
||||||
|
type PendingBookingReview,
|
||||||
|
} from '@/api/bookings';
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import StarRating from '@/components/booking/StarRating.vue';
|
||||||
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
|
import AppInput from '@/components/ui/AppInput.vue';
|
||||||
|
import AppModal from '@/components/ui/AppModal.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { parsePriceInput } from '@/utils/format';
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const pending = ref<PendingBookingReview[]>([]);
|
||||||
|
const activeReview = ref<PendingBookingReview | null>(null);
|
||||||
|
const reviewOpen = ref(false);
|
||||||
|
const tipOpen = ref(false);
|
||||||
|
const rating = ref(0);
|
||||||
|
const error = ref('');
|
||||||
|
const submitting = ref(false);
|
||||||
|
const tipAmountInput = ref('');
|
||||||
|
const tipError = ref('');
|
||||||
|
const tipSubmitting = ref(false);
|
||||||
|
|
||||||
|
const tipAmountToman = computed(() => parsePriceInput(tipAmountInput.value));
|
||||||
|
|
||||||
|
async function loadPending() {
|
||||||
|
if (!auth.isLoggedIn || auth.isBusinessOwner) return;
|
||||||
|
try {
|
||||||
|
pending.value = await fetchPendingReviews();
|
||||||
|
if (!activeReview.value && pending.value.length) {
|
||||||
|
activeReview.value = pending.value[0] ?? null;
|
||||||
|
reviewOpen.value = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
rating.value = 0;
|
||||||
|
error.value = '';
|
||||||
|
tipAmountInput.value = '';
|
||||||
|
tipError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissReview() {
|
||||||
|
reviewOpen.value = false;
|
||||||
|
resetForm();
|
||||||
|
if (pending.value.length > 1) {
|
||||||
|
pending.value = pending.value.slice(1);
|
||||||
|
activeReview.value = pending.value[0] ?? null;
|
||||||
|
if (activeReview.value) {
|
||||||
|
reviewOpen.value = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeReview.value = null;
|
||||||
|
pending.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTipModal() {
|
||||||
|
if (!rating.value) return;
|
||||||
|
tipOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTipModal() {
|
||||||
|
tipOpen.value = false;
|
||||||
|
tipError.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitWithoutTip() {
|
||||||
|
if (!activeReview.value || !rating.value) return;
|
||||||
|
submitting.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await submitBookingReview(activeReview.value.bookingId, rating.value);
|
||||||
|
reviewOpen.value = false;
|
||||||
|
resetForm();
|
||||||
|
pending.value = pending.value.filter(
|
||||||
|
(item) => item.bookingId !== activeReview.value?.bookingId,
|
||||||
|
);
|
||||||
|
activeReview.value = pending.value[0] ?? null;
|
||||||
|
if (activeReview.value) {
|
||||||
|
reviewOpen.value = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'ثبت امتیاز ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitTip() {
|
||||||
|
if (!activeReview.value || !rating.value || !tipAmountToman.value) return;
|
||||||
|
const bookingId = activeReview.value.bookingId;
|
||||||
|
tipSubmitting.value = true;
|
||||||
|
tipError.value = '';
|
||||||
|
try {
|
||||||
|
await submitBookingReview(bookingId, rating.value);
|
||||||
|
const result = await initiateBookingTip(bookingId, tipAmountToman.value);
|
||||||
|
tipOpen.value = false;
|
||||||
|
reviewOpen.value = false;
|
||||||
|
resetForm();
|
||||||
|
pending.value = pending.value.filter((item) => item.bookingId !== bookingId);
|
||||||
|
activeReview.value = pending.value[0] ?? null;
|
||||||
|
if (result.mock) {
|
||||||
|
if (activeReview.value) reviewOpen.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = result.startUrl;
|
||||||
|
} catch (e) {
|
||||||
|
tipError.value = e instanceof ApiError ? e.message : 'پرداخت ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
tipSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => auth.isLoggedIn,
|
||||||
|
(loggedIn) => {
|
||||||
|
if (loggedIn) void loadPending();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadPending();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
<div class="min-w-0 max-w-full space-y-6 overflow-x-hidden pb-24 lg:pb-0">
|
<div class="min-w-0 max-w-full space-y-6 overflow-x-hidden pb-24 lg:pb-0">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 class="text-xl font-bold text-text lg:text-2xl">رزرو خدمات</h1>
|
<h1 class="text-xl font-bold text-text lg:text-2xl">رزرو خدمات</h1>
|
||||||
|
<BusinessRatingBadge
|
||||||
|
:average-rating="business.averageRating"
|
||||||
|
:review-count="business.reviewCount"
|
||||||
|
/>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="homeTo"
|
:to="homeTo"
|
||||||
class="inline-flex items-center gap-2 text-sm text-muted transition hover:text-brand-600"
|
class="inline-flex items-center gap-2 text-sm text-muted transition hover:text-brand-600"
|
||||||
@@ -269,6 +273,7 @@ import { fetchBusinessServiceClasses } from '@/api/business-service-classes';
|
|||||||
import { ApiError } from '@/api/client';
|
import { ApiError } from '@/api/client';
|
||||||
import type { Booking, Business, BusinessService, BusinessServiceClass } from '@/api/types';
|
import type { Booking, Business, BusinessService, BusinessServiceClass } from '@/api/types';
|
||||||
import BookingOrderSummary from '@/components/booking/BookingOrderSummary.vue';
|
import BookingOrderSummary from '@/components/booking/BookingOrderSummary.vue';
|
||||||
|
import BusinessRatingBadge from '@/components/business/BusinessRatingBadge.vue';
|
||||||
import BookingServiceClassStep from '@/components/booking/BookingServiceClassStep.vue';
|
import BookingServiceClassStep from '@/components/booking/BookingServiceClassStep.vue';
|
||||||
import BookingStepper from '@/components/booking/BookingStepper.vue';
|
import BookingStepper from '@/components/booking/BookingStepper.vue';
|
||||||
import BookingSuccessView from '@/components/booking/BookingSuccessView.vue';
|
import BookingSuccessView from '@/components/booking/BookingSuccessView.vue';
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-center gap-2" role="radiogroup" :aria-label="ariaLabel">
|
||||||
|
<button
|
||||||
|
v-for="star in 5"
|
||||||
|
:key="star"
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg p-1 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/60"
|
||||||
|
:aria-label="`${star} از ۵`"
|
||||||
|
:aria-checked="star <= modelValue"
|
||||||
|
role="radio"
|
||||||
|
@click="emit('update:modelValue', star)"
|
||||||
|
@mouseenter="hovered = star"
|
||||||
|
@mouseleave="hovered = 0"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="h-10 w-10 drop-shadow-sm transition sm:h-12 sm:w-12"
|
||||||
|
:class="star <= displayValue ? 'text-amber-400' : 'text-slate-300 dark:text-slate-600'"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 2.5l2.86 5.8 6.4.93-4.63 4.52 1.09 6.37L12 17.77l-5.72 3.01 1.09-6.37L2.74 9.23l6.4-.93L12 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: number;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}>(),
|
||||||
|
{ ariaLabel: 'امتیاز از ۱ تا ۵' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: number];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const hovered = ref(0);
|
||||||
|
|
||||||
|
const displayValue = computed(() => hovered.value || props.modelValue);
|
||||||
|
</script>
|
||||||
@@ -3,9 +3,16 @@
|
|||||||
:to="{ name: 'app-business-detail', params: { id: business.id } }"
|
:to="{ name: 'app-business-detail', params: { id: business.id } }"
|
||||||
class="flex items-center gap-3 rounded-xl border border-border bg-surface-2 p-2.5 transition hover:border-brand-500/40 hover:shadow-sm"
|
class="flex items-center gap-3 rounded-xl border border-border bg-surface-2 p-2.5 transition hover:border-brand-500/40 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<h2 class="truncate text-sm font-semibold text-text">{{ business.title }}</h2>
|
<div class="min-w-0">
|
||||||
|
<h2 class="truncate text-sm font-semibold text-text">{{ business.title }}</h2>
|
||||||
|
<BusinessRatingBadge
|
||||||
|
class="mt-1"
|
||||||
|
:average-rating="business.averageRating"
|
||||||
|
:review-count="business.reviewCount"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="business.category"
|
v-if="business.category"
|
||||||
class="shrink-0 rounded-lg bg-brand-500/10 px-1.5 py-0.5 text-[10px] text-brand-700 dark:text-brand-500"
|
class="shrink-0 rounded-lg bg-brand-500/10 px-1.5 py-0.5 text-[10px] text-brand-700 dark:text-brand-500"
|
||||||
@@ -49,6 +56,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { Business } from '@/api/types';
|
import type { Business } from '@/api/types';
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
|
import BusinessRatingBadge from '@/components/business/BusinessRatingBadge.vue';
|
||||||
import { businessCoverUrl } from '@/utils/media';
|
import { businessCoverUrl } from '@/utils/media';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="reviewCount > 0 && averageRating != null"
|
||||||
|
class="inline-flex items-center gap-1 rounded-lg bg-amber-500/10 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 text-amber-400" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M10 1.5l2.38 4.82 5.32.77-3.85 3.76.91 5.3L10 13.77l-4.76 2.5.91-5.3-3.85-3.76 5.32-.77L10 1.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ formattedRating }}</span>
|
||||||
|
<span class="text-amber-600/70 dark:text-amber-500/70">({{ reviewCount }})</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
averageRating?: number | null;
|
||||||
|
reviewCount?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const reviewCount = computed(() => props.reviewCount ?? 0);
|
||||||
|
|
||||||
|
const formattedRating = computed(() => {
|
||||||
|
if (props.averageRating == null) return '—';
|
||||||
|
return props.averageRating.toLocaleString('fa-IR', {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -19,6 +19,12 @@
|
|||||||
|
|
||||||
<h3 class="text-base font-bold text-slate-800 dark:text-text">{{ business.title }}</h3>
|
<h3 class="text-base font-bold text-slate-800 dark:text-text">{{ business.title }}</h3>
|
||||||
|
|
||||||
|
<BusinessRatingBadge
|
||||||
|
class="mx-auto mt-2"
|
||||||
|
:average-rating="business.averageRating"
|
||||||
|
:review-count="business.reviewCount"
|
||||||
|
/>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="business.description"
|
v-if="business.description"
|
||||||
class="mt-2 line-clamp-3 min-h-[4.5rem] text-sm leading-7 text-slate-600 dark:text-muted"
|
class="mt-2 line-clamp-3 min-h-[4.5rem] text-sm leading-7 text-slate-600 dark:text-muted"
|
||||||
@@ -51,6 +57,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import type { Business } from '@/api/types';
|
import type { Business } from '@/api/types';
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
|
import BusinessRatingBadge from '@/components/business/BusinessRatingBadge.vue';
|
||||||
import { businessCoverUrl } from '@/utils/media';
|
import { businessCoverUrl } from '@/utils/media';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@@ -78,9 +78,11 @@
|
|||||||
:can-confirm="canConfirmEntry(entry.bookings)"
|
:can-confirm="canConfirmEntry(entry.bookings)"
|
||||||
:can-cancel="canCancelEntry(entry.bookings)"
|
:can-cancel="canCancelEntry(entry.bookings)"
|
||||||
:can-pay="canPayEntry(entry.bookings)"
|
:can-pay="canPayEntry(entry.bookings)"
|
||||||
|
:can-complete="canCompleteEntry(entry.bookings)"
|
||||||
@confirm="onConfirm"
|
@confirm="onConfirm"
|
||||||
@cancel="onCancelRequest"
|
@cancel="onCancelRequest"
|
||||||
@pay="onPay"
|
@pay="onPay"
|
||||||
|
@complete="onComplete"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,6 +140,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import {
|
import {
|
||||||
cancelBooking,
|
cancelBooking,
|
||||||
confirmBooking,
|
confirmBooking,
|
||||||
|
completeBooking,
|
||||||
fetchBookings,
|
fetchBookings,
|
||||||
initiateBookingPayment,
|
initiateBookingPayment,
|
||||||
} from '@/api/bookings';
|
} from '@/api/bookings';
|
||||||
@@ -277,6 +280,31 @@ async function onConfirm(entry: Booking[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canComplete(booking: Booking) {
|
||||||
|
return auth.isBusinessOwner && booking.status === 'confirmed';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCompleteEntry(entry: Booking[]) {
|
||||||
|
return entry.some((booking) => canComplete(booking));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onComplete(entry: Booking[]) {
|
||||||
|
const completable = entry.filter((booking) => canComplete(booking));
|
||||||
|
if (!completable.length) return;
|
||||||
|
|
||||||
|
actingKey.value = entry[0]?.bookingGroupId ?? entry[0]?.id ?? null;
|
||||||
|
try {
|
||||||
|
const updated = await Promise.all(
|
||||||
|
completable.map((booking) => completeBooking(booking.id)),
|
||||||
|
);
|
||||||
|
applyUpdatedBookings(updated);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'ثبت انجام خدمت ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
actingKey.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onPay(entry: Booking[]) {
|
async function onPay(entry: Booking[]) {
|
||||||
const payable = entry.find((booking) => canPay(booking));
|
const payable = entry.find((booking) => canPay(booking));
|
||||||
if (!payable) return;
|
if (!payable) return;
|
||||||
|
|||||||
@@ -163,6 +163,21 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-border bg-surface px-4 py-3">
|
||||||
|
<input
|
||||||
|
v-model="bookingForm.tipsEnabled"
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block text-sm font-medium text-text">حسن انجام کار</span>
|
||||||
|
<span class="mt-1 block text-xs text-muted">
|
||||||
|
اگر فعال باشد، پس از اتمام خدمت مشتری میتواند علاوه بر امتیاز، مبلغی را بهعنوان
|
||||||
|
قدردانی پرداخت کند. در غیر این صورت فقط امتیازدهی نمایش داده میشود.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<AppButton :loading="savingBooking" @click="onSaveBookingSettings">
|
<AppButton :loading="savingBooking" @click="onSaveBookingSettings">
|
||||||
ذخیره تنظیمات نوبتدهی
|
ذخیره تنظیمات نوبتدهی
|
||||||
</AppButton>
|
</AppButton>
|
||||||
@@ -543,6 +558,7 @@ const bookingForm = ref({
|
|||||||
concurrentCapacity: '1',
|
concurrentCapacity: '1',
|
||||||
slotStepMinutes: '30',
|
slotStepMinutes: '30',
|
||||||
autoApproveBookings: false,
|
autoApproveBookings: false,
|
||||||
|
tipsEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookingExpanded = ref(false);
|
const bookingExpanded = ref(false);
|
||||||
@@ -590,7 +606,8 @@ const servicesSummary = computed(() => {
|
|||||||
|
|
||||||
const bookingSummary = computed(() => {
|
const bookingSummary = computed(() => {
|
||||||
const auto = bookingForm.value.autoApproveBookings ? 'تأیید خودکار' : 'تأیید دستی';
|
const auto = bookingForm.value.autoApproveBookings ? 'تأیید خودکار' : 'تأیید دستی';
|
||||||
return `${bookingForm.value.concurrentCapacity} همزمان · گام ${bookingForm.value.slotStepMinutes} دقیقه · ${auto}`;
|
const tips = bookingForm.value.tipsEnabled ? 'حسن انجام کار فعال' : 'بدون حسن انجام کار';
|
||||||
|
return `${bookingForm.value.concurrentCapacity} همزمان · گام ${bookingForm.value.slotStepMinutes} دقیقه · ${auto} · ${tips}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
function emptyClassPriceForm() {
|
function emptyClassPriceForm() {
|
||||||
@@ -690,6 +707,7 @@ onMounted(async () => {
|
|||||||
concurrentCapacity: String(business.value.concurrentCapacity ?? 1),
|
concurrentCapacity: String(business.value.concurrentCapacity ?? 1),
|
||||||
slotStepMinutes: String(business.value.slotStepMinutes ?? 30),
|
slotStepMinutes: String(business.value.slotStepMinutes ?? 30),
|
||||||
autoApproveBookings: business.value.autoApproveBookings === true,
|
autoApproveBookings: business.value.autoApproveBookings === true,
|
||||||
|
tipsEnabled: business.value.tipsEnabled === true,
|
||||||
};
|
};
|
||||||
await loadServicesAndTemplates();
|
await loadServicesAndTemplates();
|
||||||
}
|
}
|
||||||
@@ -813,6 +831,7 @@ async function onSaveBookingSettings() {
|
|||||||
concurrentCapacity,
|
concurrentCapacity,
|
||||||
slotStepMinutes,
|
slotStepMinutes,
|
||||||
autoApproveBookings: bookingForm.value.autoApproveBookings,
|
autoApproveBookings: bookingForm.value.autoApproveBookings,
|
||||||
|
tipsEnabled: bookingForm.value.tipsEnabled,
|
||||||
});
|
});
|
||||||
tenant.syncSingleBusiness(business.value);
|
tenant.syncSingleBusiness(business.value);
|
||||||
bookingMessage.value = 'تنظیمات نوبتدهی ذخیره شد.';
|
bookingMessage.value = 'تنظیمات نوبتدهی ذخیره شد.';
|
||||||
|
|||||||
Reference in New Issue
Block a user