landing desktop
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user