landing desktop
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<aside class="hidden min-h-dvh p-3 pe-1 lg:block">
|
||||
<div
|
||||
class="login-hero-panel relative h-full min-h-[calc(100dvh-1.5rem)] overflow-hidden bg-slate-950 shadow-[0_20px_50px_-18px_rgba(0,0,0,0.55)]"
|
||||
>
|
||||
<img
|
||||
:src="heroSrc"
|
||||
alt=""
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
@error="onImageError"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-slate-950/88 via-slate-900/72 to-emerald-950/50"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="login-hero-panel pointer-events-none absolute inset-0 border-b-[3px] border-e-[3px] border-brand-500/55"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-[1] flex h-full min-h-[calc(100dvh-1.5rem)] flex-col px-8 pb-8 pt-4 xl:px-11 xl:pb-10 xl:pt-5"
|
||||
dir="rtl"
|
||||
>
|
||||
<div
|
||||
class="grid w-full grid-cols-[auto_minmax(0,1fr)] items-start gap-x-6 gap-y-4"
|
||||
dir="ltr"
|
||||
>
|
||||
<div class="flex shrink-0 items-center gap-3 pt-1">
|
||||
<img
|
||||
src="/LogoDark.png"
|
||||
alt=""
|
||||
class="h-11 w-11 shrink-0 rounded-xl object-cover"
|
||||
/>
|
||||
<img
|
||||
:src="LOGIN_WELCOME_WORDMARK"
|
||||
alt="منتایم"
|
||||
class="h-8 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="max-w-lg justify-self-end text-right" dir="rtl">
|
||||
<h1 class="text-[2.35rem] font-black leading-[1.35] text-white xl:text-[2.75rem]">
|
||||
<span class="block">وقت شما</span>
|
||||
<span class="block text-brand-500">ارزشمند است</span>
|
||||
</h1>
|
||||
|
||||
<p class="mt-0.5 text-[15px] font-medium leading-7 text-white/85 xl:text-base">
|
||||
نوبتگیری سریع و مدیریت آسان خدمات
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-0 ms-20 mt-auto grid max-w-[calc(100%-2rem)] grid-cols-3 gap-3 pt-5 xl:mb-1 xl:ms-24 xl:gap-4 xl:pt-6"
|
||||
>
|
||||
<div
|
||||
v-for="item in features"
|
||||
:key="item.label"
|
||||
class="flex flex-col items-center space-y-2 text-center"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/10 text-brand-500 ring-1 ring-white/15"
|
||||
>
|
||||
<AppIcon :name="item.icon" size="sm" />
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-white">{{ item.label }}</p>
|
||||
<p class="text-xs leading-5 text-white/70">
|
||||
<span
|
||||
v-for="(line, lineIndex) in item.description"
|
||||
:key="lineIndex"
|
||||
class="block"
|
||||
>
|
||||
{{ line }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { usePlatformSettingsStore } from '@/stores/platform-settings';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
const LOGIN_WELCOME_WORDMARK = '/welcome%20lite.png';
|
||||
|
||||
const platformSettings = usePlatformSettingsStore();
|
||||
const { loginHeroSrc } = storeToRefs(platformSettings);
|
||||
|
||||
const heroSrc = ref(loginHeroSrc.value);
|
||||
const fallbackUsed = ref(false);
|
||||
|
||||
watch(loginHeroSrc, (src) => {
|
||||
fallbackUsed.value = false;
|
||||
heroSrc.value = src;
|
||||
});
|
||||
|
||||
const features: { label: string; description: string[]; icon: AppIconName }[] = [
|
||||
{
|
||||
label: 'نوبتگیری سریع',
|
||||
description: ['در کمتر از یک دقیقه', 'نوبت خود را رزرو کنید'],
|
||||
icon: 'sparkles',
|
||||
},
|
||||
{
|
||||
label: 'اطمینان از کیفیت',
|
||||
description: ['همکاری با بهترین', 'ارائهدهندگان خدمات'],
|
||||
icon: 'shield',
|
||||
},
|
||||
{
|
||||
label: 'مدیریت رزروها',
|
||||
description: ['همه نوبتهای شما', 'در یکجا'],
|
||||
icon: 'calendar',
|
||||
},
|
||||
];
|
||||
|
||||
function onImageError() {
|
||||
if (fallbackUsed.value) return;
|
||||
fallbackUsed.value = true;
|
||||
heroSrc.value = '/landing/hero-car-wash.jpg';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-hero-panel {
|
||||
border-top-left-radius: 1rem;
|
||||
border-bottom-left-radius: 1rem;
|
||||
border-bottom-right-radius: 15rem;
|
||||
}
|
||||
|
||||
.login-hero-title {
|
||||
margin: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.login-hero-title span + span {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.login-hero-subtitle {
|
||||
margin: 0;
|
||||
padding-top: 0.125rem;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.login-hero-panel {
|
||||
border-bottom-right-radius: 19rem;
|
||||
}
|
||||
|
||||
.login-hero-subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,45 +1,71 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-text">
|
||||
{{ booking.business?.title ?? 'کسبوکار' }}
|
||||
<div class="flex h-full min-w-0 flex-col">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="line-clamp-2 font-semibold text-text">
|
||||
{{ primary.business?.title ?? 'کسبوکار' }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-sm text-muted">
|
||||
{{ serviceTitle }}
|
||||
</p>
|
||||
<p v-if="groupHint" class="mt-0.5 text-xs text-brand-600 dark:text-brand-500">
|
||||
{{ groupHint }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ formatDate(booking.scheduledAt) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCustomer && booking.customer?.mobile"
|
||||
class="mt-1 text-xs text-muted"
|
||||
dir="ltr"
|
||||
>
|
||||
{{ booking.customer.fullName ? `${booking.customer.fullName} · ` : '' }}{{ booking.customer.mobile }}
|
||||
</p>
|
||||
<p v-if="booking.note" class="mt-2 text-xs text-muted">{{ booking.note }}</p>
|
||||
<span
|
||||
class="mt-2 inline-block rounded-lg px-2 py-0.5 text-xs"
|
||||
:class="statusClass"
|
||||
v-if="!showPerItemStatus"
|
||||
class="shrink-0 rounded-lg px-2 py-0.5 text-[10px]"
|
||||
:class="statusClassFor(groupStatus)"
|
||||
>
|
||||
{{ bookingStatusLabel(booking.status) }}
|
||||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="classTitle" class="mt-1 text-xs font-medium text-brand-600">
|
||||
کلاس {{ classTitle }}
|
||||
</p>
|
||||
|
||||
<ul v-if="isGroup" class="mt-1 space-y-0.5">
|
||||
<li
|
||||
v-for="booking in bookings"
|
||||
:key="booking.id"
|
||||
class="flex items-start justify-between gap-2 text-xs text-muted"
|
||||
>
|
||||
<span class="line-clamp-2">{{ serviceTitleForBooking(booking) }}</span>
|
||||
<span
|
||||
v-if="showPerItemStatus"
|
||||
class="shrink-0 rounded-md px-1.5 py-0.5 text-[10px]"
|
||||
:class="statusClassFor(booking.status)"
|
||||
>
|
||||
{{ bookingStatusLabel(booking.status) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="mt-1 line-clamp-2 text-sm text-muted">
|
||||
{{ serviceTitleForBooking(primary) }}
|
||||
</p>
|
||||
|
||||
<p class="mt-2 text-xs text-muted">
|
||||
{{ formatDate(primary.scheduledAt) }}
|
||||
</p>
|
||||
<p
|
||||
v-if="showCustomer && primary.customer?.mobile"
|
||||
class="mt-1 truncate text-xs text-muted"
|
||||
dir="ltr"
|
||||
>
|
||||
{{
|
||||
primary.customer.fullName
|
||||
? `${primary.customer.fullName} · `
|
||||
: ''
|
||||
}}{{ primary.customer.mobile }}
|
||||
</p>
|
||||
<p v-if="primary.note" class="mt-2 line-clamp-2 text-xs text-muted">
|
||||
{{ primary.note }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="canConfirm || canCancel"
|
||||
class="mt-3 flex flex-wrap gap-2 border-t border-border pt-3"
|
||||
class="mt-auto flex flex-wrap gap-2 border-t border-border pt-3"
|
||||
>
|
||||
<AppButton
|
||||
v-if="canConfirm"
|
||||
class="!w-auto flex-1 px-3"
|
||||
:loading="actingId === booking.id"
|
||||
@click="emit('confirm', booking)"
|
||||
:loading="acting"
|
||||
@click="emit('confirm', bookings)"
|
||||
>
|
||||
تأیید
|
||||
</AppButton>
|
||||
@@ -47,8 +73,8 @@
|
||||
v-if="canCancel"
|
||||
variant="danger"
|
||||
class="!w-auto flex-1 px-3"
|
||||
:loading="actingId === booking.id"
|
||||
@click="emit('cancel', booking)"
|
||||
:loading="acting"
|
||||
@click="emit('cancel', bookings)"
|
||||
>
|
||||
لغو
|
||||
</AppButton>
|
||||
@@ -58,37 +84,46 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Booking } from '@/api/types';
|
||||
import type { Booking, BookingStatus } from '@/api/types';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import {
|
||||
bookingClassTitle,
|
||||
groupBookingStatus,
|
||||
serviceTitleForBooking,
|
||||
} from '@/utils/booking-groups';
|
||||
import { bookingStatusLabel, formatDate } from '@/utils/format';
|
||||
|
||||
const props = defineProps<{
|
||||
booking: Booking;
|
||||
actingId: string | null;
|
||||
bookings: Booking[];
|
||||
acting: boolean;
|
||||
showCustomer: boolean;
|
||||
canConfirm: boolean;
|
||||
canCancel: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [booking: Booking];
|
||||
cancel: [booking: Booking];
|
||||
confirm: [bookings: Booking[]];
|
||||
cancel: [bookings: Booking[]];
|
||||
}>();
|
||||
|
||||
const serviceTitle = computed(() => {
|
||||
const title = props.booking.businessService?.serviceTemplate?.title ?? 'خدمت';
|
||||
if (props.booking.serviceClass?.title) {
|
||||
return `${title} · ${props.booking.serviceClass.title}`;
|
||||
const primary = computed(() => props.bookings[0]!);
|
||||
|
||||
const isGroup = computed(() => props.bookings.length > 1);
|
||||
|
||||
const classTitle = computed(() => bookingClassTitle(primary.value));
|
||||
|
||||
const groupStatus = computed(() => groupBookingStatus(props.bookings));
|
||||
|
||||
const showPerItemStatus = computed(() => groupStatus.value === 'mixed');
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (groupStatus.value === 'mixed') {
|
||||
return 'وضعیت متفاوت';
|
||||
}
|
||||
return title;
|
||||
return bookingStatusLabel(groupStatus.value);
|
||||
});
|
||||
|
||||
const groupHint = computed(() =>
|
||||
props.booking.bookingGroupId ? 'بخشی از رزرو چندخدمتی' : '',
|
||||
);
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const status = props.booking.status;
|
||||
function statusClassFor(status: BookingStatus | 'mixed') {
|
||||
if (status === 'confirmed' || status === 'completed') {
|
||||
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
|
||||
}
|
||||
@@ -98,6 +133,9 @@ const statusClass = computed(() => {
|
||||
if (status === 'cancelled') {
|
||||
return 'bg-red-500/15 text-red-600 dark:text-red-400';
|
||||
}
|
||||
if (status === 'mixed') {
|
||||
return 'bg-surface-3 text-muted';
|
||||
}
|
||||
return 'bg-surface-3 text-muted';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<aside
|
||||
class="min-w-0 w-full max-w-full rounded-2xl border border-border bg-white p-4 shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface sm:p-5"
|
||||
>
|
||||
<h2 class="mb-3 text-sm font-bold text-text sm:mb-4 sm:text-base">خلاصه سفارش</h2>
|
||||
|
||||
<div v-if="items.length" class="space-y-3 sm:space-y-4">
|
||||
<p v-if="classTitle" class="text-xs font-medium text-brand-600">
|
||||
کلاس {{ classTitle }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="items.length === 1"
|
||||
class="overflow-hidden rounded-xl border border-border lg:block"
|
||||
>
|
||||
<div
|
||||
v-if="imageUrl"
|
||||
class="hidden aspect-[16/10] bg-surface-2 bg-cover bg-center lg:block"
|
||||
:style="{ backgroundImage: `url(${imageUrl})` }"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="hidden aspect-[16/10] items-center justify-center bg-brand-50 text-brand-600 dark:bg-brand-500/10 lg:flex"
|
||||
>
|
||||
<AppIcon name="spray" size="xl" />
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<p class="font-semibold text-text">{{ items[0].title }}</p>
|
||||
<p v-if="items[0].durationLabel" class="mt-1 text-xs text-muted sm:text-sm">
|
||||
مدت زمان: {{ items[0].durationLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-1.5 sm:space-y-2">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="rounded-xl border border-border px-3 py-2 sm:py-2.5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2 sm:gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-text">{{ item.title }}</p>
|
||||
<p v-if="item.durationLabel" class="mt-0.5 text-xs text-muted">
|
||||
{{ item.durationLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="shrink-0 text-xs font-medium text-text sm:text-sm">
|
||||
{{ formatPriceToman(item.price) }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<dl class="space-y-1.5 text-xs sm:space-y-2 sm:text-sm">
|
||||
<div
|
||||
v-if="items.length === 1"
|
||||
class="flex items-center justify-between gap-2"
|
||||
>
|
||||
<dt class="text-muted">قیمت خدمت</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{{ formatPriceToman(items[0].price) }} تومان
|
||||
</dd>
|
||||
</div>
|
||||
<div
|
||||
v-if="totalDurationMinutes"
|
||||
class="flex items-center justify-between gap-2"
|
||||
:class="{ 'border-t border-border pt-2': items.length === 1 }"
|
||||
>
|
||||
<dt class="text-muted">مدت کل</dt>
|
||||
<dd class="font-medium text-text">{{ totalDurationMinutes }} دقیقه</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2 border-t border-border pt-2">
|
||||
<dt class="font-semibold text-text">جمع کل</dt>
|
||||
<dd class="text-sm font-bold text-text sm:text-base">
|
||||
{{ formatPriceToman(totalPrice) }} تومان
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div
|
||||
v-if="earliestSlot"
|
||||
class="rounded-xl border border-brand-500/30 bg-brand-50 p-3 dark:bg-brand-500/10 sm:p-4"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<AppIcon name="zap" size="sm" class="mt-0.5 shrink-0 text-brand-600" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-medium text-brand-700 dark:text-brand-500">
|
||||
زودترین زمان خالی
|
||||
</p>
|
||||
<p class="mt-0.5 truncate text-sm font-bold text-text">
|
||||
{{ earliestSlot.dateLabel }}
|
||||
</p>
|
||||
<p class="text-xs text-muted sm:text-sm">{{ earliestSlot.timeLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<AppButton class="mt-2.5 sm:mt-3" @click="emit('select-earliest')">
|
||||
انتخاب این زمان
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-sm text-muted">ابتدا یک یا چند خدمت انتخاب کنید.</p>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { formatPriceToman } from '@/utils/format';
|
||||
|
||||
export type BookingSummaryItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
price: string;
|
||||
durationLabel: string | null;
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
items: BookingSummaryItem[];
|
||||
classTitle?: string | null;
|
||||
totalPrice: string;
|
||||
totalDurationMinutes: number | null;
|
||||
imageUrl: string | null;
|
||||
earliestSlot: { dateLabel: string; timeLabel: string } | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-earliest': [];
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-muted">ابتدا کلاس خودرو یا خدمت خود را انتخاب کنید.</p>
|
||||
|
||||
<ListCardSkeleton v-if="loading" :count="3" />
|
||||
<p v-else-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
|
||||
<p
|
||||
v-else-if="!classes.length"
|
||||
class="rounded-2xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted"
|
||||
>
|
||||
کلاس خدماتی تعریف نشده است. میتوانید با زدن «مرحله بعد» ادامه دهید.
|
||||
</p>
|
||||
|
||||
<div v-else class="grid grid-cols-2 gap-4 lg:grid-cols-3">
|
||||
<button
|
||||
v-for="item in classes"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="rounded-2xl border p-5 text-right transition"
|
||||
:class="
|
||||
model === item.id
|
||||
? 'border-brand-500 bg-brand-50 ring-1 ring-brand-500/30 dark:bg-brand-500/10'
|
||||
: 'border-border bg-white hover:border-brand-500/40 dark:bg-surface'
|
||||
"
|
||||
@click="model = item.id"
|
||||
>
|
||||
<div
|
||||
class="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-brand-50 text-brand-600 dark:bg-brand-500/10"
|
||||
>
|
||||
<AppIcon name="car" size="lg" />
|
||||
</div>
|
||||
<p class="font-semibold text-text">{{ item.title }}</p>
|
||||
<p v-if="item.description" class="mt-2 text-sm leading-6 text-muted">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BusinessServiceClass } from '@/api/types';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
||||
|
||||
defineProps<{
|
||||
classes: BusinessServiceClass[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}>();
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
</script>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<nav aria-label="مراحل رزرو" class="mb-4 min-w-0 max-w-full overflow-hidden lg:mb-8">
|
||||
<ol class="flex min-w-0 items-center justify-between gap-0.5 sm:gap-1">
|
||||
<li
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
class="flex min-w-0 flex-1 items-center"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
|
||||
<div
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold transition sm:h-9 sm:w-9 sm:text-sm"
|
||||
:class="circleClass(index + 1)"
|
||||
>
|
||||
<AppIcon v-if="index + 1 < current" name="circle-check" size="sm" />
|
||||
<span v-else>{{ index + 1 }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="max-w-[2.75rem] truncate text-center text-[8px] font-medium leading-3 sm:max-w-[4rem] sm:text-[9px] lg:max-w-none lg:text-xs"
|
||||
:class="index + 1 <= current ? 'text-brand-600' : 'text-muted'"
|
||||
>
|
||||
{{ step.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="mx-0.5 mb-5 h-0.5 flex-1"
|
||||
:class="index + 1 < current ? 'bg-brand-500' : 'bg-border'"
|
||||
/>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
current: number;
|
||||
}>();
|
||||
|
||||
const steps = [
|
||||
{ id: 1, label: 'کلاس خدمات' },
|
||||
{ id: 2, label: 'انتخاب خدمات' },
|
||||
{ id: 3, label: 'تاریخ و ساعت' },
|
||||
{ id: 4, label: 'اطلاعات و پرداخت' },
|
||||
{ id: 5, label: 'تایید نهایی' },
|
||||
] as const;
|
||||
|
||||
function circleClass(step: number) {
|
||||
if (step < props.current) {
|
||||
return 'bg-brand-500 text-white';
|
||||
}
|
||||
if (step === props.current) {
|
||||
return 'border-2 border-brand-500 bg-white text-brand-600';
|
||||
}
|
||||
return 'border border-border bg-surface-2 text-muted';
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl space-y-8 py-4 text-center">
|
||||
<div class="relative mx-auto flex h-24 w-24 items-center justify-center">
|
||||
<span
|
||||
v-for="(dot, index) in confettiDots"
|
||||
:key="index"
|
||||
class="absolute h-2 w-2 rounded-full"
|
||||
:class="dot.color"
|
||||
:style="dot.style"
|
||||
/>
|
||||
<div
|
||||
class="relative flex h-20 w-20 items-center justify-center rounded-full bg-brand-500 text-white shadow-lg"
|
||||
>
|
||||
<AppIcon name="circle-check" size="xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-text">رزرو شما با موفقیت ثبت شد!</h2>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
جزئیات رزرو برای شما پیامک و در بخش رزروها ذخیره شد.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl border border-border bg-white text-right shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface"
|
||||
>
|
||||
<div class="grid gap-4 p-5 sm:grid-cols-[140px_1fr] sm:items-center">
|
||||
<div
|
||||
class="mx-auto flex h-32 w-full max-w-[140px] items-center justify-center rounded-xl bg-brand-50 dark:bg-brand-500/10"
|
||||
>
|
||||
<AppIcon name="car" size="xl" class="text-brand-600" />
|
||||
</div>
|
||||
<dl class="space-y-3 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">خدمت انتخابی</dt>
|
||||
<dd class="font-medium text-text">{{ serviceTitle }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">تاریخ</dt>
|
||||
<dd class="font-medium text-text">{{ dateLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">ساعت</dt>
|
||||
<dd class="font-medium text-text">{{ timeLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">مدت زمان</dt>
|
||||
<dd class="font-medium text-text">{{ durationLabel }}</dd>
|
||||
</div>
|
||||
<div v-if="address" class="flex justify-between gap-4">
|
||||
<dt class="shrink-0 text-muted">آدرس</dt>
|
||||
<dd class="text-left font-medium text-text">{{ address }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 border-t border-border pt-3">
|
||||
<dt class="text-muted">شماره رزرو</dt>
|
||||
<dd class="font-bold text-brand-600">#{{ bookingRef }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-center sm:gap-3">
|
||||
<AppButton @click="emit('view-bookings')">
|
||||
مشاهده رزروها
|
||||
</AppButton>
|
||||
<AppButton variant="outline" @click="emit('add-to-calendar')">
|
||||
<span class="inline-flex items-center justify-center gap-2">
|
||||
<AppIcon name="calendar" size="sm" />
|
||||
افزودن به تقویم
|
||||
</span>
|
||||
</AppButton>
|
||||
<AppButton variant="outline" @click="emit('go-home')">
|
||||
<span class="inline-flex items-center justify-center gap-2">
|
||||
<AppIcon name="home" size="sm" />
|
||||
بازگشت به صفحه اصلی
|
||||
</span>
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
serviceTitle: string;
|
||||
dateLabel: string;
|
||||
timeLabel: string;
|
||||
durationLabel: string;
|
||||
address?: string | null;
|
||||
bookingRef: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'view-bookings': [];
|
||||
'add-to-calendar': [];
|
||||
'go-home': [];
|
||||
}>();
|
||||
|
||||
const confettiDots = [
|
||||
{ color: 'bg-amber-400', style: { top: '4px', right: '12px' } },
|
||||
{ color: 'bg-sky-400', style: { top: '18px', left: '0' } },
|
||||
{ color: 'bg-rose-400', style: { bottom: '8px', right: '0' } },
|
||||
{ color: 'bg-violet-400', style: { bottom: '0', left: '16px' } },
|
||||
{ color: 'bg-emerald-400', style: { top: '0', left: '50%' } },
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div
|
||||
class="min-w-0 w-full max-w-full overflow-hidden rounded-2xl border border-border bg-white shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface"
|
||||
>
|
||||
<div class="space-y-5 p-4 sm:space-y-6 sm:p-5">
|
||||
<div class="min-w-0 max-w-full">
|
||||
<p class="mb-3 text-sm font-medium text-text">انتخاب تاریخ</p>
|
||||
<div class="flex min-w-0 gap-2 overflow-x-auto overscroll-x-contain pb-1 [-webkit-overflow-scrolling:touch]">
|
||||
<button
|
||||
v-for="day in dateOptions"
|
||||
:key="`${day.jy}-${day.jm}-${day.jd}`"
|
||||
type="button"
|
||||
class="flex w-[4.5rem] shrink-0 flex-col items-center rounded-xl border px-2 py-2.5 text-center transition sm:w-[5.5rem] sm:px-3 sm:py-3"
|
||||
:class="
|
||||
isSelectedDay(day)
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-500'
|
||||
: 'border-border bg-surface-2 text-text hover:border-brand-500/40'
|
||||
"
|
||||
@click="selectDay(day)"
|
||||
>
|
||||
<span class="text-[10px] text-muted sm:text-xs">{{ day.weekdayLabel }}</span>
|
||||
<span class="mt-1 text-xs font-semibold sm:text-sm">{{ day.dateLabel }}</span>
|
||||
<span v-if="day.isToday" class="mt-0.5 text-[10px] text-brand-600">امروز</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 max-w-full">
|
||||
<p class="mb-3 text-sm font-medium text-text">انتخاب ساعت</p>
|
||||
<div v-if="loadingSlots" class="py-2">
|
||||
<SlotGridSkeleton />
|
||||
</div>
|
||||
<p v-else-if="slotsError" class="text-sm text-red-600">{{ slotsError }}</p>
|
||||
<p
|
||||
v-else-if="!slots.length"
|
||||
class="rounded-xl border border-dashed border-border px-4 py-6 text-center text-sm text-muted"
|
||||
>
|
||||
برای این روز ساعت خالی وجود ندارد. روز دیگری انتخاب کنید.
|
||||
</p>
|
||||
<div v-else class="grid grid-cols-3 gap-1.5 sm:grid-cols-4 sm:gap-2">
|
||||
<button
|
||||
v-for="slot in slots"
|
||||
:key="slot"
|
||||
type="button"
|
||||
class="inline-flex min-w-0 items-center justify-center gap-0.5 rounded-xl border px-1 py-2 text-xs transition sm:gap-1 sm:px-2 sm:py-2.5 sm:text-sm"
|
||||
:class="
|
||||
model === slot
|
||||
? 'border-brand-500 bg-brand-50 text-brand-700 dark:bg-brand-500/10 dark:text-brand-500'
|
||||
: 'border-border bg-surface-2 text-text hover:border-brand-500/40'
|
||||
"
|
||||
@click="selectSlot(slot)"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="slot === earliestSlotIso"
|
||||
name="zap"
|
||||
size="sm"
|
||||
class="text-brand-600"
|
||||
/>
|
||||
{{ formatSlotLabel(slot) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="durationMinutes"
|
||||
class="flex items-center gap-2 rounded-b-2xl border-t border-brand-500/20 bg-brand-50 px-4 py-2.5 text-xs text-brand-700 dark:bg-brand-500/10 dark:text-brand-500 sm:px-5 sm:py-3 sm:text-sm"
|
||||
>
|
||||
<AppIcon name="clock" size="sm" />
|
||||
<span>مدت تقریبی انجام خدمات: {{ durationMinutes }} دقیقه</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { fetchAvailableSlots } from '@/api/bookings';
|
||||
import { ApiError } from '@/api/client';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import SlotGridSkeleton from '@/components/ui/SlotGridSkeleton.vue';
|
||||
import {
|
||||
clampJalaliDay,
|
||||
firstAllowedJalaliDate,
|
||||
formatJalaliDateShort,
|
||||
generateJalaliDateRange,
|
||||
gregorianToJalaliParts,
|
||||
isSameJalaliDay,
|
||||
isoToJalaliParts,
|
||||
jalaliPartsToDate,
|
||||
jalaliToGregorianYmd,
|
||||
persianWeekdayName,
|
||||
} from '@/utils/jalali';
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
|
||||
const props = defineProps<{
|
||||
businessId: string;
|
||||
businessServiceIds: string[];
|
||||
serviceClassId?: string | null;
|
||||
durationMinutes?: number | null;
|
||||
earliestSlotIso?: string | null;
|
||||
}>();
|
||||
|
||||
const selectedDay = ref(firstAllowedJalaliDate());
|
||||
const slots = ref<string[]>([]);
|
||||
const loadingSlots = ref(false);
|
||||
const slotsError = ref('');
|
||||
|
||||
const today = computed(() => {
|
||||
const j = gregorianToJalaliParts(new Date());
|
||||
return { jy: j.jy, jm: j.jm, jd: j.jd };
|
||||
});
|
||||
|
||||
const dateOptions = computed(() => {
|
||||
const start = firstAllowedJalaliDate();
|
||||
return generateJalaliDateRange(start, 14).map((day) => {
|
||||
const date = jalaliPartsToDate({ ...day, hour: 0, minute: 0 });
|
||||
return {
|
||||
...day,
|
||||
weekdayLabel: persianWeekdayName(date),
|
||||
dateLabel: formatJalaliDateShort(day),
|
||||
isToday: isSameJalaliDay(day, today.value),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function isSelectedDay(day: { jy: number; jm: number; jd: number }) {
|
||||
return isSameJalaliDay(day, selectedDay.value);
|
||||
}
|
||||
|
||||
function formatSlotLabel(iso: string): string {
|
||||
const parsed = isoToJalaliParts(iso);
|
||||
if (!parsed) return '—';
|
||||
return `${String(parsed.hour).padStart(2, '0')}:${String(parsed.minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function loadSlots() {
|
||||
if (!props.businessServiceIds.length) return;
|
||||
loadingSlots.value = true;
|
||||
slotsError.value = '';
|
||||
slots.value = [];
|
||||
try {
|
||||
selectedDay.value = clampJalaliDay({ ...selectedDay.value, hour: 0, minute: 0 });
|
||||
const ymd = jalaliToGregorianYmd(selectedDay.value);
|
||||
const res = await fetchAvailableSlots(props.businessId, ymd, {
|
||||
businessServiceIds: props.businessServiceIds,
|
||||
serviceClassId: props.serviceClassId ?? undefined,
|
||||
});
|
||||
slots.value = res.slots;
|
||||
if (model.value && !slots.value.includes(model.value)) {
|
||||
model.value = '';
|
||||
}
|
||||
} catch (e) {
|
||||
slotsError.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری ساعات ناموفق بود';
|
||||
} finally {
|
||||
loadingSlots.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectDay(day: { jy: number; jm: number; jd: number }) {
|
||||
selectedDay.value = { ...day };
|
||||
model.value = '';
|
||||
void loadSlots();
|
||||
}
|
||||
|
||||
function selectSlot(iso: string) {
|
||||
model.value = iso;
|
||||
}
|
||||
|
||||
function selectDayAndSlot(iso: string) {
|
||||
const parsed = isoToJalaliParts(iso);
|
||||
if (!parsed) return;
|
||||
selectedDay.value = { jy: parsed.jy, jm: parsed.jm, jd: parsed.jd };
|
||||
model.value = iso;
|
||||
void loadSlots();
|
||||
}
|
||||
|
||||
defineExpose({ selectDayAndSlot });
|
||||
|
||||
watch(
|
||||
() => [props.businessId, props.businessServiceIds.join(','), props.serviceClassId],
|
||||
() => {
|
||||
model.value = '';
|
||||
void loadSlots();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.businessServiceIds.length) {
|
||||
void loadSlots();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,624 @@
|
||||
<template>
|
||||
<div class="min-w-0 max-w-full space-y-6 overflow-x-hidden pb-24 lg:pb-0">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-text lg:text-2xl">رزرو خدمات</h1>
|
||||
<RouterLink
|
||||
:to="homeTo"
|
||||
class="inline-flex items-center gap-2 text-sm text-muted transition hover:text-brand-600"
|
||||
>
|
||||
<AppIcon name="home" size="sm" />
|
||||
بازگشت به صفحه اصلی
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<BookingSuccessView
|
||||
v-if="phase === 'success' && successDetails"
|
||||
v-bind="successDetails"
|
||||
@view-bookings="emit('view-bookings')"
|
||||
@add-to-calendar="downloadCalendar"
|
||||
@go-home="emit('go-home')"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<BookingStepper :current="currentStep" />
|
||||
<p class="text-center text-sm font-semibold text-brand-600 lg:hidden">
|
||||
{{ currentStepLabel }}
|
||||
</p>
|
||||
|
||||
<BookingServiceClassStep
|
||||
v-if="currentStep === 1"
|
||||
v-model="selectedClassId"
|
||||
:classes="serviceClasses"
|
||||
:loading="loadingClasses"
|
||||
:error="classesError"
|
||||
/>
|
||||
|
||||
<div v-else-if="currentStep === 2" class="space-y-4">
|
||||
<p class="text-sm text-muted">
|
||||
میتوانید یک یا چند خدمت انتخاب کنید. برای لغو انتخاب، دوباره روی همان خدمت بزنید.
|
||||
</p>
|
||||
<ServiceGridSkeleton v-if="loadingServices" />
|
||||
<p v-else-if="servicesError" class="text-sm text-red-600">{{ servicesError }}</p>
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-2 gap-4 xl:grid-cols-3">
|
||||
<button
|
||||
v-for="service in services"
|
||||
:key="service.id"
|
||||
type="button"
|
||||
class="relative rounded-2xl border p-4 text-right transition"
|
||||
:class="
|
||||
isServiceSelected(service.id)
|
||||
? 'border-brand-500 bg-brand-50 ring-1 ring-brand-500/30 dark:bg-brand-500/10'
|
||||
: 'border-border bg-white hover:border-brand-500/40 dark:bg-surface'
|
||||
"
|
||||
:disabled="!service.isActive"
|
||||
@click="toggleService(service.id)"
|
||||
>
|
||||
<span
|
||||
v-if="isServiceSelected(service.id)"
|
||||
class="absolute left-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-brand-500 text-white"
|
||||
>
|
||||
<AppIcon name="circle-check" size="sm" />
|
||||
</span>
|
||||
<p class="font-semibold text-text">
|
||||
{{ service.serviceTemplate?.title ?? 'خدمت' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-muted">
|
||||
مدت زمان: {{ durationFor(service) }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm font-bold text-brand-600">
|
||||
{{ priceFor(service) }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedServices.length"
|
||||
class="flex items-center justify-between rounded-xl border border-brand-500/30 bg-brand-50 px-4 py-3 text-sm dark:bg-brand-500/10"
|
||||
>
|
||||
<span class="text-text">
|
||||
{{ selectedServices.length }} خدمت انتخاب شده
|
||||
</span>
|
||||
<span class="font-bold text-brand-700 dark:text-brand-500">
|
||||
جمع: {{ formatPriceToman(selectedPrice) }} تومان
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="auth.isLoggedIn"
|
||||
class="grid min-w-0 grid-cols-1 items-start gap-4 lg:grid-cols-3 lg:gap-6"
|
||||
>
|
||||
<BookingOrderSummary
|
||||
class="order-2 min-w-0 w-full lg:order-1 lg:col-span-1 lg:sticky lg:top-24"
|
||||
:items="summaryItems"
|
||||
:class-title="selectedClassTitle"
|
||||
:total-price="selectedPrice"
|
||||
:total-duration-minutes="selectedDurationMinutes"
|
||||
:image-url="coverImageUrl"
|
||||
:earliest-slot="earliestSlotLabel"
|
||||
@select-earliest="applyEarliestSlot"
|
||||
/>
|
||||
|
||||
<div class="order-1 min-w-0 w-full space-y-4 lg:order-2 lg:col-span-2">
|
||||
<DesktopBookingDateTime
|
||||
v-if="currentStep === 3"
|
||||
ref="dateTimeRef"
|
||||
v-model="scheduledAt"
|
||||
:business-id="business.id"
|
||||
:business-service-ids="selectedServiceIds"
|
||||
:service-class-id="selectedClassId || null"
|
||||
:duration-minutes="selectedDurationMinutes"
|
||||
:earliest-slot-iso="earliestSlotIso"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="currentStep === 4"
|
||||
class="rounded-2xl border border-border bg-white p-5 shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface"
|
||||
>
|
||||
<h3 class="mb-4 font-semibold text-text">اطلاعات تکمیلی</h3>
|
||||
<AppTextarea
|
||||
v-model="note"
|
||||
label="یادداشت (اختیاری)"
|
||||
placeholder="در صورت نیاز توضیحی بنویسید..."
|
||||
/>
|
||||
<p class="mt-4 text-sm text-muted">
|
||||
پرداخت در محل انجام میشود. نیازی به پرداخت آنلاین نیست.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="currentStep === 5"
|
||||
class="rounded-2xl border border-border bg-white p-5 shadow-[0_2px_12px_rgba(15,23,42,0.06)] dark:bg-surface"
|
||||
>
|
||||
<h3 class="mb-4 font-semibold text-text">بررسی نهایی</h3>
|
||||
<dl class="space-y-3 text-sm">
|
||||
<div v-if="selectedClassTitle" class="flex justify-between gap-4">
|
||||
<dt class="text-muted">کلاس</dt>
|
||||
<dd class="font-medium text-text">{{ selectedClassTitle }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="shrink-0 text-muted">خدمات</dt>
|
||||
<dd class="max-w-[60%] text-left font-medium text-text">
|
||||
{{ selectedServiceTitle }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">تاریخ و ساعت</dt>
|
||||
<dd class="font-medium text-text">{{ scheduledLabel }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-muted">مبلغ</dt>
|
||||
<dd class="font-medium text-text">
|
||||
{{ formatPriceToman(selectedPrice) }} تومان
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="note.trim()" class="flex justify-between gap-4">
|
||||
<dt class="text-muted">یادداشت</dt>
|
||||
<dd class="max-w-[60%] text-left font-medium text-text">{{ note }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p v-if="bookingError" class="mt-4 text-sm text-red-600">{{ bookingError }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden flex-wrap items-center justify-between gap-3 lg:flex"
|
||||
>
|
||||
<AppButton variant="ghost" class="!w-auto" @click="goBack">
|
||||
مرحله قبل
|
||||
</AppButton>
|
||||
<AppButton
|
||||
class="!w-auto min-w-[140px]"
|
||||
:disabled="!canProceed"
|
||||
:loading="bookingLoading && currentStep === 5"
|
||||
@click="goNext"
|
||||
>
|
||||
{{ nextButtonLabel }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showMobileActions"
|
||||
class="fixed inset-x-0 bottom-16 z-20 border-t border-border bg-surface/95 backdrop-blur lg:hidden"
|
||||
>
|
||||
<div class="mx-auto flex w-full max-w-app gap-2 px-4 py-3">
|
||||
<AppButton
|
||||
v-if="currentStep > 1"
|
||||
variant="outline"
|
||||
class="!w-auto shrink-0 px-4"
|
||||
@click="goBack"
|
||||
>
|
||||
قبل
|
||||
</AppButton>
|
||||
<AppButton
|
||||
class="flex-1"
|
||||
:disabled="!canProceed"
|
||||
:loading="bookingLoading && currentStep === 5"
|
||||
@click="goNext"
|
||||
>
|
||||
{{ nextButtonLabel }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!auth.isLoggedIn && currentStep > 2"
|
||||
class="rounded-2xl border border-brand-500/30 bg-brand-50 p-5 dark:bg-brand-500/10"
|
||||
>
|
||||
<p class="text-sm text-text">برای ادامه رزرو، ابتدا وارد حساب کاربری شوید.</p>
|
||||
<AppButton class="mt-4 !w-auto" @click="emit('login')">ورود / ثبتنام</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { createBooking, createBookingBatch, fetchAvailableSlots } from '@/api/bookings';
|
||||
import { fetchBusinessServiceClasses } from '@/api/business-service-classes';
|
||||
import { ApiError } from '@/api/client';
|
||||
import type { Booking, Business, BusinessService, BusinessServiceClass } from '@/api/types';
|
||||
import BookingOrderSummary from '@/components/booking/BookingOrderSummary.vue';
|
||||
import BookingServiceClassStep from '@/components/booking/BookingServiceClassStep.vue';
|
||||
import BookingStepper from '@/components/booking/BookingStepper.vue';
|
||||
import BookingSuccessView from '@/components/booking/BookingSuccessView.vue';
|
||||
import DesktopBookingDateTime from '@/components/booking/DesktopBookingDateTime.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import AppTextarea from '@/components/ui/AppTextarea.vue';
|
||||
import ServiceGridSkeleton from '@/components/ui/ServiceGridSkeleton.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { formatPriceToman } from '@/utils/format';
|
||||
import {
|
||||
formatJalaliDateLong,
|
||||
formatJalaliDateShort,
|
||||
formatJalaliDateTime,
|
||||
generateJalaliDateRange,
|
||||
gregorianToJalaliParts,
|
||||
isoToJalaliParts,
|
||||
isSameJalaliDay,
|
||||
jalaliToGregorianYmd,
|
||||
firstAllowedJalaliDate,
|
||||
persianWeekdayName,
|
||||
jalaliPartsToDate,
|
||||
} from '@/utils/jalali';
|
||||
import { businessCoverUrl } from '@/utils/media';
|
||||
import {
|
||||
resolveServicePrice,
|
||||
serviceDurationLabel,
|
||||
servicePriceLabel,
|
||||
sumServicesDurationMinutes,
|
||||
sumServicesPrice,
|
||||
} from '@/utils/service-pricing';
|
||||
|
||||
const props = defineProps<{
|
||||
business: Business;
|
||||
services: BusinessService[];
|
||||
loadingServices: boolean;
|
||||
servicesError: string;
|
||||
homeTo: string | { name: string };
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
login: [];
|
||||
'view-bookings': [];
|
||||
'go-home': [];
|
||||
}>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const currentStep = ref(1);
|
||||
const phase = ref<'booking' | 'success'>('booking');
|
||||
const selectedClassId = ref('');
|
||||
const selectedServiceIds = ref<string[]>([]);
|
||||
const scheduledAt = ref('');
|
||||
const note = ref('');
|
||||
const bookingLoading = ref(false);
|
||||
const bookingError = ref('');
|
||||
const createdBooking = ref<Booking | null>(null);
|
||||
const earliestSlotIso = ref<string | null>(null);
|
||||
const dateTimeRef = ref<InstanceType<typeof DesktopBookingDateTime> | null>(null);
|
||||
|
||||
const serviceClasses = ref<BusinessServiceClass[]>([]);
|
||||
const loadingClasses = ref(false);
|
||||
const classesError = ref('');
|
||||
|
||||
const classStepRequired = computed(
|
||||
() => Boolean(props.business.serviceClassesEnabled) && serviceClasses.value.length > 0,
|
||||
);
|
||||
|
||||
const perClass = computed(
|
||||
() => Boolean(props.business.serviceClassesEnabled) && Boolean(selectedClassId.value),
|
||||
);
|
||||
|
||||
const selectedServices = computed(() =>
|
||||
props.services.filter((service) => selectedServiceIds.value.includes(service.id)),
|
||||
);
|
||||
|
||||
const selectedClass = computed(
|
||||
() => serviceClasses.value.find((c) => c.id === selectedClassId.value) ?? null,
|
||||
);
|
||||
|
||||
const selectedClassTitle = computed(() => selectedClass.value?.title ?? null);
|
||||
|
||||
const selectedServiceTitle = computed(() => {
|
||||
if (!selectedServices.value.length) return 'خدمت';
|
||||
return selectedServices.value
|
||||
.map((service) => service.serviceTemplate?.title ?? 'خدمت')
|
||||
.join('، ');
|
||||
});
|
||||
|
||||
const selectedPrice = computed(() => {
|
||||
if (!selectedServices.value.length) return '0';
|
||||
return String(
|
||||
sumServicesPrice(
|
||||
selectedServices.value,
|
||||
perClass.value,
|
||||
selectedClassId.value || undefined,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const selectedDurationMinutes = computed(() => {
|
||||
if (!selectedServices.value.length) return null;
|
||||
const total = sumServicesDurationMinutes(
|
||||
selectedServices.value,
|
||||
perClass.value,
|
||||
selectedClassId.value || undefined,
|
||||
);
|
||||
return total > 0 ? total : null;
|
||||
});
|
||||
|
||||
const selectedDurationLabel = computed(() => {
|
||||
if (!selectedDurationMinutes.value) return null;
|
||||
return `${selectedDurationMinutes.value} دقیقه`;
|
||||
});
|
||||
|
||||
const summaryItems = computed(() =>
|
||||
selectedServices.value.map((service) => ({
|
||||
id: service.id,
|
||||
title: service.serviceTemplate?.title ?? 'خدمت',
|
||||
price: resolveServicePrice(
|
||||
service,
|
||||
perClass.value,
|
||||
selectedClassId.value || undefined,
|
||||
),
|
||||
durationLabel: serviceDurationLabel(
|
||||
service,
|
||||
perClass.value,
|
||||
serviceClasses.value,
|
||||
selectedClassId.value || undefined,
|
||||
),
|
||||
})),
|
||||
);
|
||||
|
||||
const coverImageUrl = computed(() => businessCoverUrl(props.business.images));
|
||||
|
||||
const scheduledLabel = computed(() => {
|
||||
const parsed = isoToJalaliParts(scheduledAt.value);
|
||||
if (!parsed) return '—';
|
||||
return formatJalaliDateTime(parsed);
|
||||
});
|
||||
|
||||
const earliestSlotLabel = computed(() => {
|
||||
if (!earliestSlotIso.value) return null;
|
||||
const parsed = isoToJalaliParts(earliestSlotIso.value);
|
||||
if (!parsed) return null;
|
||||
const today = gregorianToJalaliParts(new Date());
|
||||
const date = jalaliPartsToDate({
|
||||
jy: parsed.jy,
|
||||
jm: parsed.jm,
|
||||
jd: parsed.jd,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
});
|
||||
const prefix = isSameJalaliDay(parsed, today) ? 'امروز' : persianWeekdayName(date);
|
||||
return {
|
||||
dateLabel: `${prefix} — ${formatJalaliDateShort(parsed)}`,
|
||||
timeLabel: `${String(parsed.hour).padStart(2, '0')}:${String(parsed.minute).padStart(2, '0')}`,
|
||||
};
|
||||
});
|
||||
|
||||
const successDetails = computed(() => {
|
||||
if (!createdBooking.value) return null;
|
||||
const parsed = isoToJalaliParts(createdBooking.value.scheduledAt);
|
||||
const serviceTitle = selectedClassTitle.value
|
||||
? `${selectedServiceTitle.value} · ${selectedClassTitle.value}`
|
||||
: selectedServiceTitle.value;
|
||||
return {
|
||||
serviceTitle,
|
||||
dateLabel: parsed ? formatJalaliDateLong(parsed) : '—',
|
||||
timeLabel: parsed
|
||||
? `${String(parsed.hour).padStart(2, '0')}:${String(parsed.minute).padStart(2, '0')}`
|
||||
: '—',
|
||||
durationLabel: selectedDurationLabel.value ?? '—',
|
||||
address: props.business.address,
|
||||
bookingRef: createdBooking.value.id.slice(0, 8).toUpperCase(),
|
||||
};
|
||||
});
|
||||
|
||||
const stepLabels = [
|
||||
'کلاس خدمات',
|
||||
'انتخاب خدمات',
|
||||
'تاریخ و ساعت',
|
||||
'اطلاعات و پرداخت',
|
||||
'تایید نهایی',
|
||||
] as const;
|
||||
|
||||
const currentStepLabel = computed(() => stepLabels[currentStep.value - 1] ?? '');
|
||||
|
||||
const nextButtonLabel = computed(() =>
|
||||
currentStep.value === 5 ? 'ثبت نهایی رزرو' : 'مرحله بعد',
|
||||
);
|
||||
|
||||
const showMobileActions = computed(
|
||||
() =>
|
||||
phase.value === 'booking' &&
|
||||
(currentStep.value <= 2 || auth.isLoggedIn) &&
|
||||
!(currentStep.value > 2 && !auth.isLoggedIn),
|
||||
);
|
||||
|
||||
const canProceed = computed(() => {
|
||||
if (currentStep.value === 1) {
|
||||
return classStepRequired.value ? Boolean(selectedClassId.value) : true;
|
||||
}
|
||||
if (currentStep.value === 2) return selectedServiceIds.value.length > 0;
|
||||
if (currentStep.value === 3) return Boolean(scheduledAt.value);
|
||||
if (currentStep.value === 4) return true;
|
||||
if (currentStep.value === 5) {
|
||||
return Boolean(
|
||||
scheduledAt.value &&
|
||||
selectedServiceIds.value.length > 0 &&
|
||||
(!classStepRequired.value || selectedClassId.value),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
function durationFor(service: BusinessService) {
|
||||
return serviceDurationLabel(
|
||||
service,
|
||||
perClass.value,
|
||||
serviceClasses.value,
|
||||
selectedClassId.value || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function priceFor(service: BusinessService) {
|
||||
return servicePriceLabel(
|
||||
service,
|
||||
perClass.value,
|
||||
serviceClasses.value,
|
||||
selectedClassId.value || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function isServiceSelected(serviceId: string) {
|
||||
return selectedServiceIds.value.includes(serviceId);
|
||||
}
|
||||
|
||||
function toggleService(serviceId: string) {
|
||||
if (isServiceSelected(serviceId)) {
|
||||
selectedServiceIds.value = selectedServiceIds.value.filter((id) => id !== serviceId);
|
||||
} else {
|
||||
selectedServiceIds.value = [...selectedServiceIds.value, serviceId];
|
||||
}
|
||||
scheduledAt.value = '';
|
||||
bookingError.value = '';
|
||||
void loadEarliestSlot();
|
||||
}
|
||||
|
||||
async function loadClasses() {
|
||||
loadingClasses.value = true;
|
||||
classesError.value = '';
|
||||
try {
|
||||
serviceClasses.value = await fetchBusinessServiceClasses(props.business.id, {
|
||||
activeOnly: true,
|
||||
});
|
||||
if (serviceClasses.value.length === 1) {
|
||||
selectedClassId.value = serviceClasses.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
classesError.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری کلاسهای خدمات ناموفق بود';
|
||||
} finally {
|
||||
loadingClasses.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEarliestSlot() {
|
||||
earliestSlotIso.value = null;
|
||||
if (!selectedServiceIds.value.length) return;
|
||||
const days = generateJalaliDateRange(firstAllowedJalaliDate(), 7);
|
||||
for (const day of days) {
|
||||
try {
|
||||
const res = await fetchAvailableSlots(
|
||||
props.business.id,
|
||||
jalaliToGregorianYmd(day),
|
||||
{
|
||||
businessServiceIds: selectedServiceIds.value,
|
||||
serviceClassId: selectedClassId.value || undefined,
|
||||
},
|
||||
);
|
||||
if (res.slots.length) {
|
||||
earliestSlotIso.value = res.slots[0];
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* try next day */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyEarliestSlot() {
|
||||
if (!earliestSlotIso.value) return;
|
||||
scheduledAt.value = earliestSlotIso.value;
|
||||
dateTimeRef.value?.selectDayAndSlot(earliestSlotIso.value);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value -= 1;
|
||||
bookingError.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function goNext() {
|
||||
if (currentStep.value === 2 && !auth.isLoggedIn) {
|
||||
emit('login');
|
||||
return;
|
||||
}
|
||||
if (currentStep.value < 5) {
|
||||
currentStep.value += 1;
|
||||
return;
|
||||
}
|
||||
await submitBooking();
|
||||
}
|
||||
|
||||
async function submitBooking() {
|
||||
if (!auth.user || !selectedServiceIds.value.length || !scheduledAt.value) {
|
||||
bookingError.value = 'لطفاً خدمت و زمان را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
if (classStepRequired.value && !selectedClassId.value) {
|
||||
bookingError.value = 'لطفاً کلاس خدمات را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
bookingLoading.value = true;
|
||||
bookingError.value = '';
|
||||
try {
|
||||
const payload = {
|
||||
customerId: auth.user.id,
|
||||
businessId: props.business.id,
|
||||
serviceClassId: selectedClassId.value || null,
|
||||
scheduledAt: scheduledAt.value,
|
||||
note: note.value.trim() || null,
|
||||
};
|
||||
if (selectedServiceIds.value.length === 1) {
|
||||
createdBooking.value = await createBooking({
|
||||
...payload,
|
||||
businessServiceId: selectedServiceIds.value[0],
|
||||
});
|
||||
} else {
|
||||
const bookings = await createBookingBatch({
|
||||
...payload,
|
||||
items: selectedServiceIds.value.map((businessServiceId) => ({
|
||||
businessServiceId,
|
||||
})),
|
||||
});
|
||||
createdBooking.value = bookings[0] ?? null;
|
||||
}
|
||||
phase.value = 'success';
|
||||
} catch (e) {
|
||||
bookingError.value = e instanceof ApiError ? e.message : 'ثبت رزرو ناموفق بود.';
|
||||
} finally {
|
||||
bookingLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadCalendar() {
|
||||
if (!createdBooking.value) return;
|
||||
const start = new Date(createdBooking.value.scheduledAt);
|
||||
const end = new Date(start.getTime() + (selectedDurationMinutes.value ?? 60) * 60_000);
|
||||
const formatIcs = (d: Date) =>
|
||||
d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
const ics = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'BEGIN:VEVENT',
|
||||
`DTSTART:${formatIcs(start)}`,
|
||||
`DTEND:${formatIcs(end)}`,
|
||||
`SUMMARY:${selectedServiceTitle.value}`,
|
||||
`LOCATION:${props.business.address ?? ''}`,
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
].join('\r\n');
|
||||
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'booking.ics';
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
watch(selectedClassId, () => {
|
||||
selectedServiceIds.value = [];
|
||||
scheduledAt.value = '';
|
||||
earliestSlotIso.value = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
selectedServiceIds,
|
||||
() => {
|
||||
void loadEarliestSlot();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
void loadClasses();
|
||||
});
|
||||
</script>
|
||||
@@ -11,13 +11,18 @@
|
||||
:class="['hidden dark:block', imgClass]"
|
||||
/>
|
||||
<p class="max-w-xs text-sm leading-7 text-muted">
|
||||
نوبت گیری سریع ، مدیرت آسان زمان
|
||||
{{ brand.tagline }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
|
||||
const tenant = useTenantStore();
|
||||
const { brand } = storeToRefs(tenant);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-full bg-white p-1.5 shadow-sm ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-600"
|
||||
>
|
||||
<AppLogo variant="horizontal" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<span class="text-lg font-bold text-slate-800 dark:text-slate-100">
|
||||
{{ brand.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import AppLogo from '@/components/brand/AppLogo.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
|
||||
const tenant = useTenantStore();
|
||||
const { brand } = storeToRefs(tenant);
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'app-business-detail', params: { id: business.id } }"
|
||||
class="group flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-6 text-center shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md dark:border-border dark:bg-surface"
|
||||
:class="featured ? 'border-brand-500 ring-1 ring-brand-500/20' : ''"
|
||||
>
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-brand-500/10 text-brand-600 transition group-hover:bg-brand-500/15 dark:text-brand-500"
|
||||
>
|
||||
<img
|
||||
v-if="cover"
|
||||
:src="cover"
|
||||
:alt="business.title"
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<AppIcon v-else name="store" size="lg" />
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-bold text-slate-800 dark:text-text">{{ business.title }}</h3>
|
||||
|
||||
<p
|
||||
v-if="business.description"
|
||||
class="mt-2 line-clamp-3 min-h-[4.5rem] text-sm leading-7 text-slate-600 dark:text-muted"
|
||||
>
|
||||
{{ business.description }}
|
||||
</p>
|
||||
<p v-else class="mt-2 min-h-[4.5rem] text-sm text-slate-400 dark:text-muted">—</p>
|
||||
|
||||
<p v-if="business.category" class="mt-3 text-xs font-medium text-brand-600 dark:text-brand-500">
|
||||
{{ business.category.title }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="business.address"
|
||||
class="mt-2 flex items-center justify-center gap-1 text-xs text-slate-500 dark:text-muted"
|
||||
>
|
||||
<AppIcon name="map-pin" size="sm" class="shrink-0" />
|
||||
<span class="line-clamp-1">{{ business.address }}</span>
|
||||
</p>
|
||||
|
||||
<span
|
||||
class="mt-auto pt-5 text-sm font-semibold text-brand-600 transition group-hover:text-brand-700 dark:text-brand-500"
|
||||
>
|
||||
مشاهده و رزرو
|
||||
</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Business } from '@/api/types';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { businessCoverUrl } from '@/utils/media';
|
||||
|
||||
const props = defineProps<{
|
||||
business: Business;
|
||||
featured?: boolean;
|
||||
}>();
|
||||
|
||||
const cover = computed(() => businessCoverUrl(props.business.images));
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div :class="props.class">
|
||||
<BusinessCardSkeleton v-if="loading" class="py-2" />
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<div
|
||||
v-else-if="!businesses.length"
|
||||
class="rounded-2xl border border-dashed border-border bg-surface-2 p-8 text-center text-sm text-muted"
|
||||
>
|
||||
کسبوکاری یافت نشد.
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
:class="
|
||||
variant === 'featured'
|
||||
? 'grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3'
|
||||
: 'grid grid-cols-1 gap-2'
|
||||
"
|
||||
>
|
||||
<li v-for="(business, index) in businesses" :key="business.id">
|
||||
<HomeBusinessCard
|
||||
v-if="variant === 'featured'"
|
||||
:business="business"
|
||||
:featured="index === 1"
|
||||
/>
|
||||
<BusinessCard v-else :business="business" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Business } from '@/api/types';
|
||||
import BusinessCard from '@/components/business/BusinessCard.vue';
|
||||
import HomeBusinessCard from '@/components/home/HomeBusinessCard.vue';
|
||||
import BusinessCardSkeleton from '@/components/ui/BusinessCardSkeleton.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
businesses: Business[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
variant?: 'compact' | 'featured';
|
||||
class?: string;
|
||||
}>(),
|
||||
{ variant: 'compact', class: '' },
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<section id="club" class="scroll-mt-28 border-t border-landing-line bg-landing-surface py-14 dark:border-border dark:bg-surface-2">
|
||||
<div class="mx-auto max-w-[1200px] px-6 xl:px-8">
|
||||
<HomeSectionTitle subtitle="با عضویت در باشگاه مشتریان منتایم از تخفیفها و پیشنهادهای ویژه بهرهمند شوید.">
|
||||
باشگاه مشتریان
|
||||
</HomeSectionTitle>
|
||||
|
||||
<div class="mx-auto grid max-w-4xl gap-6 md:grid-cols-3">
|
||||
<div
|
||||
v-for="perk in perks"
|
||||
:key="perk.title"
|
||||
class="rounded-2xl border border-slate-200 bg-white p-6 text-center shadow-sm dark:border-border dark:bg-surface"
|
||||
>
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-50 text-brand-600 dark:bg-brand-500/10 dark:text-brand-500"
|
||||
>
|
||||
<AppIcon :name="perk.icon" size="md" />
|
||||
</div>
|
||||
<h3 class="font-bold text-landing-ink dark:text-text">{{ perk.title }}</h3>
|
||||
<p class="mt-2 text-sm leading-7 text-landing-muted dark:text-muted">{{ perk.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-6 py-3 text-sm font-semibold text-white hover:bg-brand-700"
|
||||
@click="emit('join')"
|
||||
>
|
||||
<AppIcon name="user" size="sm" />
|
||||
عضویت در باشگاه
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeSectionTitle from '@/components/home/HomeSectionTitle.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
const emit = defineEmits<{
|
||||
join: [];
|
||||
}>();
|
||||
|
||||
const perks: { title: string; text: string; icon: AppIconName }[] = [
|
||||
{
|
||||
title: 'امتیاز وفاداری',
|
||||
text: 'با هر رزرو امتیاز جمع کنید و جایزه بگیرید.',
|
||||
icon: 'badge-percent',
|
||||
},
|
||||
{
|
||||
title: 'تخفیف اختصاصی',
|
||||
text: 'کدهای تخفیف مخصوص اعضای باشگاه.',
|
||||
icon: 'sparkles',
|
||||
},
|
||||
{
|
||||
title: 'اولویت رزرو',
|
||||
text: 'دسترسی زودتر به اسلاتهای پرتقاضا.',
|
||||
icon: 'calendar',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-text lg:text-2xl">کشف خدمات</h1>
|
||||
<p class="mt-1 text-sm text-muted lg:text-base">
|
||||
کسبوکارهای محلی نزدیک شما
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HomeSearchAndFilter
|
||||
v-model:search="searchQuery"
|
||||
:show-search="showSearch"
|
||||
:categories="activeCategories"
|
||||
:show-category-filter="showCategoryFilter"
|
||||
:selected-category-id="selectedCategoryId"
|
||||
@select-category="onCategorySelect"
|
||||
/>
|
||||
|
||||
<HomeBusinessList
|
||||
:businesses="filteredBusinesses"
|
||||
:loading="loadingBusinesses"
|
||||
:error="error"
|
||||
:variant="props.listVariant"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { fetchBusinessCategories } from '@/api/business-categories';
|
||||
import { fetchBusinesses } from '@/api/businesses';
|
||||
import { ApiError } from '@/api/client';
|
||||
import type { Business, BusinessCategory } from '@/api/types';
|
||||
import HomeBusinessList from '@/components/home/HomeBusinessList.vue';
|
||||
import HomeSearchAndFilter from '@/components/home/HomeSearchAndFilter.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
listVariant?: 'compact' | 'featured';
|
||||
}>(),
|
||||
{ listVariant: 'compact' },
|
||||
);
|
||||
|
||||
const categories = ref<BusinessCategory[]>([]);
|
||||
const businesses = ref<Business[]>([]);
|
||||
const selectedCategoryId = ref<string | null>(null);
|
||||
const searchQuery = ref('');
|
||||
const loadingBusinesses = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const activeCategories = computed(() =>
|
||||
categories.value.filter((c) => c.isActive),
|
||||
);
|
||||
|
||||
const showCategoryFilter = computed(() => activeCategories.value.length > 1);
|
||||
|
||||
const showSearch = computed(() => businesses.value.length > 1);
|
||||
|
||||
const filteredBusinesses = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return businesses.value;
|
||||
}
|
||||
return businesses.value.filter((b) => {
|
||||
const haystack = [b.title, b.description, b.address]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(q);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
categories.value = await fetchBusinessCategories();
|
||||
if (activeCategories.value.length === 1) {
|
||||
selectedCategoryId.value = activeCategories.value[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری دستهها ناموفق بود.';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBusinesses() {
|
||||
loadingBusinesses.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
businesses.value = await fetchBusinesses({
|
||||
categoryId: selectedCategoryId.value ?? undefined,
|
||||
isActive: true,
|
||||
});
|
||||
} catch (e) {
|
||||
error.value =
|
||||
e instanceof ApiError ? e.message : 'بارگذاری کسبوکارها ناموفق بود.';
|
||||
} finally {
|
||||
loadingBusinesses.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onCategorySelect(id: string | null) {
|
||||
selectedCategoryId.value = id;
|
||||
}
|
||||
|
||||
watch(selectedCategoryId, () => {
|
||||
void loadBusinesses();
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadCategories();
|
||||
await loadBusinesses();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<section class="bg-white py-8 dark:bg-surface">
|
||||
<div class="mx-auto max-w-[1200px] px-6 xl:px-8">
|
||||
<div
|
||||
class="rounded-2xl bg-[#001f3f] px-3 py-5 dark:bg-[#0f172a] sm:px-6 sm:py-7 xl:px-10 xl:py-8"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-2 lg:gap-4">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="flex flex-col items-center gap-1.5 text-center lg:flex-row lg:items-center lg:gap-3 lg:text-right"
|
||||
>
|
||||
<div
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 border-brand-500/80 text-brand-500 lg:h-11 lg:w-11"
|
||||
>
|
||||
<AppIcon :name="item.icon" size="sm" class="lg:hidden" />
|
||||
<AppIcon :name="item.icon" size="md" class="hidden lg:block" />
|
||||
</div>
|
||||
<p class="text-[10px] font-bold leading-4 text-white lg:text-sm lg:leading-6">
|
||||
{{ item.label }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
const items: { label: string; icon: AppIconName }[] = [
|
||||
{ label: 'پشتیبانی ۲۴/۷', icon: 'headphones' },
|
||||
{ label: 'پرداخت امن', icon: 'circle-check' },
|
||||
{ label: 'تخفیفهای ویژه', icon: 'badge-percent' },
|
||||
{ label: 'رزرو سریع و آسان', icon: 'clock' },
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<section class="relative overflow-hidden bg-[#f5f7fa] pb-10 pt-6 dark:bg-surface">
|
||||
<div
|
||||
class="landing-hero-dots pointer-events-none absolute start-4 top-10 h-36 w-36 opacity-80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="landing-hero-lines pointer-events-none absolute bottom-6 end-4 h-44 w-52 opacity-70"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<svg width="0" height="0" class="absolute" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id="landing-hero-wave" clipPathUnits="objectBoundingBox">
|
||||
<path
|
||||
d="M0,0 H1 V0.78 C0.94,0.84 0.86,0.9 0.76,0.95 C0.64,0.98 0.5,1 0.36,0.98 C0.24,0.93 0.12,0.86 0,0.8 Z"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class="mx-auto grid max-w-[1200px] items-center gap-8 px-6 lg:grid-cols-2 lg:gap-12 lg:px-8"
|
||||
>
|
||||
<div class="relative z-10 space-y-7">
|
||||
<h1
|
||||
class="flex flex-wrap items-baseline gap-x-4 gap-y-2 text-[1.85rem] font-black leading-[1.45] text-landing-ink dark:text-text lg:text-[2.1rem]"
|
||||
>
|
||||
<span>{{ heroTitle }}</span>
|
||||
<span v-if="heroSubtitle" class="text-brand-600">{{ heroSubtitle }}</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
v-if="heroDescription"
|
||||
class="max-w-md text-[15px] leading-8 text-landing-muted dark:text-muted"
|
||||
>
|
||||
{{ heroDescription }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-6 py-3 text-[15px] font-semibold text-white shadow-sm transition hover:bg-brand-700"
|
||||
@click="emit('book')"
|
||||
>
|
||||
<AppIcon name="calendar" size="sm" />
|
||||
رزرو نوبت
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-6 py-3 text-[15px] font-semibold text-landing-ink transition hover:border-brand-600 hover:text-brand-600 dark:border-border dark:bg-surface dark:text-text"
|
||||
@click="emit('browse')"
|
||||
>
|
||||
مشاهده خدمات
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-3 pt-1">
|
||||
<div
|
||||
v-for="item in highlights"
|
||||
:key="item.label"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<AppIcon :name="item.icon" size="sm" class="shrink-0 text-brand-600" />
|
||||
<span class="text-xs font-medium text-landing-muted dark:text-muted">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="landing-hero-blob relative overflow-hidden bg-slate-200 shadow-[0_20px_50px_-12px_rgba(0,0,0,0.18)]">
|
||||
<img
|
||||
v-if="heroImage && !heroImageFailed"
|
||||
:key="heroImage"
|
||||
:src="heroImage"
|
||||
:alt="heroTitle"
|
||||
class="h-[320px] w-full object-cover lg:h-[390px]"
|
||||
@error="onHeroImageError"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-[320px] w-full items-center justify-center bg-gradient-to-br from-brand-50 via-slate-100 to-brand-100 lg:h-[390px]"
|
||||
>
|
||||
<AppIcon name="car" size="xl" class="h-20 w-20 text-brand-600/30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
import { businessCoverUrl } from '@/utils/media';
|
||||
|
||||
const emit = defineEmits<{
|
||||
book: [];
|
||||
browse: [];
|
||||
}>();
|
||||
|
||||
const tenant = useTenantStore();
|
||||
const { singleBusiness } = storeToRefs(tenant);
|
||||
|
||||
const heroImageFailed = ref(false);
|
||||
|
||||
const heroTitle = computed(() => singleBusiness.value?.title ?? '');
|
||||
|
||||
const heroSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
|
||||
|
||||
const heroDescription = computed(() => singleBusiness.value?.description?.trim() ?? '');
|
||||
|
||||
const heroImage = computed(() =>
|
||||
businessCoverUrl(singleBusiness.value?.images),
|
||||
);
|
||||
|
||||
watch(heroImage, () => {
|
||||
heroImageFailed.value = false;
|
||||
});
|
||||
|
||||
const highlights: { label: string; icon: AppIconName }[] = [
|
||||
{ label: 'رضایت مشتریان', icon: 'shield' },
|
||||
{ label: 'تجهیزات مدرن', icon: 'settings' },
|
||||
{ label: 'مواد شوینده با کیفیت', icon: 'droplet' },
|
||||
];
|
||||
|
||||
function onHeroImageError() {
|
||||
heroImageFailed.value = true;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div :class="desktop ? 'mx-auto max-w-3xl space-y-4' : 'space-y-4'">
|
||||
<div v-if="showSearch" class="relative">
|
||||
<label class="sr-only" for="home-search">جستجو</label>
|
||||
<AppIcon
|
||||
name="search"
|
||||
size="sm"
|
||||
class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted"
|
||||
/>
|
||||
<input
|
||||
id="home-search"
|
||||
:value="search"
|
||||
type="search"
|
||||
placeholder="جستجو در نام یا آدرس…"
|
||||
class="w-full rounded-xl border border-border bg-surface-2 py-3 pe-4 ps-10 text-sm text-text outline-none placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
:class="desktop ? 'bg-white dark:bg-surface' : ''"
|
||||
@input="onSearchInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CategoryFilter
|
||||
v-if="showCategoryFilter"
|
||||
:categories="categories"
|
||||
:selected-id="selectedCategoryId"
|
||||
@select="emit('select-category', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { BusinessCategory } from '@/api/types';
|
||||
import CategoryFilter from '@/components/business/CategoryFilter.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
search: string;
|
||||
showSearch: boolean;
|
||||
categories: BusinessCategory[];
|
||||
showCategoryFilter: boolean;
|
||||
selectedCategoryId: string | null;
|
||||
desktop?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search': [value: string];
|
||||
'select-category': [id: string | null];
|
||||
}>();
|
||||
|
||||
function onSearchInput(event: Event) {
|
||||
emit('update:search', (event.target as HTMLInputElement).value);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="mb-10 text-center">
|
||||
<div class="flex items-center justify-center gap-5">
|
||||
<span class="h-px w-20 bg-brand-600/50" aria-hidden="true" />
|
||||
<h2 class="text-[1.35rem] font-bold text-landing-ink dark:text-text">
|
||||
<slot />
|
||||
</h2>
|
||||
<span class="h-px w-20 bg-brand-600/50" aria-hidden="true" />
|
||||
</div>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mx-auto mt-4 max-w-2xl text-sm leading-7 text-landing-muted dark:text-muted"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
subtitle?: string;
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="to"
|
||||
class="flex min-w-0 flex-1 flex-col rounded-xl border bg-white px-4 py-5 text-center shadow-[0_2px_12px_rgba(15,23,42,0.06)] transition hover:-translate-y-0.5 hover:shadow-md dark:bg-surface"
|
||||
:class="
|
||||
featured
|
||||
? 'border-brand-600 ring-1 ring-brand-600/25'
|
||||
: 'border-slate-100 dark:border-border'
|
||||
"
|
||||
>
|
||||
<div class="mb-3 flex justify-center text-slate-400 dark:text-muted">
|
||||
<AppIcon :name="icon" size="lg" />
|
||||
</div>
|
||||
|
||||
<h3 class="text-[15px] font-bold leading-7 text-landing-ink dark:text-text">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-1 text-xs leading-6 text-landing-muted dark:text-muted"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<p v-if="durationLabel" class="mt-3 text-xs text-landing-muted dark:text-muted">
|
||||
مدت زمان: {{ durationLabel }}
|
||||
</p>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
defineProps<{
|
||||
to:
|
||||
| string
|
||||
| { name: string; params?: Record<string, string>; hash?: string }
|
||||
| { path: string; hash?: string };
|
||||
title: string;
|
||||
description?: string | null;
|
||||
durationLabel?: string | null;
|
||||
icon: AppIconName;
|
||||
featured?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<section id="services" class="scroll-mt-28 bg-white py-14 dark:bg-surface">
|
||||
<div class="mx-auto max-w-[1200px] px-6 xl:px-8">
|
||||
<HomeSectionTitle
|
||||
subtitle="با انتخاب سرویس مورد نظر، در زمان دلخواه نوبت خود را رزرو کنید."
|
||||
>
|
||||
خدمات ما
|
||||
</HomeSectionTitle>
|
||||
|
||||
<ServiceGridSkeleton v-if="loading" :count="5" />
|
||||
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-center text-sm text-red-600"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-else-if="!items.length"
|
||||
class="rounded-2xl border border-dashed border-landing-line p-10 text-center text-sm text-landing-muted"
|
||||
>
|
||||
هنوز خدمتی ثبت نشده است.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-2 gap-4 lg:gap-4"
|
||||
:class="gridClass"
|
||||
>
|
||||
<HomeServiceCard
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
:to="serviceLink(item)"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:duration-label="item.durationLabel"
|
||||
:icon="item.icon"
|
||||
:featured="index === featuredIndex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import HomeSectionTitle from '@/components/home/HomeSectionTitle.vue';
|
||||
import HomeServiceCard from '@/components/home/HomeServiceCard.vue';
|
||||
import ServiceGridSkeleton from '@/components/ui/ServiceGridSkeleton.vue';
|
||||
import type { LandingServiceItem } from '@/types/landing';
|
||||
|
||||
const props = defineProps<{
|
||||
items: LandingServiceItem[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}>();
|
||||
|
||||
const gridClass = computed(() => {
|
||||
const count = props.items.length;
|
||||
if (count >= 5) return 'lg:grid-cols-5';
|
||||
if (count === 4) return 'lg:grid-cols-4';
|
||||
if (count === 3) return 'lg:grid-cols-3';
|
||||
return 'lg:grid-cols-2';
|
||||
});
|
||||
|
||||
const featuredIndex = computed(() => {
|
||||
const len = props.items.length;
|
||||
if (!len) return 0;
|
||||
return Math.min(2, len - 1);
|
||||
});
|
||||
|
||||
function serviceLink(item: LandingServiceItem) {
|
||||
return {
|
||||
name: 'app-business-detail',
|
||||
params: { id: item.businessId },
|
||||
hash: 'services',
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<DesktopSiteLayout>
|
||||
<HomeDiscoveryContent list-variant="featured" />
|
||||
</DesktopSiteLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HomeDiscoveryContent from '@/components/home/HomeDiscoveryContent.vue';
|
||||
import DesktopSiteLayout from '@/layouts/DesktopSiteLayout.vue';
|
||||
</script>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<DesktopSiteLayout content-width="full">
|
||||
<HomeHeroSection @book="goToBooking" @browse="scrollToServices" />
|
||||
|
||||
<HomeServicesSection
|
||||
:items="services"
|
||||
:loading="loadingServices"
|
||||
:error="servicesError"
|
||||
/>
|
||||
|
||||
<HomeFeatureBar />
|
||||
</DesktopSiteLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
import HomeFeatureBar from '@/components/home/HomeFeatureBar.vue';
|
||||
import HomeHeroSection from '@/components/home/HomeHeroSection.vue';
|
||||
import HomeServicesSection from '@/components/home/HomeServicesSection.vue';
|
||||
import DesktopSiteLayout from '@/layouts/DesktopSiteLayout.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { LandingServiceItem } from '@/types/landing';
|
||||
|
||||
defineProps<{
|
||||
services: LandingServiceItem[];
|
||||
loadingServices: boolean;
|
||||
servicesError: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const tenant = useTenantStore();
|
||||
const { singleBusinessId } = storeToRefs(tenant);
|
||||
|
||||
function scrollToServices() {
|
||||
const el = document.getElementById('services');
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
function goToBooking() {
|
||||
const id = singleBusinessId.value;
|
||||
if (!id) {
|
||||
scrollToServices();
|
||||
return;
|
||||
}
|
||||
|
||||
void router.push({
|
||||
name: 'app-business-detail',
|
||||
params: { id },
|
||||
hash: '#services',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div ref="rootRef" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="transition"
|
||||
:class="triggerClass"
|
||||
@click="profileMenu.toggle()"
|
||||
>
|
||||
<AppIcon
|
||||
v-if="variant === 'desktop-cta' || variant === 'bottom-nav'"
|
||||
name="user"
|
||||
:size="variant === 'bottom-nav' ? 'lg' : 'sm'"
|
||||
/>
|
||||
<span
|
||||
class="max-w-[9rem] truncate"
|
||||
:dir="labelIsMobile ? 'ltr' : undefined"
|
||||
>
|
||||
{{ triggerLabel }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-150 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="profileMenu.isOpen"
|
||||
class="absolute z-50 max-h-[min(70dvh,32rem)] w-80 max-w-[calc(100vw-2rem)] overflow-y-auto rounded-2xl border border-border bg-surface p-4 shadow-xl dark:border-slate-700 dark:bg-[#121a24]"
|
||||
:class="panelClass"
|
||||
>
|
||||
<ProfilePanel />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { onClickOutside, onKeyStroke } from '@vueuse/core';
|
||||
import ProfilePanel from '@/components/profile/ProfilePanel.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useProfileModalStore } from '@/stores/profile-modal';
|
||||
import { userDisplayName, userDisplayNameIsMobile } from '@/utils/user-display';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'header' | 'sidebar' | 'desktop-cta' | 'bottom-nav';
|
||||
}>(),
|
||||
{ variant: 'header' },
|
||||
);
|
||||
|
||||
const auth = useAuthStore();
|
||||
const profileMenu = useProfileModalStore();
|
||||
const rootRef = ref<HTMLElement | null>(null);
|
||||
|
||||
const triggerLabel = computed(() => userDisplayName(auth.user));
|
||||
const labelIsMobile = computed(() => userDisplayNameIsMobile(auth.user));
|
||||
|
||||
const triggerClass = computed(() => {
|
||||
const active = profileMenu.isOpen;
|
||||
|
||||
if (props.variant === 'bottom-nav') {
|
||||
return [
|
||||
'flex w-full flex-col items-center gap-0.5 rounded-xl px-2 py-2 text-xs',
|
||||
active
|
||||
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||
: 'text-muted hover:bg-surface-3 hover:text-text',
|
||||
];
|
||||
}
|
||||
|
||||
if (props.variant === 'desktop-cta') {
|
||||
return [
|
||||
'inline-flex items-center gap-2 text-sm font-semibold',
|
||||
active
|
||||
? 'rounded-lg border border-brand-600 px-5 py-2.5 text-[15px] text-brand-600 dark:border-brand-400 dark:text-brand-400'
|
||||
: 'rounded-lg border border-slate-300 px-5 py-2.5 text-[15px] text-slate-700 hover:border-brand-600 hover:text-brand-600 dark:border-slate-600 dark:text-slate-200 dark:hover:border-brand-400 dark:hover:text-brand-400',
|
||||
];
|
||||
}
|
||||
|
||||
if (props.variant === 'sidebar') {
|
||||
return [
|
||||
'inline-flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold',
|
||||
active
|
||||
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||
: 'text-muted hover:bg-surface-3 hover:text-text',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'inline-flex items-center rounded-xl px-3 py-2 text-sm font-semibold',
|
||||
active
|
||||
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||
: 'text-muted hover:bg-surface-3 hover:text-text',
|
||||
];
|
||||
});
|
||||
|
||||
const panelClass = computed(() => {
|
||||
if (props.variant === 'bottom-nav') {
|
||||
return 'bottom-full mb-2 left-1/2 -translate-x-1/2';
|
||||
}
|
||||
return 'top-full mt-2 end-0';
|
||||
});
|
||||
|
||||
onClickOutside(rootRef, () => {
|
||||
if (profileMenu.isOpen) {
|
||||
profileMenu.close();
|
||||
}
|
||||
});
|
||||
|
||||
onKeyStroke('Escape', (event) => {
|
||||
if (!profileMenu.isOpen) return;
|
||||
event.preventDefault();
|
||||
profileMenu.close();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<AppModal :open="profileModal.isOpen" title="پروفایل" @close="profileModal.close()">
|
||||
<ProfilePanel />
|
||||
</AppModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProfilePanel from '@/components/profile/ProfilePanel.vue';
|
||||
import AppModal from '@/components/ui/AppModal.vue';
|
||||
import { useProfileModalStore } from '@/stores/profile-modal';
|
||||
|
||||
const profileModal = useProfileModalStore();
|
||||
</script>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div v-if="auth.user" class="space-y-4">
|
||||
<p
|
||||
v-if="message"
|
||||
class="rounded-xl border border-brand-500/40 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
|
||||
>
|
||||
{{ message }}
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<dl class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<dt class="shrink-0 text-xs text-muted">موبایل</dt>
|
||||
<dd class="font-medium" dir="ltr">{{ auth.user.mobile }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<dt class="shrink-0 text-xs text-muted">نقش</dt>
|
||||
<dd class="font-medium">{{ roleLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section
|
||||
v-if="auth.isCustomer"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-text">کسبوکار من</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
میتوانید درخواست ثبت کسبوکار دهید تا پس از تأیید مدیریت، پنل صاحب کسبوکار فعال شود.
|
||||
</p>
|
||||
<RouterLink
|
||||
to="/app/register-business"
|
||||
class="mt-3 inline-flex w-full items-center justify-center rounded-xl bg-brand-600 px-4 py-3 text-sm font-semibold text-white"
|
||||
@click="profileModal.close()"
|
||||
>
|
||||
ثبت کسبوکار من
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="auth.isBusinessOwner"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-text">مدیریت کسبوکار</h2>
|
||||
<RouterLink
|
||||
to="/app/owner"
|
||||
class="mt-3 inline-flex w-full items-center justify-center rounded-xl border border-border px-4 py-3 text-sm font-semibold text-text hover:bg-surface-3"
|
||||
@click="profileModal.close()"
|
||||
>
|
||||
ورود به پنل کسبوکار
|
||||
</RouterLink>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
|
||||
<h2 class="text-sm font-semibold text-text">
|
||||
{{ auth.user.hasPassword ? 'تغییر رمز عبور' : 'تنظیم رمز عبور' }}
|
||||
</h2>
|
||||
<p class="text-xs text-muted">
|
||||
پس از تنظیم رمز، ورود با رمز عبور یا کد یکبارمصرف هر دو ممکن است.
|
||||
</p>
|
||||
|
||||
<AppInput id="profileNewPassword" v-model="newPassword" label="رمز جدید" type="password" ltr />
|
||||
<AppInput
|
||||
id="profileConfirmPassword"
|
||||
v-model="confirmPassword"
|
||||
label="تکرار رمز جدید"
|
||||
type="password"
|
||||
ltr
|
||||
/>
|
||||
<AppButton :loading="saving" @click="onSavePassword">
|
||||
{{ auth.user.hasPassword ? 'بهروزرسانی رمز' : 'ذخیره رمز' }}
|
||||
</AppButton>
|
||||
</section>
|
||||
|
||||
<section class="rounded-2xl border border-border bg-surface-2 p-4">
|
||||
<h2 class="text-sm font-semibold text-text">خروج از حساب</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
با خروج، برای رزرو مجدد باید دوباره وارد شوید.
|
||||
</p>
|
||||
<AppButton variant="danger" class="mt-3" @click="onLogout">
|
||||
خروج
|
||||
</AppButton>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { setPassword } from '@/api/auth';
|
||||
import { ApiError } from '@/api/client';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useProfileModalStore } from '@/stores/profile-modal';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const profileModal = useProfileModalStore();
|
||||
const router = useRouter();
|
||||
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const message = ref('');
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
switch (auth.user?.role) {
|
||||
case 'admin':
|
||||
return 'مدیر سیستم';
|
||||
case 'business_owner':
|
||||
return 'صاحب کسبوکار';
|
||||
default:
|
||||
return 'مشتری';
|
||||
}
|
||||
});
|
||||
|
||||
function onLogout() {
|
||||
profileModal.close();
|
||||
auth.logout();
|
||||
void router.push({ name: 'app-home' });
|
||||
}
|
||||
|
||||
async function onSavePassword() {
|
||||
if (!auth.user) return;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
|
||||
if (newPassword.value.length < 8) {
|
||||
error.value = 'رمز باید حداقل ۸ کاراکتر باشد.';
|
||||
return;
|
||||
}
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = 'تکرار رمز با رمز جدید یکسان نیست.';
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const updated = await setPassword({
|
||||
password: newPassword.value,
|
||||
confirmPassword: confirmPassword.value,
|
||||
});
|
||||
auth.setUser(updated);
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
message.value = 'رمز عبور ذخیره شد.';
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره رمز';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
class="mx-auto flex min-h-dvh w-full max-w-app flex-col bg-surface shadow-xl shadow-black/5 dark:shadow-black/30"
|
||||
>
|
||||
<slot />
|
||||
<div class="min-h-dvh w-full bg-surface-2 lg:bg-surface-3">
|
||||
<div
|
||||
class="mx-auto flex h-dvh max-h-dvh w-full min-w-0 max-w-full flex-col overflow-hidden bg-surface shadow-xl shadow-black/5 dark:shadow-black/30 max-w-app lg:max-w-none lg:shadow-none"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ProfileDropdown v-if="auth.isLoggedIn" :variant="variant" />
|
||||
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-xl text-sm font-semibold transition"
|
||||
:class="
|
||||
variant === 'desktop-cta'
|
||||
? 'rounded-lg bg-brand-600 px-5 py-2.5 text-[15px] text-white shadow-sm hover:bg-brand-700'
|
||||
: variant === 'sidebar'
|
||||
? 'w-full bg-brand-600 px-3 py-2 text-white hover:bg-brand-700'
|
||||
: 'bg-brand-600 px-3 py-2 text-white hover:bg-brand-700'
|
||||
"
|
||||
@click="onLogin"
|
||||
>
|
||||
<AppIcon v-if="variant === 'desktop-cta'" name="user" size="sm" />
|
||||
<span>{{ variant === 'desktop-cta' ? 'ورود / ثبتنام' : 'ورود' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import ProfileDropdown from '@/components/profile/ProfileDropdown.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { loginRouteWithRedirect } from '@/utils/auth-redirect';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'header' | 'sidebar' | 'desktop-cta';
|
||||
}>(),
|
||||
{ variant: 'header' },
|
||||
);
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
function onLogin() {
|
||||
void router.push(loginRouteWithRedirect(route.fullPath));
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-30 border-b border-landing-line bg-white/95 backdrop-blur dark:border-slate-700/70 dark:bg-[#121a24]/95"
|
||||
>
|
||||
<div
|
||||
class="mx-auto grid max-w-[1200px] grid-cols-[1fr_auto_1fr] items-center gap-4 px-6 py-3.5 lg:px-8"
|
||||
>
|
||||
<RouterLink to="/app" class="justify-self-start">
|
||||
<BrandMark />
|
||||
</RouterLink>
|
||||
|
||||
<nav class="hidden items-center justify-center gap-8 lg:flex">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="relative whitespace-nowrap pb-1 text-[15px] font-medium transition"
|
||||
:class="
|
||||
isActive(item)
|
||||
? 'text-brand-600 after:absolute after:inset-x-0 after:-bottom-0.5 after:h-0.5 after:rounded-full after:bg-brand-600 dark:text-brand-400 dark:after:bg-brand-400'
|
||||
: 'text-slate-700 hover:text-brand-600 dark:text-slate-300 dark:hover:text-brand-400'
|
||||
"
|
||||
@click="goTo(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<ThemeToggle
|
||||
class="!h-10 !w-10 !rounded-full !border-slate-200 dark:!border-slate-600 dark:!bg-slate-800 dark:!text-slate-200 dark:hover:!text-white"
|
||||
/>
|
||||
<AuthHeaderAction variant="desktop-cta" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BrandMark from '@/components/brand/BrandMark.vue';
|
||||
import AuthHeaderAction from '@/components/shell/AuthHeaderAction.vue';
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
||||
import { useLandingNav } from '@/composables/useLandingNav';
|
||||
|
||||
const { navItems, isActive, goTo } = useLandingNav();
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-brand-600 text-white shadow-lg shadow-brand-600/30 transition hover:bg-brand-700 hover:shadow-xl"
|
||||
aria-label="گفتگو با پشتیبانی"
|
||||
@click="onClick"
|
||||
>
|
||||
<AppIcon name="message-circle" size="lg" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
function onClick() {
|
||||
const el = document.getElementById('contact');
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<footer class="border-t border-landing-line bg-landing-surface dark:border-border dark:bg-surface-2">
|
||||
<div id="club" class="sr-only" aria-hidden="true" />
|
||||
|
||||
<div class="mx-auto max-w-[1200px] px-6 py-12 lg:px-8">
|
||||
<div class="grid grid-cols-1 gap-10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<section>
|
||||
<h3 class="mb-4 text-sm font-bold text-landing-ink dark:text-text">ما را دنبال کنید</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<a
|
||||
href="#"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:border-brand-600 hover:text-brand-600 dark:border-border dark:bg-surface"
|
||||
aria-label="اینستاگرام"
|
||||
>
|
||||
<AppIcon name="camera" size="sm" />
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:border-brand-600 hover:text-brand-600 dark:border-border dark:bg-surface"
|
||||
aria-label="تلگرام"
|
||||
>
|
||||
<AppIcon name="send" size="sm" />
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:border-brand-600 hover:text-brand-600 dark:border-border dark:bg-surface"
|
||||
aria-label="واتساپ"
|
||||
>
|
||||
<AppIcon name="message-circle" size="sm" />
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="contact" class="scroll-mt-28">
|
||||
<h3 class="mb-4 text-sm font-bold text-landing-ink dark:text-text">تماس با ما</h3>
|
||||
<ul class="space-y-3 text-sm text-landing-muted dark:text-muted">
|
||||
<li v-if="contactPhone" class="flex items-center gap-2" dir="ltr">
|
||||
<AppIcon name="phone" size="sm" class="shrink-0 text-brand-600" />
|
||||
{{ contactPhone }}
|
||||
</li>
|
||||
<li v-if="contactMobile" class="flex items-center gap-2" dir="ltr">
|
||||
<AppIcon name="phone" size="sm" class="shrink-0 text-brand-600" />
|
||||
{{ contactMobile }}
|
||||
</li>
|
||||
<li v-if="address" class="flex items-start gap-2">
|
||||
<AppIcon name="map-pin" size="sm" class="mt-0.5 shrink-0 text-brand-600" />
|
||||
{{ address }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="mb-4 text-sm font-bold text-landing-ink dark:text-text">ساعات کاری</h3>
|
||||
<p class="text-sm leading-8 text-landing-muted dark:text-muted">
|
||||
همه روزه ۸:۰۰ صبح تا ۲۰:۰۰ شب
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="about" class="scroll-mt-28">
|
||||
<h3 class="mb-4 text-sm font-bold text-landing-ink dark:text-text">درباره ما</h3>
|
||||
<p class="text-sm leading-8 text-landing-muted dark:text-muted">
|
||||
{{ brand.aboutText }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="mt-10 border-t border-landing-line pt-6 text-center text-xs text-landing-muted dark:border-border dark:text-muted"
|
||||
>
|
||||
تمامی حقوق محفوظ است © ۱۴۰۳ {{ brand.copyrightName }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
|
||||
const tenant = useTenantStore();
|
||||
const { brand, singleBusiness, isSingleBusiness } = storeToRefs(tenant);
|
||||
|
||||
const contactPhone = computed(() => {
|
||||
if (isSingleBusiness.value && singleBusiness.value?.contactPhone) {
|
||||
return singleBusiness.value.contactPhone;
|
||||
}
|
||||
return '021-12345678';
|
||||
});
|
||||
|
||||
const contactMobile = computed(() => {
|
||||
if (isSingleBusiness.value) return null;
|
||||
return '0912-123-4567';
|
||||
});
|
||||
|
||||
const address = computed(() => {
|
||||
if (isSingleBusiness.value && singleBusiness.value?.address) {
|
||||
return singleBusiness.value.address;
|
||||
}
|
||||
return 'تهران، خیابان ولیعصر، پلاک ۱۲۳';
|
||||
});
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@ import { computed } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'ghost' | 'danger';
|
||||
variant?: 'primary' | 'ghost' | 'danger' | 'outline';
|
||||
type?: 'button' | 'submit';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
@@ -28,6 +28,8 @@ const variantClass = computed(() => {
|
||||
return 'bg-transparent text-muted hover:bg-surface-3 hover:text-text';
|
||||
case 'danger':
|
||||
return 'bg-red-500/15 text-red-600 dark:text-red-400 hover:bg-red-500/25';
|
||||
case 'outline':
|
||||
return 'border border-border bg-white text-text hover:bg-surface-2 dark:bg-surface dark:hover:bg-surface-2';
|
||||
default:
|
||||
return 'bg-brand-600 text-white hover:bg-brand-700';
|
||||
}
|
||||
|
||||
@@ -19,31 +19,46 @@ export default defineComponent({
|
||||
import type { Component } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
Armchair,
|
||||
ArrowRight,
|
||||
BadgePercent,
|
||||
Calendar,
|
||||
Camera,
|
||||
Car,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CircleCheck,
|
||||
Clock,
|
||||
ClipboardList,
|
||||
Cog,
|
||||
Droplet,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderOpen,
|
||||
Headphones,
|
||||
Home,
|
||||
LayoutDashboard,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Moon,
|
||||
Plus,
|
||||
Phone,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
Shield,
|
||||
Sparkles,
|
||||
SprayCan,
|
||||
Store,
|
||||
Sun,
|
||||
Trash2,
|
||||
User,
|
||||
UserCog,
|
||||
Users,
|
||||
Wind,
|
||||
X,
|
||||
Zap,
|
||||
} from '@lucide/vue';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
|
||||
@@ -73,12 +88,27 @@ const iconMap: Record<AppIconName, Component> = {
|
||||
'user-cog': UserCog,
|
||||
eye: Eye,
|
||||
'eye-off': EyeOff,
|
||||
shield: Shield,
|
||||
settings: Settings,
|
||||
droplet: Droplet,
|
||||
headphones: Headphones,
|
||||
'badge-percent': BadgePercent,
|
||||
'message-circle': MessageCircle,
|
||||
phone: Phone,
|
||||
send: Send,
|
||||
sparkles: Sparkles,
|
||||
armchair: Armchair,
|
||||
wind: Wind,
|
||||
car: Car,
|
||||
cog: Cog,
|
||||
spray: SprayCan,
|
||||
zap: Zap,
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: AppIconName;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
strokeWidth?: number;
|
||||
}>(),
|
||||
{
|
||||
@@ -88,7 +118,12 @@ const props = withDefaults(
|
||||
);
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const map = { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-6 w-6' } as const;
|
||||
const map = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-5 w-5',
|
||||
lg: 'h-6 w-6',
|
||||
xl: 'h-8 w-8',
|
||||
} as const;
|
||||
return map[props.size];
|
||||
});
|
||||
|
||||
|
||||
@@ -30,13 +30,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
onKeyStroke('Escape', (event) => {
|
||||
if (!props.open) return;
|
||||
event.preventDefault();
|
||||
emit('close');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label v-if="label" :for="id" class="block text-sm font-medium text-muted">
|
||||
{{ label }}
|
||||
</label>
|
||||
<select
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
:class="ltr ? 'text-left' : ''"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-if="placeholder" disabled value="">{{ placeholder }}</option>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label?: string;
|
||||
id?: string;
|
||||
modelValue: string;
|
||||
options: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
ltr?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||
|
||||
function onChange(e: Event) {
|
||||
emit('update:modelValue', (e.target as HTMLSelectElement).value);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<label v-if="label" :for="id" class="block text-sm font-medium text-muted">
|
||||
{{ label }}
|
||||
</label>
|
||||
<textarea
|
||||
:id="id"
|
||||
v-model="model"
|
||||
:rows="rows"
|
||||
class="w-full resize-y rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none transition placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const model = defineModel<string>({ default: '' });
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
id?: string;
|
||||
rows?: number;
|
||||
}>(),
|
||||
{ rows: 3 },
|
||||
);
|
||||
</script>
|
||||
Reference in New Issue
Block a user