tipsEnabled

This commit is contained in:
2026-06-29 19:32:19 +03:30
parent 129a34f7eb
commit f4d5109b12
12 changed files with 418 additions and 5 deletions
+2
View File
@@ -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>
+31
View File
@@ -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,
+4
View File
@@ -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 = {
+12 -1
View File
@@ -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';
+48
View File
@@ -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>
+11 -3
View File
@@ -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>
+7
View File
@@ -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<{
+28
View File
@@ -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;
+20 -1
View File
@@ -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 = 'تنظیمات نوبت‌دهی ذخیره شد.';