landing desktop

This commit is contained in:
2026-06-12 20:16:14 +03:30
parent 820e80ac16
commit 5eec309b54
89 changed files with 4343 additions and 2309 deletions
+2 -8
View File
@@ -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
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
<html><body>404</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 875 KiB

+4 -1
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
import { api } from './client';
export type PlatformSettings = {
loginHeroImageUrl: string | null;
};
export function fetchPlatformSettings() {
return api<PlatformSettings>('/platform-settings');
}
+82 -103
View File
@@ -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>
+85 -47
View File
@@ -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>
+58
View File
@@ -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>
+6 -1
View File
@@ -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<{
+21
View File
@@ -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>
+62
View File
@@ -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>
+52
View File
@@ -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>
+39
View File
@@ -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>
+135
View File
@@ -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>
+23
View File
@@ -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>
+47
View File
@@ -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>
+120
View File
@@ -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>
+13
View File
@@ -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>
+158
View File
@@ -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>
+6 -4
View File
@@ -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>
+43
View File
@@ -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>
+103
View File
@@ -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>
+3 -1
View File
@@ -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';
}
+38 -3
View File
@@ -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];
});
+8 -1
View File
@@ -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>
+36
View File
@@ -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>
+29
View File
@@ -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>
+179
View File
@@ -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,
};
}
+27
View File
@@ -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);
}
});
}
+59
View File
@@ -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 };
}
+33
View File
@@ -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: 'کارواش نیوواش',
};
+3 -4
View File
@@ -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>
+48
View File
@@ -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>
␍
+39
View File
@@ -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>
+17
View File
@@ -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>
+41
View File
@@ -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>
+43 -58
View File
@@ -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>
+3
View File
@@ -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
View File
@@ -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) {
+41
View File
@@ -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,
};
});
+20
View File
@@ -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 };
});
+75
View File
@@ -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,
};
});
+55
View File
@@ -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;
}
+38
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+10
View File
@@ -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;
};
+19
View File
@@ -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 },
};
}
+45
View File
@@ -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;
}
+58
View File
@@ -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));
}
+56
View File
@@ -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
View File
@@ -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);
}
+12
View File
@@ -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);
}
+90 -82
View File
@@ -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>
+41 -480
View File
@@ -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
View File
@@ -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
View File
@@ -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>
-140
View File
@@ -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>
+63 -160
View File
@@ -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>
+41 -34
View File
@@ -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 : 'خطا در ورود. دوباره تلاش کنید.';
+19 -7
View File
@@ -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
View File
@@ -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',
},
},
},
},
-6
View File
@@ -1,10 +1,4 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },