landing desktop

This commit is contained in:
2026-06-12 20:16:14 +03:30
parent 820e80ac16
commit 5eec309b54
89 changed files with 4343 additions and 2309 deletions
+62
View File
@@ -0,0 +1,62 @@
<template>
<RouterLink
:to="{ name: 'app-business-detail', params: { id: business.id } }"
class="group flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-6 text-center shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md dark:border-border dark:bg-surface"
:class="featured ? 'border-brand-500 ring-1 ring-brand-500/20' : ''"
>
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-brand-500/10 text-brand-600 transition group-hover:bg-brand-500/15 dark:text-brand-500"
>
<img
v-if="cover"
:src="cover"
:alt="business.title"
class="h-full w-full object-cover"
loading="lazy"
/>
<AppIcon v-else name="store" size="lg" />
</div>
<h3 class="text-base font-bold text-slate-800 dark:text-text">{{ business.title }}</h3>
<p
v-if="business.description"
class="mt-2 line-clamp-3 min-h-[4.5rem] text-sm leading-7 text-slate-600 dark:text-muted"
>
{{ business.description }}
</p>
<p v-else class="mt-2 min-h-[4.5rem] text-sm text-slate-400 dark:text-muted">—</p>
<p v-if="business.category" class="mt-3 text-xs font-medium text-brand-600 dark:text-brand-500">
{{ business.category.title }}
</p>
<p
v-if="business.address"
class="mt-2 flex items-center justify-center gap-1 text-xs text-slate-500 dark:text-muted"
>
<AppIcon name="map-pin" size="sm" class="shrink-0" />
<span class="line-clamp-1">{{ business.address }}</span>
</p>
<span
class="mt-auto pt-5 text-sm font-semibold text-brand-600 transition group-hover:text-brand-700 dark:text-brand-500"
>
مشاهده و رزرو
</span>
</RouterLink>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Business } from '@/api/types';
import AppIcon from '@/components/ui/AppIcon.vue';
import { businessCoverUrl } from '@/utils/media';
const props = defineProps<{
business: Business;
featured?: boolean;
}>();
const cover = computed(() => businessCoverUrl(props.business.images));
</script>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div :class="props.class">
<BusinessCardSkeleton v-if="loading" class="py-2" />
<p
v-else-if="error"
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
>
{{ error }}
</p>
<div
v-else-if="!businesses.length"
class="rounded-2xl border border-dashed border-border bg-surface-2 p-8 text-center text-sm text-muted"
>
کسب‌وکاری یافت نشد.
</div>
<ul
v-else
:class="
variant === 'featured'
? 'grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3'
: 'grid grid-cols-1 gap-2'
"
>
<li v-for="(business, index) in businesses" :key="business.id">
<HomeBusinessCard
v-if="variant === 'featured'"
:business="business"
:featured="index === 1"
/>
<BusinessCard v-else :business="business" />
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import type { Business } from '@/api/types';
import BusinessCard from '@/components/business/BusinessCard.vue';
import HomeBusinessCard from '@/components/home/HomeBusinessCard.vue';
import BusinessCardSkeleton from '@/components/ui/BusinessCardSkeleton.vue';
const props = withDefaults(
defineProps<{
businesses: Business[];
loading: boolean;
error: string;
variant?: 'compact' | 'featured';
class?: string;
}>(),
{ variant: 'compact', class: '' },
);
</script>
@@ -0,0 +1,64 @@
<template>
<section id="club" class="scroll-mt-28 border-t border-landing-line bg-landing-surface py-14 dark:border-border dark:bg-surface-2">
<div class="mx-auto max-w-[1200px] px-6 xl:px-8">
<HomeSectionTitle subtitle="با عضویت در باشگاه مشتریان منتایم از تخفیف‌ها و پیشنهادهای ویژه بهره‌مند شوید.">
باشگاه مشتریان
</HomeSectionTitle>
<div class="mx-auto grid max-w-4xl gap-6 md:grid-cols-3">
<div
v-for="perk in perks"
:key="perk.title"
class="rounded-2xl border border-slate-200 bg-white p-6 text-center shadow-sm dark:border-border dark:bg-surface"
>
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-50 text-brand-600 dark:bg-brand-500/10 dark:text-brand-500"
>
<AppIcon :name="perk.icon" size="md" />
</div>
<h3 class="font-bold text-landing-ink dark:text-text">{{ perk.title }}</h3>
<p class="mt-2 text-sm leading-7 text-landing-muted dark:text-muted">{{ perk.text }}</p>
</div>
</div>
<div class="mt-8 text-center">
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-6 py-3 text-sm font-semibold text-white hover:bg-brand-700"
@click="emit('join')"
>
<AppIcon name="user" size="sm" />
عضویت در باشگاه
</button>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import HomeSectionTitle from '@/components/home/HomeSectionTitle.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import type { AppIconName } from '@/types/icons';
const emit = defineEmits<{
join: [];
}>();
const perks: { title: string; text: string; icon: AppIconName }[] = [
{
title: 'امتیاز وفاداری',
text: 'با هر رزرو امتیاز جمع کنید و جایزه بگیرید.',
icon: 'badge-percent',
},
{
title: 'تخفیف اختصاصی',
text: 'کدهای تخفیف مخصوص اعضای باشگاه.',
icon: 'sparkles',
},
{
title: 'اولویت رزرو',
text: 'دسترسی زودتر به اسلات‌های پرتقاضا.',
icon: 'calendar',
},
];
</script>
@@ -0,0 +1,113 @@
<template>
<section class="space-y-5">
<div>
<h1 class="text-xl font-bold text-text lg:text-2xl">کشف خدمات</h1>
<p class="mt-1 text-sm text-muted lg:text-base">
کسب‌وکارهای محلی نزدیک شما
</p>
</div>
<HomeSearchAndFilter
v-model:search="searchQuery"
:show-search="showSearch"
:categories="activeCategories"
:show-category-filter="showCategoryFilter"
:selected-category-id="selectedCategoryId"
@select-category="onCategorySelect"
/>
<HomeBusinessList
:businesses="filteredBusinesses"
:loading="loadingBusinesses"
:error="error"
:variant="props.listVariant"
/>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { fetchBusinessCategories } from '@/api/business-categories';
import { fetchBusinesses } from '@/api/businesses';
import { ApiError } from '@/api/client';
import type { Business, BusinessCategory } from '@/api/types';
import HomeBusinessList from '@/components/home/HomeBusinessList.vue';
import HomeSearchAndFilter from '@/components/home/HomeSearchAndFilter.vue';
const props = withDefaults(
defineProps<{
listVariant?: 'compact' | 'featured';
}>(),
{ listVariant: 'compact' },
);
const categories = ref<BusinessCategory[]>([]);
const businesses = ref<Business[]>([]);
const selectedCategoryId = ref<string | null>(null);
const searchQuery = ref('');
const loadingBusinesses = ref(false);
const error = ref('');
const activeCategories = computed(() =>
categories.value.filter((c) => c.isActive),
);
const showCategoryFilter = computed(() => activeCategories.value.length > 1);
const showSearch = computed(() => businesses.value.length > 1);
const filteredBusinesses = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) {
return businesses.value;
}
return businesses.value.filter((b) => {
const haystack = [b.title, b.description, b.address]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(q);
});
});
async function loadCategories() {
try {
categories.value = await fetchBusinessCategories();
if (activeCategories.value.length === 1) {
selectedCategoryId.value = activeCategories.value[0].id;
}
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'بارگذاری دسته‌ها ناموفق بود.';
}
}
async function loadBusinesses() {
loadingBusinesses.value = true;
error.value = '';
try {
businesses.value = await fetchBusinesses({
categoryId: selectedCategoryId.value ?? undefined,
isActive: true,
});
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'بارگذاری کسب‌وکارها ناموفق بود.';
} finally {
loadingBusinesses.value = false;
}
}
function onCategorySelect(id: string | null) {
selectedCategoryId.value = id;
}
watch(selectedCategoryId, () => {
void loadBusinesses();
});
onMounted(async () => {
await loadCategories();
await loadBusinesses();
});
</script>
+39
View File
@@ -0,0 +1,39 @@
<template>
<section class="bg-white py-8 dark:bg-surface">
<div class="mx-auto max-w-[1200px] px-6 xl:px-8">
<div
class="rounded-2xl bg-[#001f3f] px-3 py-5 dark:bg-[#0f172a] sm:px-6 sm:py-7 xl:px-10 xl:py-8"
>
<div class="grid grid-cols-4 gap-2 lg:gap-4">
<div
v-for="item in items"
:key="item.label"
class="flex flex-col items-center gap-1.5 text-center lg:flex-row lg:items-center lg:gap-3 lg:text-right"
>
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border-2 border-brand-500/80 text-brand-500 lg:h-11 lg:w-11"
>
<AppIcon :name="item.icon" size="sm" class="lg:hidden" />
<AppIcon :name="item.icon" size="md" class="hidden lg:block" />
</div>
<p class="text-[10px] font-bold leading-4 text-white lg:text-sm lg:leading-6">
{{ item.label }}
</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import AppIcon from '@/components/ui/AppIcon.vue';
import type { AppIconName } from '@/types/icons';
const items: { label: string; icon: AppIconName }[] = [
{ label: 'پشتیبانی ۲۴/۷', icon: 'headphones' },
{ label: 'پرداخت امن', icon: 'circle-check' },
{ label: 'تخفیف‌های ویژه', icon: 'badge-percent' },
{ label: 'رزرو سریع و آسان', icon: 'clock' },
];
</script>
+135
View File
@@ -0,0 +1,135 @@
<template>
<section class="relative overflow-hidden bg-[#f5f7fa] pb-10 pt-6 dark:bg-surface">
<div
class="landing-hero-dots pointer-events-none absolute start-4 top-10 h-36 w-36 opacity-80"
aria-hidden="true"
/>
<div
class="landing-hero-lines pointer-events-none absolute bottom-6 end-4 h-44 w-52 opacity-70"
aria-hidden="true"
/>
<svg width="0" height="0" class="absolute" aria-hidden="true">
<defs>
<clipPath id="landing-hero-wave" clipPathUnits="objectBoundingBox">
<path
d="M0,0 H1 V0.78 C0.94,0.84 0.86,0.9 0.76,0.95 C0.64,0.98 0.5,1 0.36,0.98 C0.24,0.93 0.12,0.86 0,0.8 Z"
/>
</clipPath>
</defs>
</svg>
<div
class="mx-auto grid max-w-[1200px] items-center gap-8 px-6 lg:grid-cols-2 lg:gap-12 lg:px-8"
>
<div class="relative z-10 space-y-7">
<h1
class="flex flex-wrap items-baseline gap-x-4 gap-y-2 text-[1.85rem] font-black leading-[1.45] text-landing-ink dark:text-text lg:text-[2.1rem]"
>
<span>{{ heroTitle }}</span>
<span v-if="heroSubtitle" class="text-brand-600">{{ heroSubtitle }}</span>
</h1>
<p
v-if="heroDescription"
class="max-w-md text-[15px] leading-8 text-landing-muted dark:text-muted"
>
{{ heroDescription }}
</p>
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-6 py-3 text-[15px] font-semibold text-white shadow-sm transition hover:bg-brand-700"
@click="emit('book')"
>
<AppIcon name="calendar" size="sm" />
رزرو نوبت
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-6 py-3 text-[15px] font-semibold text-landing-ink transition hover:border-brand-600 hover:text-brand-600 dark:border-border dark:bg-surface dark:text-text"
@click="emit('browse')"
>
مشاهده خدمات
</button>
</div>
<div class="flex flex-wrap items-center gap-x-8 gap-y-3 pt-1">
<div
v-for="item in highlights"
:key="item.label"
class="flex items-center gap-2"
>
<AppIcon :name="item.icon" size="sm" class="shrink-0 text-brand-600" />
<span class="text-xs font-medium text-landing-muted dark:text-muted">
{{ item.label }}
</span>
</div>
</div>
</div>
<div class="relative">
<div class="landing-hero-blob relative overflow-hidden bg-slate-200 shadow-[0_20px_50px_-12px_rgba(0,0,0,0.18)]">
<img
v-if="heroImage && !heroImageFailed"
:key="heroImage"
:src="heroImage"
:alt="heroTitle"
class="h-[320px] w-full object-cover lg:h-[390px]"
@error="onHeroImageError"
/>
<div
v-else
class="flex h-[320px] w-full items-center justify-center bg-gradient-to-br from-brand-50 via-slate-100 to-brand-100 lg:h-[390px]"
>
<AppIcon name="car" size="xl" class="h-20 w-20 text-brand-600/30" />
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import AppIcon from '@/components/ui/AppIcon.vue';
import { useTenantStore } from '@/stores/tenant';
import type { AppIconName } from '@/types/icons';
import { businessCoverUrl } from '@/utils/media';
const emit = defineEmits<{
book: [];
browse: [];
}>();
const tenant = useTenantStore();
const { singleBusiness } = storeToRefs(tenant);
const heroImageFailed = ref(false);
const heroTitle = computed(() => singleBusiness.value?.title ?? '');
const heroSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
const heroDescription = computed(() => singleBusiness.value?.description?.trim() ?? '');
const heroImage = computed(() =>
businessCoverUrl(singleBusiness.value?.images),
);
watch(heroImage, () => {
heroImageFailed.value = false;
});
const highlights: { label: string; icon: AppIconName }[] = [
{ label: 'رضایت مشتریان', icon: 'shield' },
{ label: 'تجهیزات مدرن', icon: 'settings' },
{ label: 'مواد شوینده با کیفیت', icon: 'droplet' },
];
function onHeroImageError() {
heroImageFailed.value = true;
}
</script>
@@ -0,0 +1,52 @@
<template>
<div :class="desktop ? 'mx-auto max-w-3xl space-y-4' : 'space-y-4'">
<div v-if="showSearch" class="relative">
<label class="sr-only" for="home-search">جستجو</label>
<AppIcon
name="search"
size="sm"
class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted"
/>
<input
id="home-search"
:value="search"
type="search"
placeholder="جستجو در نام یا آدرس…"
class="w-full rounded-xl border border-border bg-surface-2 py-3 pe-4 ps-10 text-sm text-text outline-none placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
:class="desktop ? 'bg-white dark:bg-surface' : ''"
@input="onSearchInput"
/>
</div>
<CategoryFilter
v-if="showCategoryFilter"
:categories="categories"
:selected-id="selectedCategoryId"
@select="emit('select-category', $event)"
/>
</div>
</template>
<script setup lang="ts">
import type { BusinessCategory } from '@/api/types';
import CategoryFilter from '@/components/business/CategoryFilter.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
defineProps<{
search: string;
showSearch: boolean;
categories: BusinessCategory[];
showCategoryFilter: boolean;
selectedCategoryId: string | null;
desktop?: boolean;
}>();
const emit = defineEmits<{
'update:search': [value: string];
'select-category': [id: string | null];
}>();
function onSearchInput(event: Event) {
emit('update:search', (event.target as HTMLInputElement).value);
}
</script>
+23
View File
@@ -0,0 +1,23 @@
<template>
<div class="mb-10 text-center">
<div class="flex items-center justify-center gap-5">
<span class="h-px w-20 bg-brand-600/50" aria-hidden="true" />
<h2 class="text-[1.35rem] font-bold text-landing-ink dark:text-text">
<slot />
</h2>
<span class="h-px w-20 bg-brand-600/50" aria-hidden="true" />
</div>
<p
v-if="subtitle"
class="mx-auto mt-4 max-w-2xl text-sm leading-7 text-landing-muted dark:text-muted"
>
{{ subtitle }}
</p>
</div>
</template>
<script setup lang="ts">
defineProps<{
subtitle?: string;
}>();
</script>
+47
View File
@@ -0,0 +1,47 @@
<template>
<RouterLink
:to="to"
class="flex min-w-0 flex-1 flex-col rounded-xl border bg-white px-4 py-5 text-center shadow-[0_2px_12px_rgba(15,23,42,0.06)] transition hover:-translate-y-0.5 hover:shadow-md dark:bg-surface"
:class="
featured
? 'border-brand-600 ring-1 ring-brand-600/25'
: 'border-slate-100 dark:border-border'
"
>
<div class="mb-3 flex justify-center text-slate-400 dark:text-muted">
<AppIcon :name="icon" size="lg" />
</div>
<h3 class="text-[15px] font-bold leading-7 text-landing-ink dark:text-text">
{{ title }}
</h3>
<p
v-if="description"
class="mt-1 text-xs leading-6 text-landing-muted dark:text-muted"
>
{{ description }}
</p>
<p v-if="durationLabel" class="mt-3 text-xs text-landing-muted dark:text-muted">
مدت زمان: {{ durationLabel }}
</p>
</RouterLink>
</template>
<script setup lang="ts">
import AppIcon from '@/components/ui/AppIcon.vue';
import type { AppIconName } from '@/types/icons';
defineProps<{
to:
| string
| { name: string; params?: Record<string, string>; hash?: string }
| { path: string; hash?: string };
title: string;
description?: string | null;
durationLabel?: string | null;
icon: AppIconName;
featured?: boolean;
}>();
</script>
@@ -0,0 +1,80 @@
<template>
<section id="services" class="scroll-mt-28 bg-white py-14 dark:bg-surface">
<div class="mx-auto max-w-[1200px] px-6 xl:px-8">
<HomeSectionTitle
subtitle="با انتخاب سرویس مورد نظر، در زمان دلخواه نوبت خود را رزرو کنید."
>
خدمات ما
</HomeSectionTitle>
<ServiceGridSkeleton v-if="loading" :count="5" />
<p
v-else-if="error"
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-center text-sm text-red-600"
>
{{ error }}
</p>
<div
v-else-if="!items.length"
class="rounded-2xl border border-dashed border-landing-line p-10 text-center text-sm text-landing-muted"
>
هنوز خدمتی ثبت نشده است.
</div>
<div
v-else
class="grid grid-cols-2 gap-4 lg:gap-4"
:class="gridClass"
>
<HomeServiceCard
v-for="(item, index) in items"
:key="item.id"
:to="serviceLink(item)"
:title="item.title"
:description="item.description"
:duration-label="item.durationLabel"
:icon="item.icon"
:featured="index === featuredIndex"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import HomeSectionTitle from '@/components/home/HomeSectionTitle.vue';
import HomeServiceCard from '@/components/home/HomeServiceCard.vue';
import ServiceGridSkeleton from '@/components/ui/ServiceGridSkeleton.vue';
import type { LandingServiceItem } from '@/types/landing';
const props = defineProps<{
items: LandingServiceItem[];
loading: boolean;
error: string;
}>();
const gridClass = computed(() => {
const count = props.items.length;
if (count >= 5) return 'lg:grid-cols-5';
if (count === 4) return 'lg:grid-cols-4';
if (count === 3) return 'lg:grid-cols-3';
return 'lg:grid-cols-2';
});
const featuredIndex = computed(() => {
const len = props.items.length;
if (!len) return 0;
return Math.min(2, len - 1);
});
function serviceLink(item: LandingServiceItem) {
return {
name: 'app-business-detail',
params: { id: item.businessId },
hash: 'services',
};
}
</script>