AddServiceLoyalty
This commit is contained in:
+5
-1
@@ -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',
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user