AddFirstBookingDiscount
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = 'تنظیمات نوبتدهی ذخیره شد.';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user