AddServiceLoyalty

This commit is contained in:
2026-07-15 20:42:03 +03:30
parent 0d4158db4e
commit a2d0232113
6 changed files with 137 additions and 5 deletions
+5 -1
View File
@@ -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',
+2
View File
@@ -13,6 +13,8 @@ export type UpdateBusinessServicePayload = {
price?: number;
durationMinutes?: number | null;
isActive?: boolean;
loyaltyEnabled?: boolean;
loyaltyEveryN?: number | null;
};
export function fetchBusinessServices(
+11
View File
@@ -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;
+60 -2
View File
@@ -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,
+47
View File
@@ -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;
+12 -2
View File
@@ -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