AddFirstBookingDiscount

This commit is contained in:
2026-07-16 12:42:59 +03:30
parent a2d0232113
commit a3d2764a4e
8 changed files with 277 additions and 36 deletions
+13
View File
@@ -38,6 +38,11 @@ export type Business = {
serviceClassesEnabled?: boolean;
autoApproveBookings?: boolean;
tipsEnabled?: boolean;
promoDiscountEnabled?: boolean;
promoDiscountTitle?: string | null;
promoDiscountPercent?: number | null;
promoDiscountCode?: string | null;
promoDiscountMode?: 'first_booking' | 'always';
averageRating?: number | null;
reviewCount?: number;
createdAt?: string;
@@ -57,6 +62,11 @@ export type UpdateBusinessPayload = {
serviceClassesEnabled?: boolean;
autoApproveBookings?: boolean;
tipsEnabled?: boolean;
promoDiscountEnabled?: boolean;
promoDiscountTitle?: string | null;
promoDiscountPercent?: number | null;
promoDiscountCode?: string | null;
promoDiscountMode?: 'first_booking' | 'always';
};
export type ServiceTemplate = {
@@ -122,6 +132,9 @@ export type Booking = {
paymentToken?: string | null;
paymentAmount?: string | null;
loyaltyRedeemed?: boolean;
promoDiscountApplied?: boolean;
promoDiscountPercent?: number | null;
promoDiscountTitle?: string | null;
business?: Business;
businessService?: BusinessService;
serviceClass?: BusinessServiceClass;
+30 -1
View File
@@ -6,6 +6,15 @@
@browse="scrollToServices"
/>
<FirstBookingDiscountBanner
:enabled="discountEnabled"
:title="discountTitle"
:percent="discountPercent"
:code="discountCode"
:mode="discountMode"
:compact="false"
/>
<HomeServicesSection
:items="services"
:loading="loadingServices"
@@ -18,11 +27,13 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useRouter } from 'vue-router';
import HomeFeatureBar from '@/components/home/HomeFeatureBar.vue';
import HomeHeroSection from '@/components/home/HomeHeroSection.vue';
import HomeServicesSection from '@/components/home/HomeServicesSection.vue';
import FirstBookingDiscountBanner from '@/components/landing/FirstBookingDiscountBanner.vue';
import DesktopSiteLayout from '@/layouts/DesktopSiteLayout.vue';
import { useAuthStore } from '@/stores/auth';
import { useTenantStore } from '@/stores/tenant';
@@ -42,7 +53,25 @@ const props = withDefaults(
const router = useRouter();
const auth = useAuthStore();
const tenant = useTenantStore();
const { landingBusinessId } = storeToRefs(tenant);
const { landingBusinessId, landingBusiness } = storeToRefs(tenant);
const discountEnabled = computed(
() =>
landingBusiness.value?.promoDiscountEnabled === true &&
!!landingBusiness.value?.promoDiscountPercent,
);
const discountTitle = computed(
() => landingBusiness.value?.promoDiscountTitle?.trim() || 'تخفیف ویژه',
);
const discountPercent = computed(
() => landingBusiness.value?.promoDiscountPercent ?? null,
);
const discountCode = computed(
() => landingBusiness.value?.promoDiscountCode?.trim() ?? null,
);
const discountMode = computed(
() => landingBusiness.value?.promoDiscountMode ?? 'first_booking',
);
function scrollToServices() {
const el = document.getElementById('services');
@@ -0,0 +1,67 @@
<template>
<section
v-if="enabled"
id="club"
class="scroll-mt-24"
:class="compact ? 'px-5 pb-6' : 'mx-auto max-w-[1200px] px-6 pb-10 lg:px-8'"
>
<div
class="flex items-center justify-between gap-3 rounded-2xl bg-brand-50 px-4 py-4 dark:bg-brand-500/10"
>
<div class="min-w-0 flex-1 space-y-1">
<p class="text-xs text-landing-muted dark:text-muted">{{ title }}</p>
<p class="text-2xl font-black text-brand-600 dark:text-brand-500">
{{ percentLabel }}٪ تخفیف
</p>
<p v-if="code" class="text-xs font-medium text-landing-ink dark:text-text">
کد تخفیف:
<span class="font-bold" dir="ltr">{{ code }}</span>
</p>
<p class="text-[11px] leading-5 text-landing-muted dark:text-muted">
{{ helperText }}
</p>
</div>
<div
class="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-white shadow-sm dark:bg-surface dark:shadow-black/20"
aria-hidden="true"
>
<AppIcon name="gift" size="lg" class="text-brand-600 dark:text-brand-500" />
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import AppIcon from '@/components/ui/AppIcon.vue';
const props = withDefaults(
defineProps<{
enabled?: boolean;
title?: string | null;
percent?: number | null;
code?: string | null;
mode?: 'first_booking' | 'always' | null;
compact?: boolean;
}>(),
{
enabled: false,
title: 'تخفیف ویژه',
percent: null,
code: null,
mode: 'first_booking',
compact: true,
},
);
const percentLabel = computed(() => {
const n = props.percent ?? 0;
return n.toLocaleString('fa-IR');
});
const helperText = computed(() =>
props.mode === 'always'
? 'این تخفیف هنگام رزرو به‌صورت خودکار روی مبلغ اعمال می‌شود.'
: 'برای اولین رزرو شما در این کسب‌وکار به‌صورت خودکار اعمال می‌شود.',
);
</script>
@@ -70,26 +70,14 @@
</div>
</section>
<section id="club" class="scroll-mt-24 px-5 pb-6">
<div
class="flex items-center justify-between gap-3 rounded-2xl bg-brand-50 px-4 py-4 dark:bg-brand-500/10"
>
<div class="min-w-0 flex-1 space-y-1">
<p class="text-xs text-landing-muted dark:text-muted">اولین رزرو شما</p>
<p class="text-2xl font-black text-brand-600 dark:text-brand-500">۱۰٪ تخفیف</p>
<p class="text-xs font-medium text-landing-ink dark:text-text">
کد تخفیف:
<span class="font-bold" dir="ltr">NEW10</span>
</p>
</div>
<div
class="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-white shadow-sm dark:bg-surface dark:shadow-black/20"
aria-hidden="true"
>
<AppIcon name="gift" size="lg" class="text-brand-600 dark:text-brand-500" />
</div>
</div>
</section>
<FirstBookingDiscountBanner
:enabled="discountEnabled"
:title="discountTitle"
:percent="discountPercent"
:code="discountCode"
:mode="discountMode"
compact
/>
<HomeServicesSection
:items="services"
@@ -115,6 +103,7 @@ import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
import HomeServicesSection from '@/components/home/HomeServicesSection.vue';
import FirstBookingDiscountBanner from '@/components/landing/FirstBookingDiscountBanner.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import { useTenantStore } from '@/stores/tenant';
import type { AppIconName } from '@/types/icons';
@@ -138,17 +127,40 @@ const businessTitle = computed(() => landingBusiness.value?.title ?? '');
const businessSubtitle = computed(() => landingBusiness.value?.subtitle?.trim() ?? '');
const aboutText = computed(() => landingBusiness.value?.description?.trim() ?? '');
const quickActions: {
id: string;
label: string;
icon: AppIconName;
targetId: string;
}[] = [
{ id: 'services', label: 'خدمات کارواش', icon: 'car', targetId: 'services' },
{ id: 'pricing', label: 'تعرفه‌ها', icon: 'badge-percent', targetId: 'services' },
{ id: 'club', label: 'باشگاه مشتریان', icon: 'sparkles', targetId: 'club' },
{ id: 'about', label: 'درباره ما', icon: 'info', targetId: 'about' },
];
const discountEnabled = computed(
() =>
landingBusiness.value?.promoDiscountEnabled === true &&
!!landingBusiness.value?.promoDiscountPercent,
);
const discountTitle = computed(
() => landingBusiness.value?.promoDiscountTitle?.trim() || 'تخفیف ویژه',
);
const discountPercent = computed(
() => landingBusiness.value?.promoDiscountPercent ?? null,
);
const discountCode = computed(
() => landingBusiness.value?.promoDiscountCode?.trim() ?? null,
);
const discountMode = computed(
() => landingBusiness.value?.promoDiscountMode ?? 'first_booking',
);
const quickActions = computed(() => {
const items: {
id: string;
label: string;
icon: AppIconName;
targetId: string;
}[] = [
{ id: 'services', label: 'خدمات', icon: 'car', targetId: 'services' },
{ id: 'pricing', label: 'تعرفه‌ها', icon: 'badge-percent', targetId: 'services' },
];
if (discountEnabled.value) {
items.push({ id: 'club', label: 'تخفیف ویژه', icon: 'sparkles', targetId: 'club' });
}
items.push({ id: 'about', label: 'درباره ما', icon: 'info', targetId: 'about' });
return items;
});
function scrollTo(targetId: string) {
const el = document.getElementById(targetId);
-2
View File
@@ -1,7 +1,5 @@
<template>
<footer class="border-t border-landing-line bg-landing-surface dark:border-border dark:bg-surface-2">
<div id="club" class="sr-only" aria-hidden="true" />
<div class="mx-auto max-w-[1200px] px-6 py-12 lg:px-8">
<div class="grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
<section>
+104 -1
View File
@@ -178,6 +178,56 @@
</span>
</label>
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-border bg-surface px-4 py-3">
<input
v-model="bookingForm.promoDiscountEnabled"
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>
<template v-if="bookingForm.promoDiscountEnabled">
<AppInput
id="ownerPromoDiscountTitle"
v-model="bookingForm.promoDiscountTitle"
label="عنوان تخفیف"
placeholder="مثلاً: اولین رزرو شما / تخفیف ویژه تابستان"
required
/>
<AppInput
id="ownerPromoDiscountPercent"
v-model="bookingForm.promoDiscountPercent"
label="درصد تخفیف"
type="number"
inputmode="numeric"
ltr
placeholder="مثلاً ۱۰"
required
/>
<AppInput
id="ownerPromoDiscountCode"
v-model="bookingForm.promoDiscountCode"
label="کد تخفیف (نمایشی)"
ltr
placeholder="مثلاً NEW10"
/>
<AppSelect
id="ownerPromoDiscountMode"
v-model="bookingForm.promoDiscountMode"
label="محدوده اعمال"
:options="promoDiscountModeOptions"
/>
<p class="-mt-2 text-xs text-muted">
اگر کد خالی باشد، به‌صورت خودکار با فرمت NEW + درصد ساخته می‌شود (مثلاً NEW10).
</p>
</template>
<AppButton :loading="savingBooking" @click="onSaveBookingSettings">
ذخیره تنظیمات نوبت‌دهی
</AppButton>
@@ -587,8 +637,18 @@ const bookingForm = ref({
slotStepMinutes: '30',
autoApproveBookings: false,
tipsEnabled: false,
promoDiscountEnabled: false,
promoDiscountTitle: 'اولین رزرو شما',
promoDiscountPercent: '10',
promoDiscountCode: '',
promoDiscountMode: 'first_booking',
});
const promoDiscountModeOptions = [
{ value: 'first_booking', label: 'فقط اولین رزرو مشتری' },
{ value: 'always', label: 'همه رزروها (تا وقتی فعال است)' },
];
const bookingExpanded = ref(false);
const bookingMessage = ref('');
const bookingError = ref('');
@@ -637,7 +697,10 @@ const servicesSummary = computed(() => {
const bookingSummary = computed(() => {
const auto = bookingForm.value.autoApproveBookings ? 'تأیید خودکار' : 'تأیید دستی';
const tips = bookingForm.value.tipsEnabled ? 'حسن انجام کار فعال' : 'بدون حسن انجام کار';
return `${bookingForm.value.concurrentCapacity} همزمان · گام ${bookingForm.value.slotStepMinutes} دقیقه · ${auto} · ${tips}`;
const discount = bookingForm.value.promoDiscountEnabled
? `${bookingForm.value.promoDiscountTitle || 'تخفیف'} ${bookingForm.value.promoDiscountPercent}٪`
: 'بدون تخفیف';
return `${bookingForm.value.concurrentCapacity} همزمان · گام ${bookingForm.value.slotStepMinutes} دقیقه · ${auto} · ${tips} · ${discount}`;
});
function emptyClassPriceForm() {
@@ -738,6 +801,14 @@ onMounted(async () => {
slotStepMinutes: String(business.value.slotStepMinutes ?? 30),
autoApproveBookings: business.value.autoApproveBookings === true,
tipsEnabled: business.value.tipsEnabled === true,
promoDiscountEnabled: business.value.promoDiscountEnabled === true,
promoDiscountTitle: business.value.promoDiscountTitle?.trim() || 'اولین رزرو شما',
promoDiscountPercent:
business.value.promoDiscountPercent != null
? String(business.value.promoDiscountPercent)
: '10',
promoDiscountCode: business.value.promoDiscountCode ?? '',
promoDiscountMode: business.value.promoDiscountMode === 'always' ? 'always' : 'first_booking',
};
await loadServicesAndTemplates();
}
@@ -851,6 +922,33 @@ async function onSaveBookingSettings() {
bookingMessage.value = '';
return;
}
let promoDiscountPercent: number | null = null;
let promoDiscountCode: string | null = null;
let promoDiscountTitle: string | null = null;
let promoDiscountMode: 'first_booking' | 'always' = 'first_booking';
if (bookingForm.value.promoDiscountEnabled) {
promoDiscountTitle = bookingForm.value.promoDiscountTitle.trim();
if (!promoDiscountTitle) {
bookingError.value = 'عنوان تخفیف را وارد کنید.';
bookingMessage.value = '';
return;
}
promoDiscountPercent = Number.parseInt(bookingForm.value.promoDiscountPercent, 10);
if (
!Number.isFinite(promoDiscountPercent) ||
promoDiscountPercent < 1 ||
promoDiscountPercent > 100
) {
bookingError.value = 'درصد تخفیف باید عددی بین ۱ تا ۱۰۰ باشد.';
bookingMessage.value = '';
return;
}
promoDiscountCode = bookingForm.value.promoDiscountCode.trim().toUpperCase() || null;
promoDiscountMode =
bookingForm.value.promoDiscountMode === 'always' ? 'always' : 'first_booking';
}
savingBooking.value = true;
bookingError.value = '';
bookingMessage.value = '';
@@ -862,6 +960,11 @@ async function onSaveBookingSettings() {
slotStepMinutes,
autoApproveBookings: bookingForm.value.autoApproveBookings,
tipsEnabled: bookingForm.value.tipsEnabled,
promoDiscountEnabled: bookingForm.value.promoDiscountEnabled,
promoDiscountTitle,
promoDiscountPercent,
promoDiscountCode,
promoDiscountMode,
});
tenant.syncSingleBusiness(business.value);
bookingMessage.value = 'تنظیمات نوبت‌دهی ذخیره شد.';
+20
View File
@@ -58,12 +58,25 @@
</template>
</dd>
</div>
<p
v-if="promoDiscountLabel"
class="text-xs text-brand-700 dark:text-brand-400"
>
{{ promoDiscountLabel }}
</p>
</dl>
<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>
<span
v-else-if="booking.promoDiscountApplied && booking.promoDiscountPercent"
class="text-brand-600"
>
({{ booking.promoDiscountPercent.toLocaleString('fa-IR') }}٪
{{ booking.promoDiscountTitle || 'تخفیف' }})
</span>
</li>
</ul>
@@ -127,6 +140,13 @@ const canPay = computed(() => {
return details.value.bookings.some((booking) => booking.status === 'awaiting_payment');
});
const promoDiscountLabel = computed(() => {
const hit = details.value?.bookings.find((booking) => booking.promoDiscountApplied);
if (!hit?.promoDiscountPercent) return '';
const title = hit.promoDiscountTitle?.trim() || 'تخفیف';
return `${title} — ${hit.promoDiscountPercent.toLocaleString('fa-IR')}٪ اعمال شد.`;
});
async function load() {
const token = String(route.query.token ?? '').trim();
if (!token) {
-1
View File
@@ -15,7 +15,6 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}