awaiting_payment

This commit is contained in:
2026-06-29 15:49:13 +03:30
parent ddc24612cb
commit 54e2d31a51
11 changed files with 363 additions and 15 deletions
+26
View File
@@ -60,6 +60,32 @@ export function confirmBooking(id: string) {
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(
businessId: string,
dateYmd: string,
+10 -1
View File
@@ -1,4 +1,9 @@
export type BookingStatus = 'pending' | 'confirmed' | 'cancelled' | 'completed';
export type BookingStatus =
| 'pending'
| 'awaiting_payment'
| 'confirmed'
| 'cancelled'
| 'completed';
export type BusinessCategory = {
id: string;
@@ -31,6 +36,7 @@ export type Business = {
concurrentCapacity?: number;
slotStepMinutes?: number;
serviceClassesEnabled?: boolean;
autoApproveBookings?: boolean;
createdAt?: string;
updatedAt?: string;
};
@@ -46,6 +52,7 @@ export type UpdateBusinessPayload = {
concurrentCapacity?: number;
slotStepMinutes?: number;
serviceClassesEnabled?: boolean;
autoApproveBookings?: boolean;
};
export type ServiceTemplate = {
@@ -98,6 +105,8 @@ export type Booking = {
status: BookingStatus;
note?: string | null;
cancellationReason?: string | null;
paymentToken?: string | null;
paymentAmount?: string | null;
business?: Business;
businessService?: BusinessService;
serviceClass?: BusinessServiceClass;
+14 -1
View File
@@ -65,9 +65,17 @@
</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"
>
<AppButton
v-if="canPay"
class="!w-auto flex-1 px-3"
:loading="acting"
@click="emit('pay', bookings)"
>
پرداخت
</AppButton>
<AppButton
v-if="canConfirm"
class="!w-auto flex-1 px-3"
@@ -106,11 +114,13 @@ const props = defineProps<{
showCustomer: boolean;
canConfirm: boolean;
canCancel: boolean;
canPay: boolean;
}>();
const emit = defineEmits<{
confirm: [bookings: Booking[]];
cancel: [bookings: Booking[]];
pay: [bookings: Booking[]];
}>();
const primary = computed(() => props.bookings[0]!);
@@ -139,6 +149,9 @@ function statusClassFor(status: BookingStatus | 'mixed') {
if (status === 'confirmed' || status === 'completed') {
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') {
return 'bg-amber-500/15 text-amber-700 dark:text-amber-400';
}
+23 -4
View File
@@ -16,9 +16,9 @@
</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">
جزئیات رزرو برای شما پیامک و در بخش رزروها ذخیره شد.
{{ subtitle }}
</p>
</div>
@@ -61,7 +61,10 @@
</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">
<AppButton @click="emit('view-bookings')">
<AppButton v-if="showPayButton" @click="emit('pay')">
پرداخت آنلاین
</AppButton>
<AppButton :variant="showPayButton ? 'outline' : 'primary'" @click="emit('view-bookings')">
مشاهده رزروها
</AppButton>
<AppButton variant="outline" @click="emit('add-to-calendar')">
@@ -81,22 +84,38 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
defineProps<{
const props = defineProps<{
serviceTitle: string;
dateLabel: string;
timeLabel: string;
durationLabel: string;
address?: string | null;
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<{
'view-bookings': [];
'add-to-calendar': [];
'go-home': [];
pay: [];
}>();
const confettiDots = [
+34 -2
View File
@@ -17,6 +17,7 @@
@view-bookings="emit('view-bookings')"
@add-to-calendar="downloadCalendar"
@go-home="emit('go-home')"
@pay="goToPayment"
/>
<template v-else>
@@ -123,7 +124,7 @@
placeholder="در صورت نیاز توضیحی بنویسید..."
/>
<p class="mt-4 text-sm text-muted">
پرداخت در محل انجام می‌شود. نیازی به پرداخت آنلاین نیست.
{{ paymentInfoText }}
</p>
</div>
@@ -223,6 +224,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { createBooking, createBookingBatch, fetchAvailableSlots } from '@/api/bookings';
import { fetchBusinessServiceClasses } from '@/api/business-service-classes';
import { ApiError } from '@/api/client';
@@ -275,8 +277,13 @@ const emit = defineEmits<{
}>();
const auth = useAuthStore();
const router = useRouter();
const currentStep = ref(1);
const paymentInfoText = computed(() =>
props.business.autoApproveBookings
? 'پس از ثبت نهایی، مستقیماً به صفحه پرداخت آنلاین هدایت می‌شوید.'
: 'پس از ثبت، درخواست شما بررسی می‌شود. در صورت تأیید صاحب کسب‌وکار، لینک پرداخت آنلاین برای شما در اپ و پیامک ارسال می‌شود.',
);
const phase = ref<'booking' | 'success'>('booking');
const selectedClassId = ref('');
const selectedServiceIds = ref<string[]>([]);
@@ -405,6 +412,11 @@ const successDetails = computed(() => {
durationLabel: selectedDurationLabel.value ?? '—',
address: props.business.address,
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;
}
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';
} catch (e) {
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() {
if (!createdBooking.value) return;
const start = new Date(createdBooking.value.scheduledAt);
+6
View File
@@ -36,6 +36,12 @@ const router = createRouter({
component: () => import('@/views/app/AppBookingsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/app/bookings/pay',
name: 'app-booking-pay',
component: () => import('@/views/app/BookingPaymentView.vue'),
meta: { requiresAuth: true },
},
{
path: '/app/profile',
name: 'app-profile',
+9 -1
View File
@@ -57,7 +57,15 @@ export function entryMatchesOwnerTab(
}
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';
+1
View File
@@ -89,6 +89,7 @@ export function formatDate(value?: string | Date): string {
export function bookingStatusLabel(status: string): string {
const map: Record<string, string> = {
pending: 'در انتظار تأیید',
awaiting_payment: 'منتظر پرداخت',
confirmed: 'تأیید شده',
completed: 'انجام شده',
cancelled: 'لغو شده',
+53 -2
View File
@@ -77,8 +77,10 @@
:show-customer="auth.isBusinessOwner"
:can-confirm="canConfirmEntry(entry.bookings)"
:can-cancel="canCancelEntry(entry.bookings)"
:can-pay="canPayEntry(entry.bookings)"
@confirm="onConfirm"
@cancel="onCancelRequest"
@pay="onPay"
/>
</div>
</div>
@@ -132,7 +134,13 @@
<script setup lang="ts">
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 type { Booking } from '@/api/types';
import BookingItem from '@/components/booking/BookingItem.vue';
@@ -150,6 +158,7 @@ import {
} from '@/utils/booking-groups';
const auth = useAuthStore();
const router = useRouter();
const bookings = ref<Booking[]>([]);
const loading = ref(true);
const error = ref('');
@@ -203,7 +212,15 @@ function canConfirm(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[]) {
@@ -214,6 +231,10 @@ function canCancelEntry(entry: Booking[]) {
return entry.some((booking) => canCancel(booking));
}
function canPayEntry(entry: Booking[]) {
return entry.some((booking) => canPay(booking));
}
function entryKey(entry: BookingListEntry) {
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[]) {
if (auth.isBusinessOwner) {
pendingCancelEntry.value = entry;
+22 -4
View File
@@ -135,6 +135,21 @@
فاصله بین ساعت‌های قابل رزرو. مثلاً ۳۰ یعنی ۱۰:۰۰، ۱۰:۳۰، ۱۱:۰۰ و …
</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>
@@ -514,6 +529,7 @@ const form = ref({
const bookingForm = ref({
concurrentCapacity: '1',
slotStepMinutes: '30',
autoApproveBookings: false,
});
const bookingExpanded = ref(false);
@@ -557,10 +573,10 @@ const servicesSummary = computed(() => {
return `${count} خدمت · ${activeCount} فعال`;
});
const bookingSummary = computed(
() =>
`${bookingForm.value.concurrentCapacity} همزمان · گام ${bookingForm.value.slotStepMinutes} دقیقه`,
);
const bookingSummary = computed(() => {
const auto = bookingForm.value.autoApproveBookings ? 'تأیید خودکار' : 'تأیید دستی';
return `${bookingForm.value.concurrentCapacity} همزمان · گام ${bookingForm.value.slotStepMinutes} دقیقه · ${auto}`;
});
function emptyClassPriceForm() {
const result: Record<string, { price: string; durationMinutes: string }> = {};
@@ -658,6 +674,7 @@ onMounted(async () => {
bookingForm.value = {
concurrentCapacity: String(business.value.concurrentCapacity ?? 1),
slotStepMinutes: String(business.value.slotStepMinutes ?? 30),
autoApproveBookings: business.value.autoApproveBookings === true,
};
await loadServicesAndTemplates();
}
@@ -776,6 +793,7 @@ async function onSaveBookingSettings() {
business.value = await updateBusiness(business.value.id, {
concurrentCapacity,
slotStepMinutes,
autoApproveBookings: bookingForm.value.autoApproveBookings,
});
tenant.syncSingleBusiness(business.value);
message.value = 'تنظیمات نوبت‌دهی ذخیره شد.';
+165
View File
@@ -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>