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
@@ -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>