2026-05-18 20:18:28 +03:30
|
|
|
|
<template>
|
|
|
|
|
|
<WebAppLayout>
|
2026-05-23 13:14:52 +03:30
|
|
|
|
<section class="space-y-5">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 class="text-xl font-bold text-text">کشف خدمات</h1>
|
|
|
|
|
|
<p class="mt-1 text-sm text-muted">کسبوکارهای محلی نزدیک شما</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-25 16:29:03 +03:30
|
|
|
|
<div class="relative">
|
2026-05-23 13:14:52 +03:30
|
|
|
|
<label class="sr-only" for="search">جستجو</label>
|
2026-05-25 16:29:03 +03:30
|
|
|
|
<AppIcon
|
|
|
|
|
|
name="search"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted"
|
|
|
|
|
|
/>
|
2026-05-23 13:14:52 +03:30
|
|
|
|
<input
|
|
|
|
|
|
id="search"
|
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
placeholder="جستجو در نام یا آدرس…"
|
2026-05-25 16:29:03 +03:30
|
|
|
|
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"
|
2026-05-23 13:14:52 +03:30
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="loadingCategories" class="text-center text-sm text-muted">
|
|
|
|
|
|
در حال بارگذاری دستهها…
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<CategoryFilter
|
|
|
|
|
|
v-else-if="categories.length"
|
|
|
|
|
|
:categories="activeCategories"
|
|
|
|
|
|
:selected-id="selectedCategoryId"
|
|
|
|
|
|
@select="onCategorySelect"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-if="loadingBusinesses" class="py-8 text-center text-sm text-muted">
|
|
|
|
|
|
در حال بارگذاری…
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<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 }}
|
2026-05-18 20:18:28 +03:30
|
|
|
|
</p>
|
|
|
|
|
|
<div
|
2026-05-23 13:14:52 +03:30
|
|
|
|
v-else-if="!filteredBusinesses.length"
|
|
|
|
|
|
class="rounded-2xl border border-dashed border-border bg-surface-2 p-8 text-center text-sm text-muted"
|
2026-05-18 20:18:28 +03:30
|
|
|
|
>
|
2026-05-23 13:14:52 +03:30
|
|
|
|
کسبوکاری یافت نشد.
|
2026-05-18 20:18:28 +03:30
|
|
|
|
</div>
|
2026-05-24 13:11:57 +03:30
|
|
|
|
<ul v-else class="space-y-2">
|
2026-05-23 13:14:52 +03:30
|
|
|
|
<li v-for="b in filteredBusinesses" :key="b.id">
|
|
|
|
|
|
<BusinessCard :business="b" />
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
2026-05-18 20:18:28 +03:30
|
|
|
|
</section>
|
|
|
|
|
|
</WebAppLayout>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-05-23 13:14:52 +03:30
|
|
|
|
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 BusinessCard from '@/components/business/BusinessCard.vue';
|
2026-05-25 16:29:03 +03:30
|
|
|
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
2026-05-23 13:14:52 +03:30
|
|
|
|
import CategoryFilter from '@/components/business/CategoryFilter.vue';
|
2026-05-18 20:18:28 +03:30
|
|
|
|
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
2026-05-23 13:14:52 +03:30
|
|
|
|
|
|
|
|
|
|
const categories = ref<BusinessCategory[]>([]);
|
|
|
|
|
|
const businesses = ref<Business[]>([]);
|
|
|
|
|
|
const selectedCategoryId = ref<string | null>(null);
|
|
|
|
|
|
const searchQuery = ref('');
|
|
|
|
|
|
const loadingCategories = ref(true);
|
|
|
|
|
|
const loadingBusinesses = ref(true);
|
|
|
|
|
|
const error = ref('');
|
|
|
|
|
|
|
|
|
|
|
|
const activeCategories = computed(() =>
|
|
|
|
|
|
categories.value.filter((c) => c.isActive),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
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() {
|
|
|
|
|
|
loadingCategories.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const all = await fetchBusinessCategories();
|
|
|
|
|
|
categories.value = all;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value =
|
|
|
|
|
|
e instanceof ApiError ? e.message : 'بارگذاری دستهها ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingCategories.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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, () => {
|
|
|
|
|
|
loadBusinesses();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-05-31 14:12:18 +03:30
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void Promise.all([loadCategories(), loadBusinesses()]);
|
2026-05-23 13:14:52 +03:30
|
|
|
|
});
|
2026-05-18 20:18:28 +03:30
|
|
|
|
</script>
|