AddServiceLoyalty
This commit is contained in:
+5
-1
@@ -1,5 +1,5 @@
|
||||
import { api } from './client';
|
||||
import type { Booking, BookingStatus } from './types';
|
||||
import type { Booking, BookingStatus, ServiceLoyaltyStatus } from './types';
|
||||
|
||||
export type CreateBookingPayload = {
|
||||
customerId: string;
|
||||
@@ -99,6 +99,10 @@ export function fetchPendingReviews() {
|
||||
return api<PendingBookingReview[]>('/bookings/pending-reviews');
|
||||
}
|
||||
|
||||
export function fetchServiceLoyaltyStatus(businessServiceId: string) {
|
||||
return api<ServiceLoyaltyStatus>(`/bookings/loyalty/${businessServiceId}`);
|
||||
}
|
||||
|
||||
export function submitBookingReview(bookingId: string, rating: number) {
|
||||
return api<{ id: string; rating: number }>(`/bookings/${bookingId}/review`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -13,6 +13,8 @@ export type UpdateBusinessServicePayload = {
|
||||
price?: number;
|
||||
durationMinutes?: number | null;
|
||||
isActive?: boolean;
|
||||
loyaltyEnabled?: boolean;
|
||||
loyaltyEveryN?: number | null;
|
||||
};
|
||||
|
||||
export function fetchBusinessServices(
|
||||
|
||||
@@ -94,10 +94,20 @@ export type BusinessService = {
|
||||
price: string;
|
||||
durationMinutes?: number | null;
|
||||
isActive: boolean;
|
||||
loyaltyEnabled?: boolean;
|
||||
loyaltyEveryN?: number | null;
|
||||
serviceTemplate?: ServiceTemplate;
|
||||
classPrices?: BusinessServiceClassPrice[];
|
||||
};
|
||||
|
||||
export type ServiceLoyaltyStatus = {
|
||||
enabled: boolean;
|
||||
everyN: number | null;
|
||||
paidCount: number;
|
||||
nextIsFree: boolean;
|
||||
remainingPaidVisits: number | null;
|
||||
};
|
||||
|
||||
export type Booking = {
|
||||
id: string;
|
||||
customerId: string;
|
||||
@@ -111,6 +121,7 @@ export type Booking = {
|
||||
cancellationReason?: string | null;
|
||||
paymentToken?: string | null;
|
||||
paymentAmount?: string | null;
|
||||
loyaltyRedeemed?: boolean;
|
||||
business?: Business;
|
||||
businessService?: BusinessService;
|
||||
serviceClass?: BusinessServiceClass;
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
{{ formatPriceToman(selectedPrice) }} تومان
|
||||
</dd>
|
||||
</div>
|
||||
<p
|
||||
v-if="loyaltyHint"
|
||||
class="border-t border-brand-500/20 pt-1.5 text-xs leading-6 text-brand-700 dark:text-brand-400"
|
||||
>
|
||||
{{ loyaltyHint }}
|
||||
</p>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -268,10 +274,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { createBooking, createBookingBatch, fetchAvailableSlots } from '@/api/bookings';
|
||||
import {
|
||||
createBooking,
|
||||
createBookingBatch,
|
||||
fetchAvailableSlots,
|
||||
fetchServiceLoyaltyStatus,
|
||||
} from '@/api/bookings';
|
||||
import { fetchBusinessServiceClasses } from '@/api/business-service-classes';
|
||||
import { ApiError } from '@/api/client';
|
||||
import type { Booking, Business, BusinessService, BusinessServiceClass } from '@/api/types';
|
||||
import type {
|
||||
Booking,
|
||||
Business,
|
||||
BusinessService,
|
||||
BusinessServiceClass,
|
||||
ServiceLoyaltyStatus,
|
||||
} from '@/api/types';
|
||||
import BookingOrderSummary from '@/components/booking/BookingOrderSummary.vue';
|
||||
import BusinessRatingBadge from '@/components/business/BusinessRatingBadge.vue';
|
||||
import BookingServiceClassStep from '@/components/booking/BookingServiceClassStep.vue';
|
||||
@@ -343,6 +360,7 @@ const dateTimeRef = ref<InstanceType<typeof DesktopBookingDateTime> | null>(null
|
||||
const serviceClasses = ref<BusinessServiceClass[]>([]);
|
||||
const loadingClasses = ref(true);
|
||||
const classesError = ref('');
|
||||
const loyaltyByServiceId = ref<Record<string, ServiceLoyaltyStatus>>({});
|
||||
|
||||
const classStepRequired = computed(() => {
|
||||
if (loadingClasses.value && props.business.serviceClassesEnabled) {
|
||||
@@ -414,6 +432,46 @@ const selectedDurationLabel = computed(() => {
|
||||
return `${selectedDurationMinutes.value} دقیقه`;
|
||||
});
|
||||
|
||||
const loyaltyHint = computed(() => {
|
||||
if (!auth.isLoggedIn || !selectedServices.value.length) return '';
|
||||
const parts: string[] = [];
|
||||
for (const service of selectedServices.value) {
|
||||
const status = loyaltyByServiceId.value[service.id];
|
||||
if (!status?.enabled || !status.everyN) continue;
|
||||
const title = service.serviceTemplate?.title ?? 'این خدمت';
|
||||
if (status.nextIsFree) {
|
||||
parts.push(`«${title}» در نوبت بعدی رایگان میشود.`);
|
||||
} else if (status.remainingPaidVisits != null) {
|
||||
parts.push(
|
||||
`«${title}»: ${status.remainingPaidVisits} نوبت تا رایگان بعدی (هر ${status.everyN} بار).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return parts.join(' ');
|
||||
});
|
||||
|
||||
watch(
|
||||
[selectedServiceIds, () => auth.isLoggedIn],
|
||||
async ([ids, loggedIn]) => {
|
||||
if (!loggedIn || !ids.length) {
|
||||
loyaltyByServiceId.value = {};
|
||||
return;
|
||||
}
|
||||
const next: Record<string, ServiceLoyaltyStatus> = {};
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
try {
|
||||
next[id] = await fetchServiceLoyaltyStatus(id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}),
|
||||
);
|
||||
loyaltyByServiceId.value = next;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const summaryItems = computed(() =>
|
||||
selectedServices.value.map((service) => ({
|
||||
id: service.id,
|
||||
|
||||
@@ -301,6 +301,28 @@
|
||||
ltr
|
||||
/>
|
||||
</template>
|
||||
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-border bg-surface px-3 py-3">
|
||||
<input v-model="editForm.loyaltyEnabled" 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>
|
||||
<AppInput
|
||||
v-if="editForm.loyaltyEnabled"
|
||||
id="editLoyaltyEveryN"
|
||||
v-model="editForm.loyaltyEveryN"
|
||||
label="هر چند بار یکبار رایگان؟"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
ltr
|
||||
placeholder="مثلاً ۳ یا ۵"
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<AppButton class="!w-auto flex-1" :loading="savingService" @click="onSaveEdit(svc)">
|
||||
ذخیره
|
||||
@@ -331,6 +353,12 @@
|
||||
<span>·</span>
|
||||
<span>{{ serviceDurationLabel(svc, false, serviceClasses) }}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="svc.loyaltyEnabled && svc.loyaltyEveryN"
|
||||
class="text-xs font-medium text-brand-600 dark:text-brand-500"
|
||||
>
|
||||
هر {{ svc.loyaltyEveryN }} بار · یکبار رایگان
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -574,6 +602,8 @@ const serviceForm = ref({
|
||||
const editForm = ref({
|
||||
price: '',
|
||||
durationMinutes: '',
|
||||
loyaltyEnabled: false,
|
||||
loyaltyEveryN: '3',
|
||||
});
|
||||
|
||||
const classPriceForm = ref<Record<string, { price: string; durationMinutes: string }>>({});
|
||||
@@ -952,6 +982,8 @@ function startEdit(svc: BusinessService) {
|
||||
editForm.value = {
|
||||
price: formatPriceInputDisplay(Math.round(Number.parseFloat(svc.price))),
|
||||
durationMinutes: svc.durationMinutes != null ? String(svc.durationMinutes) : '',
|
||||
loyaltyEnabled: svc.loyaltyEnabled === true,
|
||||
loyaltyEveryN: svc.loyaltyEveryN != null ? String(svc.loyaltyEveryN) : '3',
|
||||
};
|
||||
const form = emptyClassPriceForm();
|
||||
for (const classPrice of svc.classPrices ?? []) {
|
||||
@@ -974,6 +1006,15 @@ async function onSaveEdit(svc: BusinessService) {
|
||||
savingService.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
let loyaltyEveryN: number | null = null;
|
||||
if (editForm.value.loyaltyEnabled) {
|
||||
loyaltyEveryN = Number.parseInt(editForm.value.loyaltyEveryN, 10);
|
||||
if (!Number.isFinite(loyaltyEveryN) || loyaltyEveryN < 2 || loyaltyEveryN > 100) {
|
||||
error.value = 'تعداد دفعات باید عددی بین ۲ تا ۱۰۰ باشد.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasServiceClasses.value) {
|
||||
const classPrices = buildClassPricesPayload(editClassPrices.value);
|
||||
if (!classPrices?.length) {
|
||||
@@ -981,6 +1022,10 @@ async function onSaveEdit(svc: BusinessService) {
|
||||
return;
|
||||
}
|
||||
await setBusinessServiceClassPrices(svc.id, classPrices);
|
||||
await updateBusinessService(svc.id, {
|
||||
loyaltyEnabled: editForm.value.loyaltyEnabled,
|
||||
loyaltyEveryN,
|
||||
});
|
||||
} else {
|
||||
const price = parsePriceInput(editForm.value.price);
|
||||
if (price === null) {
|
||||
@@ -995,6 +1040,8 @@ async function onSaveEdit(svc: BusinessService) {
|
||||
await updateBusinessService(svc.id, {
|
||||
price,
|
||||
durationMinutes: duration ?? null,
|
||||
loyaltyEnabled: editForm.value.loyaltyEnabled,
|
||||
loyaltyEveryN,
|
||||
});
|
||||
}
|
||||
editingId.value = null;
|
||||
|
||||
@@ -52,7 +52,10 @@
|
||||
<div class="flex justify-between gap-4 border-t border-border pt-3">
|
||||
<dt class="text-muted">مبلغ قابل پرداخت</dt>
|
||||
<dd class="text-lg font-bold text-brand-600">
|
||||
{{ formatPriceToman(String(details.totalAmountToman)) }} تومان
|
||||
<template v-if="details.totalAmountToman <= 0">رایگان</template>
|
||||
<template v-else>
|
||||
{{ formatPriceToman(String(details.totalAmountToman)) }} تومان
|
||||
</template>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -60,6 +63,7 @@
|
||||
<ul class="space-y-1 text-sm text-muted">
|
||||
<li v-for="booking in details.bookings" :key="booking.id">
|
||||
{{ booking.businessService?.serviceTemplate?.title ?? 'خدمت' }}
|
||||
<span v-if="booking.loyaltyRedeemed" class="text-brand-600"> (رایگان)</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -69,7 +73,13 @@
|
||||
:loading="paying"
|
||||
@click="startPayment"
|
||||
>
|
||||
{{ mockMode ? 'پرداخت آزمایشی' : 'پرداخت آنلاین' }}
|
||||
{{
|
||||
details.totalAmountToman <= 0
|
||||
? 'ثبت رایگان'
|
||||
: mockMode
|
||||
? 'پرداخت آزمایشی'
|
||||
: 'پرداخت آنلاین'
|
||||
}}
|
||||
</AppButton>
|
||||
|
||||
<RouterLink
|
||||
|
||||
Reference in New Issue
Block a user