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 { api } from './client';
import type { Booking, BookingStatus } from './types'; import type { Booking, BookingStatus, ServiceLoyaltyStatus } from './types';
export type CreateBookingPayload = { export type CreateBookingPayload = {
customerId: string; customerId: string;
@@ -99,6 +99,10 @@ export function fetchPendingReviews() {
return api<PendingBookingReview[]>('/bookings/pending-reviews'); return api<PendingBookingReview[]>('/bookings/pending-reviews');
} }
export function fetchServiceLoyaltyStatus(businessServiceId: string) {
return api<ServiceLoyaltyStatus>(`/bookings/loyalty/${businessServiceId}`);
}
export function submitBookingReview(bookingId: string, rating: number) { export function submitBookingReview(bookingId: string, rating: number) {
return api<{ id: string; rating: number }>(`/bookings/${bookingId}/review`, { return api<{ id: string; rating: number }>(`/bookings/${bookingId}/review`, {
method: 'POST', method: 'POST',
+2
View File
@@ -13,6 +13,8 @@ export type UpdateBusinessServicePayload = {
price?: number; price?: number;
durationMinutes?: number | null; durationMinutes?: number | null;
isActive?: boolean; isActive?: boolean;
loyaltyEnabled?: boolean;
loyaltyEveryN?: number | null;
}; };
export function fetchBusinessServices( export function fetchBusinessServices(
+11
View File
@@ -94,10 +94,20 @@ export type BusinessService = {
price: string; price: string;
durationMinutes?: number | null; durationMinutes?: number | null;
isActive: boolean; isActive: boolean;
loyaltyEnabled?: boolean;
loyaltyEveryN?: number | null;
serviceTemplate?: ServiceTemplate; serviceTemplate?: ServiceTemplate;
classPrices?: BusinessServiceClassPrice[]; classPrices?: BusinessServiceClassPrice[];
}; };
export type ServiceLoyaltyStatus = {
enabled: boolean;
everyN: number | null;
paidCount: number;
nextIsFree: boolean;
remainingPaidVisits: number | null;
};
export type Booking = { export type Booking = {
id: string; id: string;
customerId: string; customerId: string;
@@ -111,6 +121,7 @@ export type Booking = {
cancellationReason?: string | null; cancellationReason?: string | null;
paymentToken?: string | null; paymentToken?: string | null;
paymentAmount?: string | null; paymentAmount?: string | null;
loyaltyRedeemed?: boolean;
business?: Business; business?: Business;
businessService?: BusinessService; businessService?: BusinessService;
serviceClass?: BusinessServiceClass; serviceClass?: BusinessServiceClass;
+60 -2
View File
@@ -54,6 +54,12 @@
{{ formatPriceToman(selectedPrice) }} تومان {{ formatPriceToman(selectedPrice) }} تومان
</dd> </dd>
</div> </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> </dl>
</div> </div>
@@ -268,10 +274,21 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; 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 { 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,
ServiceLoyaltyStatus,
} 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 BusinessRatingBadge from '@/components/business/BusinessRatingBadge.vue';
import BookingServiceClassStep from '@/components/booking/BookingServiceClassStep.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 serviceClasses = ref<BusinessServiceClass[]>([]);
const loadingClasses = ref(true); const loadingClasses = ref(true);
const classesError = ref(''); const classesError = ref('');
const loyaltyByServiceId = ref<Record<string, ServiceLoyaltyStatus>>({});
const classStepRequired = computed(() => { const classStepRequired = computed(() => {
if (loadingClasses.value && props.business.serviceClassesEnabled) { if (loadingClasses.value && props.business.serviceClassesEnabled) {
@@ -414,6 +432,46 @@ const selectedDurationLabel = computed(() => {
return `${selectedDurationMinutes.value} دقیقه`; 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(() => const summaryItems = computed(() =>
selectedServices.value.map((service) => ({ selectedServices.value.map((service) => ({
id: service.id, id: service.id,
+47
View File
@@ -301,6 +301,28 @@
ltr ltr
/> />
</template> </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"> <div class="flex gap-2">
<AppButton class="!w-auto flex-1" :loading="savingService" @click="onSaveEdit(svc)"> <AppButton class="!w-auto flex-1" :loading="savingService" @click="onSaveEdit(svc)">
ذخیره ذخیره
@@ -331,6 +353,12 @@
<span>·</span> <span>·</span>
<span>{{ serviceDurationLabel(svc, false, serviceClasses) }}</span> <span>{{ serviceDurationLabel(svc, false, serviceClasses) }}</span>
</div> </div>
<p
v-if="svc.loyaltyEnabled && svc.loyaltyEveryN"
class="text-xs font-medium text-brand-600 dark:text-brand-500"
>
هر {{ svc.loyaltyEveryN }} بار · یک‌بار رایگان
</p>
</div> </div>
</li> </li>
</ul> </ul>
@@ -574,6 +602,8 @@ const serviceForm = ref({
const editForm = ref({ const editForm = ref({
price: '', price: '',
durationMinutes: '', durationMinutes: '',
loyaltyEnabled: false,
loyaltyEveryN: '3',
}); });
const classPriceForm = ref<Record<string, { price: string; durationMinutes: string }>>({}); const classPriceForm = ref<Record<string, { price: string; durationMinutes: string }>>({});
@@ -952,6 +982,8 @@ function startEdit(svc: BusinessService) {
editForm.value = { editForm.value = {
price: formatPriceInputDisplay(Math.round(Number.parseFloat(svc.price))), price: formatPriceInputDisplay(Math.round(Number.parseFloat(svc.price))),
durationMinutes: svc.durationMinutes != null ? String(svc.durationMinutes) : '', durationMinutes: svc.durationMinutes != null ? String(svc.durationMinutes) : '',
loyaltyEnabled: svc.loyaltyEnabled === true,
loyaltyEveryN: svc.loyaltyEveryN != null ? String(svc.loyaltyEveryN) : '3',
}; };
const form = emptyClassPriceForm(); const form = emptyClassPriceForm();
for (const classPrice of svc.classPrices ?? []) { for (const classPrice of svc.classPrices ?? []) {
@@ -974,6 +1006,15 @@ async function onSaveEdit(svc: BusinessService) {
savingService.value = true; savingService.value = true;
error.value = ''; error.value = '';
try { 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) { if (hasServiceClasses.value) {
const classPrices = buildClassPricesPayload(editClassPrices.value); const classPrices = buildClassPricesPayload(editClassPrices.value);
if (!classPrices?.length) { if (!classPrices?.length) {
@@ -981,6 +1022,10 @@ async function onSaveEdit(svc: BusinessService) {
return; return;
} }
await setBusinessServiceClassPrices(svc.id, classPrices); await setBusinessServiceClassPrices(svc.id, classPrices);
await updateBusinessService(svc.id, {
loyaltyEnabled: editForm.value.loyaltyEnabled,
loyaltyEveryN,
});
} else { } else {
const price = parsePriceInput(editForm.value.price); const price = parsePriceInput(editForm.value.price);
if (price === null) { if (price === null) {
@@ -995,6 +1040,8 @@ async function onSaveEdit(svc: BusinessService) {
await updateBusinessService(svc.id, { await updateBusinessService(svc.id, {
price, price,
durationMinutes: duration ?? null, durationMinutes: duration ?? null,
loyaltyEnabled: editForm.value.loyaltyEnabled,
loyaltyEveryN,
}); });
} }
editingId.value = null; editingId.value = null;
+12 -2
View File
@@ -52,7 +52,10 @@
<div class="flex justify-between gap-4 border-t border-border pt-3"> <div class="flex justify-between gap-4 border-t border-border pt-3">
<dt class="text-muted">مبلغ قابل پرداخت</dt> <dt class="text-muted">مبلغ قابل پرداخت</dt>
<dd class="text-lg font-bold text-brand-600"> <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> </dd>
</div> </div>
</dl> </dl>
@@ -60,6 +63,7 @@
<ul class="space-y-1 text-sm text-muted"> <ul class="space-y-1 text-sm text-muted">
<li v-for="booking in details.bookings" :key="booking.id"> <li v-for="booking in details.bookings" :key="booking.id">
{{ booking.businessService?.serviceTemplate?.title ?? 'خدمت' }} {{ booking.businessService?.serviceTemplate?.title ?? 'خدمت' }}
<span v-if="booking.loyaltyRedeemed" class="text-brand-600"> (رایگان)</span>
</li> </li>
</ul> </ul>
@@ -69,7 +73,13 @@
:loading="paying" :loading="paying"
@click="startPayment" @click="startPayment"
> >
{{ mockMode ? 'پرداخت آزمایشی' : 'پرداخت آنلاین' }} {{
details.totalAmountToman <= 0
? 'ثبت رایگان'
: mockMode
? 'پرداخت آزمایشی'
: 'پرداخت آنلاین'
}}
</AppButton> </AppButton>
<RouterLink <RouterLink