awaiting_payment
This commit is contained in:
@@ -60,6 +60,32 @@ export function confirmBooking(id: string) {
|
|||||||
return api<Booking>(`/bookings/${id}/confirm`, { method: 'POST' });
|
return api<Booking>(`/bookings/${id}/confirm`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchPaymentByToken(token: string) {
|
||||||
|
return api<{
|
||||||
|
bookings: Booking[];
|
||||||
|
totalAmountToman: number;
|
||||||
|
}>(`/bookings/payment/by-token/${encodeURIComponent(token)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentInitResult = {
|
||||||
|
startUrl: string;
|
||||||
|
amountToman: number;
|
||||||
|
mock?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function initiateBookingPayment(id: string) {
|
||||||
|
return api<PaymentInitResult>(`/bookings/${id}/payment/init`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initiateBookingPaymentByToken(token: string) {
|
||||||
|
return api<PaymentInitResult>(
|
||||||
|
`/bookings/payment/by-token/${encodeURIComponent(token)}/init`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchAvailableSlots(
|
export function fetchAvailableSlots(
|
||||||
businessId: string,
|
businessId: string,
|
||||||
dateYmd: string,
|
dateYmd: string,
|
||||||
|
|||||||
+10
-1
@@ -1,4 +1,9 @@
|
|||||||
export type BookingStatus = 'pending' | 'confirmed' | 'cancelled' | 'completed';
|
export type BookingStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'awaiting_payment'
|
||||||
|
| 'confirmed'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'completed';
|
||||||
|
|
||||||
export type BusinessCategory = {
|
export type BusinessCategory = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,6 +36,7 @@ export type Business = {
|
|||||||
concurrentCapacity?: number;
|
concurrentCapacity?: number;
|
||||||
slotStepMinutes?: number;
|
slotStepMinutes?: number;
|
||||||
serviceClassesEnabled?: boolean;
|
serviceClassesEnabled?: boolean;
|
||||||
|
autoApproveBookings?: boolean;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
};
|
};
|
||||||
@@ -46,6 +52,7 @@ export type UpdateBusinessPayload = {
|
|||||||
concurrentCapacity?: number;
|
concurrentCapacity?: number;
|
||||||
slotStepMinutes?: number;
|
slotStepMinutes?: number;
|
||||||
serviceClassesEnabled?: boolean;
|
serviceClassesEnabled?: boolean;
|
||||||
|
autoApproveBookings?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceTemplate = {
|
export type ServiceTemplate = {
|
||||||
@@ -98,6 +105,8 @@ export type Booking = {
|
|||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
cancellationReason?: string | null;
|
cancellationReason?: string | null;
|
||||||
|
paymentToken?: string | null;
|
||||||
|
paymentAmount?: string | null;
|
||||||
business?: Business;
|
business?: Business;
|
||||||
businessService?: BusinessService;
|
businessService?: BusinessService;
|
||||||
serviceClass?: BusinessServiceClass;
|
serviceClass?: BusinessServiceClass;
|
||||||
|
|||||||
@@ -65,9 +65,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="canConfirm || canCancel"
|
v-if="canConfirm || canCancel || canPay"
|
||||||
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
|
||||||
|
v-if="canPay"
|
||||||
|
class="!w-auto flex-1 px-3"
|
||||||
|
:loading="acting"
|
||||||
|
@click="emit('pay', bookings)"
|
||||||
|
>
|
||||||
|
پرداخت
|
||||||
|
</AppButton>
|
||||||
<AppButton
|
<AppButton
|
||||||
v-if="canConfirm"
|
v-if="canConfirm"
|
||||||
class="!w-auto flex-1 px-3"
|
class="!w-auto flex-1 px-3"
|
||||||
@@ -106,11 +114,13 @@ const props = defineProps<{
|
|||||||
showCustomer: boolean;
|
showCustomer: boolean;
|
||||||
canConfirm: boolean;
|
canConfirm: boolean;
|
||||||
canCancel: boolean;
|
canCancel: boolean;
|
||||||
|
canPay: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
confirm: [bookings: Booking[]];
|
confirm: [bookings: Booking[]];
|
||||||
cancel: [bookings: Booking[]];
|
cancel: [bookings: Booking[]];
|
||||||
|
pay: [bookings: Booking[]];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const primary = computed(() => props.bookings[0]!);
|
const primary = computed(() => props.bookings[0]!);
|
||||||
@@ -139,6 +149,9 @@ function statusClassFor(status: BookingStatus | 'mixed') {
|
|||||||
if (status === 'confirmed' || status === 'completed') {
|
if (status === 'confirmed' || status === 'completed') {
|
||||||
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
|
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
|
||||||
}
|
}
|
||||||
|
if (status === 'awaiting_payment') {
|
||||||
|
return 'bg-sky-500/15 text-sky-700 dark:text-sky-400';
|
||||||
|
}
|
||||||
if (status === 'pending') {
|
if (status === 'pending') {
|
||||||
return 'bg-amber-500/15 text-amber-700 dark:text-amber-400';
|
return 'bg-amber-500/15 text-amber-700 dark:text-amber-400';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-text">رزرو شما با موفقیت ثبت شد!</h2>
|
<h2 class="text-2xl font-bold text-text">{{ title }}</h2>
|
||||||
<p class="mt-2 text-sm text-muted">
|
<p class="mt-2 text-sm text-muted">
|
||||||
جزئیات رزرو برای شما پیامک و در بخش رزروها ذخیره شد.
|
{{ subtitle }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,7 +61,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center sm:gap-3">
|
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center sm:gap-3">
|
||||||
<AppButton @click="emit('view-bookings')">
|
<AppButton v-if="showPayButton" @click="emit('pay')">
|
||||||
|
پرداخت آنلاین
|
||||||
|
</AppButton>
|
||||||
|
<AppButton :variant="showPayButton ? 'outline' : 'primary'" @click="emit('view-bookings')">
|
||||||
مشاهده رزروها
|
مشاهده رزروها
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton variant="outline" @click="emit('add-to-calendar')">
|
<AppButton variant="outline" @click="emit('add-to-calendar')">
|
||||||
@@ -81,22 +84,38 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
import AppButton from '@/components/ui/AppButton.vue';
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
serviceTitle: string;
|
serviceTitle: string;
|
||||||
dateLabel: string;
|
dateLabel: string;
|
||||||
timeLabel: string;
|
timeLabel: string;
|
||||||
durationLabel: string;
|
durationLabel: string;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
bookingRef: string;
|
bookingRef: string;
|
||||||
|
successMode?: 'pending_approval' | 'awaiting_payment';
|
||||||
|
showPayButton?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const title = computed(() =>
|
||||||
|
props.successMode === 'awaiting_payment'
|
||||||
|
? 'رزرو تأیید شد — آماده پرداخت'
|
||||||
|
: 'درخواست رزرو ثبت شد',
|
||||||
|
);
|
||||||
|
|
||||||
|
const subtitle = computed(() =>
|
||||||
|
props.successMode === 'awaiting_payment'
|
||||||
|
? 'برای نهایی شدن رزرو، پرداخت آنلاین را انجام دهید.'
|
||||||
|
: 'درخواست شما برای صاحب کسبوکار ارسال شد. پس از تأیید، لینک پرداخت در اپ و پیامک برای شما ارسال میشود.',
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'view-bookings': [];
|
'view-bookings': [];
|
||||||
'add-to-calendar': [];
|
'add-to-calendar': [];
|
||||||
'go-home': [];
|
'go-home': [];
|
||||||
|
pay: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const confettiDots = [
|
const confettiDots = [
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
@view-bookings="emit('view-bookings')"
|
@view-bookings="emit('view-bookings')"
|
||||||
@add-to-calendar="downloadCalendar"
|
@add-to-calendar="downloadCalendar"
|
||||||
@go-home="emit('go-home')"
|
@go-home="emit('go-home')"
|
||||||
|
@pay="goToPayment"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
placeholder="در صورت نیاز توضیحی بنویسید..."
|
placeholder="در صورت نیاز توضیحی بنویسید..."
|
||||||
/>
|
/>
|
||||||
<p class="mt-4 text-sm text-muted">
|
<p class="mt-4 text-sm text-muted">
|
||||||
پرداخت در محل انجام میشود. نیازی به پرداخت آنلاین نیست.
|
{{ paymentInfoText }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -223,6 +224,7 @@
|
|||||||
|
|
||||||
<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 { createBooking, createBookingBatch, fetchAvailableSlots } from '@/api/bookings';
|
import { createBooking, createBookingBatch, fetchAvailableSlots } 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';
|
||||||
@@ -275,8 +277,13 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const currentStep = ref(1);
|
const paymentInfoText = computed(() =>
|
||||||
|
props.business.autoApproveBookings
|
||||||
|
? 'پس از ثبت نهایی، مستقیماً به صفحه پرداخت آنلاین هدایت میشوید.'
|
||||||
|
: 'پس از ثبت، درخواست شما بررسی میشود. در صورت تأیید صاحب کسبوکار، لینک پرداخت آنلاین برای شما در اپ و پیامک ارسال میشود.',
|
||||||
|
);
|
||||||
const phase = ref<'booking' | 'success'>('booking');
|
const phase = ref<'booking' | 'success'>('booking');
|
||||||
const selectedClassId = ref('');
|
const selectedClassId = ref('');
|
||||||
const selectedServiceIds = ref<string[]>([]);
|
const selectedServiceIds = ref<string[]>([]);
|
||||||
@@ -405,6 +412,11 @@ const successDetails = computed(() => {
|
|||||||
durationLabel: selectedDurationLabel.value ?? '—',
|
durationLabel: selectedDurationLabel.value ?? '—',
|
||||||
address: props.business.address,
|
address: props.business.address,
|
||||||
bookingRef: createdBooking.value.id.slice(0, 8).toUpperCase(),
|
bookingRef: createdBooking.value.id.slice(0, 8).toUpperCase(),
|
||||||
|
successMode:
|
||||||
|
createdBooking.value.status === 'awaiting_payment'
|
||||||
|
? 'awaiting_payment'
|
||||||
|
: 'pending_approval',
|
||||||
|
showPayButton: createdBooking.value.status === 'awaiting_payment',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -581,6 +593,17 @@ async function submitBooking() {
|
|||||||
});
|
});
|
||||||
createdBooking.value = bookings[0] ?? null;
|
createdBooking.value = bookings[0] ?? null;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
createdBooking.value?.status === 'awaiting_payment' &&
|
||||||
|
createdBooking.value.paymentToken &&
|
||||||
|
props.business.autoApproveBookings
|
||||||
|
) {
|
||||||
|
await router.push({
|
||||||
|
path: '/app/bookings/pay',
|
||||||
|
query: { token: createdBooking.value.paymentToken },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
phase.value = 'success';
|
phase.value = 'success';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
bookingError.value = e instanceof ApiError ? e.message : 'ثبت رزرو ناموفق بود.';
|
bookingError.value = e instanceof ApiError ? e.message : 'ثبت رزرو ناموفق بود.';
|
||||||
@@ -589,6 +612,15 @@ async function submitBooking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToPayment() {
|
||||||
|
const token = createdBooking.value?.paymentToken;
|
||||||
|
if (!token) return;
|
||||||
|
void router.push({
|
||||||
|
path: '/app/bookings/pay',
|
||||||
|
query: { token },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function downloadCalendar() {
|
function downloadCalendar() {
|
||||||
if (!createdBooking.value) return;
|
if (!createdBooking.value) return;
|
||||||
const start = new Date(createdBooking.value.scheduledAt);
|
const start = new Date(createdBooking.value.scheduledAt);
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/app/AppBookingsView.vue'),
|
component: () => import('@/views/app/AppBookingsView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/app/bookings/pay',
|
||||||
|
name: 'app-booking-pay',
|
||||||
|
component: () => import('@/views/app/BookingPaymentView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/app/profile',
|
path: '/app/profile',
|
||||||
name: 'app-profile',
|
name: 'app-profile',
|
||||||
|
|||||||
@@ -57,7 +57,15 @@ export function entryMatchesOwnerTab(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (tab === 'confirmed') {
|
if (tab === 'confirmed') {
|
||||||
return status === 'confirmed' || status === 'completed';
|
return (
|
||||||
|
status === 'confirmed' ||
|
||||||
|
status === 'completed' ||
|
||||||
|
status === 'awaiting_payment' ||
|
||||||
|
(status === 'mixed' &&
|
||||||
|
bookings.some((booking) =>
|
||||||
|
['confirmed', 'completed', 'awaiting_payment'].includes(booking.status),
|
||||||
|
))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return status === 'cancelled';
|
return status === 'cancelled';
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export function formatDate(value?: string | Date): string {
|
|||||||
export function bookingStatusLabel(status: string): string {
|
export function bookingStatusLabel(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
pending: 'در انتظار تأیید',
|
pending: 'در انتظار تأیید',
|
||||||
|
awaiting_payment: 'منتظر پرداخت',
|
||||||
confirmed: 'تأیید شده',
|
confirmed: 'تأیید شده',
|
||||||
completed: 'انجام شده',
|
completed: 'انجام شده',
|
||||||
cancelled: 'لغو شده',
|
cancelled: 'لغو شده',
|
||||||
|
|||||||
@@ -77,8 +77,10 @@
|
|||||||
:show-customer="auth.isBusinessOwner"
|
:show-customer="auth.isBusinessOwner"
|
||||||
: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)"
|
||||||
@confirm="onConfirm"
|
@confirm="onConfirm"
|
||||||
@cancel="onCancelRequest"
|
@cancel="onCancelRequest"
|
||||||
|
@pay="onPay"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +134,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { cancelBooking, confirmBooking, fetchBookings } from '@/api/bookings';
|
import { useRouter } from 'vue-router';
|
||||||
|
import {
|
||||||
|
cancelBooking,
|
||||||
|
confirmBooking,
|
||||||
|
fetchBookings,
|
||||||
|
initiateBookingPayment,
|
||||||
|
} from '@/api/bookings';
|
||||||
import { ApiError } from '@/api/client';
|
import { ApiError } from '@/api/client';
|
||||||
import type { Booking } from '@/api/types';
|
import type { Booking } from '@/api/types';
|
||||||
import BookingItem from '@/components/booking/BookingItem.vue';
|
import BookingItem from '@/components/booking/BookingItem.vue';
|
||||||
@@ -150,6 +158,7 @@ import {
|
|||||||
} from '@/utils/booking-groups';
|
} from '@/utils/booking-groups';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
const bookings = ref<Booking[]>([]);
|
const bookings = ref<Booking[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
@@ -203,7 +212,15 @@ function canConfirm(booking: Booking) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canCancel(booking: Booking) {
|
function canCancel(booking: Booking) {
|
||||||
return booking.status === 'pending' || booking.status === 'confirmed';
|
return (
|
||||||
|
booking.status === 'pending' ||
|
||||||
|
booking.status === 'awaiting_payment' ||
|
||||||
|
booking.status === 'confirmed'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPay(booking: Booking) {
|
||||||
|
return !auth.isBusinessOwner && booking.status === 'awaiting_payment';
|
||||||
}
|
}
|
||||||
|
|
||||||
function canConfirmEntry(entry: Booking[]) {
|
function canConfirmEntry(entry: Booking[]) {
|
||||||
@@ -214,6 +231,10 @@ function canCancelEntry(entry: Booking[]) {
|
|||||||
return entry.some((booking) => canCancel(booking));
|
return entry.some((booking) => canCancel(booking));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canPayEntry(entry: Booking[]) {
|
||||||
|
return entry.some((booking) => canPay(booking));
|
||||||
|
}
|
||||||
|
|
||||||
function entryKey(entry: BookingListEntry) {
|
function entryKey(entry: BookingListEntry) {
|
||||||
return entry.key;
|
return entry.key;
|
||||||
}
|
}
|
||||||
@@ -256,6 +277,36 @@ async function onConfirm(entry: Booking[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onPay(entry: Booking[]) {
|
||||||
|
const payable = entry.find((booking) => canPay(booking));
|
||||||
|
if (!payable) return;
|
||||||
|
|
||||||
|
if (payable.paymentToken) {
|
||||||
|
await router.push({
|
||||||
|
path: '/app/bookings/pay',
|
||||||
|
query: { token: payable.paymentToken },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actingKey.value = entry[0]?.bookingGroupId ?? entry[0]?.id ?? null;
|
||||||
|
try {
|
||||||
|
const result = await initiateBookingPayment(payable.id);
|
||||||
|
if (result.mock && payable.paymentToken) {
|
||||||
|
await router.push({
|
||||||
|
path: '/app/bookings/pay',
|
||||||
|
query: { token: payable.paymentToken, payment: 'success' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = result.startUrl;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'شروع پرداخت ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
actingKey.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onCancelRequest(entry: Booking[]) {
|
function onCancelRequest(entry: Booking[]) {
|
||||||
if (auth.isBusinessOwner) {
|
if (auth.isBusinessOwner) {
|
||||||
pendingCancelEntry.value = entry;
|
pendingCancelEntry.value = entry;
|
||||||
|
|||||||
@@ -135,6 +135,21 @@
|
|||||||
فاصله بین ساعتهای قابل رزرو. مثلاً ۳۰ یعنی ۱۰:۰۰، ۱۰:۳۰، ۱۱:۰۰ و …
|
فاصله بین ساعتهای قابل رزرو. مثلاً ۳۰ یعنی ۱۰:۰۰، ۱۰:۳۰، ۱۱:۰۰ و …
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-border bg-surface px-4 py-3">
|
||||||
|
<input
|
||||||
|
v-model="bookingForm.autoApproveBookings"
|
||||||
|
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>
|
||||||
@@ -514,6 +529,7 @@ const form = ref({
|
|||||||
const bookingForm = ref({
|
const bookingForm = ref({
|
||||||
concurrentCapacity: '1',
|
concurrentCapacity: '1',
|
||||||
slotStepMinutes: '30',
|
slotStepMinutes: '30',
|
||||||
|
autoApproveBookings: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookingExpanded = ref(false);
|
const bookingExpanded = ref(false);
|
||||||
@@ -557,10 +573,10 @@ const servicesSummary = computed(() => {
|
|||||||
return `${count} خدمت · ${activeCount} فعال`;
|
return `${count} خدمت · ${activeCount} فعال`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookingSummary = computed(
|
const bookingSummary = computed(() => {
|
||||||
() =>
|
const auto = bookingForm.value.autoApproveBookings ? 'تأیید خودکار' : 'تأیید دستی';
|
||||||
`${bookingForm.value.concurrentCapacity} همزمان · گام ${bookingForm.value.slotStepMinutes} دقیقه`,
|
return `${bookingForm.value.concurrentCapacity} همزمان · گام ${bookingForm.value.slotStepMinutes} دقیقه · ${auto}`;
|
||||||
);
|
});
|
||||||
|
|
||||||
function emptyClassPriceForm() {
|
function emptyClassPriceForm() {
|
||||||
const result: Record<string, { price: string; durationMinutes: string }> = {};
|
const result: Record<string, { price: string; durationMinutes: string }> = {};
|
||||||
@@ -658,6 +674,7 @@ onMounted(async () => {
|
|||||||
bookingForm.value = {
|
bookingForm.value = {
|
||||||
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,
|
||||||
};
|
};
|
||||||
await loadServicesAndTemplates();
|
await loadServicesAndTemplates();
|
||||||
}
|
}
|
||||||
@@ -776,6 +793,7 @@ async function onSaveBookingSettings() {
|
|||||||
business.value = await updateBusiness(business.value.id, {
|
business.value = await updateBusiness(business.value.id, {
|
||||||
concurrentCapacity,
|
concurrentCapacity,
|
||||||
slotStepMinutes,
|
slotStepMinutes,
|
||||||
|
autoApproveBookings: bookingForm.value.autoApproveBookings,
|
||||||
});
|
});
|
||||||
tenant.syncSingleBusiness(business.value);
|
tenant.syncSingleBusiness(business.value);
|
||||||
message.value = 'تنظیمات نوبتدهی ذخیره شد.';
|
message.value = 'تنظیمات نوبتدهی ذخیره شد.';
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<template>
|
||||||
|
<ResponsiveAppLayout>
|
||||||
|
<h1 class="mb-1 text-xl font-bold text-text">پرداخت رزرو</h1>
|
||||||
|
<p class="mb-4 text-sm text-muted">پرداخت آنلاین رزرو تأییدشده</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="mockMode && canPay"
|
||||||
|
class="mb-4 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
حالت تست: پرداخت واقعی انجام نمیشود و فقط برای آزمایش جریان رزرو است.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="paymentStatus === 'success'"
|
||||||
|
class="mb-4 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
پرداخت با موفقیت انجام شد. رزرو شما نهایی شد.
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="paymentStatus === 'failed'"
|
||||||
|
class="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
|
||||||
|
>
|
||||||
|
پرداخت ناموفق بود. میتوانید دوباره تلاش کنید.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="error"
|
||||||
|
class="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ListCardSkeleton v-if="loading" :count="1" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="details"
|
||||||
|
class="mx-auto max-w-lg space-y-4 rounded-2xl border border-border bg-surface-2 p-5"
|
||||||
|
>
|
||||||
|
<dl class="space-y-3 text-sm">
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-muted">کسبوکار</dt>
|
||||||
|
<dd class="font-medium text-text">
|
||||||
|
{{ details.bookings[0]?.business?.title ?? '—' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between gap-4">
|
||||||
|
<dt class="text-muted">تاریخ</dt>
|
||||||
|
<dd class="font-medium text-text">
|
||||||
|
{{ formatDate(details.bookings[0]?.scheduledAt ?? '') }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<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)) }} تومان
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<ul class="space-y-1 text-sm text-muted">
|
||||||
|
<li v-for="booking in details.bookings" :key="booking.id">
|
||||||
|
{{ booking.businessService?.serviceTemplate?.title ?? 'خدمت' }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<AppButton
|
||||||
|
v-if="canPay"
|
||||||
|
class="w-full"
|
||||||
|
:loading="paying"
|
||||||
|
@click="startPayment"
|
||||||
|
>
|
||||||
|
{{ mockMode ? 'پرداخت آزمایشی' : 'پرداخت آنلاین' }}
|
||||||
|
</AppButton>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
to="/app/bookings"
|
||||||
|
class="block text-center text-sm text-brand-600 dark:text-brand-500"
|
||||||
|
>
|
||||||
|
بازگشت به رزروها
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</ResponsiveAppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import {
|
||||||
|
fetchPaymentByToken,
|
||||||
|
initiateBookingPaymentByToken,
|
||||||
|
} from '@/api/bookings';
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
|
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
||||||
|
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
|
||||||
|
import { formatDate, formatPriceToman } from '@/utils/format';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const loading = ref(true);
|
||||||
|
const paying = ref(false);
|
||||||
|
const mockMode = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const details = ref<{
|
||||||
|
bookings: Awaited<ReturnType<typeof fetchPaymentByToken>>['bookings'];
|
||||||
|
totalAmountToman: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const paymentStatus = computed(() => {
|
||||||
|
const raw = route.query.payment;
|
||||||
|
return raw === 'success' || raw === 'failed' ? raw : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canPay = computed(() => {
|
||||||
|
if (!details.value?.bookings.length) return false;
|
||||||
|
if (paymentStatus.value === 'success') return false;
|
||||||
|
return details.value.bookings.some((booking) => booking.status === 'awaiting_payment');
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const token = String(route.query.token ?? '').trim();
|
||||||
|
if (!token) {
|
||||||
|
error.value = 'لینک پرداخت معتبر نیست.';
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
details.value = await fetchPaymentByToken(token);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'بارگذاری اطلاعات پرداخت ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPayment() {
|
||||||
|
const token = String(route.query.token ?? '').trim();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
paying.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const result = await initiateBookingPaymentByToken(token);
|
||||||
|
if (result.mock) {
|
||||||
|
await router.replace({
|
||||||
|
path: '/app/bookings/pay',
|
||||||
|
query: { token, payment: 'success' },
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = result.startUrl;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'شروع پرداخت ناموفق بود.';
|
||||||
|
paying.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user