landing desktop
This commit is contained in:
Vendored
+2
-8
@@ -1,11 +1,5 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"vetur.validation.script": false,
|
||||
"vetur.validation.template": false,
|
||||
"vetur.validation.style": false,
|
||||
|
||||
"vetur.useWorkspaceDependencies": true,
|
||||
"vetur.ignoreProjectWarning": true,
|
||||
"vetur.experimental.templateInterpolationService": false,
|
||||
"vue.server.hybridMode": false
|
||||
}
|
||||
"vue.server.hybridMode": false
|
||||
+2
-11
@@ -2,20 +2,11 @@
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#16a34a" />
|
||||
<title>منتایم</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>منتایم</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
<html><body>404</body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 875 KiB |
+4
-1
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
<ProfileModal />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import ProfileModal from '@/components/profile/ProfileModal.vue';
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { api } from './client';
|
||||
|
||||
export type PlatformSettings = {
|
||||
loginHeroImageUrl: string | null;
|
||||
};
|
||||
|
||||
export function fetchPlatformSettings() {
|
||||
return api<PlatformSettings>('/platform-settings');
|
||||
}
|
||||
+82
-103
@@ -1,3 +1,5 @@
|
||||
export type BookingStatus = 'pending' | 'confirmed' | 'cancelled' | 'completed';
|
||||
|
||||
export type BusinessCategory = {
|
||||
id: string;
|
||||
slug: string;
|
||||
@@ -8,94 +10,68 @@ export type BusinessCategory = {
|
||||
export type BusinessImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
isCover: boolean;
|
||||
originalName?: string | null;
|
||||
sortOrder: number;
|
||||
isCover: boolean;
|
||||
};
|
||||
|
||||
export type Business = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string | null;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
contactPhone: string | null;
|
||||
address: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
description?: string | null;
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
isActive: boolean;
|
||||
serviceClassesEnabled?: boolean;
|
||||
concurrentCapacity?: number;
|
||||
slotStepMinutes?: number;
|
||||
categoryId: string;
|
||||
ownerId?: string | null;
|
||||
category?: BusinessCategory;
|
||||
images?: BusinessImage[];
|
||||
};
|
||||
|
||||
export type BusinessRegistrationStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export type BusinessRegistrationRequest = {
|
||||
id: string;
|
||||
applicantId: string;
|
||||
status: BusinessRegistrationStatus;
|
||||
title: string;
|
||||
slug: string;
|
||||
categoryId: string;
|
||||
description: string | null;
|
||||
contactPhone: string | null;
|
||||
address: string | null;
|
||||
adminNote: string | null;
|
||||
businessId: string | null;
|
||||
createdAt: string;
|
||||
category?: BusinessCategory;
|
||||
applicant?: { id: string; mobile: string; fullName: string | null };
|
||||
};
|
||||
|
||||
export type CreateBusinessRegistrationPayload = {
|
||||
title: string;
|
||||
categoryId: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
contactPhone?: string;
|
||||
address?: string;
|
||||
concurrentCapacity?: number;
|
||||
slotStepMinutes?: number;
|
||||
serviceClassesEnabled?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type UpdateBusinessPayload = {
|
||||
title?: string;
|
||||
subtitle?: string | null;
|
||||
description?: string | null;
|
||||
contactPhone?: string | null;
|
||||
address?: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
serviceClassesEnabled?: boolean;
|
||||
phone?: string | null;
|
||||
isActive?: boolean;
|
||||
concurrentCapacity?: number;
|
||||
slotStepMinutes?: number;
|
||||
};
|
||||
|
||||
export type BusinessServiceClass = {
|
||||
id: string;
|
||||
businessId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type BusinessServiceClassPrice = {
|
||||
id: string;
|
||||
businessServiceId: string;
|
||||
classId: string;
|
||||
price: string;
|
||||
durationMinutes: number | null;
|
||||
isActive: boolean;
|
||||
serviceClass?: BusinessServiceClass;
|
||||
serviceClassesEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type ServiceTemplate = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
defaultDurationMinutes: number | null;
|
||||
description?: string | null;
|
||||
defaultDurationMinutes?: number | null;
|
||||
isActive: boolean;
|
||||
categoryId: string;
|
||||
};
|
||||
|
||||
export type BusinessServiceClassPrice = {
|
||||
id: string;
|
||||
classId: string;
|
||||
price: string;
|
||||
durationMinutes?: number | null;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export type BusinessServiceClass = {
|
||||
id: string;
|
||||
businessId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
classPrices?: BusinessServiceClassPrice[];
|
||||
};
|
||||
|
||||
export type BusinessService = {
|
||||
@@ -103,43 +79,12 @@ export type BusinessService = {
|
||||
businessId: string;
|
||||
serviceTemplateId: string;
|
||||
price: string;
|
||||
durationMinutes: number | null;
|
||||
durationMinutes?: number | null;
|
||||
isActive: boolean;
|
||||
serviceTemplate?: ServiceTemplate;
|
||||
classPrices?: BusinessServiceClassPrice[];
|
||||
};
|
||||
|
||||
export type BusinessServiceRequestStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export type BusinessServiceRequest = {
|
||||
id: string;
|
||||
businessId: string;
|
||||
requestedById: string;
|
||||
status: BusinessServiceRequestStatus;
|
||||
title: string;
|
||||
description: string | null;
|
||||
price: string;
|
||||
durationMinutes: number | null;
|
||||
adminNote: string | null;
|
||||
serviceTemplateId: string | null;
|
||||
businessServiceId: string | null;
|
||||
createdAt?: string;
|
||||
business?: Business;
|
||||
};
|
||||
|
||||
export type CreateBusinessServiceRequestPayload = {
|
||||
title: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
durationMinutes?: number;
|
||||
};
|
||||
|
||||
export type BookingStatus =
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
| 'cancelled'
|
||||
| 'completed';
|
||||
|
||||
export type Booking = {
|
||||
id: string;
|
||||
customerId: string;
|
||||
@@ -149,15 +94,49 @@ export type Booking = {
|
||||
bookingGroupId?: string | null;
|
||||
scheduledAt: string;
|
||||
status: BookingStatus;
|
||||
note: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
customer?: {
|
||||
id: string;
|
||||
mobile: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
note?: string | null;
|
||||
business?: Business;
|
||||
businessService?: BusinessService;
|
||||
serviceClass?: BusinessServiceClass;
|
||||
customer?: {
|
||||
id: string;
|
||||
mobile: string;
|
||||
fullName?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type BusinessRegistrationRequest = {
|
||||
id: string;
|
||||
status: string;
|
||||
businessTitle: string;
|
||||
categoryId: string;
|
||||
ownerMobile: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CreateBusinessRegistrationPayload = {
|
||||
businessTitle: string;
|
||||
categoryId: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
export type BusinessServiceRequest = {
|
||||
id: string;
|
||||
businessId: string;
|
||||
status: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
price: string;
|
||||
durationMinutes?: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CreateBusinessServiceRequestPayload = {
|
||||
businessId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
durationMinutes?: number;
|
||||
};
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<aside class="hidden min-h-dvh p-3 pe-1 lg:block">
|
||||
<div
|
||||
class="login-hero-panel relative h-full min-h-[calc(100dvh-1.5rem)] overflow-hidden bg-slate-950 shadow-[0_20px_50px_-18px_rgba(0,0,0,0.55)]"
|
||||
>
|
||||
<img
|
||||
:src="heroSrc"
|
||||
alt=""
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-slate-950/88 via-slate-900/72 to-emerald-950/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="login-hero-panel pointer-events-none absolute inset-0 border-b-[3px] border-e-[3px] border-brand-500/55"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-[1] flex h-full min-h-[calc(100dvh-1.5rem)] flex-col px-8 pb-8 pt-4 xl:px-11 xl:pb-10 xl:pt-5"
|
||||
dir="rtl"
|
||||
>
|
||||
<div
|
||||
class="grid w-full grid-cols-[auto_minmax(0,1fr)] items-start gap-x-6 gap-y-4"
|
||||
dir="ltr"
|
||||
>
|
||||
<div class="flex shrink-0 items-center gap-3 pt-1">
|
||||
<img
|
||||
src="/LogoDark.png"
|
||||
alt=""
|
||||
class="h-11 w-11 shrink-0 rounded-xl object-cover"
|
||||
/>
|
||||
<img
|
||||
:src="LOGIN_WELCOME_WORDMARK"
|
||||
alt="منتایم"
|
||||
class="h-8 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-w-lg justify-self-end text-right" dir="rtl">
|
||||
<h1 class="text-[2.35rem] font-black leading-[1.35] text-white xl:text-[2.75rem]">
|
||||
<span class="block">وقت شما</span>
|
||||
<span class="block text-brand-500">ارزشمند است</span>
|
||||
</h1>
|
||||
|
||||
<p class="mt-0.5 text-[15px] font-medium leading-7 text-white/85 xl:text-base">
|
||||
نوبتگیری سریع و مدیریت آسان خدمات
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-0 ms-20 mt-auto grid max-w-[calc(100%-2rem)] grid-cols-3 gap-3 pt-5 xl:mb-1 xl:ms-24 xl:gap-4 xl:pt-6"
|
||||
>
|
||||
<div
|
||||
v-for="item in features"
|
||||
:key="item.label"
|
||||
class="flex flex-col items-center space-y-2 text-center"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/10 text-brand-500 ring-1 ring-white/15"
|
||||
>
|
||||
<AppIcon :name="item.icon" size="sm" />
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-white">{{ item.label }}</p>
|
||||
<p class="text-xs leading-5 text-white/70">
|
||||
<span
|
||||
v-for="(line, lineIndex) in item.description"
|
||||
:key="lineIndex"
|
||||
class="block"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { usePlatformSettingsStore } from '@/stores/platform-settings';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
const LOGIN_WELCOME_WORDMARK = '/welcome%20lite.png';
|
||||
|
||||
const platformSettings = usePlatformSettingsStore();
|
||||
const { loginHeroSrc } = storeToRefs(platformSettings);
|
||||
|
||||
const heroSrc = ref(loginHeroSrc.value);
|
||||
const fallbackUsed = ref(false);
|
||||
|
||||
watch(loginHeroSrc, (src) => {
|
||||
fallbackUsed.value = false;
|
||||
heroSrc.value = src;
|
||||
});
|
||||
|
||||
const features: { label: string; description: string[]; icon: AppIconName }[] = [
|
||||
{
|
||||
label: 'نوبتگیری سریع',
|
||||
description: ['در کمتر از یک دقیقه', 'نوبت خود را رزرو کنید'],
|
||||
icon: 'sparkles',
|
||||
},
|
||||
{
|
||||
label: 'اطمینان از کیفیت',
|
||||
description: ['همکاری با بهترین', 'ارائهدهندگان خدمات'],
|
||||
icon: 'shield',
|
||||
},
|
||||
{
|
||||
label: 'مدیریت رزروها',
|
||||
description: ['همه نوبتهای شما', 'در یکجا'],
|
||||
icon: 'calendar',
|
||||
},
|
||||
];
|
||||
|
||||
function onImageError() {
|
||||
if (fallbackUsed.value) return;
|
||||
fallbackUsed.value = true;
|
||||
heroSrc.value = '/landing/hero-car-wash.jpg';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-hero-panel {
|
||||
border-top-left-radius: 1rem;
|
||||
border-bottom-left-radius: 1rem;
|
||||
border-bottom-right-radius: 15rem;
|
||||
}
|
||||
|
||||
.login-hero-title {
|
||||
margin: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.login-hero-title span + span {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.login-hero-subtitle {
|
||||
margin: 0;
|
||||
padding-top: 0.125rem;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.login-hero-panel {
|
||||
border-bottom-right-radius: 19rem;
|
||||
}
|
||||
|
||||
.login-hero-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-text">
|
||||
{{ booking.business?.title ?? 'کسبوکار' }}
|
||||
<div class="flex h-full min-w-0 flex-col">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="line-clamp-2 font-semibold text-text">
|
||||
{{ primary.business?.title ?? 'کسبوکار' }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-sm text-muted">
|
||||
{{ serviceTitle }}
|
||||
</p>
|
||||
<p v-if="groupHint" class="mt-0.5 text-xs text-brand-600 dark:text-brand-500">
|
||||
{{ groupHint }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ formatDate(booking.scheduledAt) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCustomer && booking.customer?.mobile"
|
||||
class="mt-1 text-xs text-muted"
|
||||
dir="ltr"
|
||||
>
|
||||
{{ booking.customer.fullName ? `${booking.customer.fullName} · ` : '' }}{{ booking.customer.mobile }}
|
||||
</p>
|
||||
<p v-if="booking.note" class="mt-2 text-xs text-muted">{{ booking.note }}</p>
|
||||
<span
|
||||
class="mt-2 inline-block rounded-lg px-2 py-0.5 text-xs"
|
||||
:class="statusClass"
|
||||
v-if="!showPerItemStatus"
|
||||
class="shrink-0 rounded-lg px-2 py-0.5 text-[10px]"
|
||||
:class="statusClassFor(groupStatus)"
|
||||
>
|
||||
{{ bookingStatusLabel(booking.status) }}
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="classTitle" class="mt-1 text-xs font-medium text-brand-600">
|
||||
کلاس {{ classTitle }}
|
||||
</p>
|
||||
|
||||
<ul v-if="isGroup" class="mt-1 space-y-0.5">
|
||||
<li
|
||||
v-for="booking in bookings"
|
||||
:key="booking.id"
|
||||
class="flex items-start justify-between gap-2 text-xs text-muted"
|
||||
>
|
||||
<span class="line-clamp-2">{{ serviceTitleForBooking(booking) }}</span>
|
||||
<span
|
||||
v-if="showPerItemStatus"
|
||||
class="shrink-0 rounded-md px-1.5 py-0.5 text-[10px]"
|
||||
:class="statusClassFor(booking.status)"
|
||||
>
|
||||
{{ bookingStatusLabel(booking.status) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="mt-1 line-clamp-2 text-sm text-muted">
|
||||
{{ serviceTitleForBooking(primary) }}
|
||||
</p>
|
||||
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{ formatDate(primary.scheduledAt) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCustomer && primary.customer?.mobile"
|
||||
class="mt-1 truncate text-xs text-muted"
|
||||
dir="ltr"
|
||||
>
|
||||
{{
|
||||
primary.customer.fullName
|
||||
? `${primary.customer.fullName} · `
|
||||
: ''
|
||||
}}{{ primary.customer.mobile }}
|
||||
</p>
|
||||
<p v-if="primary.note" class="mt-2 line-clamp-2 text-xs text-muted">
|
||||
{{ primary.note }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="canConfirm || canCancel"
|
||||
class="mt-3 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="canConfirm"
|
||||
class="!w-auto flex-1 px-3"
|
||||
:loading="actingId === booking.id"
|
||||
@click="emit('confirm', booking)"
|
||||
:loading="acting"
|
||||
@click="emit('confirm', bookings)"
|
||||
>
|
||||
تأیید
|
||||
</AppButton>
|
||||
@@ -47,8 +73,8 @@
|
||||
v-if="canCancel"
|
||||
variant="danger"
|
||||
class="!w-auto flex-1 px-3"
|
||||
:loading="actingId === booking.id"
|
||||
@click="emit('cancel', booking)"
|
||||
:loading="acting"
|
||||
@click="emit('cancel', bookings)"
|
||||
>
|
||||
لغو
|
||||
</AppButton>
|
||||
@@ -58,37 +84,46 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Booking } from '@/api/types';
|
||||
import type { Booking, BookingStatus } from '@/api/types';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import {
|
||||
bookingClassTitle,
|
||||
groupBookingStatus,
|
||||
serviceTitleForBooking,
|
||||
} from '@/utils/booking-groups';
|
||||
import { bookingStatusLabel, formatDate } from '@/utils/format';
|
||||
|
||||
const props = defineProps<{
|
||||
booking: Booking;
|
||||
actingId: string | null;
|
||||
bookings: Booking[];
|
||||
acting: boolean;
|
||||
showCustomer: boolean;
|
||||
canConfirm: boolean;
|
||||
canCancel: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [booking: Booking];
|
||||
cancel: [booking: Booking];
|
||||
confirm: [bookings: Booking[]];
|
||||
cancel: [bookings: Booking[]];
|
||||
}>();
|
||||
|
||||
const serviceTitle = computed(() => {
|
||||
const title = props.booking.businessService?.serviceTemplate?.title ?? 'خدمت';
|
||||
if (props.booking.serviceClass?.title) {
|
||||
return `${title} · ${props.booking.serviceClass.title}`;
|
||||
const primary = computed(() => props.bookings[0]!);
|
||||
|
||||
const isGroup = computed(() => props.bookings.length > 1);
|
||||
|
||||
const classTitle = computed(() => bookingClassTitle(primary.value));
|
||||
|
||||
const groupStatus = computed(() => groupBookingStatus(props.bookings));
|
||||
|
||||
const showPerItemStatus = computed(() => groupStatus.value === 'mixed');
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (groupStatus.value === 'mixed') {
|
||||
return 'وضعیت متفاوت';
|
||||
}
|
||||
return title;
|
||||
return bookingStatusLabel(groupStatus.value);
|
||||
});
|
||||
|
||||
const groupHint = computed(() =>
|
||||
props.booking.bookingGroupId ? 'بخشی از رزرو چندخدمتی' : '',
|
||||
);
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const status = props.booking.status;
|
||||
function statusClassFor(status: BookingStatus | 'mixed') {
|
||||
if (status === 'confirmed' || status === 'completed') {
|
||||
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
|
||||
}
|
||||
@@ -98,6 +133,9 @@ const statusClass = computed(() => {
|
||||
if (status === 'cancelled') {
|
||||
return 'bg-red-500/15 text-red-600 dark:text-red-400';
|
||||
}
|
||||
if (status === 'mixed') {
|
||||
return 'bg-surface-3 text-muted';
|
||||
}
|
||||
return 'bg-surface-3 text-muted';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<aside
|
||||
class="min-w-0 w-full max-w-full rounded-2xl border border-border bg-white p-4 shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface sm:p-5"
|
||||
>
|
||||
<h2 class="mb-3 text-sm font-bold text-text sm:mb-4 sm:text-base">خلاصه سفارش</h2>
|
||||
|
||||
<div v-if="items.length" class="space-y-3 sm:space-y-4">
|
||||
<p v-if="classTitle" class="text-xs font-medium text-brand-600">
|
||||
کلاس {{ classTitle }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="items.length === 1"
|
||||
class="overflow-hidden rounded-xl border border-border lg:block"
|
||||
>
|
||||
<div
|
||||
v-if="imageUrl"
|
||||
class="hidden aspect-[16/10] bg-surface-2 bg-cover bg-center lg:block"
|
||||
:style="{ backgroundImage: `url(${imageUrl})` }"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="hidden aspect-[16/10] items-center justify-center bg-brand-50 text-brand-600 dark:bg-brand-500/10 lg:flex"
|
||||
>
|
||||
<AppIcon name="spray" size="xl" />
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="font-semibold text-text">{{ items[0].title }}</p>
|
||||
<p v-if="items[0].durationLabel" class="mt-1 text-xs text-muted sm:text-sm">
|
||||
مدت زمان: {{ items[0].durationLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-1.5 sm:space-y-2">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="rounded-xl border border-border px-3 py-2 sm:py-2.5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2 sm:gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-text">{{ item.title }}</p>
|
||||
<p v-if="item.durationLabel" class="mt-0.5 text-xs text-muted">
|
||||
{{ item.durationLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="shrink-0 text-xs font-medium text-text sm:text-sm">
|
||||
{{ formatPriceToman(item.price) }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<dl class="space-y-1.5 text-xs sm:space-y-2 sm:text-sm">
|
||||
<div
|
||||
v-if="items.length === 1"
|
||||
class="flex items-center justify-between gap-2"
|
||||
>
|
||||
<dt class="text-muted">قیمت خدمت</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{{ formatPriceToman(items[0].price) }} تومان
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="totalDurationMinutes"
|
||||
class="flex items-center justify-between gap-2"
|
||||
:class="{ 'border-t border-border pt-2': items.length === 1 }"
|
||||
>
|
||||
<dt class="text-muted">مدت کل</dt>
|
||||
<dd class="font-medium text-text">{{ totalDurationMinutes }} دقیقه</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 border-t border-border pt-2">
|
||||
<dt class="font-semibold text-text">جمع کل</dt>
|
||||
<dd class="text-sm font-bold text-text sm:text-base">
|
||||
{{ formatPriceToman(totalPrice) }} تومان
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
v-if="earliestSlot"
|
||||
class="rounded-xl border border-brand-500/30 bg-brand-50 p-3 dark:bg-brand-500/10 sm:p-4"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<AppIcon name="zap" size="sm" class="mt-0.5 shrink-0 text-brand-600" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-medium text-brand-700 dark:text-brand-500">
|
||||
زودترین زمان خالی
|
||||
</p>
|
||||
<p class="mt-0.5 truncate text-sm font-bold text-text">
|
||||
{{ earliestSlot.dateLabel }}
|
||||
</p>
|
||||
<p class="text-xs text-muted sm:text-sm">{{ earliestSlot.timeLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppButton class="mt-2.5 sm:mt-3" @click="emit('select-earliest')">
|
||||
انتخاب این زمان
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-sm text-muted">ابتدا یک یا چند خدمت انتخاب کنید.</p>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { formatPriceToman } from '@/utils/format';
|
||||
|
||||
export type BookingSummaryItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
price: string;
|
||||
durationLabel: string | null;
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
items: BookingSummaryItem[];
|
||||
classTitle?: string | null;
|
||||
totalPrice: string;
|
||||
totalDurationMinutes: number | null;
|
||||
imageUrl: string | null;
|
||||
earliestSlot: { dateLabel: string; timeLabel: string } | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-earliest': [];
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-muted">ابتدا کلاس خودرو یا خدمت خود را انتخاب کنید.</p>
|
||||
|
||||
<ListCardSkeleton v-if="loading" :count="3" />
|
||||
<p v-else-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
|
||||
<p
|
||||
v-else-if="!classes.length"
|
||||
class="rounded-2xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted"
|
||||
>
|
||||
کلاس خدماتی تعریف نشده است. میتوانید با زدن «مرحله بعد» ادامه دهید.
|
||||
</p>
|
||||
|
||||
<div v-else class="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<button
|
||||
v-for="item in classes"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="rounded-2xl border p-5 text-right transition"
|
||||
:class="
|
||||
model === item.id
|
||||
? 'border-brand-500 bg-brand-50 ring-1 ring-brand-500/30 dark:bg-brand-500/10'
|
||||
: 'border-border bg-white hover:border-brand-500/40 dark:bg-surface'
|
||||
"
|
||||
@click="model = item.id"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-brand-50 text-brand-600 dark:bg-brand-500/10"
|
||||
>
|
||||
<AppIcon name="car" size="lg" />
|
||||
</div>
|
||||
<p class="font-semibold text-text">{{ item.title }}</p>
|
||||
<p v-if="item.description" class="mt-2 text-sm leading-6 text-muted">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BusinessServiceClass } from '@/api/types';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
||||
|
||||
defineProps<{
|
||||
classes: BusinessServiceClass[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}>();
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<nav aria-label="مراحل رزرو" class="mb-4 min-w-0 max-w-full overflow-hidden lg:mb-8">
|
||||
<ol class="flex min-w-0 items-center justify-between gap-0.5 sm:gap-1">
|
||||
<li
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
class="flex min-w-0 flex-1 items-center"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
|
||||
<div
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold transition sm:h-9 sm:w-9 sm:text-sm"
|
||||
:class="circleClass(index + 1)"
|
||||
>
|
||||
<AppIcon v-if="index + 1 < current" name="circle-check" size="sm" />
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="max-w-[2.75rem] truncate text-center text-[8px] font-medium leading-3 sm:max-w-[4rem] sm:text-[9px] lg:max-w-none lg:text-xs"
|
||||
:class="index + 1 <= current ? 'text-brand-600' : 'text-muted'"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="mx-0.5 mb-5 h-0.5 flex-1"
|
||||
:class="index + 1 < current ? 'bg-brand-500' : 'bg-border'"
|
||||
/>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
current: number;
|
||||
}>();
|
||||
|
||||
const steps = [
|
||||
{ id: 1, label: 'کلاس خدمات' },
|
||||
{ id: 2, label: 'انتخاب خدمات' },
|
||||
{ id: 3, label: 'تاریخ و ساعت' },
|
||||
{ id: 4, label: 'اطلاعات و پرداخت' },
|
||||
{ id: 5, label: 'تایید نهایی' },
|
||||
] as const;
|
||||
|
||||
function circleClass(step: number) {
|
||||
if (step < props.current) {
|
||||
return 'bg-brand-500 text-white';
|
||||
}
|
||||
if (step === props.current) {
|
||||
return 'border-2 border-brand-500 bg-white text-brand-600';
|
||||
}
|
||||
return 'border border-border bg-surface-2 text-muted';
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl space-y-8 py-4 text-center">
|
||||
<div class="relative mx-auto flex h-24 w-24 items-center justify-center">
|
||||
<span
|
||||
v-for="(dot, index) in confettiDots"
|
||||
:key="index"
|
||||
class="absolute h-2 w-2 rounded-full"
|
||||
:class="dot.color"
|
||||
:style="dot.style"
|
||||
/>
|
||||
<div
|
||||
class="relative flex h-20 w-20 items-center justify-center rounded-full bg-brand-500 text-white shadow-lg"
|
||||
>
|
||||
<AppIcon name="circle-check" size="xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-text">رزرو شما با موفقیت ثبت شد!</h2>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
جزئیات رزرو برای شما پیامک و در بخش رزروها ذخیره شد.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl border border-border bg-white text-right shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface"
|
||||
>
|
||||
<div class="grid gap-4 p-5 sm:grid-cols-[140px_1fr] sm:items-center">
|
||||
<div
|
||||
class="mx-auto flex h-32 w-full max-w-[140px] items-center justify-center rounded-xl bg-brand-50 dark:bg-brand-500/10"
|
||||
>
|
||||
<AppIcon name="car" size="xl" class="text-brand-600" />
|
||||
</div>
|
||||
<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">{{ serviceTitle }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">تاریخ</dt>
|
||||
<dd class="font-medium text-text">{{ dateLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">ساعت</dt>
|
||||
<dd class="font-medium text-text">{{ timeLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">مدت زمان</dt>
|
||||
<dd class="font-medium text-text">{{ durationLabel }}</dd>
|
||||
</div>
|
||||
<div v-if="address" class="flex justify-between gap-4">
|
||||
<dt class="shrink-0 text-muted">آدرس</dt>
|
||||
<dd class="text-left font-medium text-text">{{ address }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-t border-border pt-3">
|
||||
<dt class="text-muted">شماره رزرو</dt>
|
||||
<dd class="font-bold text-brand-600">#{{ bookingRef }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</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">
|
||||
<AppButton @click="emit('view-bookings')">
|
||||
مشاهده رزروها
|
||||
</AppButton>
|
||||
<AppButton variant="outline" @click="emit('add-to-calendar')">
|
||||
<span class="inline-flex items-center justify-center gap-2">
|
||||
<AppIcon name="calendar" size="sm" />
|
||||
افزودن به تقویم
|
||||
</span>
|
||||
</AppButton>
|
||||
<AppButton variant="outline" @click="emit('go-home')">
|
||||
<span class="inline-flex items-center justify-center gap-2">
|
||||
<AppIcon name="home" size="sm" />
|
||||
بازگشت به صفحه اصلی
|
||||
</span>
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
serviceTitle: string;
|
||||
dateLabel: string;
|
||||
timeLabel: string;
|
||||
durationLabel: string;
|
||||
address?: string | null;
|
||||
bookingRef: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'view-bookings': [];
|
||||
'add-to-calendar': [];
|
||||
'go-home': [];
|
||||
}>();
|
||||
|
||||
const confettiDots = [
|
||||
{ color: 'bg-amber-400', style: { top: '4px', right: '12px' } },
|
||||
{ color: 'bg-sky-400', style: { top: '18px', left: '0' } },
|
||||
{ color: 'bg-rose-400', style: { bottom: '8px', right: '0' } },
|
||||
{ color: 'bg-violet-400', style: { bottom: '0', left: '16px' } },
|
||||
{ color: 'bg-emerald-400', style: { top: '0', left: '50%' } },
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div
|
||||
class="min-w-0 w-full max-w-full overflow-hidden rounded-2xl border border-border bg-white shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface"
|
||||
>
|
||||
<div class="space-y-5 p-4 sm:space-y-6 sm:p-5">
|
||||
<div class="min-w-0 max-w-full">
|
||||
<p class="mb-3 text-sm font-medium text-text">انتخاب تاریخ</p>
|
||||
<div class="flex min-w-0 gap-2 overflow-x-auto overscroll-x-contain pb-1 [-webkit-overflow-scrolling:touch]">
|
||||
<button
|
||||
v-for="day in dateOptions"
|
||||
:key="`${day.jy}-${day.jm}-${day.jd}`"
|
||||
type="button"
|
||||
class="flex w-[4.5rem] shrink-0 flex-col items-center rounded-xl border px-2 py-2.5 text-center transition sm:w-[5.5rem] sm:px-3 sm:py-3"
|
||||
:class="
|
||||
isSelectedDay(day)
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-500'
|
||||
: 'border-border bg-surface-2 text-text hover:border-brand-500/40'
|
||||
"
|
||||
@click="selectDay(day)"
|
||||
>
|
||||
<span class="text-[10px] text-muted sm:text-xs">{{ day.weekdayLabel }}</span>
|
||||
<span class="mt-1 text-xs font-semibold sm:text-sm">{{ day.dateLabel }}</span>
|
||||
<span v-if="day.isToday" class="mt-0.5 text-[10px] text-brand-600">امروز</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 max-w-full">
|
||||
<p class="mb-3 text-sm font-medium text-text">انتخاب ساعت</p>
|
||||
<div v-if="loadingSlots" class="py-2">
|
||||
<SlotGridSkeleton />
|
||||
</div>
|
||||
<p v-else-if="slotsError" class="text-sm text-red-600">{{ slotsError }}</p>
|
||||
<p
|
||||
v-else-if="!slots.length"
|
||||
class="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted"
|
||||
>
|
||||
برای این روز ساعت خالی وجود ندارد. روز دیگری انتخاب کنید.
|
||||
</p>
|
||||
<div v-else class="grid grid-cols-3 gap-1.5 sm:grid-cols-4 sm:gap-2">
|
||||
<button
|
||||
v-for="slot in slots"
|
||||
:key="slot"
|
||||
type="button"
|
||||
class="inline-flex min-w-0 items-center justify-center gap-0.5 rounded-xl border px-1 py-2 text-xs transition sm:gap-1 sm:px-2 sm:py-2.5 sm:text-sm"
|
||||
:class="
|
||||
model === slot
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-500'
|
||||
: 'border-border bg-surface-2 text-text hover:border-brand-500/40'
|
||||
"
|
||||
@click="selectSlot(slot)"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="slot === earliestSlotIso"
|
||||
name="zap"
|
||||
size="sm"
|
||||
class="text-brand-600"
|
||||
/>
|
||||
{{ formatSlotLabel(slot) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="durationMinutes"
|
||||
class="flex items-center gap-2 rounded-b-2xl border-t border-brand-500/20 bg-brand-50 px-4 py-2.5 text-xs text-brand-700 dark:bg-brand-500/10 dark:text-brand-500 sm:px-5 sm:py-3 sm:text-sm"
|
||||
>
|
||||
<AppIcon name="clock" size="sm" />
|
||||
<span>مدت تقریبی انجام خدمات: {{ durationMinutes }} دقیقه</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { fetchAvailableSlots } from '@/api/bookings';
|
||||
import { ApiError } from '@/api/client';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import SlotGridSkeleton from '@/components/ui/SlotGridSkeleton.vue';
|
||||
import {
|
||||
clampJalaliDay,
|
||||
firstAllowedJalaliDate,
|
||||
formatJalaliDateShort,
|
||||
generateJalaliDateRange,
|
||||
gregorianToJalaliParts,
|
||||
isSameJalaliDay,
|
||||
isoToJalaliParts,
|
||||
jalaliPartsToDate,
|
||||
jalaliToGregorianYmd,
|
||||
persianWeekdayName,
|
||||
} from '@/utils/jalali';
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
|
||||
const props = defineProps<{
|
||||
businessId: string;
|
||||
businessServiceIds: string[];
|
||||
serviceClassId?: string | null;
|
||||
durationMinutes?: number | null;
|
||||
earliestSlotIso?: string | null;
|
||||
}>();
|
||||
|
||||
const selectedDay = ref(firstAllowedJalaliDate());
|
||||
const slots = ref<string[]>([]);
|
||||
const loadingSlots = ref(false);
|
||||
const slotsError = ref('');
|
||||
|
||||
const today = computed(() => {
|
||||
const j = gregorianToJalaliParts(new Date());
|
||||
return { jy: j.jy, jm: j.jm, jd: j.jd };
|
||||
});
|
||||
|
||||
const dateOptions = computed(() => {
|
||||
const start = firstAllowedJalaliDate();
|
||||
return generateJalaliDateRange(start, 14).map((day) => {
|
||||
const date = jalaliPartsToDate({ ...day, hour: 0, minute: 0 });
|
||||
return {
|
||||
...day,
|
||||
weekdayLabel: persianWeekdayName(date),
|
||||
dateLabel: formatJalaliDateShort(day),
|
||||
isToday: isSameJalaliDay(day, today.value),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function isSelectedDay(day: { jy: number; jm: number; jd: number }) {
|
||||
return isSameJalaliDay(day, selectedDay.value);
|
||||
}
|
||||
|
||||
function formatSlotLabel(iso: string): string {
|
||||
const parsed = isoToJalaliParts(iso);
|
||||
if (!parsed) return '—';
|
||||
return `${String(parsed.hour).padStart(2, '0')}:${String(parsed.minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function loadSlots() {
|
||||
if (!props.businessServiceIds.length) return;
|
||||
loadingSlots.value = true;
|
||||
slotsError.value = '';
|
||||
slots.value = [];
|
||||
try {
|
||||
selectedDay.value = clampJalaliDay({ ...selectedDay.value, hour: 0, minute: 0 });
|
||||
const ymd = jalaliToGregorianYmd(selectedDay.value);
|
||||
const res = await fetchAvailableSlots(props.businessId, ymd, {
|
||||
businessServiceIds: props.businessServiceIds,
|
||||
serviceClassId: props.serviceClassId ?? undefined,
|
||||
});
|
||||
slots.value = res.slots;
|
||||
if (model.value && !slots.value.includes(model.value)) {
|
||||
model.value = '';
|
||||
}
|
||||
} catch (e) {
|
||||
slotsError.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری ساعات ناموفق بود';
|
||||
} finally {
|
||||
loadingSlots.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectDay(day: { jy: number; jm: number; jd: number }) {
|
||||
selectedDay.value = { ...day };
|
||||
model.value = '';
|
||||
void loadSlots();
|
||||
}
|
||||
|
||||
function selectSlot(iso: string) {
|
||||
model.value = iso;
|
||||
}
|
||||
|
||||
function selectDayAndSlot(iso: string) {
|
||||
const parsed = isoToJalaliParts(iso);
|
||||
if (!parsed) return;
|
||||
selectedDay.value = { jy: parsed.jy, jm: parsed.jm, jd: parsed.jd };
|
||||
model.value = iso;
|
||||
void loadSlots();
|
||||
}
|
||||
|
||||
defineExpose({ selectDayAndSlot });
|
||||
|
||||
watch(
|
||||
() => [props.businessId, props.businessServiceIds.join(','), props.serviceClassId],
|
||||
() => {
|
||||
model.value = '';
|
||||
void loadSlots();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.businessServiceIds.length) {
|
||||
void loadSlots();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<div class="min-w-0 max-w-full space-y-6 overflow-x-hidden pb-24 lg:pb-0">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-text lg:text-2xl">رزرو خدمات</h1>
|
||||
<RouterLink
|
||||
:to="homeTo"
|
||||
class="inline-flex items-center gap-2 text-sm text-muted transition hover:text-brand-600"
|
||||
>
|
||||
<AppIcon name="home" size="sm" />
|
||||
بازگشت به صفحه اصلی
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<BookingSuccessView
|
||||
v-if="phase === 'success' && successDetails"
|
||||
v-bind="successDetails"
|
||||
@view-bookings="emit('view-bookings')"
|
||||
@add-to-calendar="downloadCalendar"
|
||||
@go-home="emit('go-home')"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<BookingStepper :current="currentStep" />
|
||||
<p class="text-center text-sm font-semibold text-brand-600 lg:hidden">
|
||||
{{ currentStepLabel }}
|
||||
</p>
|
||||
|
||||
<BookingServiceClassStep
|
||||
v-if="currentStep === 1"
|
||||
v-model="selectedClassId"
|
||||
:classes="serviceClasses"
|
||||
:loading="loadingClasses"
|
||||
:error="classesError"
|
||||
/>
|
||||
|
||||
<div v-else-if="currentStep === 2" class="space-y-4">
|
||||
<p class="text-sm text-muted">
|
||||
میتوانید یک یا چند خدمت انتخاب کنید. برای لغو انتخاب، دوباره روی همان خدمت بزنید.
|
||||
</p>
|
||||
<ServiceGridSkeleton v-if="loadingServices" />
|
||||
<p v-else-if="servicesError" class="text-sm text-red-600">{{ servicesError }}</p>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-2 gap-4 xl:grid-cols-3">
|
||||
<button
|
||||
v-for="service in services"
|
||||
:key="service.id"
|
||||
type="button"
|
||||
class="relative rounded-2xl border p-4 text-right transition"
|
||||
:class="
|
||||
isServiceSelected(service.id)
|
||||
? 'border-brand-500 bg-brand-50 ring-1 ring-brand-500/30 dark:bg-brand-500/10'
|
||||
: 'border-border bg-white hover:border-brand-500/40 dark:bg-surface'
|
||||
"
|
||||
:disabled="!service.isActive"
|
||||
@click="toggleService(service.id)"
|
||||
>
|
||||
<span
|
||||
v-if="isServiceSelected(service.id)"
|
||||
class="absolute left-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white"
|
||||
>
|
||||
<AppIcon name="circle-check" size="sm" />
|
||||
</span>
|
||||
<p class="font-semibold text-text">
|
||||
{{ service.serviceTemplate?.title ?? 'خدمت' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
مدت زمان: {{ durationFor(service) }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-bold text-brand-600">
|
||||
{{ priceFor(service) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedServices.length"
|
||||
class="flex items-center justify-between rounded-xl border border-brand-500/30 bg-brand-50 px-4 py-3 text-sm dark:bg-brand-500/10"
|
||||
>
|
||||
<span class="text-text">
|
||||
{{ selectedServices.length }} خدمت انتخاب شده
|
||||
</span>
|
||||
<span class="font-bold text-brand-700 dark:text-brand-500">
|
||||
جمع: {{ formatPriceToman(selectedPrice) }} تومان
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="auth.isLoggedIn"
|
||||
class="grid min-w-0 grid-cols-1 items-start gap-4 lg:grid-cols-3 lg:gap-6"
|
||||
>
|
||||
<BookingOrderSummary
|
||||
class="order-2 min-w-0 w-full lg:order-1 lg:col-span-1 lg:sticky lg:top-24"
|
||||
:items="summaryItems"
|
||||
:class-title="selectedClassTitle"
|
||||
:total-price="selectedPrice"
|
||||
:total-duration-minutes="selectedDurationMinutes"
|
||||
:image-url="coverImageUrl"
|
||||
:earliest-slot="earliestSlotLabel"
|
||||
@select-earliest="applyEarliestSlot"
|
||||
/>
|
||||
|
||||
<div class="order-1 min-w-0 w-full space-y-4 lg:order-2 lg:col-span-2">
|
||||
<DesktopBookingDateTime
|
||||
v-if="currentStep === 3"
|
||||
ref="dateTimeRef"
|
||||
v-model="scheduledAt"
|
||||
:business-id="business.id"
|
||||
:business-service-ids="selectedServiceIds"
|
||||
:service-class-id="selectedClassId || null"
|
||||
:duration-minutes="selectedDurationMinutes"
|
||||
:earliest-slot-iso="earliestSlotIso"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="currentStep === 4"
|
||||
class="rounded-2xl border border-border bg-white p-5 shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface"
|
||||
>
|
||||
<h3 class="mb-4 font-semibold text-text">اطلاعات تکمیلی</h3>
|
||||
<AppTextarea
|
||||
v-model="note"
|
||||
label="یادداشت (اختیاری)"
|
||||
placeholder="در صورت نیاز توضیحی بنویسید..."
|
||||
/>
|
||||
<p class="mt-4 text-sm text-muted">
|
||||
پرداخت در محل انجام میشود. نیازی به پرداخت آنلاین نیست.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="currentStep === 5"
|
||||
class="rounded-2xl border border-border bg-white p-5 shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface"
|
||||
>
|
||||
<h3 class="mb-4 font-semibold text-text">بررسی نهایی</h3>
|
||||
<dl class="space-y-3 text-sm">
|
||||
<div v-if="selectedClassTitle" class="flex justify-between gap-4">
|
||||
<dt class="text-muted">کلاس</dt>
|
||||
<dd class="font-medium text-text">{{ selectedClassTitle }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="shrink-0 text-muted">خدمات</dt>
|
||||
<dd class="max-w-[60%] text-left font-medium text-text">
|
||||
{{ selectedServiceTitle }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">تاریخ و ساعت</dt>
|
||||
<dd class="font-medium text-text">{{ scheduledLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">مبلغ</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{{ formatPriceToman(selectedPrice) }} تومان
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="note.trim()" class="flex justify-between gap-4">
|
||||
<dt class="text-muted">یادداشت</dt>
|
||||
<dd class="max-w-[60%] text-left font-medium text-text">{{ note }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-if="bookingError" class="mt-4 text-sm text-red-600">{{ bookingError }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden flex-wrap items-center justify-between gap-3 lg:flex"
|
||||
>
|
||||
<AppButton variant="ghost" class="!w-auto" @click="goBack">
|
||||
مرحله قبل
|
||||
</AppButton>
|
||||
<AppButton
|
||||
class="!w-auto min-w-[140px]"
|
||||
:disabled="!canProceed"
|
||||
:loading="bookingLoading && currentStep === 5"
|
||||
@click="goNext"
|
||||
>
|
||||
{{ nextButtonLabel }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showMobileActions"
|
||||
class="fixed inset-x-0 bottom-16 z-20 border-t border-border bg-surface/95 backdrop-blur lg:hidden"
|
||||
>
|
||||
<div class="mx-auto flex w-full max-w-app gap-2 px-4 py-3">
|
||||
<AppButton
|
||||
v-if="currentStep > 1"
|
||||
variant="outline"
|
||||
class="!w-auto shrink-0 px-4"
|
||||
@click="goBack"
|
||||
>
|
||||
قبل
|
||||
</AppButton>
|
||||
<AppButton
|
||||
class="flex-1"
|
||||
:disabled="!canProceed"
|
||||
:loading="bookingLoading && currentStep === 5"
|
||||
@click="goNext"
|
||||
>
|
||||
{{ nextButtonLabel }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!auth.isLoggedIn && currentStep > 2"
|
||||
class="rounded-2xl border border-brand-500/30 bg-brand-50 p-5 dark:bg-brand-500/10"
|
||||
>
|
||||
<p class="text-sm text-text">برای ادامه رزرو، ابتدا وارد حساب کاربری شوید.</p>
|
||||
<AppButton class="mt-4 !w-auto" @click="emit('login')">ورود / ثبتنام</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { createBooking, createBookingBatch, fetchAvailableSlots } from '@/api/bookings';
|
||||
import { fetchBusinessServiceClasses } from '@/api/business-service-classes';
|
||||
import { ApiError } from '@/api/client';
|
||||
import type { Booking, Business, BusinessService, BusinessServiceClass } from '@/api/types';
|
||||
import BookingOrderSummary from '@/components/booking/BookingOrderSummary.vue';
|
||||
import BookingServiceClassStep from '@/components/booking/BookingServiceClassStep.vue';
|
||||
import BookingStepper from '@/components/booking/BookingStepper.vue';
|
||||
import BookingSuccessView from '@/components/booking/BookingSuccessView.vue';
|
||||
import DesktopBookingDateTime from '@/components/booking/DesktopBookingDateTime.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import AppTextarea from '@/components/ui/AppTextarea.vue';
|
||||
import ServiceGridSkeleton from '@/components/ui/ServiceGridSkeleton.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { formatPriceToman } from '@/utils/format';
|
||||
import {
|
||||
formatJalaliDateLong,
|
||||
formatJalaliDateShort,
|
||||
formatJalaliDateTime,
|
||||
generateJalaliDateRange,
|
||||
gregorianToJalaliParts,
|
||||
isoToJalaliParts,
|
||||
isSameJalaliDay,
|
||||
jalaliToGregorianYmd,
|
||||
firstAllowedJalaliDate,
|
||||
persianWeekdayName,
|
||||
jalaliPartsToDate,
|
||||
} from '@/utils/jalali';
|
||||
import { businessCoverUrl } from '@/utils/media';
|
||||
import {
|
||||
resolveServicePrice,
|
||||
serviceDurationLabel,
|
||||
servicePriceLabel,
|
||||
sumServicesDurationMinutes,
|
||||
sumServicesPrice,
|
||||
} from '@/utils/service-pricing';
|
||||
|
||||
const props = defineProps<{
|
||||
business: Business;
|
||||
services: BusinessService[];
|
||||
loadingServices: boolean;
|
||||
servicesError: string;
|
||||
homeTo: string | { name: string };
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
login: [];
|
||||
'view-bookings': [];
|
||||
'go-home': [];
|
||||
}>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const currentStep = ref(1);
|
||||
const phase = ref<'booking' | 'success'>('booking');
|
||||
const selectedClassId = ref('');
|
||||
const selectedServiceIds = ref<string[]>([]);
|
||||
const scheduledAt = ref('');
|
||||
const note = ref('');
|
||||
const bookingLoading = ref(false);
|
||||
const bookingError = ref('');
|
||||
const createdBooking = ref<Booking | null>(null);
|
||||
const earliestSlotIso = ref<string | null>(null);
|
||||
const dateTimeRef = ref<InstanceType<typeof DesktopBookingDateTime> | null>(null);
|
||||
|
||||
const serviceClasses = ref<BusinessServiceClass[]>([]);
|
||||
const loadingClasses = ref(false);
|
||||
const classesError = ref('');
|
||||
|
||||
const classStepRequired = computed(
|
||||
() => Boolean(props.business.serviceClassesEnabled) && serviceClasses.value.length > 0,
|
||||
);
|
||||
|
||||
const perClass = computed(
|
||||
() => Boolean(props.business.serviceClassesEnabled) && Boolean(selectedClassId.value),
|
||||
);
|
||||
|
||||
const selectedServices = computed(() =>
|
||||
props.services.filter((service) => selectedServiceIds.value.includes(service.id)),
|
||||
);
|
||||
|
||||
const selectedClass = computed(
|
||||
() => serviceClasses.value.find((c) => c.id === selectedClassId.value) ?? null,
|
||||
);
|
||||
|
||||
const selectedClassTitle = computed(() => selectedClass.value?.title ?? null);
|
||||
|
||||
const selectedServiceTitle = computed(() => {
|
||||
if (!selectedServices.value.length) return 'خدمت';
|
||||
return selectedServices.value
|
||||
.map((service) => service.serviceTemplate?.title ?? 'خدمت')
|
||||
.join('، ');
|
||||
});
|
||||
|
||||
const selectedPrice = computed(() => {
|
||||
if (!selectedServices.value.length) return '0';
|
||||
return String(
|
||||
sumServicesPrice(
|
||||
selectedServices.value,
|
||||
perClass.value,
|
||||
selectedClassId.value || undefined,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const selectedDurationMinutes = computed(() => {
|
||||
if (!selectedServices.value.length) return null;
|
||||
const total = sumServicesDurationMinutes(
|
||||
selectedServices.value,
|
||||
perClass.value,
|
||||
selectedClassId.value || undefined,
|
||||
);
|
||||
return total > 0 ? total : null;
|
||||
});
|
||||
|
||||
const selectedDurationLabel = computed(() => {
|
||||
if (!selectedDurationMinutes.value) return null;
|
||||
return `${selectedDurationMinutes.value} دقیقه`;
|
||||
});
|
||||
|
||||
const summaryItems = computed(() =>
|
||||
selectedServices.value.map((service) => ({
|
||||
id: service.id,
|
||||
title: service.serviceTemplate?.title ?? 'خدمت',
|
||||
price: resolveServicePrice(
|
||||
service,
|
||||
perClass.value,
|
||||
selectedClassId.value || undefined,
|
||||
),
|
||||
durationLabel: serviceDurationLabel(
|
||||
service,
|
||||
perClass.value,
|
||||
serviceClasses.value,
|
||||
selectedClassId.value || undefined,
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
const coverImageUrl = computed(() => businessCoverUrl(props.business.images));
|
||||
|
||||
const scheduledLabel = computed(() => {
|
||||
const parsed = isoToJalaliParts(scheduledAt.value);
|
||||
if (!parsed) return '—';
|
||||
return formatJalaliDateTime(parsed);
|
||||
});
|
||||
|
||||
const earliestSlotLabel = computed(() => {
|
||||
if (!earliestSlotIso.value) return null;
|
||||
const parsed = isoToJalaliParts(earliestSlotIso.value);
|
||||
if (!parsed) return null;
|
||||
const today = gregorianToJalaliParts(new Date());
|
||||
const date = jalaliPartsToDate({
|
||||
jy: parsed.jy,
|
||||
jm: parsed.jm,
|
||||
jd: parsed.jd,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
});
|
||||
const prefix = isSameJalaliDay(parsed, today) ? 'امروز' : persianWeekdayName(date);
|
||||
return {
|
||||
dateLabel: `${prefix} — ${formatJalaliDateShort(parsed)}`,
|
||||
timeLabel: `${String(parsed.hour).padStart(2, '0')}:${String(parsed.minute).padStart(2, '0')}`,
|
||||
};
|
||||
});
|
||||
|
||||
const successDetails = computed(() => {
|
||||
if (!createdBooking.value) return null;
|
||||
const parsed = isoToJalaliParts(createdBooking.value.scheduledAt);
|
||||
const serviceTitle = selectedClassTitle.value
|
||||
? `${selectedServiceTitle.value} · ${selectedClassTitle.value}`
|
||||
: selectedServiceTitle.value;
|
||||
return {
|
||||
serviceTitle,
|
||||
dateLabel: parsed ? formatJalaliDateLong(parsed) : '—',
|
||||
timeLabel: parsed
|
||||
? `${String(parsed.hour).padStart(2, '0')}:${String(parsed.minute).padStart(2, '0')}`
|
||||
: '—',
|
||||
durationLabel: selectedDurationLabel.value ?? '—',
|
||||
address: props.business.address,
|
||||
bookingRef: createdBooking.value.id.slice(0, 8).toUpperCase(),
|
||||
};
|
||||
});
|
||||
|
||||
const stepLabels = [
|
||||
'کلاس خدمات',
|
||||
'انتخاب خدمات',
|
||||
'تاریخ و ساعت',
|
||||
'اطلاعات و پرداخت',
|
||||
'تایید نهایی',
|
||||
] as const;
|
||||
|
||||
const currentStepLabel = computed(() => stepLabels[currentStep.value - 1] ?? '');
|
||||
|
||||
const nextButtonLabel = computed(() =>
|
||||
currentStep.value === 5 ? 'ثبت نهایی رزرو' : 'مرحله بعد',
|
||||
);
|
||||
|
||||
const showMobileActions = computed(
|
||||
() =>
|
||||
phase.value === 'booking' &&
|
||||
(currentStep.value <= 2 || auth.isLoggedIn) &&
|
||||
!(currentStep.value > 2 && !auth.isLoggedIn),
|
||||
);
|
||||
|
||||
const canProceed = computed(() => {
|
||||
if (currentStep.value === 1) {
|
||||
return classStepRequired.value ? Boolean(selectedClassId.value) : true;
|
||||
}
|
||||
if (currentStep.value === 2) return selectedServiceIds.value.length > 0;
|
||||
if (currentStep.value === 3) return Boolean(scheduledAt.value);
|
||||
if (currentStep.value === 4) return true;
|
||||
if (currentStep.value === 5) {
|
||||
return Boolean(
|
||||
scheduledAt.value &&
|
||||
selectedServiceIds.value.length > 0 &&
|
||||
(!classStepRequired.value || selectedClassId.value),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function durationFor(service: BusinessService) {
|
||||
return serviceDurationLabel(
|
||||
service,
|
||||
perClass.value,
|
||||
serviceClasses.value,
|
||||
selectedClassId.value || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function priceFor(service: BusinessService) {
|
||||
return servicePriceLabel(
|
||||
service,
|
||||
perClass.value,
|
||||
serviceClasses.value,
|
||||
selectedClassId.value || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function isServiceSelected(serviceId: string) {
|
||||
return selectedServiceIds.value.includes(serviceId);
|
||||
}
|
||||
|
||||
function toggleService(serviceId: string) {
|
||||
if (isServiceSelected(serviceId)) {
|
||||
selectedServiceIds.value = selectedServiceIds.value.filter((id) => id !== serviceId);
|
||||
} else {
|
||||
selectedServiceIds.value = [...selectedServiceIds.value, serviceId];
|
||||
}
|
||||
scheduledAt.value = '';
|
||||
bookingError.value = '';
|
||||
void loadEarliestSlot();
|
||||
}
|
||||
|
||||
async function loadClasses() {
|
||||
loadingClasses.value = true;
|
||||
classesError.value = '';
|
||||
try {
|
||||
serviceClasses.value = await fetchBusinessServiceClasses(props.business.id, {
|
||||
activeOnly: true,
|
||||
});
|
||||
if (serviceClasses.value.length === 1) {
|
||||
selectedClassId.value = serviceClasses.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
classesError.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری کلاسهای خدمات ناموفق بود';
|
||||
} finally {
|
||||
loadingClasses.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEarliestSlot() {
|
||||
earliestSlotIso.value = null;
|
||||
if (!selectedServiceIds.value.length) return;
|
||||
const days = generateJalaliDateRange(firstAllowedJalaliDate(), 7);
|
||||
for (const day of days) {
|
||||
try {
|
||||
const res = await fetchAvailableSlots(
|
||||
props.business.id,
|
||||
jalaliToGregorianYmd(day),
|
||||
{
|
||||
businessServiceIds: selectedServiceIds.value,
|
||||
serviceClassId: selectedClassId.value || undefined,
|
||||
},
|
||||
);
|
||||
if (res.slots.length) {
|
||||
earliestSlotIso.value = res.slots[0];
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* try next day */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyEarliestSlot() {
|
||||
if (!earliestSlotIso.value) return;
|
||||
scheduledAt.value = earliestSlotIso.value;
|
||||
dateTimeRef.value?.selectDayAndSlot(earliestSlotIso.value);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value -= 1;
|
||||
bookingError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
if (currentStep.value === 2 && !auth.isLoggedIn) {
|
||||
emit('login');
|
||||
return;
|
||||
}
|
||||
if (currentStep.value < 5) {
|
||||
currentStep.value += 1;
|
||||
return;
|
||||
}
|
||||
await submitBooking();
|
||||
}
|
||||
|
||||
async function submitBooking() {
|
||||
if (!auth.user || !selectedServiceIds.value.length || !scheduledAt.value) {
|
||||
bookingError.value = 'لطفاً خدمت و زمان را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
if (classStepRequired.value && !selectedClassId.value) {
|
||||
bookingError.value = 'لطفاً کلاس خدمات را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
bookingLoading.value = true;
|
||||
bookingError.value = '';
|
||||
try {
|
||||
const payload = {
|
||||
customerId: auth.user.id,
|
||||
businessId: props.business.id,
|
||||
serviceClassId: selectedClassId.value || null,
|
||||
scheduledAt: scheduledAt.value,
|
||||
note: note.value.trim() || null,
|
||||
};
|
||||
if (selectedServiceIds.value.length === 1) {
|
||||
createdBooking.value = await createBooking({
|
||||
...payload,
|
||||
businessServiceId: selectedServiceIds.value[0],
|
||||
});
|
||||
} else {
|
||||
const bookings = await createBookingBatch({
|
||||
...payload,
|
||||
items: selectedServiceIds.value.map((businessServiceId) => ({
|
||||
businessServiceId,
|
||||
})),
|
||||
});
|
||||
createdBooking.value = bookings[0] ?? null;
|
||||
}
|
||||
phase.value = 'success';
|
||||
} catch (e) {
|
||||
bookingError.value = e instanceof ApiError ? e.message : 'ثبت رزرو ناموفق بود.';
|
||||
} finally {
|
||||
bookingLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCalendar() {
|
||||
if (!createdBooking.value) return;
|
||||
const start = new Date(createdBooking.value.scheduledAt);
|
||||
const end = new Date(start.getTime() + (selectedDurationMinutes.value ?? 60) * 60_000);
|
||||
const formatIcs = (d: Date) =>
|
||||
d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'BEGIN:VEVENT',
|
||||
`DTSTART:${formatIcs(start)}`,
|
||||
`DTEND:${formatIcs(end)}`,
|
||||
`SUMMARY:${selectedServiceTitle.value}`,
|
||||
`LOCATION:${props.business.address ?? ''}`,
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'booking.ics';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
watch(selectedClassId, () => {
|
||||
selectedServiceIds.value = [];
|
||||
scheduledAt.value = '';
|
||||
earliestSlotIso.value = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
selectedServiceIds,
|
||||
() => {
|
||||
void loadEarliestSlot();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void loadClasses();
|
||||
});
|
||||
</script>
|
||||
@@ -11,13 +11,18 @@
|
||||
:class="['hidden dark:block', imgClass]"
|
||||
/>
|
||||
<p class="max-w-xs text-sm leading-7 text-muted">
|
||||
نوبت گیری سریع ، مدیرت آسان زمان
|
||||
{{ brand.tagline }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
|
||||
const tenant = useTenantStore();
|
||||
const { brand } = storeToRefs(tenant);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white p-1.5 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-600"
|
||||
>
|
||||
<AppLogo variant="horizontal" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<span class="text-lg font-bold text-slate-800 dark:text-slate-100">
|
||||
{{ brand.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
|
||||
const tenant = useTenantStore();
|
||||
const { brand } = storeToRefs(tenant);
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'app-business-detail', params: { id: business.id } }"
|
||||
class="group flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-6 text-center shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md dark:border-border dark:bg-surface"
|
||||
:class="featured ? 'border-brand-500 ring-1 ring-brand-500/20' : ''"
|
||||
>
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-brand-500/10 text-brand-600 transition group-hover:bg-brand-500/15 dark:text-brand-500"
|
||||
>
|
||||
<img
|
||||
v-if="cover"
|
||||
:src="cover"
|
||||
:alt="business.title"
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<AppIcon v-else name="store" size="lg" />
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-bold text-slate-800 dark:text-text">{{ business.title }}</h3>
|
||||
|
||||
<p
|
||||
v-if="business.description"
|
||||
class="mt-2 line-clamp-3 min-h-[4.5rem] text-sm leading-7 text-slate-600 dark:text-muted"
|
||||
>
|
||||
{{ business.description }}
|
||||
</p>
|
||||
<p v-else class="mt-2 min-h-[4.5rem] text-sm text-slate-400 dark:text-muted">—</p>
|
||||
|
||||
<p v-if="business.category" class="mt-3 text-xs font-medium text-brand-600 dark:text-brand-500">
|
||||
{{ business.category.title }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="business.address"
|
||||
class="mt-2 flex items-center justify-center gap-1 text-xs text-slate-500 dark:text-muted"
|
||||
>
|
||||
<AppIcon name="map-pin" size="sm" class="shrink-0" />
|
||||
<span class="line-clamp-1">{{ business.address }}</span>
|
||||
</p>
|
||||
|
||||
<span
|
||||
class="mt-auto pt-5 text-sm font-semibold text-brand-600 transition group-hover:text-brand-700 dark:text-brand-500"
|
||||
>
|
||||
مشاهده و رزرو
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Business } from '@/api/types';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { businessCoverUrl } from '@/utils/media';
|
||||
|
||||
const props = defineProps<{
|
||||
business: Business;
|
||||
featured?: boolean;
|
||||
}>();
|
||||
|
||||
const cover = computed(() => businessCoverUrl(props.business.images));
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div :class="props.class">
|
||||
<BusinessCardSkeleton v-if="loading" class="py-2" />
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<div
|
||||
v-else-if="!businesses.length"
|
||||
class="rounded-2xl border border-dashed border-border bg-surface-2 p-8 text-center text-sm text-muted"
|
||||
>
|
||||
کسبوکاری یافت نشد.
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
:class="
|
||||
variant === 'featured'
|
||||
? 'grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3'
|
||||
: 'grid grid-cols-1 gap-2'
|
||||
"
|
||||
>
|
||||
<li v-for="(business, index) in businesses" :key="business.id">
|
||||
<HomeBusinessCard
|
||||
v-if="variant === 'featured'"
|
||||
:business="business"
|
||||
:featured="index === 1"
|
||||
/>
|
||||
<BusinessCard v-else :business="business" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Business } from '@/api/types';
|
||||
import BusinessCard from '@/components/business/BusinessCard.vue';
|
||||
import HomeBusinessCard from '@/components/home/HomeBusinessCard.vue';
|
||||
import BusinessCardSkeleton from '@/components/ui/BusinessCardSkeleton.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
businesses: Business[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
variant?: 'compact' | 'featured';
|
||||
class?: string;
|
||||
}>(),
|
||||
{ variant: 'compact', class: '' },
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<section id="club" class="scroll-mt-28 border-t border-landing-line bg-landing-surface py-14 dark:border-border dark:bg-surface-2">
|
||||
<div class="mx-auto max-w-[1200px] px-6 xl:px-8">
|
||||
<HomeSectionTitle subtitle="با عضویت در باشگاه مشتریان منتایم از تخفیفها و پیشنهادهای ویژه بهرهمند شوید.">
|
||||
باشگاه مشتریان
|
||||
</HomeSectionTitle>
|
||||
|
||||
<div class="mx-auto grid max-w-4xl gap-6 md:grid-cols-3">
|
||||
<div
|
||||
v-for="perk in perks"
|
||||
:key="perk.title"
|
||||
class="rounded-2xl border border-slate-200 bg-white p-6 text-center shadow-sm dark:border-border dark:bg-surface"
|
||||
>
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-50 text-brand-600 dark:bg-brand-500/10 dark:text-brand-500"
|
||||
>
|
||||
<AppIcon :name="perk.icon" size="md" />
|
||||
</div>
|
||||
<h3 class="font-bold text-landing-ink dark:text-text">{{ perk.title }}</h3>
|
||||
<p class="mt-2 text-sm leading-7 text-landing-muted dark:text-muted">{{ perk.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-6 py-3 text-sm font-semibold text-white hover:bg-brand-700"
|
||||
@click="emit('join')"
|
||||
>
|
||||
<AppIcon name="user" size="sm" />
|
||||
عضویت در باشگاه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeSectionTitle from '@/components/home/HomeSectionTitle.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
const emit = defineEmits<{
|
||||
join: [];
|
||||
}>();
|
||||
|
||||
const perks: { title: string; text: string; icon: AppIconName }[] = [
|
||||
{
|
||||
title: 'امتیاز وفاداری',
|
||||
text: 'با هر رزرو امتیاز جمع کنید و جایزه بگیرید.',
|
||||
icon: 'badge-percent',
|
||||
},
|
||||
{
|
||||
title: 'تخفیف اختصاصی',
|
||||
text: 'کدهای تخفیف مخصوص اعضای باشگاه.',
|
||||
icon: 'sparkles',
|
||||
},
|
||||
{
|
||||
title: 'اولویت رزرو',
|
||||
text: 'دسترسی زودتر به اسلاتهای پرتقاضا.',
|
||||
icon: 'calendar',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-text lg:text-2xl">کشف خدمات</h1>
|
||||
<p class="mt-1 text-sm text-muted lg:text-base">
|
||||
کسبوکارهای محلی نزدیک شما
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HomeSearchAndFilter
|
||||
v-model:search="searchQuery"
|
||||
:show-search="showSearch"
|
||||
:categories="activeCategories"
|
||||
:show-category-filter="showCategoryFilter"
|
||||
:selected-category-id="selectedCategoryId"
|
||||
@select-category="onCategorySelect"
|
||||
/>
|
||||
|
||||
<HomeBusinessList
|
||||
:businesses="filteredBusinesses"
|
||||
:loading="loadingBusinesses"
|
||||
:error="error"
|
||||
:variant="props.listVariant"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { fetchBusinessCategories } from '@/api/business-categories';
|
||||
import { fetchBusinesses } from '@/api/businesses';
|
||||
import { ApiError } from '@/api/client';
|
||||
import type { Business, BusinessCategory } from '@/api/types';
|
||||
import HomeBusinessList from '@/components/home/HomeBusinessList.vue';
|
||||
import HomeSearchAndFilter from '@/components/home/HomeSearchAndFilter.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
listVariant?: 'compact' | 'featured';
|
||||
}>(),
|
||||
{ listVariant: 'compact' },
|
||||
);
|
||||
|
||||
const categories = ref<BusinessCategory[]>([]);
|
||||
const businesses = ref<Business[]>([]);
|
||||
const selectedCategoryId = ref<string | null>(null);
|
||||
const searchQuery = ref('');
|
||||
const loadingBusinesses = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const activeCategories = computed(() =>
|
||||
categories.value.filter((c) => c.isActive),
|
||||
);
|
||||
|
||||
const showCategoryFilter = computed(() => activeCategories.value.length > 1);
|
||||
|
||||
const showSearch = computed(() => businesses.value.length > 1);
|
||||
|
||||
const filteredBusinesses = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return businesses.value;
|
||||
}
|
||||
return businesses.value.filter((b) => {
|
||||
const haystack = [b.title, b.description, b.address]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
categories.value = await fetchBusinessCategories();
|
||||
if (activeCategories.value.length === 1) {
|
||||
selectedCategoryId.value = activeCategories.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری دستهها ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBusinesses() {
|
||||
loadingBusinesses.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
businesses.value = await fetchBusinesses({
|
||||
categoryId: selectedCategoryId.value ?? undefined,
|
||||
isActive: true,
|
||||
});
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری کسبوکارها ناموفق بود.';
|
||||
} finally {
|
||||
loadingBusinesses.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onCategorySelect(id: string | null) {
|
||||
selectedCategoryId.value = id;
|
||||
}
|
||||
|
||||
watch(selectedCategoryId, () => {
|
||||
void loadBusinesses();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
await loadBusinesses();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<section class="bg-white py-8 dark:bg-surface">
|
||||
<div class="mx-auto max-w-[1200px] px-6 xl:px-8">
|
||||
<div
|
||||
class="rounded-2xl bg-[#001f3f] px-3 py-5 dark:bg-[#0f172a] sm:px-6 sm:py-7 xl:px-10 xl:py-8"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-2 lg:gap-4">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="flex flex-col items-center gap-1.5 text-center lg:flex-row lg:items-center lg:gap-3 lg:text-right"
|
||||
>
|
||||
<div
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 border-brand-500/80 text-brand-500 lg:h-11 lg:w-11"
|
||||
>
|
||||
<AppIcon :name="item.icon" size="sm" class="lg:hidden" />
|
||||
<AppIcon :name="item.icon" size="md" class="hidden lg:block" />
|
||||
</div>
|
||||
<p class="text-[10px] font-bold leading-4 text-white lg:text-sm lg:leading-6">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
const items: { label: string; icon: AppIconName }[] = [
|
||||
{ label: 'پشتیبانی ۲۴/۷', icon: 'headphones' },
|
||||
{ label: 'پرداخت امن', icon: 'circle-check' },
|
||||
{ label: 'تخفیفهای ویژه', icon: 'badge-percent' },
|
||||
{ label: 'رزرو سریع و آسان', icon: 'clock' },
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<section class="relative overflow-hidden bg-[#f5f7fa] pb-10 pt-6 dark:bg-surface">
|
||||
<div
|
||||
class="landing-hero-dots pointer-events-none absolute start-4 top-10 h-36 w-36 opacity-80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="landing-hero-lines pointer-events-none absolute bottom-6 end-4 h-44 w-52 opacity-70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<svg width="0" height="0" class="absolute" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id="landing-hero-wave" clipPathUnits="objectBoundingBox">
|
||||
<path
|
||||
d="M0,0 H1 V0.78 C0.94,0.84 0.86,0.9 0.76,0.95 C0.64,0.98 0.5,1 0.36,0.98 C0.24,0.93 0.12,0.86 0,0.8 Z"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class="mx-auto grid max-w-[1200px] items-center gap-8 px-6 lg:grid-cols-2 lg:gap-12 lg:px-8"
|
||||
>
|
||||
<div class="relative z-10 space-y-7">
|
||||
<h1
|
||||
class="flex flex-wrap items-baseline gap-x-4 gap-y-2 text-[1.85rem] font-black leading-[1.45] text-landing-ink dark:text-text lg:text-[2.1rem]"
|
||||
>
|
||||
<span>{{ heroTitle }}</span>
|
||||
<span v-if="heroSubtitle" class="text-brand-600">{{ heroSubtitle }}</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="heroDescription"
|
||||
class="max-w-md text-[15px] leading-8 text-landing-muted dark:text-muted"
|
||||
>
|
||||
{{ heroDescription }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-6 py-3 text-[15px] font-semibold text-white shadow-sm transition hover:bg-brand-700"
|
||||
@click="emit('book')"
|
||||
>
|
||||
<AppIcon name="calendar" size="sm" />
|
||||
رزرو نوبت
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-6 py-3 text-[15px] font-semibold text-landing-ink transition hover:border-brand-600 hover:text-brand-600 dark:border-border dark:bg-surface dark:text-text"
|
||||
@click="emit('browse')"
|
||||
>
|
||||
مشاهده خدمات
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-3 pt-1">
|
||||
<div
|
||||
v-for="item in highlights"
|
||||
:key="item.label"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<AppIcon :name="item.icon" size="sm" class="shrink-0 text-brand-600" />
|
||||
<span class="text-xs font-medium text-landing-muted dark:text-muted">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="landing-hero-blob relative overflow-hidden bg-slate-200 shadow-[0_20px_50px_-12px_rgba(0,0,0,0.18)]">
|
||||
<img
|
||||
v-if="heroImage && !heroImageFailed"
|
||||
:key="heroImage"
|
||||
:src="heroImage"
|
||||
:alt="heroTitle"
|
||||
class="h-[320px] w-full object-cover lg:h-[390px]"
|
||||
@error="onHeroImageError"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-[320px] w-full items-center justify-center bg-gradient-to-br from-brand-50 via-slate-100 to-brand-100 lg:h-[390px]"
|
||||
>
|
||||
<AppIcon name="car" size="xl" class="h-20 w-20 text-brand-600/30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
import { businessCoverUrl } from '@/utils/media';
|
||||
|
||||
const emit = defineEmits<{
|
||||
book: [];
|
||||
browse: [];
|
||||
}>();
|
||||
|
||||
const tenant = useTenantStore();
|
||||
const { singleBusiness } = storeToRefs(tenant);
|
||||
|
||||
const heroImageFailed = ref(false);
|
||||
|
||||
const heroTitle = computed(() => singleBusiness.value?.title ?? '');
|
||||
|
||||
const heroSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
|
||||
|
||||
const heroDescription = computed(() => singleBusiness.value?.description?.trim() ?? '');
|
||||
|
||||
const heroImage = computed(() =>
|
||||
businessCoverUrl(singleBusiness.value?.images),
|
||||
);
|
||||
|
||||
watch(heroImage, () => {
|
||||
heroImageFailed.value = false;
|
||||
});
|
||||
|
||||
const highlights: { label: string; icon: AppIconName }[] = [
|
||||
{ label: 'رضایت مشتریان', icon: 'shield' },
|
||||
{ label: 'تجهیزات مدرن', icon: 'settings' },
|
||||
{ label: 'مواد شوینده با کیفیت', icon: 'droplet' },
|
||||
];
|
||||
|
||||
function onHeroImageError() {
|
||||
heroImageFailed.value = true;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div :class="desktop ? 'mx-auto max-w-3xl space-y-4' : 'space-y-4'">
|
||||
<div v-if="showSearch" class="relative">
|
||||
<label class="sr-only" for="home-search">جستجو</label>
|
||||
<AppIcon
|
||||
name="search"
|
||||
size="sm"
|
||||
class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted"
|
||||
/>
|
||||
<input
|
||||
id="home-search"
|
||||
:value="search"
|
||||
type="search"
|
||||
placeholder="جستجو در نام یا آدرس…"
|
||||
class="w-full rounded-xl border border-border bg-surface-2 py-3 pe-4 ps-10 text-sm text-text outline-none placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
:class="desktop ? 'bg-white dark:bg-surface' : ''"
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CategoryFilter
|
||||
v-if="showCategoryFilter"
|
||||
:categories="categories"
|
||||
:selected-id="selectedCategoryId"
|
||||
@select="emit('select-category', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BusinessCategory } from '@/api/types';
|
||||
import CategoryFilter from '@/components/business/CategoryFilter.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
search: string;
|
||||
showSearch: boolean;
|
||||
categories: BusinessCategory[];
|
||||
showCategoryFilter: boolean;
|
||||
selectedCategoryId: string | null;
|
||||
desktop?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search': [value: string];
|
||||
'select-category': [id: string | null];
|
||||
}>();
|
||||
|
||||
function onSearchInput(event: Event) {
|
||||
emit('update:search', (event.target as HTMLInputElement).value);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="mb-10 text-center">
|
||||
<div class="flex items-center justify-center gap-5">
|
||||
<span class="h-px w-20 bg-brand-600/50" aria-hidden="true" />
|
||||
<h2 class="text-[1.35rem] font-bold text-landing-ink dark:text-text">
|
||||
<slot />
|
||||
</h2>
|
||||
<span class="h-px w-20 bg-brand-600/50" aria-hidden="true" />
|
||||
</div>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mx-auto mt-4 max-w-2xl text-sm leading-7 text-landing-muted dark:text-muted"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
subtitle?: string;
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="to"
|
||||
class="flex min-w-0 flex-1 flex-col rounded-xl border bg-white px-4 py-5 text-center shadow-[0_2px_12px_rgba(15,23,42,0.06)] transition hover:-translate-y-0.5 hover:shadow-md dark:bg-surface"
|
||||
:class="
|
||||
featured
|
||||
? 'border-brand-600 ring-1 ring-brand-600/25'
|
||||
: 'border-slate-100 dark:border-border'
|
||||
"
|
||||
>
|
||||
<div class="mb-3 flex justify-center text-slate-400 dark:text-muted">
|
||||
<AppIcon :name="icon" size="lg" />
|
||||
</div>
|
||||
|
||||
<h3 class="text-[15px] font-bold leading-7 text-landing-ink dark:text-text">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-1 text-xs leading-6 text-landing-muted dark:text-muted"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<p v-if="durationLabel" class="mt-3 text-xs text-landing-muted dark:text-muted">
|
||||
مدت زمان: {{ durationLabel }}
|
||||
</p>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
defineProps<{
|
||||
to:
|
||||
| string
|
||||
| { name: string; params?: Record<string, string>; hash?: string }
|
||||
| { path: string; hash?: string };
|
||||
title: string;
|
||||
description?: string | null;
|
||||
durationLabel?: string | null;
|
||||
icon: AppIconName;
|
||||
featured?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<section id="services" class="scroll-mt-28 bg-white py-14 dark:bg-surface">
|
||||
<div class="mx-auto max-w-[1200px] px-6 xl:px-8">
|
||||
<HomeSectionTitle
|
||||
subtitle="با انتخاب سرویس مورد نظر، در زمان دلخواه نوبت خود را رزرو کنید."
|
||||
>
|
||||
خدمات ما
|
||||
</HomeSectionTitle>
|
||||
|
||||
<ServiceGridSkeleton v-if="loading" :count="5" />
|
||||
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-center text-sm text-red-600"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else-if="!items.length"
|
||||
class="rounded-2xl border border-dashed border-landing-line p-10 text-center text-sm text-landing-muted"
|
||||
>
|
||||
هنوز خدمتی ثبت نشده است.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-2 gap-4 lg:gap-4"
|
||||
:class="gridClass"
|
||||
>
|
||||
<HomeServiceCard
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
:to="serviceLink(item)"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:duration-label="item.durationLabel"
|
||||
:icon="item.icon"
|
||||
:featured="index === featuredIndex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import HomeSectionTitle from '@/components/home/HomeSectionTitle.vue';
|
||||
import HomeServiceCard from '@/components/home/HomeServiceCard.vue';
|
||||
import ServiceGridSkeleton from '@/components/ui/ServiceGridSkeleton.vue';
|
||||
import type { LandingServiceItem } from '@/types/landing';
|
||||
|
||||
const props = defineProps<{
|
||||
items: LandingServiceItem[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}>();
|
||||
|
||||
const gridClass = computed(() => {
|
||||
const count = props.items.length;
|
||||
if (count >= 5) return 'lg:grid-cols-5';
|
||||
if (count === 4) return 'lg:grid-cols-4';
|
||||
if (count === 3) return 'lg:grid-cols-3';
|
||||
return 'lg:grid-cols-2';
|
||||
});
|
||||
|
||||
const featuredIndex = computed(() => {
|
||||
const len = props.items.length;
|
||||
if (!len) return 0;
|
||||
return Math.min(2, len - 1);
|
||||
});
|
||||
|
||||
function serviceLink(item: LandingServiceItem) {
|
||||
return {
|
||||
name: 'app-business-detail',
|
||||
params: { id: item.businessId },
|
||||
hash: 'services',
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<DesktopSiteLayout>
|
||||
<HomeDiscoveryContent list-variant="featured" />
|
||||
</DesktopSiteLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeDiscoveryContent from '@/components/home/HomeDiscoveryContent.vue';
|
||||
import DesktopSiteLayout from '@/layouts/DesktopSiteLayout.vue';
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<DesktopSiteLayout content-width="full">
|
||||
<HomeHeroSection @book="goToBooking" @browse="scrollToServices" />
|
||||
|
||||
<HomeServicesSection
|
||||
:items="services"
|
||||
:loading="loadingServices"
|
||||
:error="servicesError"
|
||||
/>
|
||||
|
||||
<HomeFeatureBar />
|
||||
</DesktopSiteLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 DesktopSiteLayout from '@/layouts/DesktopSiteLayout.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { LandingServiceItem } from '@/types/landing';
|
||||
|
||||
defineProps<{
|
||||
services: LandingServiceItem[];
|
||||
loadingServices: boolean;
|
||||
servicesError: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const tenant = useTenantStore();
|
||||
const { singleBusinessId } = storeToRefs(tenant);
|
||||
|
||||
function scrollToServices() {
|
||||
const el = document.getElementById('services');
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function goToBooking() {
|
||||
const id = singleBusinessId.value;
|
||||
if (!id) {
|
||||
scrollToServices();
|
||||
return;
|
||||
}
|
||||
|
||||
void router.push({
|
||||
name: 'app-business-detail',
|
||||
params: { id },
|
||||
hash: '#services',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div ref="rootRef" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="transition"
|
||||
:class="triggerClass"
|
||||
@click="profileMenu.toggle()"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="variant === 'desktop-cta' || variant === 'bottom-nav'"
|
||||
name="user"
|
||||
:size="variant === 'bottom-nav' ? 'lg' : 'sm'"
|
||||
/>
|
||||
<span
|
||||
class="max-w-[9rem] truncate"
|
||||
:dir="labelIsMobile ? 'ltr' : undefined"
|
||||
>
|
||||
{{ triggerLabel }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="profileMenu.isOpen"
|
||||
class="absolute z-50 max-h-[min(70dvh,32rem)] w-80 max-w-[calc(100vw-2rem)] overflow-y-auto rounded-2xl border border-border bg-surface p-4 shadow-xl dark:border-slate-700 dark:bg-[#121a24]"
|
||||
:class="panelClass"
|
||||
>
|
||||
<ProfilePanel />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onClickOutside, onKeyStroke } from '@vueuse/core';
|
||||
import ProfilePanel from '@/components/profile/ProfilePanel.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useProfileModalStore } from '@/stores/profile-modal';
|
||||
import { userDisplayName, userDisplayNameIsMobile } from '@/utils/user-display';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'header' | 'sidebar' | 'desktop-cta' | 'bottom-nav';
|
||||
}>(),
|
||||
{ variant: 'header' },
|
||||
);
|
||||
|
||||
const auth = useAuthStore();
|
||||
const profileMenu = useProfileModalStore();
|
||||
const rootRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const triggerLabel = computed(() => userDisplayName(auth.user));
|
||||
const labelIsMobile = computed(() => userDisplayNameIsMobile(auth.user));
|
||||
|
||||
const triggerClass = computed(() => {
|
||||
const active = profileMenu.isOpen;
|
||||
|
||||
if (props.variant === 'bottom-nav') {
|
||||
return [
|
||||
'flex w-full flex-col items-center gap-0.5 rounded-xl px-2 py-2 text-xs',
|
||||
active
|
||||
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||
: 'text-muted hover:bg-surface-3 hover:text-text',
|
||||
];
|
||||
}
|
||||
|
||||
if (props.variant === 'desktop-cta') {
|
||||
return [
|
||||
'inline-flex items-center gap-2 text-sm font-semibold',
|
||||
active
|
||||
? 'rounded-lg border border-brand-600 px-5 py-2.5 text-[15px] text-brand-600 dark:border-brand-400 dark:text-brand-400'
|
||||
: 'rounded-lg border border-slate-300 px-5 py-2.5 text-[15px] text-slate-700 hover:border-brand-600 hover:text-brand-600 dark:border-slate-600 dark:text-slate-200 dark:hover:border-brand-400 dark:hover:text-brand-400',
|
||||
];
|
||||
}
|
||||
|
||||
if (props.variant === 'sidebar') {
|
||||
return [
|
||||
'inline-flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold',
|
||||
active
|
||||
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||
: 'text-muted hover:bg-surface-3 hover:text-text',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'inline-flex items-center rounded-xl px-3 py-2 text-sm font-semibold',
|
||||
active
|
||||
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||
: 'text-muted hover:bg-surface-3 hover:text-text',
|
||||
];
|
||||
});
|
||||
|
||||
const panelClass = computed(() => {
|
||||
if (props.variant === 'bottom-nav') {
|
||||
return 'bottom-full mb-2 left-1/2 -translate-x-1/2';
|
||||
}
|
||||
return 'top-full mt-2 end-0';
|
||||
});
|
||||
|
||||
onClickOutside(rootRef, () => {
|
||||
if (profileMenu.isOpen) {
|
||||
profileMenu.close();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke('Escape', (event) => {
|
||||
if (!profileMenu.isOpen) return;
|
||||
event.preventDefault();
|
||||
profileMenu.close();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<AppModal :open="profileModal.isOpen" title="پروفایل" @close="profileModal.close()">
|
||||
<ProfilePanel />
|
||||
</AppModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProfilePanel from '@/components/profile/ProfilePanel.vue';
|
||||
import AppModal from '@/components/ui/AppModal.vue';
|
||||
import { useProfileModalStore } from '@/stores/profile-modal';
|
||||
|
||||
const profileModal = useProfileModalStore();
|
||||
</script>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div v-if="auth.user" class="space-y-4">
|
||||
<p
|
||||
v-if="message"
|
||||
class="rounded-xl border border-brand-500/40 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<dl class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<dt class="shrink-0 text-xs text-muted">موبایل</dt>
|
||||
<dd class="font-medium" dir="ltr">{{ auth.user.mobile }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<dt class="shrink-0 text-xs text-muted">نقش</dt>
|
||||
<dd class="font-medium">{{ roleLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section
|
||||
v-if="auth.isCustomer"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-text">کسبوکار من</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
میتوانید درخواست ثبت کسبوکار دهید تا پس از تأیید مدیریت، پنل صاحب کسبوکار فعال شود.
|
||||
</p>
|
||||
<RouterLink
|
||||
to="/app/register-business"
|
||||
class="mt-3 inline-flex w-full items-center justify-center rounded-xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white"
|
||||
@click="profileModal.close()"
|
||||
>
|
||||
ثبت کسبوکار من
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="auth.isBusinessOwner"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-text">مدیریت کسبوکار</h2>
|
||||
<RouterLink
|
||||
to="/app/owner"
|
||||
class="mt-3 inline-flex w-full items-center justify-center rounded-xl border border-border px-4 py-3 text-sm font-semibold text-text hover:bg-surface-3"
|
||||
@click="profileModal.close()"
|
||||
>
|
||||
ورود به پنل کسبوکار
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
|
||||
<h2 class="text-sm font-semibold text-text">
|
||||
{{ auth.user.hasPassword ? 'تغییر رمز عبور' : 'تنظیم رمز عبور' }}
|
||||
</h2>
|
||||
<p class="text-xs text-muted">
|
||||
پس از تنظیم رمز، ورود با رمز عبور یا کد یکبارمصرف هر دو ممکن است.
|
||||
</p>
|
||||
|
||||
<AppInput id="profileNewPassword" v-model="newPassword" label="رمز جدید" type="password" ltr />
|
||||
<AppInput
|
||||
id="profileConfirmPassword"
|
||||
v-model="confirmPassword"
|
||||
label="تکرار رمز جدید"
|
||||
type="password"
|
||||
ltr
|
||||
/>
|
||||
<AppButton :loading="saving" @click="onSavePassword">
|
||||
{{ auth.user.hasPassword ? 'بهروزرسانی رمز' : 'ذخیره رمز' }}
|
||||
</AppButton>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-border bg-surface-2 p-4">
|
||||
<h2 class="text-sm font-semibold text-text">خروج از حساب</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
با خروج، برای رزرو مجدد باید دوباره وارد شوید.
|
||||
</p>
|
||||
<AppButton variant="danger" class="mt-3" @click="onLogout">
|
||||
خروج
|
||||
</AppButton>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { setPassword } from '@/api/auth';
|
||||
import { ApiError } from '@/api/client';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useProfileModalStore } from '@/stores/profile-modal';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const profileModal = useProfileModalStore();
|
||||
const router = useRouter();
|
||||
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const message = ref('');
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
switch (auth.user?.role) {
|
||||
case 'admin':
|
||||
return 'مدیر سیستم';
|
||||
case 'business_owner':
|
||||
return 'صاحب کسبوکار';
|
||||
default:
|
||||
return 'مشتری';
|
||||
}
|
||||
});
|
||||
|
||||
function onLogout() {
|
||||
profileModal.close();
|
||||
auth.logout();
|
||||
void router.push({ name: 'app-home' });
|
||||
}
|
||||
|
||||
async function onSavePassword() {
|
||||
if (!auth.user) return;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
|
||||
if (newPassword.value.length < 8) {
|
||||
error.value = 'رمز باید حداقل ۸ کاراکتر باشد.';
|
||||
return;
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = 'تکرار رمز با رمز جدید یکسان نیست.';
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const updated = await setPassword({
|
||||
password: newPassword.value,
|
||||
confirmPassword: confirmPassword.value,
|
||||
});
|
||||
auth.setUser(updated);
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
message.value = 'رمز عبور ذخیره شد.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره رمز';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
class="mx-auto flex min-h-dvh w-full max-w-app flex-col bg-surface shadow-xl shadow-black/5 dark:shadow-black/30"
|
||||
>
|
||||
<slot />
|
||||
<div class="min-h-dvh w-full bg-surface-2 lg:bg-surface-3">
|
||||
<div
|
||||
class="mx-auto flex h-dvh max-h-dvh w-full min-w-0 max-w-full flex-col overflow-hidden bg-surface shadow-xl shadow-black/5 dark:shadow-black/30 max-w-app lg:max-w-none lg:shadow-none"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ProfileDropdown v-if="auth.isLoggedIn" :variant="variant" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-xl text-sm font-semibold transition"
|
||||
:class="
|
||||
variant === 'desktop-cta'
|
||||
? 'rounded-lg bg-brand-600 px-5 py-2.5 text-[15px] text-white shadow-sm hover:bg-brand-700'
|
||||
: variant === 'sidebar'
|
||||
? 'w-full bg-brand-600 px-3 py-2 text-white hover:bg-brand-700'
|
||||
: 'bg-brand-600 px-3 py-2 text-white hover:bg-brand-700'
|
||||
"
|
||||
@click="onLogin"
|
||||
>
|
||||
<AppIcon v-if="variant === 'desktop-cta'" name="user" size="sm" />
|
||||
<span>{{ variant === 'desktop-cta' ? 'ورود / ثبتنام' : 'ورود' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import ProfileDropdown from '@/components/profile/ProfileDropdown.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { loginRouteWithRedirect } from '@/utils/auth-redirect';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'header' | 'sidebar' | 'desktop-cta';
|
||||
}>(),
|
||||
{ variant: 'header' },
|
||||
);
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
function onLogin() {
|
||||
void router.push(loginRouteWithRedirect(route.fullPath));
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-30 border-b border-landing-line bg-white/95 backdrop-blur dark:border-slate-700/70 dark:bg-[#121a24]/95"
|
||||
>
|
||||
<div
|
||||
class="mx-auto grid max-w-[1200px] grid-cols-[1fr_auto_1fr] items-center gap-4 px-6 py-3.5 lg:px-8"
|
||||
>
|
||||
<RouterLink to="/app" class="justify-self-start">
|
||||
<BrandMark />
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden items-center justify-center gap-8 lg:flex">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="relative whitespace-nowrap pb-1 text-[15px] font-medium transition"
|
||||
:class="
|
||||
isActive(item)
|
||||
? 'text-brand-600 after:absolute after:inset-x-0 after:-bottom-0.5 after:h-0.5 after:rounded-full after:bg-brand-600 dark:text-brand-400 dark:after:bg-brand-400'
|
||||
: 'text-slate-700 hover:text-brand-600 dark:text-slate-300 dark:hover:text-brand-400'
|
||||
"
|
||||
@click="goTo(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<ThemeToggle
|
||||
class="!h-10 !w-10 !rounded-full !border-slate-200 dark:!border-slate-600 dark:!bg-slate-800 dark:!text-slate-200 dark:hover:!text-white"
|
||||
/>
|
||||
<AuthHeaderAction variant="desktop-cta" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BrandMark from '@/components/brand/BrandMark.vue';
|
||||
import AuthHeaderAction from '@/components/shell/AuthHeaderAction.vue';
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
||||
import { useLandingNav } from '@/composables/useLandingNav';
|
||||
|
||||
const { navItems, isActive, goTo } = useLandingNav();
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-brand-600 text-white shadow-lg shadow-brand-600/30 transition hover:bg-brand-700 hover:shadow-xl"
|
||||
aria-label="گفتگو با پشتیبانی"
|
||||
@click="onClick"
|
||||
>
|
||||
<AppIcon name="message-circle" size="lg" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
function onClick() {
|
||||
const el = document.getElementById('contact');
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
<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>
|
||||
<h3 class="mb-4 text-sm font-bold text-landing-ink dark:text-text">ما را دنبال کنید</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
href="#"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:border-brand-600 hover:text-brand-600 dark:border-border dark:bg-surface"
|
||||
aria-label="اینستاگرام"
|
||||
>
|
||||
<AppIcon name="camera" size="sm" />
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:border-brand-600 hover:text-brand-600 dark:border-border dark:bg-surface"
|
||||
aria-label="تلگرام"
|
||||
>
|
||||
<AppIcon name="send" size="sm" />
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:border-brand-600 hover:text-brand-600 dark:border-border dark:bg-surface"
|
||||
aria-label="واتساپ"
|
||||
>
|
||||
<AppIcon name="message-circle" size="sm" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="contact" class="scroll-mt-28">
|
||||
<h3 class="mb-4 text-sm font-bold text-landing-ink dark:text-text">تماس با ما</h3>
|
||||
<ul class="space-y-3 text-sm text-landing-muted dark:text-muted">
|
||||
<li v-if="contactPhone" class="flex items-center gap-2" dir="ltr">
|
||||
<AppIcon name="phone" size="sm" class="shrink-0 text-brand-600" />
|
||||
{{ contactPhone }}
|
||||
</li>
|
||||
<li v-if="contactMobile" class="flex items-center gap-2" dir="ltr">
|
||||
<AppIcon name="phone" size="sm" class="shrink-0 text-brand-600" />
|
||||
{{ contactMobile }}
|
||||
</li>
|
||||
<li v-if="address" class="flex items-start gap-2">
|
||||
<AppIcon name="map-pin" size="sm" class="mt-0.5 shrink-0 text-brand-600" />
|
||||
{{ address }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="mb-4 text-sm font-bold text-landing-ink dark:text-text">ساعات کاری</h3>
|
||||
<p class="text-sm leading-8 text-landing-muted dark:text-muted">
|
||||
همه روزه ۸:۰۰ صبح تا ۲۰:۰۰ شب
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="about" class="scroll-mt-28">
|
||||
<h3 class="mb-4 text-sm font-bold text-landing-ink dark:text-text">درباره ما</h3>
|
||||
<p class="text-sm leading-8 text-landing-muted dark:text-muted">
|
||||
{{ brand.aboutText }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="mt-10 border-t border-landing-line pt-6 text-center text-xs text-landing-muted dark:border-border dark:text-muted"
|
||||
>
|
||||
تمامی حقوق محفوظ است © ۱۴۰۳ {{ brand.copyrightName }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
|
||||
const tenant = useTenantStore();
|
||||
const { brand, singleBusiness, isSingleBusiness } = storeToRefs(tenant);
|
||||
|
||||
const contactPhone = computed(() => {
|
||||
if (isSingleBusiness.value && singleBusiness.value?.contactPhone) {
|
||||
return singleBusiness.value.contactPhone;
|
||||
}
|
||||
return '021-12345678';
|
||||
});
|
||||
|
||||
const contactMobile = computed(() => {
|
||||
if (isSingleBusiness.value) return null;
|
||||
return '0912-123-4567';
|
||||
});
|
||||
|
||||
const address = computed(() => {
|
||||
if (isSingleBusiness.value && singleBusiness.value?.address) {
|
||||
return singleBusiness.value.address;
|
||||
}
|
||||
return 'تهران، خیابان ولیعصر، پلاک ۱۲۳';
|
||||
});
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@ import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'ghost' | 'danger';
|
||||
variant?: 'primary' | 'ghost' | 'danger' | 'outline';
|
||||
type?: 'button' | 'submit';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
@@ -28,6 +28,8 @@ const variantClass = computed(() => {
|
||||
return 'bg-transparent text-muted hover:bg-surface-3 hover:text-text';
|
||||
case 'danger':
|
||||
return 'bg-red-500/15 text-red-600 dark:text-red-400 hover:bg-red-500/25';
|
||||
case 'outline':
|
||||
return 'border border-border bg-white text-text hover:bg-surface-2 dark:bg-surface dark:hover:bg-surface-2';
|
||||
default:
|
||||
return 'bg-brand-600 text-white hover:bg-brand-700';
|
||||
}
|
||||
|
||||
@@ -19,31 +19,46 @@ export default defineComponent({
|
||||
import type { Component } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Armchair,
|
||||
ArrowRight,
|
||||
BadgePercent,
|
||||
Calendar,
|
||||
Camera,
|
||||
Car,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CircleCheck,
|
||||
Clock,
|
||||
ClipboardList,
|
||||
Cog,
|
||||
Droplet,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderOpen,
|
||||
Headphones,
|
||||
Home,
|
||||
LayoutDashboard,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Moon,
|
||||
Plus,
|
||||
Phone,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
Shield,
|
||||
Sparkles,
|
||||
SprayCan,
|
||||
Store,
|
||||
Sun,
|
||||
Trash2,
|
||||
User,
|
||||
UserCog,
|
||||
Users,
|
||||
Wind,
|
||||
X,
|
||||
Zap,
|
||||
} from '@lucide/vue';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
@@ -73,12 +88,27 @@ const iconMap: Record<AppIconName, Component> = {
|
||||
'user-cog': UserCog,
|
||||
eye: Eye,
|
||||
'eye-off': EyeOff,
|
||||
shield: Shield,
|
||||
settings: Settings,
|
||||
droplet: Droplet,
|
||||
headphones: Headphones,
|
||||
'badge-percent': BadgePercent,
|
||||
'message-circle': MessageCircle,
|
||||
phone: Phone,
|
||||
send: Send,
|
||||
sparkles: Sparkles,
|
||||
armchair: Armchair,
|
||||
wind: Wind,
|
||||
car: Car,
|
||||
cog: Cog,
|
||||
spray: SprayCan,
|
||||
zap: Zap,
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: AppIconName;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
strokeWidth?: number;
|
||||
}>(),
|
||||
{
|
||||
@@ -88,7 +118,12 @@ const props = withDefaults(
|
||||
);
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const map = { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-6 w-6' } as const;
|
||||
const map = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-5 w-5',
|
||||
lg: 'h-6 w-6',
|
||||
xl: 'h-8 w-8',
|
||||
} as const;
|
||||
return map[props.size];
|
||||
});
|
||||
|
||||
|
||||
@@ -30,13 +30,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
onKeyStroke('Escape', (event) => {
|
||||
if (!props.open) return;
|
||||
event.preventDefault();
|
||||
emit('close');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label v-if="label" :for="id" class="block text-sm font-medium text-muted">
|
||||
{{ label }}
|
||||
</label>
|
||||
<select
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
:class="ltr ? 'text-left' : ''"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-if="placeholder" disabled value="">{{ placeholder }}</option>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label?: string;
|
||||
id?: string;
|
||||
modelValue: string;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
ltr?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||
|
||||
function onChange(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLSelectElement).value);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label v-if="label" :for="id" class="block text-sm font-medium text-muted">
|
||||
{{ label }}
|
||||
</label>
|
||||
<textarea
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:rows="rows"
|
||||
class="w-full resize-y rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none transition placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
id?: string;
|
||||
rows?: number;
|
||||
}>(),
|
||||
{ rows: 3 },
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useProfileModalStore } from '@/stores/profile-modal';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import { loginRouteWithRedirect } from '@/utils/auth-redirect';
|
||||
|
||||
export type AppNavItem = {
|
||||
key: string;
|
||||
name: string;
|
||||
label: string;
|
||||
icon: AppIconName;
|
||||
to?: string;
|
||||
hash?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export function useAppNav() {
|
||||
const auth = useAuthStore();
|
||||
const tenant = useTenantStore();
|
||||
const profileModal = useProfileModalStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
function requireAuth(path: string) {
|
||||
if (auth.isLoggedIn) {
|
||||
void router.push(path);
|
||||
return;
|
||||
}
|
||||
void router.push(loginRouteWithRedirect(path));
|
||||
}
|
||||
|
||||
const navItems = computed((): AppNavItem[] => {
|
||||
const items: AppNavItem[] = [
|
||||
{
|
||||
key: 'home',
|
||||
to: '/app',
|
||||
name: 'app-home',
|
||||
label: 'خانه',
|
||||
icon: 'home',
|
||||
},
|
||||
{
|
||||
key: 'businesses',
|
||||
to: '/app',
|
||||
hash: '#businesses',
|
||||
name: 'app-home',
|
||||
label: 'کسبوکارها',
|
||||
icon: 'store',
|
||||
},
|
||||
];
|
||||
|
||||
if (auth.isLoggedIn) {
|
||||
items.push({
|
||||
key: 'bookings',
|
||||
to: '/app/bookings',
|
||||
name: 'app-bookings',
|
||||
label: 'رزروها',
|
||||
icon: 'calendar',
|
||||
});
|
||||
|
||||
if (auth.isBusinessOwner) {
|
||||
items.push({
|
||||
key: 'owner',
|
||||
to: '/app/owner',
|
||||
name: 'app-owner',
|
||||
label: 'مدیریت کسبوکار',
|
||||
icon: 'clipboard-list',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'profile',
|
||||
name: 'app-profile',
|
||||
label: 'پروفایل',
|
||||
icon: 'user',
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
key: 'register',
|
||||
name: 'app-register-business',
|
||||
label: 'ثبت کسبوکار',
|
||||
icon: 'plus',
|
||||
onClick: () => requireAuth('/app/register-business'),
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'about',
|
||||
to: '/app',
|
||||
hash: '#about',
|
||||
name: 'app-home',
|
||||
label: 'درباره ما',
|
||||
icon: 'users',
|
||||
});
|
||||
|
||||
if (tenant.isSingleBusiness) {
|
||||
return items.filter((item) => item.key !== 'businesses');
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const mobileNavItems = computed((): AppNavItem[] => {
|
||||
const items = navItems.value.filter((item) => item.key !== 'about');
|
||||
|
||||
if (!auth.isLoggedIn) {
|
||||
items.push({
|
||||
key: 'login',
|
||||
name: 'login',
|
||||
label: 'ورود',
|
||||
icon: 'user',
|
||||
onClick: () => void router.push(loginRouteWithRedirect(route.fullPath)),
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
function isNavActive(item: AppNavItem): boolean {
|
||||
if (item.key === 'profile') {
|
||||
return profileModal.isOpen;
|
||||
}
|
||||
if (item.hash) {
|
||||
return route.name === item.name && route.hash === item.hash;
|
||||
}
|
||||
if (item.name === 'app-home' && !item.hash) {
|
||||
return route.name === 'app-home' && !route.hash;
|
||||
}
|
||||
return route.name === item.name;
|
||||
}
|
||||
|
||||
function navLinkClass(item: AppNavItem, variant: 'mobile' | 'desktop' = 'mobile') {
|
||||
const active = isNavActive(item);
|
||||
|
||||
if (variant === 'desktop') {
|
||||
return active
|
||||
? 'text-brand-600 border-brand-600'
|
||||
: 'text-slate-600 border-transparent hover:text-brand-600 dark:text-slate-300';
|
||||
}
|
||||
|
||||
return active
|
||||
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||
: 'text-muted hover:bg-surface-3 hover:text-text';
|
||||
}
|
||||
|
||||
function goToNavItem(item: AppNavItem) {
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
return;
|
||||
}
|
||||
if (item.to && item.hash) {
|
||||
void router.push({ path: item.to, hash: item.hash.replace('#', '') });
|
||||
return;
|
||||
}
|
||||
if (item.to) {
|
||||
void router.push(item.to);
|
||||
}
|
||||
}
|
||||
|
||||
const mobileNavGridClass = computed(() => {
|
||||
const count = mobileNavItems.value.length;
|
||||
if (count <= 3) return 'grid-cols-3';
|
||||
if (count === 4) return 'grid-cols-4';
|
||||
return 'grid-cols-5';
|
||||
});
|
||||
|
||||
return {
|
||||
auth,
|
||||
route,
|
||||
router,
|
||||
navItems,
|
||||
mobileNavItems,
|
||||
mobileNavGridClass,
|
||||
navLinkClass,
|
||||
goToNavItem,
|
||||
requireAuth,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { nextTick, onMounted, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
export function useLandingHashScroll() {
|
||||
const route = useRoute();
|
||||
|
||||
function scrollToHash(hash: string) {
|
||||
const el = document.getElementById(hash.replace('#', ''));
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.hash,
|
||||
async (hash) => {
|
||||
if (!hash) return;
|
||||
await nextTick();
|
||||
scrollToHash(hash);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (route.hash) {
|
||||
await nextTick();
|
||||
scrollToHash(route.hash);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { loginRouteWithRedirect } from '@/utils/auth-redirect';
|
||||
|
||||
export type LandingNavItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
hash?: string;
|
||||
path?: string;
|
||||
requiresAuth?: boolean;
|
||||
};
|
||||
|
||||
export function useLandingNav() {
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const items: LandingNavItem[] = [
|
||||
{ key: 'home', label: 'خانه', path: '/app' },
|
||||
{ key: 'services', label: 'خدمات', hash: '#services' },
|
||||
{ key: 'bookings', label: 'رزروها', path: '/app/bookings', requiresAuth: true },
|
||||
{ key: 'club', label: 'باشگاه مشتریان', hash: '#club' },
|
||||
{ key: 'about', label: 'درباره ما', hash: '#about' },
|
||||
{ key: 'contact', label: 'تماس با ما', hash: '#contact' },
|
||||
];
|
||||
|
||||
const navItems = computed(() => items);
|
||||
|
||||
function isActive(item: LandingNavItem): boolean {
|
||||
if (item.path === '/app' && !item.hash) {
|
||||
return route.path === '/app' && !route.hash;
|
||||
}
|
||||
if (item.hash) {
|
||||
return route.path === '/app' && route.hash === item.hash;
|
||||
}
|
||||
return route.path === item.path;
|
||||
}
|
||||
|
||||
function goTo(item: LandingNavItem) {
|
||||
if (item.requiresAuth && !auth.isLoggedIn) {
|
||||
void router.push(loginRouteWithRedirect(item.path ?? '/app/bookings'));
|
||||
return;
|
||||
}
|
||||
if (item.path && item.hash) {
|
||||
void router.push({ path: item.path, hash: item.hash.replace('#', '') });
|
||||
return;
|
||||
}
|
||||
if (item.path) {
|
||||
void router.push(item.path);
|
||||
return;
|
||||
}
|
||||
if (item.hash) {
|
||||
void router.push({ path: '/app', hash: item.hash.replace('#', '') });
|
||||
}
|
||||
}
|
||||
|
||||
return { navItems, isActive, goTo, auth, route, router };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export type SiteBrand = {
|
||||
name: string;
|
||||
shortName: string;
|
||||
markLetter: string;
|
||||
tagline: string;
|
||||
heroLead: string;
|
||||
aboutText: string;
|
||||
copyrightName: string;
|
||||
};
|
||||
|
||||
export const MONTIME_BRAND: SiteBrand = {
|
||||
name: 'منتایم',
|
||||
shortName: 'منتایم',
|
||||
markLetter: 'م',
|
||||
tagline: 'رزرو آنلاین خدمات محلی',
|
||||
heroLead:
|
||||
'کسبوکارهای محلی را کشف کنید و در کوتاهترین زمان نوبت خود را رزرو کنید.',
|
||||
aboutText:
|
||||
'منتایم پلتفرم رزرو آنلاین خدمات محلی است. بهترین کسبوکارهای اطراف شما را پیدا کنید و آنلاین نوبت بگیرید.',
|
||||
copyrightName: 'منتایم',
|
||||
};
|
||||
|
||||
export const NIVASH_BRAND: SiteBrand = {
|
||||
name: 'کارواش نیوواش',
|
||||
shortName: 'نیوواش',
|
||||
markLetter: 'ن',
|
||||
tagline: 'تمیزی که میدرخشد',
|
||||
heroLead:
|
||||
'با تجهیزات پیشرفته و مواد شوینده با کیفیت، خودروی شما مثل روز اول بدرخشد.',
|
||||
aboutText:
|
||||
'کارواش نیوواش با تجهیزات پیشرفته و مواد شوینده با کیفیت، بهترین تجربه شستشو و نگهداری خودرو را برای شما فراهم میکند.',
|
||||
copyrightName: 'کارواش نیوواش',
|
||||
};
|
||||
@@ -2,9 +2,8 @@
|
||||
<AppShell>
|
||||
<header class="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<div v-if="brand" class="flex items-center gap-3">
|
||||
<AppLogo variant="horizontal" class="h-7 shrink-0" />
|
||||
<p v-if="subtitle" class="text-sm text-muted">{{ subtitle }}</p>
|
||||
<div v-if="brand" class="flex min-w-0 items-center gap-2">
|
||||
<AppLogo variant="horizontal" class="h-10 shrink-0" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<p v-if="title" class="text-lg font-bold text-text">{{ title }}</p>
|
||||
@@ -13,7 +12,7 @@
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
<main class="flex flex-1 flex-col px-4 pb-8">
|
||||
<main class="mx-auto flex min-h-0 w-full max-w-md flex-1 flex-col overflow-y-auto overscroll-y-contain px-4 pb-8 lg:max-w-lg lg:px-8 lg:pb-12">
|
||||
<slot />
|
||||
</main>
|
||||
</AppShell>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="grid min-h-dvh bg-[#f5f7fa] dark:bg-[#0d1117] lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)]" dir="ltr">
|
||||
<DesktopLoginHeroPanel />
|
||||
|
||||
<div class="flex min-h-dvh flex-col" dir="rtl">
|
||||
<header class="flex items-center justify-between px-6 py-4 lg:px-10">
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
<AppIcon name="user" size="sm" class="text-brand-600 dark:text-brand-500" />
|
||||
<span>{{ headerLabel }}</span>
|
||||
</div>
|
||||
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
<main class="flex flex-1 items-center justify-center px-6 pb-10 pt-2 lg:px-10 lg:pb-14">
|
||||
<div
|
||||
class="w-full max-w-[420px] rounded-2xl border border-slate-200/80 bg-white p-6 shadow-[0_18px_45px_-20px_rgba(15,23,42,0.25)] dark:border-slate-700 dark:bg-[#121a24] lg:p-8"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import DesktopLoginHeroPanel from '@/components/auth/DesktopLoginHeroPanel.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
||||
import { usePlatformSettingsStore } from '@/stores/platform-settings';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
headerLabel?: string;
|
||||
}>(),
|
||||
{ headerLabel: 'ورود به حساب کاربری' },
|
||||
);
|
||||
|
||||
const platformSettings = usePlatformSettingsStore();
|
||||
const tenant = useTenantStore();
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([platformSettings.load(), tenant.bootstrap()]);
|
||||
});
|
||||
</script>
|
||||
␍
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex min-h-dvh flex-col bg-white dark:bg-[#0d1117]">
|
||||
<DesktopSiteHeader />
|
||||
|
||||
<main :class="mainClass">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
<FloatingChatButton />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import DesktopSiteHeader from '@/components/shell/DesktopSiteHeader.vue';
|
||||
import FloatingChatButton from '@/components/shell/FloatingChatButton.vue';
|
||||
import SiteFooter from '@/components/shell/SiteFooter.vue';
|
||||
import { useLandingHashScroll } from '@/composables/useLandingHashScroll';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
contentWidth?: 'default' | 'narrow' | 'full';
|
||||
}>(),
|
||||
{ contentWidth: 'default' },
|
||||
);
|
||||
|
||||
const mainClass = computed(() => {
|
||||
if (props.contentWidth === 'full') {
|
||||
return 'flex-1';
|
||||
}
|
||||
if (props.contentWidth === 'narrow') {
|
||||
return 'mx-auto w-full max-w-lg flex-1 px-6 py-10 lg:px-8 lg:py-14';
|
||||
}
|
||||
return 'mx-auto w-full max-w-[1200px] flex-1 px-6 py-8 lg:px-8 lg:py-10';
|
||||
});
|
||||
|
||||
useLandingHashScroll();
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<DesktopSiteLayout v-if="isDesktop">
|
||||
<slot />
|
||||
</DesktopSiteLayout>
|
||||
|
||||
<WebAppLayout v-else>
|
||||
<slot />
|
||||
</WebAppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import DesktopSiteLayout from '@/layouts/DesktopSiteLayout.vue';
|
||||
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<DesktopLoginLayout v-if="isDesktop" :header-label="desktopHeaderLabel">
|
||||
<slot />
|
||||
</DesktopLoginLayout>
|
||||
|
||||
<AuthLayout
|
||||
v-else
|
||||
:title="title"
|
||||
:subtitle="subtitle"
|
||||
:brand="brand"
|
||||
>
|
||||
<slot />
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { computed, onMounted } from 'vue';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import DesktopLoginLayout from '@/layouts/DesktopLoginLayout.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
brand?: boolean;
|
||||
}>();
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
|
||||
const desktopHeaderLabel = computed(
|
||||
() => props.subtitle ?? props.title ?? 'ورود به حساب کاربری',
|
||||
);
|
||||
|
||||
const tenant = useTenantStore();
|
||||
|
||||
onMounted(() => {
|
||||
void tenant.bootstrap();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,87 +1,72 @@
|
||||
<template>
|
||||
<AppShell>
|
||||
<header
|
||||
class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface/95 px-4 py-3 backdrop-blur"
|
||||
class="z-20 flex shrink-0 items-center justify-between border-b border-border bg-surface/95 px-4 py-3 backdrop-blur"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<AppLogo variant="horizontal" class="h-10 shrink-0" />
|
||||
<p v-if="auth.user" class="text-xs text-muted" dir="ltr">{{ auth.user.mobile }}</p>
|
||||
<AppLogo variant="horizontal" class="h-9 shrink-0" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-xl px-3 py-2 text-sm text-muted hover:bg-surface-3 hover:text-text"
|
||||
@click="onLogout"
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
<AuthHeaderAction />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 px-4 py-4">
|
||||
<slot />
|
||||
<main class="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-y-contain lg:px-0">
|
||||
<div class="mx-auto min-w-0 w-full max-w-full px-4 py-4 lg:max-w-7xl lg:px-8 lg:py-0">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav
|
||||
class="sticky bottom-0 border-t border-border bg-surface/95 px-2 py-2 backdrop-blur"
|
||||
class="z-20 shrink-0 border-t border-border bg-surface/95 px-2 py-2 backdrop-blur"
|
||||
>
|
||||
<div
|
||||
class="grid gap-1"
|
||||
:class="navItems.length === 4 ? 'grid-cols-4' : navItems.length === 5 ? 'grid-cols-5' : 'grid-cols-3'"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex flex-col items-center rounded-xl px-2 py-2 text-xs transition"
|
||||
:class="
|
||||
route.name === item.name
|
||||
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||
: 'text-muted hover:bg-surface-3 hover:text-text'
|
||||
"
|
||||
>
|
||||
<AppIcon :name="item.icon" size="lg" />
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
<div class="grid gap-1" :class="mobileNavGridClass">
|
||||
<template v-for="item in mobileNavItems" :key="`mobile-${item.key}`">
|
||||
<ProfileDropdown
|
||||
v-if="item.key === 'profile'"
|
||||
variant="bottom-nav"
|
||||
/>
|
||||
<RouterLink
|
||||
v-else-if="item.to && !item.onClick"
|
||||
:to="item.hash ? { path: item.to, hash: item.hash.replace('#', '') } : item.to"
|
||||
class="flex flex-col items-center rounded-xl px-2 py-2 text-xs transition"
|
||||
:class="navLinkClass(item)"
|
||||
>
|
||||
<AppIcon :name="item.icon" size="lg" />
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="flex flex-col items-center rounded-xl px-2 py-2 text-xs transition"
|
||||
:class="navLinkClass(item)"
|
||||
@click="goToNavItem(item)"
|
||||
>
|
||||
<AppIcon :name="item.icon" size="lg" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { onMounted } from 'vue';
|
||||
import AppShell from '@/components/shell/AppShell.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
||||
import type { NavItem } from '@/types/icons';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import ProfileDropdown from '@/components/profile/ProfileDropdown.vue';
|
||||
import AuthHeaderAction from '@/components/shell/AuthHeaderAction.vue';
|
||||
import { useAppNav } from '@/composables/useAppNav';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { mobileNavItems, mobileNavGridClass, navLinkClass, goToNavItem } = useAppNav();
|
||||
const tenant = useTenantStore();
|
||||
|
||||
const navItems = computed((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
{ to: '/app', name: 'app-home', label: 'خانه', icon: 'home' },
|
||||
{ to: '/app/bookings', name: 'app-bookings', label: 'رزروها', icon: 'calendar' },
|
||||
{ to: '/app/profile', name: 'app-profile', label: 'پروفایل', icon: 'user' },
|
||||
];
|
||||
if (auth.isBusinessOwner) {
|
||||
items.splice(2, 0, {
|
||||
to: '/app/owner',
|
||||
name: 'app-owner',
|
||||
label: 'کسبوکار',
|
||||
icon: 'store',
|
||||
});
|
||||
}
|
||||
return items;
|
||||
onMounted(() => {
|
||||
void tenant.bootstrap();
|
||||
});
|
||||
|
||||
function onLogout() {
|
||||
auth.logout();
|
||||
router.push({ name: 'login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
import './styles/fonts.css';
|
||||
import './styles/landing.css';
|
||||
import './styles/main.css';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
+8
-5
@@ -1,10 +1,12 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useProfileModalStore } from '@/stores/profile-modal';
|
||||
import { loginRouteWithRedirect } from '@/utils/auth-redirect';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/login' },
|
||||
{ path: '/', redirect: '/app' },
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
@@ -21,13 +23,11 @@ const router = createRouter({
|
||||
path: '/app',
|
||||
name: 'app-home',
|
||||
component: () => import('@/views/app/AppHomeView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/businesses/:id',
|
||||
name: 'app-business-detail',
|
||||
component: () => import('@/views/app/AppBusinessDetailView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/app/bookings',
|
||||
@@ -38,8 +38,11 @@ const router = createRouter({
|
||||
{
|
||||
path: '/app/profile',
|
||||
name: 'app-profile',
|
||||
component: () => import('@/views/app/AppProfileView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
redirect: () => {
|
||||
useProfileModalStore().open();
|
||||
return { name: 'app-home' };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/app/register-business',
|
||||
@@ -65,7 +68,7 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isLoggedIn) {
|
||||
return { name: 'login' };
|
||||
return loginRouteWithRedirect(to.fullPath);
|
||||
}
|
||||
|
||||
if (to.meta.requiresBusinessOwner && !auth.isBusinessOwner && !auth.isAdmin) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { fetchPlatformSettings } from '@/api/platform-settings';
|
||||
import { uploadUrl } from '@/utils/media';
|
||||
|
||||
const DEFAULT_LOGIN_HERO = '/landing/hero-car-wash.jpg';
|
||||
|
||||
export const usePlatformSettingsStore = defineStore('platformSettings', () => {
|
||||
const loginHeroImageUrl = ref<string | null>(null);
|
||||
const loaded = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const loginHeroSrc = computed(
|
||||
() => uploadUrl(loginHeroImageUrl.value) || DEFAULT_LOGIN_HERO,
|
||||
);
|
||||
|
||||
async function load(force = false) {
|
||||
if (loaded.value && !force) return;
|
||||
if (loading.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchPlatformSettings();
|
||||
loginHeroImageUrl.value = data.loginHeroImageUrl;
|
||||
loaded.value = true;
|
||||
} catch {
|
||||
loginHeroImageUrl.value = null;
|
||||
loaded.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loginHeroImageUrl,
|
||||
loginHeroSrc,
|
||||
loaded,
|
||||
loading,
|
||||
load,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export const useProfileModalStore = defineStore('profileModal', () => {
|
||||
const isOpen = ref(false);
|
||||
|
||||
function open() {
|
||||
isOpen.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
|
||||
return { isOpen, open, close, toggle };
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { fetchBusinessCategories } from '@/api/business-categories';
|
||||
import { fetchBusiness, fetchBusinesses } from '@/api/businesses';
|
||||
import type { Business } from '@/api/types';
|
||||
import { MONTIME_BRAND, NIVASH_BRAND, type SiteBrand } from '@/constants/site-branding';
|
||||
|
||||
export const useTenantStore = defineStore('tenant', () => {
|
||||
const loaded = ref(false);
|
||||
const loading = ref(false);
|
||||
const isSingleBusiness = ref(false);
|
||||
const singleBusiness = ref<Business | null>(null);
|
||||
|
||||
const brand = computed<SiteBrand>(() => {
|
||||
const base = isSingleBusiness.value ? NIVASH_BRAND : MONTIME_BRAND;
|
||||
|
||||
if (isSingleBusiness.value && singleBusiness.value) {
|
||||
return {
|
||||
...base,
|
||||
name: singleBusiness.value.title,
|
||||
shortName: singleBusiness.value.title,
|
||||
copyrightName: singleBusiness.value.title,
|
||||
aboutText:
|
||||
singleBusiness.value.description?.trim() || base.aboutText,
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
});
|
||||
|
||||
const singleBusinessId = computed(() => singleBusiness.value?.id ?? null);
|
||||
|
||||
function syncSingleBusiness(business: Business) {
|
||||
if (!isSingleBusiness.value) return;
|
||||
singleBusiness.value = business;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
if (loaded.value || loading.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [categories, businesses] = await Promise.all([
|
||||
fetchBusinessCategories(),
|
||||
fetchBusinesses({ isActive: true }),
|
||||
]);
|
||||
|
||||
const activeCategories = categories.filter((c) => c.isActive);
|
||||
isSingleBusiness.value =
|
||||
activeCategories.length === 1 && businesses.length === 1;
|
||||
|
||||
if (isSingleBusiness.value) {
|
||||
singleBusiness.value = await fetchBusiness(businesses[0].id);
|
||||
} else {
|
||||
singleBusiness.value = null;
|
||||
}
|
||||
} catch {
|
||||
isSingleBusiness.value = false;
|
||||
singleBusiness.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loaded,
|
||||
loading,
|
||||
isSingleBusiness,
|
||||
singleBusiness,
|
||||
singleBusinessId,
|
||||
brand,
|
||||
bootstrap,
|
||||
syncSingleBusiness,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
@font-face {
|
||||
font-family: IRANYekanX;
|
||||
src: url('../assets/fonts/IRANYekanXFaNum-Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: IRANYekanX;
|
||||
src: url('../assets/fonts/IRANYekanXFaNum-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: IRANYekanX;
|
||||
src: url('../assets/fonts/IRANYekanXFaNum-Regular.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: IRANYekanX;
|
||||
src: url('../assets/fonts/IRANYekanXFaNum-Bold.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: IRANYekanX;
|
||||
src: url('../assets/fonts/IRANYekanXFaNum-Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: IRANYekanX;
|
||||
src: url('../assets/fonts/IRANYekanXFaNum-Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: IRANYekanX;
|
||||
src: url('../assets/fonts/IRANYekanXFaNum-ExtraBlack.woff2') format('woff2');
|
||||
font-weight: 950;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.landing-hero-blob {
|
||||
clip-path: url(#landing-hero-wave);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@supports not (clip-path: url(#landing-hero-wave)) {
|
||||
.landing-hero-blob {
|
||||
clip-path: polygon(
|
||||
0% 0%,
|
||||
100% 0%,
|
||||
100% 78%,
|
||||
94% 84%,
|
||||
86% 90%,
|
||||
76% 95%,
|
||||
64% 98%,
|
||||
50% 100%,
|
||||
36% 98%,
|
||||
24% 93%,
|
||||
12% 86%,
|
||||
0% 80%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.landing-hero-dots {
|
||||
background-image: radial-gradient(circle, rgb(0 150 64 / 0.16) 1.5px, transparent 1.5px);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.landing-hero-lines {
|
||||
background-image: repeating-linear-gradient(
|
||||
-14deg,
|
||||
transparent,
|
||||
transparent 7px,
|
||||
rgb(0 150 64 / 0.07) 7px,
|
||||
rgb(0 150 64 / 0.07) 8px
|
||||
);
|
||||
}
|
||||
+30
-37
@@ -2,50 +2,43 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f8fafc;
|
||||
--color-surface-3: #f1f5f9;
|
||||
--color-border: #e2e8f0;
|
||||
--color-text: #0f172a;
|
||||
--color-muted: #64748b;
|
||||
--color-placeholder: rgb(100 116 139 / 0.35);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-surface: #0f1419;
|
||||
--color-surface-2: #1a2332;
|
||||
--color-surface-3: #243044;
|
||||
--color-border: #334155;
|
||||
--color-text: #f1f5f9;
|
||||
--color-muted: #94a3b8;
|
||||
--color-placeholder: rgb(148 163 184 / 0.35);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f8fafc;
|
||||
--color-surface-3: #f1f5f9;
|
||||
--color-border: #e2e8f0;
|
||||
--color-text: #0f172a;
|
||||
--color-muted: #64748b;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-surface: #0f1419;
|
||||
--color-surface-2: #1a2332;
|
||||
--color-surface-3: #243044;
|
||||
--color-border: #334155;
|
||||
--color-text: #f1f5f9;
|
||||
--color-muted: #94a3b8;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply antialiased;
|
||||
@apply antialiased font-sans;
|
||||
font-family: IRANYekanX, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-surface-2 font-sans text-text;
|
||||
@apply bg-surface-2 text-text;
|
||||
}
|
||||
|
||||
#app {
|
||||
@apply min-h-dvh;
|
||||
}
|
||||
|
||||
select {
|
||||
@apply text-text;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark select {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
select option {
|
||||
background-color: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -23,7 +23,22 @@ export type AppIconName =
|
||||
| 'trash'
|
||||
| 'user-cog'
|
||||
| 'eye'
|
||||
| 'eye-off';
|
||||
| 'eye-off'
|
||||
| 'shield'
|
||||
| 'settings'
|
||||
| 'droplet'
|
||||
| 'headphones'
|
||||
| 'badge-percent'
|
||||
| 'message-circle'
|
||||
| 'phone'
|
||||
| 'send'
|
||||
| 'sparkles'
|
||||
| 'armchair'
|
||||
| 'wind'
|
||||
| 'car'
|
||||
| 'cog'
|
||||
| 'spray'
|
||||
| 'zap';
|
||||
|
||||
export type NavItem = {
|
||||
to: string;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
export type LandingServiceItem = {
|
||||
id: string;
|
||||
businessId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
durationLabel: string | null;
|
||||
icon: AppIconName;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { LocationQuery } from 'vue-router';
|
||||
|
||||
export function sanitizeRedirectPath(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function redirectFromQuery(query: LocationQuery, fallback = '/app'): string {
|
||||
return sanitizeRedirectPath(query.redirect) ?? fallback;
|
||||
}
|
||||
|
||||
export function loginRouteWithRedirect(redirectPath: string) {
|
||||
return {
|
||||
name: 'login' as const,
|
||||
query: { redirect: redirectPath },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Booking, BookingStatus } from '@/api/types';
|
||||
|
||||
export type BookingListEntry = {
|
||||
key: string;
|
||||
bookings: Booking[];
|
||||
};
|
||||
|
||||
export function groupBookingsForDisplay(bookings: Booking[]): BookingListEntry[] {
|
||||
const seenGroupIds = new Set<string>();
|
||||
const entries: BookingListEntry[] = [];
|
||||
|
||||
for (const booking of bookings) {
|
||||
const groupId = booking.bookingGroupId;
|
||||
if (groupId) {
|
||||
if (seenGroupIds.has(groupId)) continue;
|
||||
seenGroupIds.add(groupId);
|
||||
entries.push({
|
||||
key: groupId,
|
||||
bookings: bookings.filter((item) => item.bookingGroupId === groupId),
|
||||
});
|
||||
} else {
|
||||
entries.push({ key: booking.id, bookings: [booking] });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function groupBookingStatus(
|
||||
bookings: Booking[],
|
||||
): BookingStatus | 'mixed' {
|
||||
const statuses = new Set(bookings.map((booking) => booking.status));
|
||||
if (statuses.size === 1) {
|
||||
return bookings[0]?.status ?? 'pending';
|
||||
}
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
export function serviceTitleForBooking(booking: Booking): string {
|
||||
return booking.businessService?.serviceTemplate?.title ?? 'خدمت';
|
||||
}
|
||||
|
||||
export function bookingClassTitle(booking: Booking): string | null {
|
||||
return booking.serviceClass?.title?.trim() || null;
|
||||
}
|
||||
@@ -126,3 +126,61 @@ export function clampJalaliDay(parts: JalaliDateTimeParts): JalaliDateTimeParts
|
||||
jd: Math.min(Math.max(1, parts.jd), maxDay),
|
||||
};
|
||||
}
|
||||
|
||||
export const PERSIAN_WEEKDAY_NAMES = [
|
||||
'شنبه',
|
||||
'یکشنبه',
|
||||
'دوشنبه',
|
||||
'سهشنبه',
|
||||
'چهارشنبه',
|
||||
'پنجشنبه',
|
||||
'جمعه',
|
||||
] as const;
|
||||
|
||||
export function persianWeekdayName(date: Date): string {
|
||||
const persianIndex = (date.getDay() + 1) % 7;
|
||||
return PERSIAN_WEEKDAY_NAMES[persianIndex] ?? '';
|
||||
}
|
||||
|
||||
export function formatJalaliDateShort(parts: {
|
||||
jy: number;
|
||||
jm: number;
|
||||
jd: number;
|
||||
}): string {
|
||||
const month = JALALI_MONTH_NAMES[parts.jm - 1] ?? String(parts.jm);
|
||||
return `${parts.jd} ${month}`;
|
||||
}
|
||||
|
||||
export function formatJalaliDateLong(parts: {
|
||||
jy: number;
|
||||
jm: number;
|
||||
jd: number;
|
||||
}): string {
|
||||
const date = jalaliPartsToDate({ ...parts, hour: 0, minute: 0 });
|
||||
const month = JALALI_MONTH_NAMES[parts.jm - 1] ?? String(parts.jm);
|
||||
return `${persianWeekdayName(date)} ${parts.jd} ${month} ${parts.jy}`;
|
||||
}
|
||||
|
||||
export function isSameJalaliDay(
|
||||
a: { jy: number; jm: number; jd: number },
|
||||
b: { jy: number; jm: number; jd: number },
|
||||
): boolean {
|
||||
return a.jy === b.jy && a.jm === b.jm && a.jd === b.jd;
|
||||
}
|
||||
|
||||
export function addDaysToJalali(
|
||||
parts: { jy: number; jm: number; jd: number },
|
||||
days: number,
|
||||
): { jy: number; jm: number; jd: number } {
|
||||
const date = jalaliPartsToDate({ ...parts, hour: 0, minute: 0 });
|
||||
date.setDate(date.getDate() + days);
|
||||
const jalali = gregorianToJalaliParts(date);
|
||||
return { jy: jalali.jy, jm: jalali.jm, jd: jalali.jd };
|
||||
}
|
||||
|
||||
export function generateJalaliDateRange(
|
||||
start: { jy: number; jm: number; jd: number },
|
||||
count: number,
|
||||
): { jy: number; jm: number; jd: number }[] {
|
||||
return Array.from({ length: count }, (_, index) => addDaysToJalali(start, index));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { fetchBusinessServices } from '@/api/business-services';
|
||||
import { fetchBusinesses } from '@/api/businesses';
|
||||
import type { BusinessService } from '@/api/types';
|
||||
import type { LandingServiceItem } from '@/types/landing';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
const SERVICE_ICONS: AppIconName[] = ['armchair', 'wind', 'car', 'cog', 'sparkles'];
|
||||
|
||||
function pickServiceIcon(index: number, title: string): AppIconName {
|
||||
const lower = title.toLowerCase();
|
||||
if (/داخل|صندلی/.test(lower)) return 'armchair';
|
||||
if (/موتور|engine/.test(lower)) return 'cog';
|
||||
if (/واکس|براق|polish/.test(lower)) return 'sparkles';
|
||||
if (/بدنه|شست|کارواش|روش/.test(lower)) return 'car';
|
||||
if (/جارو|vacuum/.test(lower)) return 'wind';
|
||||
return SERVICE_ICONS[index % SERVICE_ICONS.length];
|
||||
}
|
||||
|
||||
function toLandingItem(
|
||||
service: BusinessService,
|
||||
businessId: string,
|
||||
index: number,
|
||||
): LandingServiceItem {
|
||||
const title = service.serviceTemplate?.title ?? 'خدمت';
|
||||
const minutes =
|
||||
service.durationMinutes ?? service.serviceTemplate?.defaultDurationMinutes ?? null;
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
businessId,
|
||||
title,
|
||||
description: service.serviceTemplate?.description ?? null,
|
||||
durationLabel: minutes ? `${minutes} دقیقه` : null,
|
||||
icon: pickServiceIcon(index, title),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLandingServices(
|
||||
businessId?: string,
|
||||
): Promise<LandingServiceItem[]> {
|
||||
if (businessId) {
|
||||
const services = await fetchBusinessServices(businessId, { activeOnly: true });
|
||||
return services.map((service, index) => toLandingItem(service, businessId, index));
|
||||
}
|
||||
|
||||
const businesses = await fetchBusinesses({ isActive: true });
|
||||
const collected: LandingServiceItem[] = [];
|
||||
|
||||
for (const business of businesses) {
|
||||
const services = await fetchBusinessServices(business.id, { activeOnly: true });
|
||||
for (const service of services) {
|
||||
collected.push(toLandingItem(service, business.id, collected.length));
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
+46
-162
@@ -1,192 +1,76 @@
|
||||
import type { BusinessService, BusinessServiceClass } from '@/api/types';
|
||||
import { formatDuration, formatPriceToman } from '@/utils/format';
|
||||
|
||||
export function activeServiceClasses(classes: BusinessServiceClass[]): BusinessServiceClass[] {
|
||||
return [...classes]
|
||||
.filter((item) => item.isActive)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.title.localeCompare(b.title, 'fa'));
|
||||
}
|
||||
|
||||
export function usesServiceClasses(
|
||||
classesEnabled: boolean,
|
||||
classes: BusinessServiceClass[],
|
||||
): boolean {
|
||||
return classesEnabled && activeServiceClasses(classes).length > 0;
|
||||
}
|
||||
import { formatPriceToman } from '@/utils/format';
|
||||
|
||||
export function getClassPriceForService(
|
||||
service: BusinessService,
|
||||
classId: string,
|
||||
) {
|
||||
return service.classPrices?.find(
|
||||
(item) => item.classId === classId && item.isActive,
|
||||
);
|
||||
return service.classPrices?.find((item) => item.classId === classId && item.isActive);
|
||||
}
|
||||
|
||||
export function serviceHasActiveClassPrices(
|
||||
export function resolveServiceMinutes(
|
||||
service: BusinessService,
|
||||
classes: BusinessServiceClass[],
|
||||
): boolean {
|
||||
const activeIds = new Set(activeServiceClasses(classes).map((item) => item.id));
|
||||
return (service.classPrices ?? []).some(
|
||||
(item) => item.isActive && activeIds.has(item.classId),
|
||||
);
|
||||
perClass: boolean,
|
||||
classId?: string,
|
||||
): number | null {
|
||||
if (perClass && classId) {
|
||||
const classPrice = getClassPriceForService(service, classId);
|
||||
if (classPrice?.durationMinutes != null) return classPrice.durationMinutes;
|
||||
}
|
||||
if (service.durationMinutes != null) return service.durationMinutes;
|
||||
return service.serviceTemplate?.defaultDurationMinutes ?? null;
|
||||
}
|
||||
|
||||
export function isServiceBookableForClass(
|
||||
export function resolveServicePrice(
|
||||
service: BusinessService,
|
||||
classId: string,
|
||||
): boolean {
|
||||
return service.isActive && !!getClassPriceForService(service, classId);
|
||||
}
|
||||
|
||||
export function isServiceBookable(
|
||||
service: BusinessService,
|
||||
classesEnabled: boolean,
|
||||
classes: BusinessServiceClass[],
|
||||
): boolean {
|
||||
if (!service.isActive) return false;
|
||||
if (!usesServiceClasses(classesEnabled, classes)) return true;
|
||||
return serviceHasActiveClassPrices(service, classes);
|
||||
perClass: boolean,
|
||||
classId?: string,
|
||||
): string {
|
||||
if (perClass && classId) {
|
||||
const classPrice = getClassPriceForService(service, classId);
|
||||
if (classPrice) return classPrice.price;
|
||||
}
|
||||
return service.price;
|
||||
}
|
||||
|
||||
export function servicePriceLabel(
|
||||
service: BusinessService,
|
||||
classesEnabled: boolean,
|
||||
classes: BusinessServiceClass[],
|
||||
classId?: string | null,
|
||||
perClass: boolean,
|
||||
_classes: BusinessServiceClass[],
|
||||
classId?: string,
|
||||
): string {
|
||||
if (classId) {
|
||||
const price = resolvedServicePrice(service, classId);
|
||||
return price ? `${formatPriceToman(price)} تومان` : 'قیمت تعیین نشده';
|
||||
}
|
||||
|
||||
if (!usesServiceClasses(classesEnabled, classes)) {
|
||||
return `${formatPriceToman(service.price)} تومان`;
|
||||
}
|
||||
|
||||
const activeClasses = activeServiceClasses(classes);
|
||||
const prices = activeClasses
|
||||
.map((item) => getClassPriceForService(service, item.id))
|
||||
.filter(Boolean)
|
||||
.map((item) => Number.parseFloat(item!.price));
|
||||
|
||||
if (!prices.length) return 'قیمت تعیین نشده';
|
||||
const min = Math.min(...prices);
|
||||
const max = Math.max(...prices);
|
||||
if (min === max) {
|
||||
return `${formatPriceToman(String(min))} تومان`;
|
||||
}
|
||||
return `از ${formatPriceToman(String(min))} تومان`;
|
||||
return `${formatPriceToman(resolveServicePrice(service, perClass, classId))} تومان`;
|
||||
}
|
||||
|
||||
export function serviceDurationLabel(
|
||||
service: BusinessService,
|
||||
classesEnabled: boolean,
|
||||
classes: BusinessServiceClass[],
|
||||
classId?: string | null,
|
||||
perClass: boolean,
|
||||
_classes: BusinessServiceClass[],
|
||||
classId?: string,
|
||||
): string {
|
||||
if (classId) {
|
||||
const classPrice = getClassPriceForService(service, classId);
|
||||
return formatDuration(
|
||||
classPrice?.durationMinutes ?? service.durationMinutes,
|
||||
service.serviceTemplate?.defaultDurationMinutes ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
if (!usesServiceClasses(classesEnabled, classes)) {
|
||||
return formatDuration(
|
||||
service.durationMinutes,
|
||||
service.serviceTemplate?.defaultDurationMinutes ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const activeClasses = activeServiceClasses(classes);
|
||||
const durations = activeClasses
|
||||
.map((item) => getClassPriceForService(service, item.id))
|
||||
.filter(Boolean)
|
||||
.map((item) =>
|
||||
item!.durationMinutes ?? service.durationMinutes ?? service.serviceTemplate?.defaultDurationMinutes,
|
||||
)
|
||||
.filter((value): value is number => value != null && value > 0);
|
||||
|
||||
if (!durations.length) {
|
||||
return formatDuration(
|
||||
service.durationMinutes,
|
||||
service.serviceTemplate?.defaultDurationMinutes ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const min = Math.min(...durations);
|
||||
const max = Math.max(...durations);
|
||||
if (min === max) return `${min} دقیقه`;
|
||||
return `${min}–${max} دقیقه`;
|
||||
const minutes = resolveServiceMinutes(service, perClass, classId);
|
||||
if (minutes == null) return '—';
|
||||
return `${minutes} دقیقه`;
|
||||
}
|
||||
|
||||
export function resolvedServicePrice(
|
||||
service: BusinessService,
|
||||
classId: string,
|
||||
): string | null {
|
||||
const classPrice = getClassPriceForService(service, classId);
|
||||
return classPrice?.price ?? null;
|
||||
}
|
||||
|
||||
export function resolveServiceDurationMinutes(
|
||||
service: BusinessService,
|
||||
classesEnabled: boolean,
|
||||
classes: BusinessServiceClass[],
|
||||
classId?: string | null,
|
||||
): number {
|
||||
if (classId) {
|
||||
const classPrice = getClassPriceForService(service, classId);
|
||||
const fromClass =
|
||||
classPrice?.durationMinutes ?? service.durationMinutes ?? service.serviceTemplate?.defaultDurationMinutes;
|
||||
if (fromClass != null && fromClass > 0) return fromClass;
|
||||
}
|
||||
|
||||
if (usesServiceClasses(classesEnabled, classes) && classId) {
|
||||
const classPrice = getClassPriceForService(service, classId);
|
||||
const value =
|
||||
classPrice?.durationMinutes ??
|
||||
service.durationMinutes ??
|
||||
service.serviceTemplate?.defaultDurationMinutes;
|
||||
if (value != null && value > 0) return value;
|
||||
}
|
||||
|
||||
const fallback =
|
||||
service.durationMinutes ?? service.serviceTemplate?.defaultDurationMinutes;
|
||||
return fallback != null && fallback > 0 ? fallback : 30;
|
||||
}
|
||||
|
||||
export function totalDurationForServices(
|
||||
export function sumServicesPrice(
|
||||
services: BusinessService[],
|
||||
classesEnabled: boolean,
|
||||
classes: BusinessServiceClass[],
|
||||
classId?: string | null,
|
||||
perClass: boolean,
|
||||
classId?: string,
|
||||
): number {
|
||||
return services.reduce(
|
||||
(sum, service) =>
|
||||
sum + resolveServiceDurationMinutes(service, classesEnabled, classes, classId),
|
||||
0,
|
||||
);
|
||||
return services.reduce((total, service) => {
|
||||
const price = Number.parseFloat(resolveServicePrice(service, perClass, classId));
|
||||
return total + (Number.isFinite(price) ? price : 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function totalPriceForServices(
|
||||
export function sumServicesDurationMinutes(
|
||||
services: BusinessService[],
|
||||
classesEnabled: boolean,
|
||||
classes: BusinessServiceClass[],
|
||||
classId?: string | null,
|
||||
): number | null {
|
||||
if (usesServiceClasses(classesEnabled, classes)) {
|
||||
if (!classId) return null;
|
||||
let total = 0;
|
||||
for (const service of services) {
|
||||
const price = resolvedServicePrice(service, classId);
|
||||
if (!price) return null;
|
||||
total += Number.parseFloat(price);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
return services.reduce((sum, service) => sum + Number.parseFloat(service.price), 0);
|
||||
perClass: boolean,
|
||||
classId?: string,
|
||||
): number {
|
||||
return services.reduce((total, service) => {
|
||||
const minutes = resolveServiceMinutes(service, perClass, classId);
|
||||
return total + (minutes ?? 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { User } from '@/api/auth';
|
||||
|
||||
export function userDisplayName(user: User | null | undefined): string {
|
||||
const name = user?.fullName?.trim();
|
||||
if (name) return name;
|
||||
if (user?.mobile) return user.mobile;
|
||||
return 'پروفایل';
|
||||
}
|
||||
|
||||
export function userDisplayNameIsMobile(user: User | null | undefined): boolean {
|
||||
return !user?.fullName?.trim() && Boolean(user?.mobile);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<WebAppLayout>
|
||||
<ResponsiveAppLayout>
|
||||
<h1 class="mb-1 text-xl font-bold text-text">رزروها</h1>
|
||||
<p class="mb-4 text-sm text-muted">
|
||||
{{
|
||||
auth.isBusinessOwner
|
||||
? 'رزروهای شما و درخواستهای مشتریان برای کسبوکار شما'
|
||||
? 'رزروهای شما و درخواستهای مشتریان'
|
||||
: 'وضعیت رزروهای ثبتشده'
|
||||
}}
|
||||
</p>
|
||||
@@ -16,65 +16,44 @@
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<ListCardSkeleton v-if="loading" list-class="space-y-3" />
|
||||
<ListCardSkeleton
|
||||
v-if="loading"
|
||||
:count="8"
|
||||
list-class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="!bookings.length"
|
||||
v-else-if="!bookingEntries.length"
|
||||
class="rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
|
||||
>
|
||||
هنوز رزروی ندارید. از صفحه کسبوکارها یک خدمت را رزرو کنید.
|
||||
<RouterLink
|
||||
to="/app"
|
||||
class="mt-3 inline-block text-brand-600 dark:text-brand-500"
|
||||
>
|
||||
مشاهده کسبوکارها
|
||||
هنوز رزروی ندارید.
|
||||
<RouterLink to="/app" class="mt-3 block text-brand-600 dark:text-brand-500">
|
||||
بازگشت به خانه
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<section v-if="myBookings.length">
|
||||
<h2 class="mb-3 text-sm font-semibold text-text">رزروهای من</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="b in myBookings"
|
||||
:key="`mine-${b.id}`"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<BookingItem
|
||||
:booking="b"
|
||||
:acting-id="actingId"
|
||||
:show-customer="false"
|
||||
:can-confirm="false"
|
||||
:can-cancel="canCancel(b)"
|
||||
@confirm="onConfirm"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="incomingBookings.length">
|
||||
<h2 class="mb-3 text-sm font-semibold text-text">رزروهای کسبوکار من</h2>
|
||||
<ul class="space-y-3">
|
||||
<li
|
||||
v-for="b in incomingBookings"
|
||||
:key="`incoming-${b.id}`"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<BookingItem
|
||||
:booking="b"
|
||||
:acting-id="actingId"
|
||||
:show-customer="true"
|
||||
:can-confirm="b.status === 'pending'"
|
||||
:can-cancel="canCancel(b)"
|
||||
@confirm="onConfirm"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="entry in bookingEntries"
|
||||
:key="entry.key"
|
||||
class="flex h-full flex-col rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<BookingItem
|
||||
class="h-full w-full"
|
||||
:bookings="entry.bookings"
|
||||
:acting="isEntryActing(entry)"
|
||||
:show-customer="auth.isBusinessOwner"
|
||||
:can-confirm="canConfirmEntry(entry.bookings)"
|
||||
:can-cancel="canCancelEntry(entry.bookings)"
|
||||
@confirm="onConfirm"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</WebAppLayout>
|
||||
</ResponsiveAppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -84,31 +63,51 @@ import { ApiError } from '@/api/client';
|
||||
import type { Booking } from '@/api/types';
|
||||
import BookingItem from '@/components/booking/BookingItem.vue';
|
||||
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
||||
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
||||
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
groupBookingsForDisplay,
|
||||
type BookingListEntry,
|
||||
} from '@/utils/booking-groups';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const bookings = ref<Booking[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const actingId = ref<string | null>(null);
|
||||
const actingKey = ref<string | null>(null);
|
||||
|
||||
const myBookings = computed(() =>
|
||||
bookings.value.filter((b) => b.customerId === auth.user?.id),
|
||||
);
|
||||
const bookingEntries = computed(() => groupBookingsForDisplay(bookings.value));
|
||||
|
||||
const incomingBookings = computed(() =>
|
||||
bookings.value.filter(
|
||||
(b) =>
|
||||
b.business?.ownerId === auth.user?.id && b.customerId !== auth.user?.id,
|
||||
),
|
||||
);
|
||||
|
||||
function canCancel(b: Booking): boolean {
|
||||
return b.status === 'pending' || b.status === 'confirmed';
|
||||
function canConfirm(booking: Booking) {
|
||||
return auth.isBusinessOwner && booking.status === 'pending';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
function canCancel(booking: Booking) {
|
||||
return booking.status === 'pending' || booking.status === 'confirmed';
|
||||
}
|
||||
|
||||
function canConfirmEntry(entry: Booking[]) {
|
||||
return entry.some((booking) => canConfirm(booking));
|
||||
}
|
||||
|
||||
function canCancelEntry(entry: Booking[]) {
|
||||
return entry.some((booking) => canCancel(booking));
|
||||
}
|
||||
|
||||
function entryKey(entry: BookingListEntry) {
|
||||
return entry.key;
|
||||
}
|
||||
|
||||
function isEntryActing(entry: BookingListEntry) {
|
||||
return actingKey.value === entryKey(entry);
|
||||
}
|
||||
|
||||
function applyUpdatedBookings(updated: Booking[]) {
|
||||
const map = new Map(updated.map((booking) => [booking.id, booking]));
|
||||
bookings.value = bookings.value.map((item) => map.get(item.id) ?? item);
|
||||
}
|
||||
|
||||
async function loadBookings() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
@@ -120,32 +119,41 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onConfirm(b: Booking) {
|
||||
actingId.value = b.id;
|
||||
error.value = '';
|
||||
async function onConfirm(entry: Booking[]) {
|
||||
const pending = entry.filter((booking) => canConfirm(booking));
|
||||
if (!pending.length) return;
|
||||
|
||||
actingKey.value = entry[0]?.bookingGroupId ?? entry[0]?.id ?? null;
|
||||
try {
|
||||
await confirmBooking(b.id);
|
||||
await load();
|
||||
const updated = await Promise.all(
|
||||
pending.map((booking) => confirmBooking(booking.id)),
|
||||
);
|
||||
applyUpdatedBookings(updated);
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تأیید رزرو ناموفق بود.';
|
||||
} finally {
|
||||
actingId.value = null;
|
||||
actingKey.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancel(b: Booking) {
|
||||
if (!confirm('این رزرو لغو شود؟')) return;
|
||||
actingId.value = b.id;
|
||||
error.value = '';
|
||||
async function onCancel(entry: Booking[]) {
|
||||
const cancellable = entry.filter((booking) => canCancel(booking));
|
||||
if (!cancellable.length) return;
|
||||
|
||||
actingKey.value = entry[0]?.bookingGroupId ?? entry[0]?.id ?? null;
|
||||
try {
|
||||
await cancelBooking(b.id);
|
||||
await load();
|
||||
const updated = await Promise.all(
|
||||
cancellable.map((booking) => cancelBooking(booking.id)),
|
||||
);
|
||||
applyUpdatedBookings(updated);
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'لغو رزرو ناموفق بود.';
|
||||
} finally {
|
||||
actingId.value = null;
|
||||
actingKey.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
onMounted(() => {
|
||||
void loadBookings();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<WebAppLayout>
|
||||
<ResponsiveAppLayout>
|
||||
<DetailPageSkeleton v-if="loading" />
|
||||
<p
|
||||
v-else-if="error"
|
||||
@@ -7,523 +7,84 @@
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<section v-else-if="business" class="space-y-6">
|
||||
<button
|
||||
v-if="showBackLink"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-sm text-brand-600 dark:text-brand-500"
|
||||
@click="router.back()"
|
||||
>
|
||||
<AppIcon name="arrow-right" size="sm" />
|
||||
بازگشت
|
||||
</button>
|
||||
|
||||
<BusinessImageGallery :images="business.images" :title="business.title" />
|
||||
|
||||
<section v-else-if="business" class="min-w-0 max-w-full space-y-6">
|
||||
<div
|
||||
v-if="!business.isActive"
|
||||
class="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-800 dark:text-amber-300"
|
||||
class="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
این کسبوکار فعلاً غیرفعال است و امکان رزرو ندارد.
|
||||
این کسبوکار فعلاً غیرفعال است.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span
|
||||
v-if="business.category"
|
||||
class="inline-block rounded-lg bg-brand-500/10 px-2 py-0.5 text-xs text-brand-700 dark:text-brand-500"
|
||||
>
|
||||
{{ business.category.title }}
|
||||
</span>
|
||||
<h1 class="mt-2 text-xl font-bold text-text">{{ business.title }}</h1>
|
||||
<p v-if="business.description" class="mt-2 text-sm text-muted">
|
||||
{{ business.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4 text-sm">
|
||||
<div v-if="business.contactPhone" class="flex items-center justify-between gap-4">
|
||||
<dt class="shrink-0 text-muted">تلفن</dt>
|
||||
<dd class="font-medium" dir="ltr">{{ business.contactPhone }}</dd>
|
||||
</div>
|
||||
<div v-if="business.address" class="flex items-start justify-between gap-4">
|
||||
<dt class="shrink-0 pt-0.5 text-muted">آدرس</dt>
|
||||
<dd class="min-w-0 max-w-[75%] text-left leading-relaxed">{{ business.address }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div>
|
||||
<h2 class="mb-3 font-semibold text-text">خدمات</h2>
|
||||
<ServiceGridSkeleton v-if="loadingServices" :count="4" />
|
||||
<p
|
||||
v-else-if="servicesError"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ servicesError }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="requiresServiceClass && canBook"
|
||||
class="mb-4 space-y-2 rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<p class="text-sm font-medium text-text">۱. کلاس خودرو را انتخاب کنید</p>
|
||||
<p class="text-xs text-muted">
|
||||
قیمت و مدت هر خدمت بر اساس کلاس انتخابشده نمایش داده میشود.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
v-for="cls in activeClasses"
|
||||
:key="cls.id"
|
||||
type="button"
|
||||
class="rounded-xl border px-3 py-3 text-center text-sm transition"
|
||||
:class="
|
||||
selectedClassId === cls.id
|
||||
? 'border-brand-500 bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||
: 'border-border bg-surface text-text hover:border-brand-500/40'
|
||||
"
|
||||
@click="selectClass(cls.id)"
|
||||
>
|
||||
<span class="font-medium">{{ cls.title }}</span>
|
||||
<span v-if="cls.description" class="mt-1 block text-xs text-muted">
|
||||
{{ cls.description }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!bookableServices.length"
|
||||
class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted"
|
||||
>
|
||||
{{
|
||||
services.length
|
||||
? 'خدمات این کسبوکار هنوز برای رزرو آماده نیست.'
|
||||
: 'هنوز خدمتی برای این کسبوکار ثبت نشده است.'
|
||||
}}
|
||||
<span v-if="auth.isBusinessOwner" class="mt-2 block">
|
||||
از بخش «کسبوکار» در منو، خدمات و قیمت را اضافه کنید.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-else-if="requiresServiceClass && canBook && !selectedClassId"
|
||||
class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted"
|
||||
>
|
||||
برای دیدن خدمات و قیمتها، ابتدا کلاس خودرو را انتخاب کنید.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else-if="requiresServiceClass && selectedClassId && !displayedServices.length"
|
||||
class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted"
|
||||
>
|
||||
برای این کلاس هنوز خدمتی با قیمت ثبت نشده است.
|
||||
</div>
|
||||
|
||||
<template v-else-if="displayedServices.length">
|
||||
<p v-if="canBook" class="mb-3 text-xs text-muted">
|
||||
{{
|
||||
requiresServiceClass
|
||||
? '۲. خدمات مورد نظر را انتخاب کنید، سپس زمان مراجعه را تعیین کنید.'
|
||||
: 'خدمات مورد نظر را انتخاب کنید، سپس زمان مراجعه را تعیین کنید.'
|
||||
}}
|
||||
</p>
|
||||
<ul class="grid grid-cols-2 gap-3">
|
||||
<li
|
||||
v-for="svc in displayedServices"
|
||||
:key="svc.id"
|
||||
class="flex flex-col items-center rounded-2xl border p-3 text-center transition"
|
||||
:class="
|
||||
canBook && isServiceSelected(svc.id)
|
||||
? 'border-brand-500 bg-brand-500/10'
|
||||
: 'border-border bg-surface-2'
|
||||
"
|
||||
>
|
||||
<label
|
||||
v-if="canBook"
|
||||
class="flex w-full cursor-pointer flex-col items-center"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
:checked="isServiceSelected(svc.id)"
|
||||
@change="toggleService(svc.id)"
|
||||
/>
|
||||
<span
|
||||
class="mb-2 flex h-5 w-5 items-center justify-center rounded-md border text-xs"
|
||||
:class="
|
||||
isServiceSelected(svc.id)
|
||||
? 'border-brand-500 bg-brand-500 text-white'
|
||||
: 'border-border bg-surface'
|
||||
"
|
||||
>
|
||||
{{ isServiceSelected(svc.id) ? '✓' : '' }}
|
||||
</span>
|
||||
<p class="font-medium text-text">
|
||||
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="svc.serviceTemplate?.description"
|
||||
class="mt-1 line-clamp-2 text-xs text-muted"
|
||||
>
|
||||
{{ svc.serviceTemplate.description }}
|
||||
</p>
|
||||
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
|
||||
<p>{{ servicePriceLabel(svc, classesEnabled, serviceClasses, selectedClassId || null) }}</p>
|
||||
<p>{{ serviceDurationLabel(svc, classesEnabled, serviceClasses, selectedClassId || null) }}</p>
|
||||
</div>
|
||||
</label>
|
||||
<template v-else>
|
||||
<p class="font-medium text-text">
|
||||
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="svc.serviceTemplate?.description"
|
||||
class="mt-1 line-clamp-2 text-xs text-muted"
|
||||
>
|
||||
{{ svc.serviceTemplate.description }}
|
||||
</p>
|
||||
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
|
||||
<p>{{ servicePriceLabel(svc, classesEnabled, serviceClasses, selectedClassId || null) }}</p>
|
||||
<p>{{ serviceDurationLabel(svc, classesEnabled, serviceClasses, selectedClassId || null) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="canBook && selectedServices.length && canProceedToBooking"
|
||||
class="sticky bottom-2 z-10 rounded-2xl border border-brand-500/40 bg-surface/95 p-4 shadow-lg backdrop-blur"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 text-sm">
|
||||
<p class="font-medium text-text">
|
||||
{{ selectedServices.length }} خدمت انتخاب شده
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
مدت تقریبی: {{ totalDurationLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<AppButton class="!w-auto shrink-0 px-5" @click="openBooking">
|
||||
انتخاب زمان
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<DesktopBookingFlow
|
||||
v-else
|
||||
:business="business"
|
||||
:services="services"
|
||||
:loading-services="loadingServices"
|
||||
:services-error="servicesError"
|
||||
:home-to="homeRoute"
|
||||
@login="goToLoginForBooking"
|
||||
@view-bookings="router.push({ name: 'app-bookings' })"
|
||||
@go-home="router.push(homeRoute)"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AppModal
|
||||
:open="bookingOpen"
|
||||
title="رزرو خدمات"
|
||||
:subtitle="selectedServices.length ? `${selectedServices.length} خدمت` : ''"
|
||||
@close="closeBooking"
|
||||
>
|
||||
<form v-if="selectedServices.length && business" class="space-y-4" @submit.prevent="onSubmitBooking">
|
||||
<p
|
||||
v-if="bookingError"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
|
||||
>
|
||||
{{ bookingError }}
|
||||
</p>
|
||||
|
||||
<div class="rounded-xl border border-border bg-surface-2 p-3 text-sm">
|
||||
<p class="text-muted">کسبوکار</p>
|
||||
<p class="font-medium text-text">{{ business.title }}</p>
|
||||
<p v-if="selectedClass" class="mt-2 text-muted">کلاس</p>
|
||||
<p v-if="selectedClass" class="font-medium text-text">{{ selectedClass.title }}</p>
|
||||
<ul class="mt-3 space-y-2 border-t border-border pt-3">
|
||||
<li
|
||||
v-for="svc in selectedServices"
|
||||
:key="svc.id"
|
||||
class="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span class="text-text">{{ svc.serviceTemplate?.title ?? 'خدمت' }}</span>
|
||||
<span class="shrink-0 text-muted">
|
||||
{{
|
||||
requiresServiceClass && selectedClassId
|
||||
? `${formatPriceToman(resolvedServicePrice(svc, selectedClassId) ?? svc.price)} تومان`
|
||||
: `${formatPriceToman(svc.price)} تومان`
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-muted">جمع</p>
|
||||
<p class="font-medium text-text">{{ bookingPriceLabel }}</p>
|
||||
<p class="mt-2 text-muted">مدت تقریبی</p>
|
||||
<p class="font-medium text-text">{{ totalDurationLabel }}</p>
|
||||
</div>
|
||||
|
||||
<BookingSlotPicker
|
||||
v-model="bookingForm.scheduledAt"
|
||||
label="تاریخ و ساعت مراجعه (شمسی)"
|
||||
:business-id="business.id"
|
||||
:business-service-ids="selectedServiceIds"
|
||||
:service-class-id="selectedClassId || null"
|
||||
/>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="bookingNote" class="block text-sm font-medium text-muted">
|
||||
یادداشت (اختیاری)
|
||||
</label>
|
||||
<textarea
|
||||
id="bookingNote"
|
||||
v-model="bookingForm.note"
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-sm text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
placeholder="توضیح کوتاه برای کسبوکار"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppButton type="submit" :loading="bookingSaving">ثبت رزرو</AppButton>
|
||||
</form>
|
||||
</AppModal>
|
||||
</WebAppLayout>
|
||||
</ResponsiveAppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { createBookingBatch } from '@/api/bookings';
|
||||
import { fetchBusinessServiceClasses } from '@/api/business-service-classes';
|
||||
import { fetchBusinessServices } from '@/api/business-services';
|
||||
import { fetchBusiness, fetchBusinesses } from '@/api/businesses';
|
||||
import { fetchBusiness } from '@/api/businesses';
|
||||
import { ApiError } from '@/api/client';
|
||||
import type { Business, BusinessService, BusinessServiceClass } from '@/api/types';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import type { Business, BusinessService } from '@/api/types';
|
||||
import DesktopBookingFlow from '@/components/booking/DesktopBookingFlow.vue';
|
||||
import DetailPageSkeleton from '@/components/ui/DetailPageSkeleton.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import ServiceGridSkeleton from '@/components/ui/ServiceGridSkeleton.vue';
|
||||
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
|
||||
import BookingSlotPicker from '@/components/booking/BookingSlotPicker.vue';
|
||||
import AppModal from '@/components/ui/AppModal.vue';
|
||||
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { formatPriceToman } from '@/utils/format';
|
||||
import {
|
||||
activeServiceClasses,
|
||||
isServiceBookable,
|
||||
isServiceBookableForClass,
|
||||
resolvedServicePrice,
|
||||
serviceDurationLabel,
|
||||
servicePriceLabel,
|
||||
totalDurationForServices,
|
||||
totalPriceForServices,
|
||||
usesServiceClasses,
|
||||
} from '@/utils/service-pricing';
|
||||
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
|
||||
import { loginRouteWithRedirect } from '@/utils/auth-redirect';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const business = ref<Business | null>(null);
|
||||
const services = ref<BusinessService[]>([]);
|
||||
const serviceClasses = ref<BusinessServiceClass[]>([]);
|
||||
const loading = ref(true);
|
||||
const loadingServices = ref(true);
|
||||
const loadingServices = ref(false);
|
||||
const error = ref('');
|
||||
const servicesError = ref('');
|
||||
|
||||
const bookingOpen = ref(false);
|
||||
const selectedClassId = ref('');
|
||||
const selectedServiceIds = ref<string[]>([]);
|
||||
const bookingSaving = ref(false);
|
||||
const bookingError = ref('');
|
||||
const bookingForm = ref({
|
||||
scheduledAt: '',
|
||||
note: '',
|
||||
});
|
||||
const homeRoute = computed(() => ({ name: 'app-home' }));
|
||||
|
||||
const showBackLink = ref(false);
|
||||
|
||||
const activeClasses = computed(() => activeServiceClasses(serviceClasses.value));
|
||||
const classesEnabled = computed(() => business.value?.serviceClassesEnabled === true);
|
||||
const requiresServiceClass = computed(() =>
|
||||
usesServiceClasses(classesEnabled.value, serviceClasses.value),
|
||||
);
|
||||
const bookableServices = computed(() =>
|
||||
services.value.filter((svc) =>
|
||||
isServiceBookable(svc, classesEnabled.value, serviceClasses.value),
|
||||
),
|
||||
);
|
||||
|
||||
const displayedServices = computed(() => {
|
||||
if (!requiresServiceClass.value) {
|
||||
return bookableServices.value;
|
||||
}
|
||||
if (!selectedClassId.value) {
|
||||
return [];
|
||||
}
|
||||
return bookableServices.value.filter((svc) =>
|
||||
isServiceBookableForClass(svc, selectedClassId.value),
|
||||
);
|
||||
});
|
||||
|
||||
const selectedClass = computed(() =>
|
||||
activeClasses.value.find((cls) => cls.id === selectedClassId.value) ?? null,
|
||||
);
|
||||
|
||||
const canProceedToBooking = computed(
|
||||
() => !requiresServiceClass.value || !!selectedClassId.value,
|
||||
);
|
||||
|
||||
const selectedServices = computed(() =>
|
||||
displayedServices.value.filter((svc) => selectedServiceIds.value.includes(svc.id)),
|
||||
);
|
||||
|
||||
const totalDurationLabel = computed(() => {
|
||||
const minutes = totalDurationForServices(
|
||||
selectedServices.value,
|
||||
classesEnabled.value,
|
||||
serviceClasses.value,
|
||||
selectedClassId.value || null,
|
||||
);
|
||||
return `${minutes} دقیقه`;
|
||||
});
|
||||
|
||||
const bookingPriceLabel = computed(() => {
|
||||
if (!selectedServices.value.length) return '—';
|
||||
const total = totalPriceForServices(
|
||||
selectedServices.value,
|
||||
classesEnabled.value,
|
||||
serviceClasses.value,
|
||||
selectedClassId.value || null,
|
||||
);
|
||||
return total != null ? `${formatPriceToman(String(total))} تومان` : '—';
|
||||
});
|
||||
|
||||
const canBook = computed(() => {
|
||||
if (!auth.user || business.value?.isActive !== true || !bookableServices.value.length) {
|
||||
return false;
|
||||
}
|
||||
if (business.value.ownerId && business.value.ownerId === auth.user.id) {
|
||||
return false;
|
||||
}
|
||||
return auth.canBookAsCustomer;
|
||||
});
|
||||
|
||||
function isServiceSelected(id: string): boolean {
|
||||
return selectedServiceIds.value.includes(id);
|
||||
}
|
||||
|
||||
function selectClass(classId: string) {
|
||||
if (selectedClassId.value === classId) {
|
||||
return;
|
||||
}
|
||||
selectedClassId.value = classId;
|
||||
selectedServiceIds.value = [];
|
||||
}
|
||||
|
||||
function toggleService(id: string) {
|
||||
if (requiresServiceClass.value && !selectedClassId.value) {
|
||||
return;
|
||||
}
|
||||
if (isServiceSelected(id)) {
|
||||
selectedServiceIds.value = selectedServiceIds.value.filter((item) => item !== id);
|
||||
} else {
|
||||
selectedServiceIds.value = [...selectedServiceIds.value, id];
|
||||
}
|
||||
}
|
||||
|
||||
function openBooking() {
|
||||
if (!auth.user || !selectedServices.value.length || !canProceedToBooking.value) return;
|
||||
bookingForm.value = {
|
||||
scheduledAt: '',
|
||||
note: '',
|
||||
};
|
||||
bookingError.value = '';
|
||||
bookingOpen.value = true;
|
||||
}
|
||||
|
||||
function closeBooking() {
|
||||
bookingOpen.value = false;
|
||||
bookingError.value = '';
|
||||
}
|
||||
|
||||
async function loadServices(businessId: string) {
|
||||
loadingServices.value = true;
|
||||
servicesError.value = '';
|
||||
try {
|
||||
const svcList = await fetchBusinessServices(businessId, { activeOnly: true });
|
||||
services.value = svcList;
|
||||
if (business.value?.serviceClassesEnabled) {
|
||||
serviceClasses.value = await fetchBusinessServiceClasses(businessId, {
|
||||
activeOnly: true,
|
||||
});
|
||||
} else {
|
||||
serviceClasses.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
services.value = [];
|
||||
serviceClasses.value = [];
|
||||
servicesError.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری خدمات ناموفق بود.';
|
||||
} finally {
|
||||
loadingServices.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const id = String(route.params.id);
|
||||
async function loadBusiness() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [businessResult, activeBusinesses] = await Promise.all([
|
||||
fetchBusiness(id),
|
||||
fetchBusinesses({ isActive: true }),
|
||||
]);
|
||||
business.value = businessResult;
|
||||
showBackLink.value = activeBusinesses.length > 1;
|
||||
await loadServices(id);
|
||||
business.value = await fetchBusiness(String(route.params.id));
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری اطلاعات ناموفق بود.';
|
||||
error.value = e instanceof ApiError ? e.message : 'بارگذاری کسبوکار ناموفق بود.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmitBooking() {
|
||||
if (!auth.user || !business.value || !selectedServices.value.length) return;
|
||||
|
||||
const scheduled = new Date(bookingForm.value.scheduledAt);
|
||||
if (Number.isNaN(scheduled.getTime())) {
|
||||
bookingError.value = 'تاریخ یا ساعت انتخابشده معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
if (!bookingForm.value.scheduledAt) {
|
||||
bookingError.value = 'لطفاً یک ساعت از لیست انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
if (requiresServiceClass.value && !selectedClassId.value) {
|
||||
bookingError.value = 'لطفاً کلاس خودرو را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
|
||||
bookingSaving.value = true;
|
||||
bookingError.value = '';
|
||||
async function loadServices() {
|
||||
if (!business.value) return;
|
||||
loadingServices.value = true;
|
||||
servicesError.value = '';
|
||||
try {
|
||||
await createBookingBatch({
|
||||
customerId: auth.user.id,
|
||||
businessId: business.value.id,
|
||||
items: selectedServices.value.map((svc) => ({
|
||||
businessServiceId: svc.id,
|
||||
})),
|
||||
serviceClassId: selectedClassId.value || null,
|
||||
scheduledAt: scheduled.toISOString(),
|
||||
note: bookingForm.value.note.trim() || null,
|
||||
});
|
||||
selectedServiceIds.value = [];
|
||||
selectedClassId.value = '';
|
||||
closeBooking();
|
||||
router.push({ name: 'app-bookings' });
|
||||
services.value = await fetchBusinessServices(business.value.id, { activeOnly: true });
|
||||
} catch (e) {
|
||||
bookingError.value =
|
||||
e instanceof ApiError ? e.message : 'ثبت رزرو ناموفق بود.';
|
||||
servicesError.value = e instanceof ApiError ? e.message : 'بارگذاری خدمات ناموفق بود.';
|
||||
} finally {
|
||||
bookingSaving.value = false;
|
||||
loadingServices.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
function goToLoginForBooking() {
|
||||
void router.push(loginRouteWithRedirect(route.fullPath));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBusiness();
|
||||
await loadServices();
|
||||
});
|
||||
</script>
|
||||
|
||||
+78
-116
@@ -1,153 +1,115 @@
|
||||
<template>
|
||||
<WebAppLayout>
|
||||
<BusinessCardSkeleton v-if="bootstrapping" class="py-2" />
|
||||
<DesktopLandingPage
|
||||
v-if="showDesktopLanding"
|
||||
:services="landingServices"
|
||||
:loading-services="loadingLandingServices"
|
||||
:services-error="landingServicesError"
|
||||
/>
|
||||
|
||||
<section v-else class="space-y-5">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-text">کشف خدمات</h1>
|
||||
<p class="mt-1 text-sm text-muted">کسبوکارهای محلی نزدیک شما</p>
|
||||
</div>
|
||||
<WebAppLayout v-else-if="showMobileLanding">
|
||||
<HomeHeroSection @book="goToBooking" @browse="scrollToServices" />
|
||||
<HomeServicesSection
|
||||
:items="landingServices"
|
||||
:loading="loadingLandingServices"
|
||||
:error="landingServicesError"
|
||||
/>
|
||||
<HomeFeatureBar />
|
||||
</WebAppLayout>
|
||||
|
||||
<div v-if="showSearch" class="relative">
|
||||
<label class="sr-only" for="search">جستجو</label>
|
||||
<AppIcon
|
||||
name="search"
|
||||
size="sm"
|
||||
class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted"
|
||||
/>
|
||||
<input
|
||||
id="search"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="جستجو در نام یا آدرس…"
|
||||
class="w-full rounded-xl border border-border bg-surface-2 py-3 pe-4 ps-10 text-sm text-text outline-none placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!tenant.loaded && isDesktop"
|
||||
class="flex min-h-dvh items-center justify-center bg-white dark:bg-[#0d1117]"
|
||||
>
|
||||
<BusinessCardSkeleton class="w-full max-w-5xl px-8" />
|
||||
</div>
|
||||
|
||||
<CategoryFilter
|
||||
v-if="showCategoryFilter"
|
||||
:categories="activeCategories"
|
||||
:selected-id="selectedCategoryId"
|
||||
@select="onCategorySelect"
|
||||
/>
|
||||
<DesktopDiscoveryPage v-else-if="tenant.loaded && isDesktop" />
|
||||
|
||||
<BusinessCardSkeleton v-if="loadingBusinesses" class="py-2" />
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<div
|
||||
v-else-if="!filteredBusinesses.length"
|
||||
class="rounded-2xl border border-dashed border-border bg-surface-2 p-8 text-center text-sm text-muted"
|
||||
>
|
||||
کسبوکاری یافت نشد.
|
||||
</div>
|
||||
<ul v-else class="space-y-2">
|
||||
<li v-for="b in filteredBusinesses" :key="b.id">
|
||||
<BusinessCard :business="b" />
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<WebAppLayout v-else>
|
||||
<BusinessCardSkeleton v-if="!tenant.loaded" class="py-2" />
|
||||
<HomeDiscoveryContent v-else />
|
||||
</WebAppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { fetchBusinessCategories } from '@/api/business-categories';
|
||||
import { fetchBusinesses } from '@/api/businesses';
|
||||
import { ApiError } from '@/api/client';
|
||||
import type { Business, BusinessCategory } from '@/api/types';
|
||||
import BusinessCard from '@/components/business/BusinessCard.vue';
|
||||
import HomeDiscoveryContent from '@/components/home/HomeDiscoveryContent.vue';
|
||||
import HomeFeatureBar from '@/components/home/HomeFeatureBar.vue';
|
||||
import HomeHeroSection from '@/components/home/HomeHeroSection.vue';
|
||||
import HomeServicesSection from '@/components/home/HomeServicesSection.vue';
|
||||
import DesktopDiscoveryPage from '@/components/landing/DesktopDiscoveryPage.vue';
|
||||
import DesktopLandingPage from '@/components/landing/DesktopLandingPage.vue';
|
||||
import BusinessCardSkeleton from '@/components/ui/BusinessCardSkeleton.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import CategoryFilter from '@/components/business/CategoryFilter.vue';
|
||||
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { LandingServiceItem } from '@/types/landing';
|
||||
import { fetchLandingServices } from '@/utils/landing-services';
|
||||
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
const router = useRouter();
|
||||
const tenant = useTenantStore();
|
||||
|
||||
const categories = ref<BusinessCategory[]>([]);
|
||||
const businesses = ref<Business[]>([]);
|
||||
const selectedCategoryId = ref<string | null>(null);
|
||||
const searchQuery = ref('');
|
||||
const bootstrapping = ref(true);
|
||||
const loadingBusinesses = ref(false);
|
||||
const error = ref('');
|
||||
const landingServices = ref<LandingServiceItem[]>([]);
|
||||
const loadingLandingServices = ref(false);
|
||||
const landingServicesError = ref('');
|
||||
|
||||
const activeCategories = computed(() =>
|
||||
categories.value.filter((c) => c.isActive),
|
||||
const showDesktopLanding = computed(
|
||||
() => tenant.loaded && tenant.isSingleBusiness && isDesktop.value,
|
||||
);
|
||||
|
||||
const showCategoryFilter = computed(() => activeCategories.value.length > 1);
|
||||
const showMobileLanding = computed(
|
||||
() => tenant.loaded && tenant.isSingleBusiness && !isDesktop.value,
|
||||
);
|
||||
|
||||
const showSearch = computed(() => businesses.value.length > 1);
|
||||
const showSingleBusinessLanding = computed(
|
||||
() => showDesktopLanding.value || showMobileLanding.value,
|
||||
);
|
||||
|
||||
const filteredBusinesses = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return businesses.value;
|
||||
}
|
||||
return businesses.value.filter((b) => {
|
||||
const haystack = [b.title, b.description, b.address]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
});
|
||||
async function loadLandingServices() {
|
||||
if (!tenant.singleBusinessId) return;
|
||||
|
||||
async function loadCategories() {
|
||||
loadingLandingServices.value = true;
|
||||
landingServicesError.value = '';
|
||||
try {
|
||||
categories.value = await fetchBusinessCategories();
|
||||
if (activeCategories.value.length === 1) {
|
||||
selectedCategoryId.value = activeCategories.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری دستهها ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBusinesses() {
|
||||
loadingBusinesses.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
businesses.value = await fetchBusinesses({
|
||||
categoryId: selectedCategoryId.value ?? undefined,
|
||||
isActive: true,
|
||||
});
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری کسبوکارها ناموفق بود.';
|
||||
landingServices.value = await fetchLandingServices(tenant.singleBusinessId);
|
||||
} catch {
|
||||
landingServices.value = [];
|
||||
} finally {
|
||||
loadingBusinesses.value = false;
|
||||
loadingLandingServices.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onCategorySelect(id: string | null) {
|
||||
selectedCategoryId.value = id;
|
||||
function scrollToServices() {
|
||||
const el = document.getElementById('services');
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
watch(selectedCategoryId, () => {
|
||||
if (!bootstrapping.value) {
|
||||
void loadBusinesses();
|
||||
function goToBooking() {
|
||||
const id = tenant.singleBusinessId;
|
||||
if (!id) {
|
||||
scrollToServices();
|
||||
return;
|
||||
}
|
||||
|
||||
void router.push({
|
||||
name: 'app-business-detail',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
watch(showSingleBusinessLanding, (show) => {
|
||||
if (show) {
|
||||
void loadLandingServices();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
await loadBusinesses();
|
||||
await tenant.bootstrap();
|
||||
|
||||
if (businesses.value.length === 1) {
|
||||
await router.replace({
|
||||
name: 'app-business-detail',
|
||||
params: { id: businesses.value[0].id },
|
||||
});
|
||||
return;
|
||||
if (showSingleBusinessLanding.value) {
|
||||
await loadLandingServices();
|
||||
}
|
||||
|
||||
bootstrapping.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
+72
-822
@@ -1,880 +1,130 @@
|
||||
<template>
|
||||
<WebAppLayout>
|
||||
<h1 class="mb-4 text-xl font-bold text-text">مدیریت کسبوکار</h1>
|
||||
|
||||
<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>
|
||||
<p
|
||||
v-if="message"
|
||||
class="mb-4 rounded-xl border border-brand-500/40 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<ResponsiveAppLayout>
|
||||
<h1 class="mb-1 text-xl font-bold text-text">مدیریت کسبوکار</h1>
|
||||
<p class="mb-6 text-sm text-muted">تنظیمات کسبوکار، خدمات و ساعات کاری</p>
|
||||
|
||||
<FormPageSkeleton v-if="loading" />
|
||||
|
||||
<div
|
||||
v-else-if="!business"
|
||||
class="rounded-2xl border border-dashed border-border p-6 text-center text-sm text-muted"
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
کسبوکاری به حساب شما وصل نیست.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-8">
|
||||
<form class="space-y-4" @submit.prevent="onSave">
|
||||
<h2 class="text-base font-semibold text-text">اطلاعات کسبوکار</h2>
|
||||
|
||||
<dl class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<dt class="shrink-0 text-xs text-muted">دسته</dt>
|
||||
<dd class="font-medium text-text">{{ business.category?.title ?? '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<dt class="shrink-0 text-xs text-muted">وضعیت</dt>
|
||||
<dd>
|
||||
<span
|
||||
class="inline-block rounded-lg px-2 py-0.5 text-xs"
|
||||
:class="business.isActive ? 'bg-green-500/15 text-green-700' : 'bg-amber-500/15 text-amber-700'"
|
||||
>
|
||||
{{ business.isActive ? 'فعال' : 'غیرفعال' }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div v-else-if="business" class="mx-auto max-w-3xl space-y-6">
|
||||
<form class="space-y-4 rounded-2xl border border-border bg-surface-2 p-4" @submit.prevent="saveBusiness">
|
||||
<AppInput id="ownerTitle" v-model="form.title" label="نام کسبوکار" required />
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="ownerDesc" class="block text-sm font-medium text-muted">توضیحات</label>
|
||||
<textarea
|
||||
id="ownerDesc"
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppInput
|
||||
id="ownerPhone"
|
||||
v-model="form.contactPhone"
|
||||
label="تلفن"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
ltr
|
||||
/>
|
||||
<AppInput id="ownerSubtitle" v-model="form.subtitle" label="زیرعنوان" />
|
||||
<AppTextarea id="ownerDescription" v-model="form.description" label="توضیحات" />
|
||||
<AppInput id="ownerAddress" v-model="form.address" label="آدرس" />
|
||||
|
||||
<AppButton type="submit" :loading="saving">ذخیره تغییرات</AppButton>
|
||||
<AppInput id="ownerPhone" v-model="form.phone" label="تلفن" ltr />
|
||||
<p v-if="saveMessage" class="text-sm text-brand-700 dark:text-brand-500">{{ saveMessage }}</p>
|
||||
<AppButton type="submit" :loading="saving">ذخیره اطلاعات</AppButton>
|
||||
</form>
|
||||
|
||||
<section class="border-t border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-surface-2 px-4 py-3 text-start transition hover:border-brand-500/30"
|
||||
:aria-expanded="bookingExpanded"
|
||||
@click="bookingExpanded = !bookingExpanded"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<AppIcon name="calendar" size="sm" class="text-brand-600 dark:text-brand-500" />
|
||||
<h2 class="text-base font-semibold text-text">تنظیمات نوبتدهی</h2>
|
||||
</div>
|
||||
<p v-if="!bookingExpanded" class="mt-1 truncate text-xs text-muted">
|
||||
{{ bookingSummary }}
|
||||
</p>
|
||||
</div>
|
||||
<AppIcon
|
||||
:name="bookingExpanded ? 'chevron-up' : 'chevron-down'"
|
||||
size="sm"
|
||||
class="shrink-0 text-muted"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div v-show="bookingExpanded" class="mt-3 space-y-4">
|
||||
<AppInput
|
||||
id="ownerConcurrentCapacity"
|
||||
v-model="bookingForm.concurrentCapacity"
|
||||
label="پذیرش همزمان"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
ltr
|
||||
required
|
||||
/>
|
||||
<p class="-mt-2 text-xs text-muted">
|
||||
حداکثر تعداد مشتری که همزمان میتوانید پذیرش کنید. مثلاً ۳ یعنی هر ساعت تا ۳ رزرو همپوشان.
|
||||
</p>
|
||||
|
||||
<AppInput
|
||||
id="ownerSlotStep"
|
||||
v-model="bookingForm.slotStepMinutes"
|
||||
label="گام نوبتدهی (دقیقه)"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
ltr
|
||||
required
|
||||
/>
|
||||
<p class="-mt-2 text-xs text-muted">
|
||||
فاصله بین ساعتهای قابل رزرو. مثلاً ۳۰ یعنی ۱۰:۰۰، ۱۰:۳۰، ۱۱:۰۰ و …
|
||||
</p>
|
||||
|
||||
<AppButton :loading="savingBooking" @click="onSaveBookingSettings">
|
||||
ذخیره تنظیمات نوبتدهی
|
||||
</AppButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BusinessAvailabilityEditor
|
||||
v-if="business"
|
||||
:business-id="business.id"
|
||||
/>
|
||||
|
||||
<ServiceClassManager
|
||||
v-if="business"
|
||||
:business-id="business.id"
|
||||
:enabled="business.serviceClassesEnabled === true"
|
||||
@updated="onServiceClassesUpdated"
|
||||
@update:enabled="onServiceClassesEnabledChange"
|
||||
:enabled="serviceClassesEnabled"
|
||||
@update:enabled="setServiceClassesEnabled"
|
||||
/>
|
||||
|
||||
<section class="border-t border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-surface-2 px-4 py-3 text-start transition hover:border-brand-500/30"
|
||||
:aria-expanded="servicesExpanded"
|
||||
@click="servicesExpanded = !servicesExpanded"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<AppIcon name="clipboard-list" size="sm" class="text-brand-600 dark:text-brand-500" />
|
||||
<h2 class="text-base font-semibold text-text">خدمات و قیمتها</h2>
|
||||
</div>
|
||||
<p v-if="!servicesExpanded" class="mt-1 truncate text-xs text-muted">
|
||||
{{ servicesSummary }}
|
||||
</p>
|
||||
</div>
|
||||
<AppIcon
|
||||
:name="servicesExpanded ? 'chevron-up' : 'chevron-down'"
|
||||
size="sm"
|
||||
class="shrink-0 text-muted"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div v-show="servicesExpanded" class="mt-3 space-y-4">
|
||||
<p class="text-xs text-muted">
|
||||
خدماتی که اینجا ثبت میکنید در صفحه کسبوکار برای مشتریان و مقایسه نمایش داده میشود.
|
||||
</p>
|
||||
|
||||
<ListCardSkeleton v-if="loadingServices" :count="3" />
|
||||
|
||||
<ul v-else-if="services.length" class="space-y-2">
|
||||
<li
|
||||
v-for="svc in services"
|
||||
:key="svc.id"
|
||||
class="rounded-xl border border-border bg-surface-2 p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-text">
|
||||
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="svc.serviceTemplate?.description"
|
||||
class="mt-0.5 text-xs text-muted"
|
||||
>
|
||||
{{ svc.serviceTemplate.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<label class="flex items-center gap-1 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="svc.isActive"
|
||||
@change="onToggleActive(svc)"
|
||||
/>
|
||||
فعال
|
||||
</label>
|
||||
<template v-if="editingId !== svc.id">
|
||||
<AppIconButton icon="pencil" label="ویرایش خدمت" @click="startEdit(svc)" />
|
||||
<AppIconButton icon="trash" label="حذف خدمت" variant="danger" @click="onDelete(svc)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editingId === svc.id" class="mt-3 space-y-2">
|
||||
<template v-if="hasServiceClasses">
|
||||
<p class="text-sm font-medium text-text">قیمت بر اساس کلاس</p>
|
||||
<div
|
||||
v-for="cls in activeClasses"
|
||||
:key="cls.id"
|
||||
class="space-y-2 rounded-xl border border-border bg-surface px-3 py-3"
|
||||
>
|
||||
<p class="text-sm font-medium text-text">{{ cls.title }}</p>
|
||||
<PriceInput
|
||||
:id="`edit-class-price-${svc.id}-${cls.id}`"
|
||||
v-model="editClassPrices[cls.id].price"
|
||||
label="قیمت (تومان)"
|
||||
required
|
||||
/>
|
||||
<AppInput
|
||||
:id="`edit-class-duration-${svc.id}-${cls.id}`"
|
||||
v-model="editClassPrices[cls.id].durationMinutes"
|
||||
label="مدت (دقیقه — اختیاری)"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
ltr
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<PriceInput
|
||||
id="editPrice"
|
||||
v-model="editForm.price"
|
||||
label="قیمت (تومان)"
|
||||
/>
|
||||
<AppInput
|
||||
id="editDuration"
|
||||
v-model="editForm.durationMinutes"
|
||||
label="مدت (دقیقه)"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
ltr
|
||||
/>
|
||||
</template>
|
||||
<div class="flex gap-2">
|
||||
<AppButton class="!w-auto flex-1" :loading="savingService" @click="onSaveEdit(svc)">
|
||||
ذخیره
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" class="!w-auto px-4" @click="cancelEdit">
|
||||
انصراف
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-2 space-y-2 text-sm text-muted">
|
||||
<template v-if="hasServiceClasses">
|
||||
<div
|
||||
v-for="cls in activeClasses"
|
||||
:key="cls.id"
|
||||
class="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span class="font-medium text-text">{{ cls.title }}:</span>
|
||||
<span>{{ servicePriceLabel(svc, true, serviceClasses, cls.id) }}</span>
|
||||
<template v-if="getClassPriceForService(svc, cls.id)">
|
||||
<span>·</span>
|
||||
<span>{{ serviceDurationLabel(svc, true, serviceClasses, cls.id) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex flex-wrap items-center gap-2">
|
||||
<span>{{ formatPriceToman(svc.price) }} تومان</span>
|
||||
<span>·</span>
|
||||
<span>{{ serviceDurationLabel(svc, false, serviceClasses) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="text-sm text-muted">هنوز خدمتی ثبت نشده است.</p>
|
||||
|
||||
<div
|
||||
v-if="!loadingServices && templates.length === 0"
|
||||
class="rounded-xl border border-dashed border-border bg-surface-2 p-4 text-sm text-muted"
|
||||
>
|
||||
برای دسته «{{ business.category?.title }}» هنوز نوع خدمتی تعریف نشده. از مدیر سیستم
|
||||
بخواهید قالب خدمت اضافه کند.
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-else-if="availableTemplates.length"
|
||||
class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4"
|
||||
@submit.prevent="onAddService"
|
||||
>
|
||||
<p class="text-sm font-medium text-text">افزودن خدمت</p>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="svcTemplate" class="block text-sm font-medium text-muted">نوع خدمت</label>
|
||||
<select
|
||||
id="svcTemplate"
|
||||
v-model="serviceForm.serviceTemplateId"
|
||||
required
|
||||
class="w-full rounded-xl border border-border bg-surface px-4 py-3 text-text outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="" disabled>انتخاب کنید</option>
|
||||
<option v-for="tpl in availableTemplates" :key="tpl.id" :value="tpl.id">
|
||||
{{ tpl.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<PriceInput
|
||||
v-if="!hasServiceClasses"
|
||||
id="svcPrice"
|
||||
v-model="serviceForm.price"
|
||||
label="قیمت (تومان)"
|
||||
required
|
||||
/>
|
||||
<AppInput
|
||||
v-if="!hasServiceClasses"
|
||||
id="svcDuration"
|
||||
v-model="serviceForm.durationMinutes"
|
||||
label="مدت (دقیقه — اختیاری)"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
ltr
|
||||
/>
|
||||
|
||||
<div v-if="hasServiceClasses" class="space-y-2">
|
||||
<p class="text-sm font-medium text-text">قیمت بر اساس کلاس</p>
|
||||
<div
|
||||
v-for="cls in activeClasses"
|
||||
:key="cls.id"
|
||||
class="space-y-2 rounded-xl border border-border bg-surface px-3 py-3"
|
||||
>
|
||||
<p class="text-sm font-medium text-text">{{ cls.title }}</p>
|
||||
<PriceInput
|
||||
:id="`add-class-price-${cls.id}`"
|
||||
v-model="classPriceForm[cls.id].price"
|
||||
label="قیمت (تومان)"
|
||||
required
|
||||
/>
|
||||
<AppInput
|
||||
:id="`add-class-duration-${cls.id}`"
|
||||
v-model="classPriceForm[cls.id].durationMinutes"
|
||||
label="مدت (دقیقه — اختیاری)"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
ltr
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppButton type="submit" :loading="addingService">افزودن</AppButton>
|
||||
</form>
|
||||
|
||||
<p
|
||||
v-else-if="!loadingServices && templates.length && !availableTemplates.length"
|
||||
class="text-sm text-muted"
|
||||
>
|
||||
همهٔ انواع خدمت این دسته را اضافه کردهاید.
|
||||
</p>
|
||||
|
||||
<section class="space-y-3 rounded-2xl border border-dashed border-brand-500/40 bg-brand-500/5 p-4">
|
||||
<h3 class="text-sm font-semibold text-text">خدمت سفارشی (خارج از لیست ادمین)</h3>
|
||||
<p class="text-xs text-muted">
|
||||
اگر خدمتی در لیست بالا نیست، اینجا پیشنهاد دهید. پس از تأیید مدیر، به خدمات کسبوکار شما
|
||||
اضافه میشود.
|
||||
</p>
|
||||
|
||||
<ul v-if="serviceRequests.length" class="space-y-2">
|
||||
<li
|
||||
v-for="req in serviceRequests"
|
||||
:key="req.id"
|
||||
class="rounded-xl border border-border bg-surface-2 px-3 py-2 text-sm"
|
||||
>
|
||||
<p class="font-medium text-text">{{ req.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">
|
||||
{{ formatPriceToman(req.price) }} تومان
|
||||
<span v-if="req.durationMinutes"> · {{ req.durationMinutes }} دقیقه</span>
|
||||
</p>
|
||||
<span
|
||||
class="mt-1 inline-block rounded-lg px-2 py-0.5 text-xs"
|
||||
:class="requestStatusClass(req.status)"
|
||||
>
|
||||
{{ requestStatusLabel(req.status) }}
|
||||
</span>
|
||||
<p v-if="req.adminNote" class="mt-1 text-xs text-red-600">{{ req.adminNote }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form class="space-y-3" @submit.prevent="onAddCustomService">
|
||||
<AppInput id="customTitle" v-model="customForm.title" label="نام خدمت" required />
|
||||
<div class="space-y-1.5">
|
||||
<label for="customDesc" class="block text-sm font-medium text-muted">توضیح (اختیاری)</label>
|
||||
<textarea
|
||||
id="customDesc"
|
||||
v-model="customForm.description"
|
||||
rows="2"
|
||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
/>
|
||||
</div>
|
||||
<PriceInput id="customPrice" v-model="customForm.price" label="قیمت پیشنهادی (تومان)" required />
|
||||
<AppInput
|
||||
id="customDuration"
|
||||
v-model="customForm.durationMinutes"
|
||||
label="مدت (دقیقه — اختیاری)"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
ltr
|
||||
/>
|
||||
<AppButton type="submit" :loading="addingCustom">ارسال برای تأیید</AppButton>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<BusinessAvailabilityEditor :business-id="business.id" />
|
||||
</div>
|
||||
</WebAppLayout>
|
||||
</ResponsiveAppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { fetchMyBusinesses, updateBusiness } from '@/api/businesses';
|
||||
import {
|
||||
createServiceRequest,
|
||||
fetchMyServiceRequests,
|
||||
} from '@/api/business-service-requests';
|
||||
import {
|
||||
createBusinessService,
|
||||
deleteBusinessService,
|
||||
fetchBusinessServices,
|
||||
setBusinessServiceClassPrices,
|
||||
updateBusinessService,
|
||||
type ClassPriceItem,
|
||||
} from '@/api/business-services';
|
||||
import { fetchServiceTemplates } from '@/api/service-templates';
|
||||
import type {
|
||||
Business,
|
||||
BusinessService,
|
||||
BusinessServiceClass,
|
||||
BusinessServiceRequest,
|
||||
BusinessServiceRequestStatus,
|
||||
} from '@/api/types';
|
||||
import { ApiError } from '@/api/client';
|
||||
import type { Business } from '@/api/types';
|
||||
import BusinessAvailabilityEditor from '@/components/owner/BusinessAvailabilityEditor.vue';
|
||||
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
|
||||
import FormPageSkeleton from '@/components/ui/FormPageSkeleton.vue';
|
||||
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import AppIconButton from '@/components/ui/AppIconButton.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import PriceInput from '@/components/ui/PriceInput.vue';
|
||||
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
||||
import {
|
||||
formatPriceInputDisplay,
|
||||
formatPriceToman,
|
||||
parseOptionalMinutes,
|
||||
parsePriceInput,
|
||||
} from '@/utils/format';
|
||||
import {
|
||||
activeServiceClasses,
|
||||
getClassPriceForService,
|
||||
serviceDurationLabel,
|
||||
servicePriceLabel,
|
||||
} from '@/utils/service-pricing';
|
||||
import AppTextarea from '@/components/ui/AppTextarea.vue';
|
||||
import FormPageSkeleton from '@/components/ui/FormPageSkeleton.vue';
|
||||
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
|
||||
const tenant = useTenantStore();
|
||||
const business = ref<Business | null>(null);
|
||||
const services = ref<BusinessService[]>([]);
|
||||
const serviceClasses = ref<BusinessServiceClass[]>([]);
|
||||
const templates = ref<Awaited<ReturnType<typeof fetchServiceTemplates>>>([]);
|
||||
const loading = ref(true);
|
||||
const loadingServices = ref(false);
|
||||
const saving = ref(false);
|
||||
const savingBooking = ref(false);
|
||||
const savingService = ref(false);
|
||||
const addingService = ref(false);
|
||||
const addingCustom = ref(false);
|
||||
const serviceRequests = ref<BusinessServiceRequest[]>([]);
|
||||
const error = ref('');
|
||||
const message = ref('');
|
||||
const editingId = ref<string | null>(null);
|
||||
const servicesExpanded = ref(false);
|
||||
|
||||
const saveMessage = ref('');
|
||||
const serviceClassesEnabled = ref(false);
|
||||
const form = ref({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
description: '',
|
||||
contactPhone: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
});
|
||||
|
||||
const bookingForm = ref({
|
||||
concurrentCapacity: '1',
|
||||
slotStepMinutes: '30',
|
||||
});
|
||||
|
||||
const bookingExpanded = ref(false);
|
||||
|
||||
const serviceForm = ref({
|
||||
serviceTemplateId: '',
|
||||
price: '',
|
||||
durationMinutes: '',
|
||||
});
|
||||
|
||||
const editForm = ref({
|
||||
price: '',
|
||||
durationMinutes: '',
|
||||
});
|
||||
|
||||
const classPriceForm = ref<Record<string, { price: string; durationMinutes: string }>>({});
|
||||
const editClassPrices = ref<Record<string, { price: string; durationMinutes: string }>>({});
|
||||
|
||||
const customForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
price: '',
|
||||
durationMinutes: '',
|
||||
});
|
||||
|
||||
const availableTemplates = computed(() => {
|
||||
const used = new Set(services.value.map((s) => s.serviceTemplateId));
|
||||
return templates.value.filter((t) => !used.has(t.id));
|
||||
});
|
||||
|
||||
const activeClasses = computed(() => activeServiceClasses(serviceClasses.value));
|
||||
const hasServiceClasses = computed(
|
||||
() => business.value?.serviceClassesEnabled === true && activeClasses.value.length > 0,
|
||||
);
|
||||
|
||||
const servicesSummary = computed(() => {
|
||||
if (loadingServices.value) return '…';
|
||||
const count = services.value.length;
|
||||
if (count === 0) return 'هنوز خدمتی ثبت نشده';
|
||||
const activeCount = services.value.filter((s) => s.isActive).length;
|
||||
return `${count} خدمت · ${activeCount} فعال`;
|
||||
});
|
||||
|
||||
const bookingSummary = computed(
|
||||
() =>
|
||||
`${bookingForm.value.concurrentCapacity} همزمان · گام ${bookingForm.value.slotStepMinutes} دقیقه`,
|
||||
);
|
||||
|
||||
function emptyClassPriceForm() {
|
||||
const result: Record<string, { price: string; durationMinutes: string }> = {};
|
||||
for (const cls of activeClasses.value) {
|
||||
result[cls.id] = { price: '', durationMinutes: '' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildClassPricesPayload(
|
||||
form: Record<string, { price: string; durationMinutes: string }>,
|
||||
): ClassPriceItem[] | null {
|
||||
const prices: ClassPriceItem[] = [];
|
||||
for (const cls of activeClasses.value) {
|
||||
const row = form[cls.id];
|
||||
const price = parsePriceInput(row?.price ?? '');
|
||||
if (price === null) {
|
||||
return null;
|
||||
}
|
||||
const duration = parseOptionalMinutes(row?.durationMinutes ?? '');
|
||||
if (duration === null) {
|
||||
return null;
|
||||
}
|
||||
prices.push({
|
||||
classId: cls.id,
|
||||
price,
|
||||
durationMinutes: duration && duration > 0 ? duration : null,
|
||||
});
|
||||
}
|
||||
return prices;
|
||||
}
|
||||
|
||||
function onServiceClassesUpdated(classes: BusinessServiceClass[]) {
|
||||
serviceClasses.value = classes;
|
||||
classPriceForm.value = emptyClassPriceForm();
|
||||
}
|
||||
|
||||
async function onServiceClassesEnabledChange(value: boolean) {
|
||||
if (!business.value) return;
|
||||
async function loadBusiness() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
try {
|
||||
const items = await fetchMyBusinesses();
|
||||
business.value = items[0] ?? null;
|
||||
if (!business.value) {
|
||||
error.value = 'کسبوکاری برای مدیریت یافت نشد.';
|
||||
return;
|
||||
}
|
||||
form.value = {
|
||||
title: business.value.title,
|
||||
subtitle: business.value.subtitle ?? '',
|
||||
description: business.value.description ?? '',
|
||||
address: business.value.address ?? '',
|
||||
phone: business.value.phone ?? '',
|
||||
};
|
||||
serviceClassesEnabled.value = business.value.serviceClassesEnabled ?? false;
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'بارگذاری کسبوکار ناموفق بود.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setServiceClassesEnabled(value: boolean) {
|
||||
if (!business.value) return;
|
||||
const previous = serviceClassesEnabled.value;
|
||||
serviceClassesEnabled.value = value;
|
||||
try {
|
||||
business.value = await updateBusiness(business.value.id, {
|
||||
serviceClassesEnabled: value,
|
||||
});
|
||||
if (!value) {
|
||||
serviceClasses.value = [];
|
||||
classPriceForm.value = {};
|
||||
}
|
||||
message.value = value
|
||||
? 'کلاسبندی فعال شد. کلاسهای خود را تعریف کنید.'
|
||||
: 'کلاسبندی غیرفعال شد. قیمتگذاری به حالت عادی برمیگردد.';
|
||||
tenant.syncSingleBusiness(business.value);
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'تغییر وضعیت کلاسبندی ناموفق بود';
|
||||
serviceClassesEnabled.value = previous;
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : 'ذخیره وضعیت کلاسبندی ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServicesAndTemplates() {
|
||||
if (!business.value) return;
|
||||
loadingServices.value = true;
|
||||
try {
|
||||
const [svcList, tplList, reqs] = await Promise.all([
|
||||
fetchBusinessServices(business.value.id),
|
||||
fetchServiceTemplates(business.value.categoryId),
|
||||
fetchMyServiceRequests(),
|
||||
]);
|
||||
services.value = svcList;
|
||||
templates.value = tplList;
|
||||
serviceRequests.value = reqs;
|
||||
if (availableTemplates.value[0] && !serviceForm.value.serviceTemplateId) {
|
||||
serviceForm.value.serviceTemplateId = availableTemplates.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری خدمات';
|
||||
} finally {
|
||||
loadingServices.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const list = await fetchMyBusinesses();
|
||||
business.value = list[0] ?? null;
|
||||
if (business.value) {
|
||||
form.value = {
|
||||
title: business.value.title,
|
||||
description: business.value.description ?? '',
|
||||
contactPhone: business.value.contactPhone ?? '',
|
||||
address: business.value.address ?? '',
|
||||
};
|
||||
bookingForm.value = {
|
||||
concurrentCapacity: String(business.value.concurrentCapacity ?? 1),
|
||||
slotStepMinutes: String(business.value.slotStepMinutes ?? 30),
|
||||
};
|
||||
await loadServicesAndTemplates();
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function onSave() {
|
||||
async function saveBusiness() {
|
||||
if (!business.value) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
saveMessage.value = '';
|
||||
try {
|
||||
business.value = await updateBusiness(business.value.id, {
|
||||
title: form.value.title.trim(),
|
||||
subtitle: form.value.subtitle.trim() || null,
|
||||
description: form.value.description.trim() || null,
|
||||
contactPhone: form.value.contactPhone.trim() || null,
|
||||
address: form.value.address.trim() || null,
|
||||
phone: form.value.phone.trim() || null,
|
||||
});
|
||||
message.value = 'اطلاعات ذخیره شد.';
|
||||
tenant.syncSingleBusiness(business.value);
|
||||
saveMessage.value = 'تغییرات ذخیره شد.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
|
||||
error.value = e instanceof ApiError ? e.message : 'ذخیره ناموفق بود.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveBookingSettings() {
|
||||
if (!business.value) return;
|
||||
const concurrentCapacity = Number.parseInt(bookingForm.value.concurrentCapacity, 10);
|
||||
if (!Number.isFinite(concurrentCapacity) || concurrentCapacity < 1 || concurrentCapacity > 20) {
|
||||
error.value = 'پذیرش همزمان باید عددی بین ۱ تا ۲۰ باشد.';
|
||||
return;
|
||||
}
|
||||
const slotStepMinutes = Number.parseInt(bookingForm.value.slotStepMinutes, 10);
|
||||
if (!Number.isFinite(slotStepMinutes) || slotStepMinutes < 5 || slotStepMinutes > 120) {
|
||||
error.value = 'گام نوبتدهی باید عددی بین ۵ تا ۱۲۰ دقیقه باشد.';
|
||||
return;
|
||||
}
|
||||
savingBooking.value = true;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
try {
|
||||
business.value = await updateBusiness(business.value.id, {
|
||||
concurrentCapacity,
|
||||
slotStepMinutes,
|
||||
});
|
||||
message.value = 'تنظیمات نوبتدهی ذخیره شد.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
|
||||
} finally {
|
||||
savingBooking.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddService() {
|
||||
if (!business.value || !serviceForm.value.serviceTemplateId) return;
|
||||
|
||||
let price = 0;
|
||||
let duration: number | null | undefined;
|
||||
let classPrices: ClassPriceItem[] | null = null;
|
||||
|
||||
if (hasServiceClasses.value) {
|
||||
classPrices = buildClassPricesPayload(classPriceForm.value);
|
||||
if (!classPrices?.length) {
|
||||
error.value = 'قیمت همه کلاسها را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
price = Math.min(...classPrices.map((item) => item.price));
|
||||
} else {
|
||||
const parsedPrice = parsePriceInput(serviceForm.value.price);
|
||||
if (parsedPrice === null) {
|
||||
error.value = 'قیمت معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
price = parsedPrice;
|
||||
duration = parseOptionalMinutes(serviceForm.value.durationMinutes);
|
||||
if (duration === null) {
|
||||
error.value = 'مدت زمان معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
addingService.value = true;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
try {
|
||||
const created = await createBusinessService({
|
||||
businessId: business.value.id,
|
||||
serviceTemplateId: serviceForm.value.serviceTemplateId,
|
||||
price,
|
||||
durationMinutes: duration && duration > 0 ? duration : undefined,
|
||||
});
|
||||
if (classPrices?.length) {
|
||||
await setBusinessServiceClassPrices(created.id, classPrices);
|
||||
}
|
||||
serviceForm.value = { serviceTemplateId: '', price: '', durationMinutes: '' };
|
||||
classPriceForm.value = emptyClassPriceForm();
|
||||
await loadServicesAndTemplates();
|
||||
if (availableTemplates.value[0]) {
|
||||
serviceForm.value.serviceTemplateId = availableTemplates.value[0].id;
|
||||
}
|
||||
message.value = 'خدمت اضافه شد.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در افزودن خدمت';
|
||||
} finally {
|
||||
addingService.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestStatusLabel(status: BusinessServiceRequestStatus): string {
|
||||
const map: Record<BusinessServiceRequestStatus, string> = {
|
||||
pending: 'در انتظار تأیید',
|
||||
approved: 'تأیید و اضافه شد',
|
||||
rejected: 'رد شده',
|
||||
};
|
||||
return map[status];
|
||||
}
|
||||
|
||||
function requestStatusClass(status: BusinessServiceRequestStatus): string {
|
||||
if (status === 'approved') return 'bg-green-500/15 text-green-700';
|
||||
if (status === 'rejected') return 'bg-red-500/15 text-red-600';
|
||||
return 'bg-amber-500/15 text-amber-700';
|
||||
}
|
||||
|
||||
async function onAddCustomService() {
|
||||
const title = customForm.value.title.trim();
|
||||
const price = parsePriceInput(customForm.value.price);
|
||||
const duration = parseOptionalMinutes(customForm.value.durationMinutes);
|
||||
if (!title || price === null) {
|
||||
error.value = 'نام و قیمت معتبر را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
if (duration === null) {
|
||||
error.value = 'مدت زمان معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
addingCustom.value = true;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
try {
|
||||
await createServiceRequest({
|
||||
title,
|
||||
description: customForm.value.description.trim() || undefined,
|
||||
price,
|
||||
durationMinutes: duration && duration > 0 ? duration : undefined,
|
||||
});
|
||||
customForm.value = { title: '', description: '', price: '', durationMinutes: '' };
|
||||
await loadServicesAndTemplates();
|
||||
message.value = 'درخواست ارسال شد. پس از تأیید مدیر در لیست خدمات نمایش داده میشود.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'ارسال درخواست ناموفق بود';
|
||||
} finally {
|
||||
addingCustom.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(svc: BusinessService) {
|
||||
editingId.value = svc.id;
|
||||
editForm.value = {
|
||||
price: formatPriceInputDisplay(Math.round(Number.parseFloat(svc.price))),
|
||||
durationMinutes: svc.durationMinutes != null ? String(svc.durationMinutes) : '',
|
||||
};
|
||||
const form = emptyClassPriceForm();
|
||||
for (const classPrice of svc.classPrices ?? []) {
|
||||
if (form[classPrice.classId]) {
|
||||
form[classPrice.classId] = {
|
||||
price: formatPriceInputDisplay(Math.round(Number.parseFloat(classPrice.price))),
|
||||
durationMinutes:
|
||||
classPrice.durationMinutes != null ? String(classPrice.durationMinutes) : '',
|
||||
};
|
||||
}
|
||||
}
|
||||
editClassPrices.value = form;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null;
|
||||
}
|
||||
|
||||
async function onSaveEdit(svc: BusinessService) {
|
||||
savingService.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
if (hasServiceClasses.value) {
|
||||
const classPrices = buildClassPricesPayload(editClassPrices.value);
|
||||
if (!classPrices?.length) {
|
||||
error.value = 'قیمت همه کلاسها را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
await setBusinessServiceClassPrices(svc.id, classPrices);
|
||||
} else {
|
||||
const price = parsePriceInput(editForm.value.price);
|
||||
if (price === null) {
|
||||
error.value = 'قیمت معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
const duration = parseOptionalMinutes(editForm.value.durationMinutes);
|
||||
if (duration === null) {
|
||||
error.value = 'مدت زمان معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
await updateBusinessService(svc.id, {
|
||||
price,
|
||||
durationMinutes: duration ?? null,
|
||||
});
|
||||
}
|
||||
editingId.value = null;
|
||||
await loadServicesAndTemplates();
|
||||
message.value = 'خدمت بهروزرسانی شد.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در ویرایش';
|
||||
} finally {
|
||||
savingService.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleActive(svc: BusinessService) {
|
||||
error.value = '';
|
||||
try {
|
||||
await updateBusinessService(svc.id, { isActive: !svc.isActive });
|
||||
await loadServicesAndTemplates();
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در تغییر وضعیت';
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(svc: BusinessService) {
|
||||
const title = svc.serviceTemplate?.title ?? 'این خدمت';
|
||||
if (!confirm(`«${title}» حذف شود؟`)) return;
|
||||
error.value = '';
|
||||
try {
|
||||
await deleteBusinessService(svc.id);
|
||||
await loadServicesAndTemplates();
|
||||
message.value = 'خدمت حذف شد.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در حذف';
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
void loadBusiness();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<WebAppLayout>
|
||||
<h1 class="mb-4 text-xl font-bold text-text">پروفایل</h1>
|
||||
|
||||
<p
|
||||
v-if="message"
|
||||
class="mb-4 rounded-xl border border-brand-500/40 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
|
||||
>
|
||||
{{ message }}
|
||||
</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>
|
||||
|
||||
<div v-if="auth.user" class="space-y-4">
|
||||
<dl class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<dt class="shrink-0 text-xs text-muted">موبایل</dt>
|
||||
<dd class="font-medium" dir="ltr">{{ auth.user.mobile }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<dt class="shrink-0 text-xs text-muted">نقش</dt>
|
||||
<dd class="font-medium">{{ roleLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section
|
||||
v-if="auth.isCustomer"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-text">کسبوکار من</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
میتوانید درخواست ثبت کسبوکار دهید تا پس از تأیید مدیریت، پنل صاحب کسبوکار فعال شود.
|
||||
</p>
|
||||
<RouterLink
|
||||
to="/app/register-business"
|
||||
class="mt-3 inline-flex w-full items-center justify-center rounded-xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white"
|
||||
>
|
||||
ثبت کسبوکار من
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="auth.isBusinessOwner"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-text">مدیریت کسبوکار</h2>
|
||||
<RouterLink
|
||||
to="/app/owner"
|
||||
class="mt-3 inline-flex w-full items-center justify-center rounded-xl border border-border px-4 py-3 text-sm font-semibold text-text hover:bg-surface-3"
|
||||
>
|
||||
ورود به پنل کسبوکار
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
|
||||
<h2 class="text-sm font-semibold text-text">
|
||||
{{ auth.user.hasPassword ? 'تغییر رمز عبور' : 'تنظیم رمز عبور' }}
|
||||
</h2>
|
||||
<p class="text-xs text-muted">
|
||||
پس از تنظیم رمز، ورود با رمز عبور یا کد یکبارمصرف هر دو ممکن است.
|
||||
</p>
|
||||
|
||||
<AppInput id="newPassword" v-model="newPassword" label="رمز جدید" type="password" ltr />
|
||||
<AppInput
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
label="تکرار رمز جدید"
|
||||
type="password"
|
||||
ltr
|
||||
/>
|
||||
<AppButton :loading="saving" @click="onSavePassword">
|
||||
{{ auth.user.hasPassword ? 'بهروزرسانی رمز' : 'ذخیره رمز' }}
|
||||
</AppButton>
|
||||
</section>
|
||||
</div>
|
||||
</WebAppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { setPassword } from '@/api/auth';
|
||||
import { ApiError } from '@/api/client';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const message = ref('');
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
switch (auth.user?.role) {
|
||||
case 'admin':
|
||||
return 'مدیر سیستم';
|
||||
case 'business_owner':
|
||||
return 'صاحب کسبوکار';
|
||||
default:
|
||||
return 'مشتری';
|
||||
}
|
||||
});
|
||||
|
||||
async function onSavePassword() {
|
||||
if (!auth.user) return;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
|
||||
if (newPassword.value.length < 8) {
|
||||
error.value = 'رمز باید حداقل ۸ کاراکتر باشد.';
|
||||
return;
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = 'تکرار رمز با رمز جدید یکسان نیست.';
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const updated = await setPassword({
|
||||
password: newPassword.value,
|
||||
confirmPassword: confirmPassword.value,
|
||||
});
|
||||
auth.setUser(updated);
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
message.value = 'رمز عبور ذخیره شد.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره رمز';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,198 +1,101 @@
|
||||
<template>
|
||||
<WebAppLayout>
|
||||
<h1 class="mb-2 text-xl font-bold text-text">ثبت کسبوکار</h1>
|
||||
<p class="mb-4 text-sm text-muted">
|
||||
درخواست شما پس از بررسی توسط مدیریت منتایم فعال میشود.
|
||||
</p>
|
||||
<ResponsiveAppLayout>
|
||||
<h1 class="mb-1 text-xl font-bold text-text">ثبت کسبوکار</h1>
|
||||
<p class="mb-6 text-sm text-muted">درخواست شما پس از بررسی ادمین تأیید میشود.</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>
|
||||
|
||||
<FormPageSkeleton v-if="loading" />
|
||||
|
||||
<div
|
||||
v-else-if="pendingRequest"
|
||||
class="space-y-4 rounded-2xl border border-amber-500/30 bg-amber-500/10 p-4"
|
||||
>
|
||||
<p class="font-semibold text-text">در انتظار تأیید</p>
|
||||
<p class="text-sm text-muted">
|
||||
درخواست «{{ pendingRequest.title }}» ثبت شده و در حال بررسی است.
|
||||
<form class="mx-auto max-w-lg space-y-4" @submit.prevent="onSubmit">
|
||||
<p
|
||||
v-if="message"
|
||||
class="rounded-xl border border-brand-500/40 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
پس از تأیید، پیامک دریافت میکنید و تب «کسبوکار» در اپ فعال میشود.
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="lastRejected"
|
||||
class="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
|
||||
>
|
||||
آخرین درخواست «{{ lastRejected.title }}» رد شد.
|
||||
<span v-if="lastRejected.adminNote"> — {{ lastRejected.adminNote }}</span>
|
||||
میتوانید دوباره درخواست دهید.
|
||||
</div>
|
||||
|
||||
<form v-if="showForm" class="space-y-4" @submit.prevent="onSubmit">
|
||||
<AppInput id="bizTitle" v-model="form.title" label="نام کسبوکار" required @blur="maybeSlug" />
|
||||
|
||||
<AppInput
|
||||
id="bizSlug"
|
||||
v-model="form.slug"
|
||||
label="اسلاگ (انگلیسی)"
|
||||
ltr
|
||||
placeholder="اختیاری — از عنوان ساخته میشود"
|
||||
@input="slugTouched = true"
|
||||
/>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="bizCategory" class="block text-sm font-medium text-muted">دسته</label>
|
||||
<select
|
||||
id="bizCategory"
|
||||
v-model="form.categoryId"
|
||||
required
|
||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
>
|
||||
<option value="" disabled>انتخاب دسته</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="bizDesc" class="block text-sm font-medium text-muted">توضیحات</label>
|
||||
<textarea
|
||||
id="bizDesc"
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppInput
|
||||
id="bizPhone"
|
||||
v-model="form.contactPhone"
|
||||
label="تلفن تماس"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
ltr
|
||||
<AppInput id="bizTitle" v-model="form.businessTitle" label="نام کسبوکار" required />
|
||||
<AppSelect
|
||||
id="bizCategory"
|
||||
v-model="form.categoryId"
|
||||
label="دستهبندی"
|
||||
:options="categoryOptions"
|
||||
required
|
||||
/>
|
||||
<AppTextarea id="bizDescription" v-model="form.description" label="توضیحات" />
|
||||
<AppInput id="bizAddress" v-model="form.address" label="آدرس" />
|
||||
<AppInput id="bizPhone" v-model="form.phone" label="تلفن" ltr />
|
||||
|
||||
<AppButton type="submit" :loading="saving">ارسال درخواست</AppButton>
|
||||
<AppButton type="submit" :loading="loading">ارسال درخواست</AppButton>
|
||||
</form>
|
||||
</WebAppLayout>
|
||||
</ResponsiveAppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { fetchBusinessCategories } from '@/api/business-categories';
|
||||
import {
|
||||
fetchMyBusinessRegistrations,
|
||||
submitBusinessRegistration,
|
||||
} from '@/api/business-registrations';
|
||||
import type { BusinessCategory, BusinessRegistrationRequest } from '@/api/types';
|
||||
import { submitBusinessRegistration } from '@/api/business-registrations';
|
||||
import { ApiError } from '@/api/client';
|
||||
import type { BusinessCategory } from '@/api/types';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import FormPageSkeleton from '@/components/ui/FormPageSkeleton.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { isValidSlug, optionalSlug, slugify } from '@/utils/slug';
|
||||
import AppSelect from '@/components/ui/AppSelect.vue';
|
||||
import AppTextarea from '@/components/ui/AppTextarea.vue';
|
||||
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const slugTouched = ref(false);
|
||||
const categories = ref<BusinessCategory[]>([]);
|
||||
const requests = ref<BusinessRegistrationRequest[]>([]);
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const message = ref('');
|
||||
const form = ref({
|
||||
title: '',
|
||||
slug: '',
|
||||
businessTitle: '',
|
||||
categoryId: '',
|
||||
description: '',
|
||||
contactPhone: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
});
|
||||
|
||||
const pendingRequest = computed(() =>
|
||||
requests.value.find((r) => r.status === 'pending'),
|
||||
const categoryOptions = computed(() =>
|
||||
categories.value
|
||||
.filter((category) => category.isActive)
|
||||
.map((category) => ({ value: category.id, label: category.title })),
|
||||
);
|
||||
|
||||
const lastRejected = computed(() =>
|
||||
requests.value.find((r) => r.status === 'rejected'),
|
||||
);
|
||||
|
||||
const showForm = computed(
|
||||
() => !pendingRequest.value && auth.isCustomer,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth.isBusinessOwner) {
|
||||
await router.replace({ name: 'app-owner' });
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
async function onSubmit() {
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
const [cats, mine] = await Promise.all([
|
||||
fetchBusinessCategories(),
|
||||
fetchMyBusinessRegistrations(),
|
||||
]);
|
||||
categories.value = cats.filter((c) => c.isActive);
|
||||
requests.value = mine;
|
||||
if (categories.value[0] && !form.value.categoryId) {
|
||||
form.value.categoryId = categories.value[0].id;
|
||||
}
|
||||
await submitBusinessRegistration({
|
||||
businessTitle: form.value.businessTitle.trim(),
|
||||
categoryId: form.value.categoryId,
|
||||
description: form.value.description.trim() || undefined,
|
||||
address: form.value.address.trim() || undefined,
|
||||
phone: form.value.phone.trim() || undefined,
|
||||
});
|
||||
message.value = 'درخواست شما ثبت شد. پس از بررسی نتیجه اعلام میشود.';
|
||||
form.value = {
|
||||
businessTitle: '',
|
||||
categoryId: '',
|
||||
description: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
};
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
|
||||
error.value = e instanceof ApiError ? e.message : 'ثبت درخواست ناموفق بود.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function maybeSlug() {
|
||||
if (!slugTouched.value && !form.value.slug.trim()) {
|
||||
form.value.slug = slugify(form.value.title);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.title.trim() || !form.value.categoryId) {
|
||||
error.value = 'نام و دسته را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
const slug = optionalSlug(form.value.slug);
|
||||
if (slug && !isValidSlug(slug)) {
|
||||
error.value = 'اسلاگ فقط حروف کوچک انگلیسی و خط تیره باشد.';
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await submitBusinessRegistration({
|
||||
title: form.value.title.trim(),
|
||||
categoryId: form.value.categoryId,
|
||||
...(slug ? { slug } : {}),
|
||||
description: form.value.description.trim() || undefined,
|
||||
contactPhone: form.value.contactPhone.trim() || undefined,
|
||||
address: form.value.address.trim() || undefined,
|
||||
});
|
||||
requests.value = await fetchMyBusinessRegistrations();
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در ارسال درخواست';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
categories.value = await fetchBusinessCategories();
|
||||
} catch {
|
||||
error.value = 'بارگذاری دستهبندیها ناموفق بود.';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<AuthLayout brand subtitle="ورود به حساب کاربری">
|
||||
<div class="flex flex-1 flex-col justify-start gap-8 pt-10">
|
||||
<section class="flex flex-col items-center text-center">
|
||||
<AppLogo variant="vertical" class="mb-3 h-28 w-auto object-contain" />
|
||||
<ResponsiveAuthLayout brand subtitle="ورود به حساب کاربری">
|
||||
<div class="flex flex-1 flex-col justify-start gap-8">
|
||||
<section class="flex flex-col items-center gap-4 text-center">
|
||||
<AppLogo variant="vertical" class="h-24 w-auto object-contain lg:h-28" />
|
||||
<AppWelcomeMessage />
|
||||
</section>
|
||||
|
||||
@@ -18,32 +18,32 @@
|
||||
<AuthModeSwitch v-model="mode" />
|
||||
|
||||
<form class="flex flex-col gap-4" @submit.prevent="onSubmit">
|
||||
<AppInput
|
||||
id="mobile"
|
||||
v-model="mobile"
|
||||
label="شماره موبایل"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
placeholder="09121234567"
|
||||
autocomplete="tel"
|
||||
ltr
|
||||
required
|
||||
/>
|
||||
<AppInput
|
||||
id="mobile"
|
||||
v-model="mobile"
|
||||
label="شماره موبایل"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
placeholder="09121234567"
|
||||
autocomplete="tel"
|
||||
ltr
|
||||
required
|
||||
/>
|
||||
|
||||
<AppInput
|
||||
v-if="mode === 'password'"
|
||||
id="password"
|
||||
v-model="password"
|
||||
label="رمز عبور"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
ltr
|
||||
required
|
||||
/>
|
||||
<AppInput
|
||||
v-if="mode === 'password'"
|
||||
id="password"
|
||||
v-model="password"
|
||||
label="رمز عبور"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
ltr
|
||||
required
|
||||
/>
|
||||
|
||||
<AppButton type="submit" :loading="loading">
|
||||
{{ mode === 'otp' ? 'دریافت کد تأیید' : 'ورود' }}
|
||||
</AppButton>
|
||||
<AppButton type="submit" :loading="loading">
|
||||
{{ mode === 'otp' ? 'دریافت کد تأیید' : 'ورود' }}
|
||||
</AppButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -51,25 +51,31 @@
|
||||
اولین بار؟ با کد یکبارمصرف وارد شوید و در پروفایل رمز بسازید.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<AppButton variant="ghost" @click="router.push({ name: 'app-home' })">
|
||||
ادامه بدون ورود
|
||||
</AppButton>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</ResponsiveAuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { loginWithPassword, requestOtp } from '@/api/auth';
|
||||
import { isValidIranMobile, normalizeIranMobile } from '@/utils/mobile';
|
||||
import { ApiError } from '@/api/client';
|
||||
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||
import AppWelcomeMessage from '@/components/brand/AppWelcomeMessage.vue';
|
||||
import AuthModeSwitch from '@/components/auth/AuthModeSwitch.vue';
|
||||
import type { AuthLoginMode } from '@/components/auth/AuthModeSwitch.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||
import AppWelcomeMessage from '@/components/brand/AppWelcomeMessage.vue';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import ResponsiveAuthLayout from '@/layouts/ResponsiveAuthLayout.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { redirectFromQuery } from '@/utils/auth-redirect';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const mode = ref<AuthLoginMode>('otp');
|
||||
@@ -95,6 +101,7 @@ async function onSubmit() {
|
||||
query: {
|
||||
mobile: normalized,
|
||||
expires: String(res.expiresInSeconds),
|
||||
...(route.query.redirect ? { redirect: route.query.redirect } : {}),
|
||||
...(res.otpCode ? { devCode: res.otpCode } : {}),
|
||||
},
|
||||
});
|
||||
@@ -103,7 +110,7 @@ async function onSubmit() {
|
||||
|
||||
const res = await loginWithPassword(normalized, password.value);
|
||||
auth.setSession(res.accessToken, res.user);
|
||||
await router.replace({ name: 'app-home' });
|
||||
await router.replace(redirectFromQuery(route.query));
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : 'خطا در ورود. دوباره تلاش کنید.';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AuthLayout title="کد تأیید" :subtitle="`کد ارسالشده به ${mobile}`">
|
||||
<ResponsiveAuthLayout title="کد تأیید" :subtitle="`کد ارسالشده به ${mobile}`">
|
||||
<p
|
||||
v-if="devCode"
|
||||
class="mb-4 rounded-xl border border-brand-500/40 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
|
||||
@@ -31,10 +31,14 @@
|
||||
ارسال مجدد کد
|
||||
</AppButton>
|
||||
|
||||
<AppButton variant="ghost" class="mt-2" @click="router.push({ name: 'login' })">
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
class="mt-2"
|
||||
@click="router.push({ name: 'login', query: route.query.redirect ? { redirect: route.query.redirect } : {} })"
|
||||
>
|
||||
تغییر شماره موبایل
|
||||
</AppButton>
|
||||
</AuthLayout>
|
||||
</ResponsiveAuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -44,8 +48,9 @@ import { requestOtp, verifyOtp } from '@/api/auth';
|
||||
import { ApiError } from '@/api/client';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import OtpPinInput from '@/components/auth/OtpPinInput.vue';
|
||||
import AuthLayout from '@/layouts/AuthLayout.vue';
|
||||
import ResponsiveAuthLayout from '@/layouts/ResponsiveAuthLayout.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { redirectFromQuery } from '@/utils/auth-redirect';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -64,7 +69,10 @@ let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
if (!mobile.value) {
|
||||
router.replace({ name: 'login' });
|
||||
await router.replace({
|
||||
name: 'login',
|
||||
query: route.query.redirect ? { redirect: route.query.redirect } : {},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -98,7 +106,7 @@ async function onSubmit() {
|
||||
try {
|
||||
const res = await verifyOtp(mobile.value, code.value);
|
||||
auth.setSession(res.accessToken, res.user);
|
||||
await router.replace({ name: 'app-home' });
|
||||
await router.replace(redirectFromQuery(route.query));
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : 'کد نامعتبر است. دوباره تلاش کنید.';
|
||||
@@ -119,7 +127,11 @@ async function resend() {
|
||||
pinRef.value?.clear();
|
||||
if (res.otpCode) {
|
||||
await router.replace({
|
||||
query: { ...route.query, devCode: res.otpCode, expires: String(res.expiresInSeconds) },
|
||||
query: {
|
||||
...route.query,
|
||||
devCode: res.otpCode,
|
||||
expires: String(res.expiresInSeconds),
|
||||
},
|
||||
});
|
||||
code.value = res.otpCode.replace(/\D/g, '').slice(0, 4);
|
||||
await nextTick();
|
||||
|
||||
+23
-15
@@ -1,31 +1,39 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Vazirmatn', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
sans: ['IRANYekanX', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
maxWidth: {
|
||||
app: '768px',
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#ecfdf5',
|
||||
100: '#d1fae5',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
},
|
||||
border: 'var(--color-border)',
|
||||
text: 'var(--color-text)',
|
||||
muted: 'var(--color-muted)',
|
||||
surface: {
|
||||
DEFAULT: 'var(--color-surface)',
|
||||
2: 'var(--color-surface-2)',
|
||||
3: 'var(--color-surface-3)',
|
||||
},
|
||||
border: 'var(--color-border)',
|
||||
text: 'var(--color-text)',
|
||||
muted: 'var(--color-muted)',
|
||||
},
|
||||
maxWidth: {
|
||||
app: '768px',
|
||||
brand: {
|
||||
50: '#e6f7ee',
|
||||
100: '#ccefdc',
|
||||
400: '#22c55e',
|
||||
500: '#00a651',
|
||||
600: '#009640',
|
||||
700: '#007a35',
|
||||
},
|
||||
landing: {
|
||||
navy: '#001f3f',
|
||||
ink: '#1e293b',
|
||||
muted: '#64748b',
|
||||
line: '#e2e8f0',
|
||||
surface: '#f8fafc',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
|
||||
Reference in New Issue
Block a user