feat: add interactive PropertyMap component and Airbnb-style split view to ListProperties
deploy-chabokan / deploy (push) Canceled after 0s
deploy-chabokan / deploy (push) Canceled after 0s
This commit is contained in:
+1
-6
@@ -51,7 +51,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: '/property/list-properties',
|
path: '/property/list-properties',
|
||||||
name: 'list-properties',
|
name: 'list-properties',
|
||||||
component: () => import('@/views/pages/Property/CreateProperty.vue')
|
component: () => import('@/views/pages/Property/ListProperties.vue')
|
||||||
},
|
},
|
||||||
// Aliases for backward compatibility
|
// Aliases for backward compatibility
|
||||||
{
|
{
|
||||||
@@ -69,11 +69,6 @@ const router = createRouter({
|
|||||||
name: 'list-belongings',
|
name: 'list-belongings',
|
||||||
component: () => import('@/views/pages/Property/ListProperties.vue')
|
component: () => import('@/views/pages/Property/ListProperties.vue')
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/property/list-properties',
|
|
||||||
name: 'list-properties-main',
|
|
||||||
component: () => import('@/views/pages/Property/ListProperties.vue')
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/categories/list-categories',
|
path: '/categories/list-categories',
|
||||||
name: 'list-categories',
|
name: 'list-categories',
|
||||||
|
|||||||
@@ -1,53 +1,56 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { FilterMatchMode } from '@primevue/core/api';
|
import { FilterMatchMode } from '@primevue/core/api';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch, computed } from 'vue';
|
||||||
import { PropertyService } from '@/service/PropertyService';
|
import { PropertyService } from '@/service/PropertyService';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import Chip from 'primevue/chip';
|
import Chip from 'primevue/chip';
|
||||||
import InputIcon from 'primevue/inputicon';
|
import InputIcon from 'primevue/inputicon';
|
||||||
import IconField from 'primevue/iconfield';
|
import IconField from 'primevue/iconfield';
|
||||||
|
import SelectButton from 'primevue/selectbutton';
|
||||||
import LoadingCheck from '@/components/LoadingCheck.vue';
|
import LoadingCheck from '@/components/LoadingCheck.vue';
|
||||||
import { useConfirm } from 'primevue/useconfirm';
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
import ConfirmPopup from 'primevue/confirmpopup';
|
import ConfirmPopup from 'primevue/confirmpopup';
|
||||||
|
import PropertyMap from './PropertyMap.vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const deleteConfirm = useConfirm();
|
const deleteConfirm = useConfirm();
|
||||||
onMounted(() => {
|
|
||||||
loadData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// View Mode
|
||||||
|
const viewMode = ref('split'); // 'table' | 'split' | 'map'
|
||||||
|
const viewModeOptions = ref([
|
||||||
|
{ label: 'جدول', value: 'table', icon: 'pi pi-table' },
|
||||||
|
{ label: 'نمای ترکیبی (Airbnb)', value: 'split', icon: 'pi pi-th-large' },
|
||||||
|
{ label: 'نقشه کامل', value: 'map', icon: 'pi pi-map' }
|
||||||
|
]);
|
||||||
|
|
||||||
const dt = ref();
|
const dt = ref();
|
||||||
const properties = ref();
|
const properties = ref([]);
|
||||||
const productDialog = ref(false);
|
|
||||||
const deleteProductDialog = ref(false);
|
|
||||||
const deleteProductsDialog = ref(false);
|
|
||||||
const property = ref({});
|
|
||||||
const selectedProperties = ref();
|
const selectedProperties = ref();
|
||||||
|
const selectedPropertyId = ref(null);
|
||||||
|
const mapRef = ref(null);
|
||||||
|
const splitMapRef = ref(null);
|
||||||
|
const propertyCardRefs = ref({});
|
||||||
|
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
|
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
|
||||||
});
|
});
|
||||||
const submitted = ref(false);
|
|
||||||
|
|
||||||
function hideDialog() {
|
const searchPhrase = ref(route.query.query || '');
|
||||||
productDialog.value = false;
|
|
||||||
submitted.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
function editProperty(propertyId) {
|
|
||||||
router.push('/property/edit-property/' + propertyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const searchPhrase = ref(route.query.query);
|
|
||||||
const page = ref(Number(route.query.page) || 1);
|
const page = ref(Number(route.query.page) || 1);
|
||||||
const per_page = ref(Number(route.query.per_page) || 20);
|
const per_page = ref(Number(route.query.per_page) || 20);
|
||||||
const totalCount = ref(0);
|
const totalCount = ref(0);
|
||||||
const pagesCount = ref(0);
|
const pagesCount = ref(0);
|
||||||
|
|
||||||
|
const DEFAULT_IMAGE = 'https://cloud.mbmanager.ir/images/item-no-image.png';
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadData();
|
||||||
|
});
|
||||||
|
|
||||||
let searchTimeout;
|
let searchTimeout;
|
||||||
|
|
||||||
watch(searchPhrase, (newPhrase, oldPhrase) => {
|
watch(searchPhrase, (newPhrase, oldPhrase) => {
|
||||||
@@ -77,17 +80,16 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveSearch = () => {
|
const handleRemoveSearch = () => {
|
||||||
removeFromQuery('searchPhrase');
|
removeFromQuery('query');
|
||||||
searchPhrase.value = '';
|
searchPhrase.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeFromQuery = (queryToRemove) => {
|
const removeFromQuery = (queryToRemove) => {
|
||||||
const newQuery = { ...route.query };
|
const newQuery = { ...route.query };
|
||||||
|
|
||||||
delete newQuery[queryToRemove];
|
delete newQuery[queryToRemove];
|
||||||
|
|
||||||
router.replace({ query: newQuery }).catch((err) => {
|
router.replace({ query: newQuery }).catch((err) => {
|
||||||
if (err.name !== 'NavigationDuplicated') {
|
if (err?.name !== 'NavigationDuplicated') {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -109,16 +111,15 @@ const onPageChange = (event) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
await PropertyService.searchProperties(route.query)
|
try {
|
||||||
.then((response) => {
|
const response = await PropertyService.searchProperties(route.query);
|
||||||
properties.value = response.properties || response.belongings || response;
|
properties.value = response.properties || response.belongings || response || [];
|
||||||
totalCount.value = response.totalCount || response.length || 0;
|
totalCount.value = response.totalCount || (Array.isArray(response) ? response.length : 0);
|
||||||
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
|
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
|
||||||
})
|
} catch (error) {
|
||||||
.catch((error) => {
|
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بازیابی اطلاعات املاک', life: 3000 });
|
||||||
toast.add({ severity: 'error', summary: 'خطا', detail: 'خطا در بازیابی اطلاعات', life: 3000 });
|
console.error('Error loading data:', error);
|
||||||
console.error('Error loading data:', error);
|
}
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -132,7 +133,7 @@ const indexProperties = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await PropertyService.indexProperties();
|
await PropertyService.indexProperties();
|
||||||
loadData();
|
await loadData();
|
||||||
indexingFinished.value = true;
|
indexingFinished.value = true;
|
||||||
indexingMessage.value = 'ایندکس با موفقیت انجام شد';
|
indexingMessage.value = 'ایندکس با موفقیت انجام شد';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -145,6 +146,14 @@ const indexProperties = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function editProperty(propertyId) {
|
||||||
|
router.push('/property/edit-property/' + propertyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInNewTab(propertyId) {
|
||||||
|
window.open('/property/edit-property/' + propertyId, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
const confirmDeleteProperty = (id, event) => {
|
const confirmDeleteProperty = (id, event) => {
|
||||||
deleteConfirm.require({
|
deleteConfirm.require({
|
||||||
target: event.currentTarget,
|
target: event.currentTarget,
|
||||||
@@ -160,100 +169,580 @@ const confirmDeleteProperty = (id, event) => {
|
|||||||
},
|
},
|
||||||
accept: async () => {
|
accept: async () => {
|
||||||
try {
|
try {
|
||||||
let url = '/api/properties/admin/delete/' + id;
|
const url = '/api/properties/admin/delete/' + id;
|
||||||
const method = 'delete';
|
await axios.delete(url, { withCredentials: true });
|
||||||
const config = {
|
|
||||||
method,
|
|
||||||
url,
|
|
||||||
withCredentials: true
|
|
||||||
};
|
|
||||||
const response = await axios(config);
|
|
||||||
toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'حذف ملک با موفقیت انجام شد', life: 3000 });
|
toast.add({ severity: 'success', summary: 'موفقیت آمیز', detail: 'حذف ملک با موفقیت انجام شد', life: 3000 });
|
||||||
|
|
||||||
await indexProperties();
|
await indexProperties();
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.add({ severity: 'error', summary: ' خطا در انجام عملیات ', detail: error.response?.data?.message || 'خطایی در حذف ملک', life: 3000 });
|
toast.add({ severity: 'error', summary: 'خطا در انجام عملیات', detail: error.response?.data?.message || 'خطایی در حذف ملک رخ داد', life: 3000 });
|
||||||
console.log(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
reject: () => {}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPropertyImage = (prop) => {
|
||||||
|
return prop.imageUrl || prop.mainImageUrls?.[0] || prop.signedMainImageUrls?.[0] || DEFAULT_IMAGE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageError = (e) => {
|
||||||
|
e.target.src = DEFAULT_IMAGE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryName = (prop) => {
|
||||||
|
if (prop.nativeCategory) return prop.nativeCategory;
|
||||||
|
if (typeof prop.category === 'object' && prop.category?.nativeName) return prop.category.nativeName;
|
||||||
|
if (typeof prop.category === 'string') return prop.category;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Selection sync between Map & Cards
|
||||||
|
const onSelectProperty = (propertyId) => {
|
||||||
|
selectedPropertyId.value = propertyId;
|
||||||
|
|
||||||
|
// Scroll to the selected property card in split view
|
||||||
|
nextTick(() => {
|
||||||
|
const cardEl = document.getElementById(`property-card-${propertyId}`);
|
||||||
|
if (cardEl) {
|
||||||
|
cardEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCardHover = (propertyId) => {
|
||||||
|
selectedPropertyId.value = propertyId;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ConfirmPopup></ConfirmPopup>
|
<ConfirmPopup></ConfirmPopup>
|
||||||
<div>
|
<div class="properties-page">
|
||||||
<div class="card">
|
<div class="card p-4">
|
||||||
<Toolbar class="mb-6">
|
<!-- ── Top Toolbar ── -->
|
||||||
<template #start>
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
<Button label="ساخت ملک جدید" icon="pi pi-plus" severity="secondary" class="mr-2 mx-3" @click="router.push('/property/create-property')" />
|
<!-- Start: Actions -->
|
||||||
<Button @click="indexProperties" :disabled="loading" severity="secondary mx-3" class="indexing-button">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
label="ساخت ملک جدید"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
severity="primary"
|
||||||
|
@click="router.push('/property/create-property')"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
@click="indexProperties"
|
||||||
|
:disabled="loading"
|
||||||
|
severity="secondary"
|
||||||
|
class="indexing-button"
|
||||||
|
>
|
||||||
<div class="button-content">
|
<div class="button-content">
|
||||||
<div v-if="!loading">ایندکس کردن املاک</div>
|
<div v-if="!loading" class="flex items-center gap-2">
|
||||||
<div v-else class="loading-content flex">
|
<i class="pi pi-refresh"></i>
|
||||||
|
<span>ایندکس کردن املاک</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="loading-content flex items-center gap-2">
|
||||||
<LoadingCheck :key="animationKey" :is-complete="indexingFinished" />
|
<LoadingCheck :key="animationKey" :is-complete="indexingFinished" />
|
||||||
<Transition enter-active-class="animate__animated animate__fadeIn animate__fast" leave-active-class="animate__animated animate__fadeOut animate__fast" mode="out-in">
|
<Transition enter-active-class="animate__animated animate__fadeIn animate__fast" leave-active-class="animate__animated animate__fadeOut animate__fast" mode="out-in">
|
||||||
<div :key="indexingMessage">{{ indexingMessage }}</div>
|
<span :key="indexingMessage">{{ indexingMessage }}</span>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<template #end> </template>
|
<!-- Center/End: Search & View Switcher -->
|
||||||
</Toolbar>
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<!-- Search Input -->
|
||||||
|
<IconField>
|
||||||
|
<InputIcon>
|
||||||
|
<i class="pi pi-search" />
|
||||||
|
</InputIcon>
|
||||||
|
<InputText v-model="searchPhrase" placeholder="جستجوی املاک..." class="w-64" />
|
||||||
|
</IconField>
|
||||||
|
<Chip v-if="searchPhrase" class="mx-1" :label="searchPhrase" removable @remove="handleRemoveSearch" />
|
||||||
|
|
||||||
<DataTable
|
<!-- View Mode Selector -->
|
||||||
ref="dt"
|
<SelectButton
|
||||||
v-model:selection="selectedProperties"
|
v-model="viewMode"
|
||||||
:value="properties"
|
:options="viewModeOptions"
|
||||||
dataKey="id"
|
optionLabel="label"
|
||||||
:paginator="true"
|
optionValue="value"
|
||||||
:rows="per_page"
|
:allowEmpty="false"
|
||||||
:filters="filters"
|
class="view-mode-selector"
|
||||||
:total-records="totalCount"
|
>
|
||||||
:current-page="page - 1"
|
<template #option="slotProps">
|
||||||
@page="onPageChange($event)"
|
<div class="flex items-center gap-2 px-2 py-1">
|
||||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
<i :class="slotProps.option.icon"></i>
|
||||||
:rowsPerPageOptions="[20, 40, 80]"
|
<span class="hidden sm:inline">{{ slotProps.option.label }}</span>
|
||||||
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} ملک"
|
</div>
|
||||||
>
|
</template>
|
||||||
<template #header>
|
</SelectButton>
|
||||||
<div class="flex flex-wrap gap-2 items-center justify-start">
|
</div>
|
||||||
<h4 class="m-0">مدیریت املاک</h4>
|
</div>
|
||||||
<IconField>
|
|
||||||
<InputIcon>
|
<!-- ── VIEW 1: DATA TABLE VIEW ── -->
|
||||||
<i class="pi pi-search" />
|
<div v-if="viewMode === 'table'">
|
||||||
</InputIcon>
|
<DataTable
|
||||||
<InputText v-model="searchPhrase" placeholder="جستجو..." />
|
ref="dt"
|
||||||
</IconField>
|
v-model:selection="selectedProperties"
|
||||||
|
:value="properties"
|
||||||
|
dataKey="_id"
|
||||||
|
:paginator="true"
|
||||||
|
:rows="per_page"
|
||||||
|
:filters="filters"
|
||||||
|
:total-records="totalCount"
|
||||||
|
:current-page="page - 1"
|
||||||
|
@page="onPageChange($event)"
|
||||||
|
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||||
|
:rowsPerPageOptions="[20, 40, 80]"
|
||||||
|
currentPageReportTemplate="نمایش {first} تا {last} از {totalRecords} ملک"
|
||||||
|
stripedRows
|
||||||
|
responsiveLayout="scroll"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex flex-wrap gap-2 items-center justify-between">
|
||||||
|
<h4 class="m-0 text-lg font-bold">لیست املاک ({{ totalCount }} مورد)</h4>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
|
||||||
|
<Column header="تصویر" style="width: 5rem">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<img
|
||||||
|
:src="getPropertyImage(slotProps.data)"
|
||||||
|
:alt="slotProps.data.name"
|
||||||
|
class="rounded-lg object-cover w-14 h-14 shadow-sm"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="propertyCode" header="کد" style="min-width: 5rem">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-mono text-sm font-semibold text-indigo-600 dark:text-indigo-400">
|
||||||
|
{{ slotProps.data.propertyCode || '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="nationalGeoCode" header="شناسه ملی جغرافیایی" style="min-width: 11rem">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-mono text-xs text-slate-600 dark:text-slate-300">
|
||||||
|
{{ slotProps.data.nationalGeoCode || '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="name" header="نام ملک" sortable style="min-width: 14rem">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{{ slotProps.data.name || 'بدون نام' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="category" header="دستهبندی" sortable style="min-width: 8rem">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<span class="px-2.5 py-1 text-xs rounded-full bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 font-medium">
|
||||||
|
{{ getCategoryName(slotProps.data) || '-' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column :exportable="false" style="min-width: 12rem">
|
||||||
|
<template #body="slotProps">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-external-link"
|
||||||
|
outlined
|
||||||
|
rounded
|
||||||
|
severity="secondary"
|
||||||
|
title="باز کردن در تب جدید"
|
||||||
|
@click="openInNewTab(slotProps.data._id || slotProps.data.id)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
outlined
|
||||||
|
rounded
|
||||||
|
severity="info"
|
||||||
|
title="ویرایش ملک"
|
||||||
|
@click="editProperty(slotProps.data._id || slotProps.data.id)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
outlined
|
||||||
|
rounded
|
||||||
|
severity="danger"
|
||||||
|
title="حذف ملک"
|
||||||
|
@click="confirmDeleteProperty(slotProps.data._id || slotProps.data.id, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── VIEW 2: AIRBNB SPLIT VIEW (Cards + Map) ── -->
|
||||||
|
<div v-else-if="viewMode === 'split'" class="airbnb-split-container">
|
||||||
|
<!-- Left/Main: Property Cards Scrollable Column -->
|
||||||
|
<div class="properties-cards-column">
|
||||||
|
<div class="cards-header">
|
||||||
|
<span class="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||||
|
{{ totalCount }} ملک یافت شد (صفحه {{ page }} از {{ pagesCount || 1 }})
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Chip v-if="searchPhrase" class="mx-2" :label="searchPhrase" removable @remove="handleRemoveSearch" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<Column selectionMode="multiple" style="width: 3rem" :exportable="false"></Column>
|
<!-- Cards Grid -->
|
||||||
<Column header="تصویر">
|
<div v-if="properties.length > 0" class="cards-grid">
|
||||||
<template #body="slotProps">
|
<div
|
||||||
<img :src="`${slotProps.data.imageUrl || slotProps.data.mainImageUrls?.[0]}`" :alt="slotProps.data.name" class="rounded" style="max-width: 64px" crossorigin="use-credentials" />
|
v-for="prop in properties"
|
||||||
</template>
|
:key="prop._id || prop.id"
|
||||||
</Column>
|
:id="`property-card-${prop._id || prop.id}`"
|
||||||
<Column field="propertyCode" header="کد" style="min-width: 4rem"></Column>
|
class="property-card"
|
||||||
<Column field="nationalGeoCode" header="شناسه ملی جغرافیایی" style="min-width: 10rem"></Column>
|
:class="{ 'card-selected': selectedPropertyId === (prop._id || prop.id) }"
|
||||||
<Column field="name" header="نام" sortable style="min-width: 14rem"></Column>
|
@mouseenter="onCardHover(prop._id || prop.id)"
|
||||||
<Column field="category" header="دسته بندی" sortable style="min-width: 6rem">
|
@click="onSelectProperty(prop._id || prop.id)"
|
||||||
<template #body="slotProps">
|
>
|
||||||
{{ slotProps.data.category?.nativeName || slotProps.data.nativeCategory }}
|
<!-- Card Image -->
|
||||||
</template>
|
<div class="card-img-wrap">
|
||||||
</Column>
|
<img
|
||||||
<Column :exportable="false" style="min-width: 12rem">
|
:src="getPropertyImage(prop)"
|
||||||
<template #body="slotProps">
|
:alt="prop.name"
|
||||||
<Button icon="pi pi-pencil" outlined rounded class="mr-2 mx-3" @click="editProperty(slotProps.data._id)" />
|
class="card-img"
|
||||||
<Button icon="pi pi-trash" outlined rounded severity="danger" class="mx-3" @click="confirmDeleteProperty(slotProps.data._id, $event)" />
|
@error="handleImageError"
|
||||||
</template>
|
/>
|
||||||
</Column>
|
<span v-if="getCategoryName(prop)" class="card-badge-category">
|
||||||
</DataTable>
|
{{ getCategoryName(prop) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="prop.propertyCode" class="card-badge-code">
|
||||||
|
#{{ prop.propertyCode }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Body -->
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title" :title="prop.name">
|
||||||
|
{{ prop.name || 'ملک بدون نام' }}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="card-details">
|
||||||
|
<div v-if="prop.nationalGeoCode" class="detail-row">
|
||||||
|
<i class="pi pi-id-card text-xs text-slate-400"></i>
|
||||||
|
<span class="font-mono text-xs">{{ prop.nationalGeoCode }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="prop.dong" class="detail-row">
|
||||||
|
<i class="pi pi-chart-pie text-xs text-slate-400"></i>
|
||||||
|
<span class="text-xs">{{ prop.dong }} دانگ</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="prop.address" class="detail-row address-row">
|
||||||
|
<i class="pi pi-map-marker text-xs text-slate-400"></i>
|
||||||
|
<span class="text-xs truncate">{{ prop.address }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Actions -->
|
||||||
|
<div class="card-actions" @click.stop>
|
||||||
|
<a
|
||||||
|
:href="'/property/edit-property/' + (prop._id || prop.id)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="card-btn card-btn-newtab"
|
||||||
|
title="باز کردن در تب جدید"
|
||||||
|
>
|
||||||
|
<i class="pi pi-external-link"></i>
|
||||||
|
<span>تب جدید</span>
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
label="ویرایش"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
class="flex-1"
|
||||||
|
@click="editProperty(prop._id || prop.id)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
@click="confirmDeleteProperty(prop._id || prop.id, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="p-8 text-center text-slate-500">
|
||||||
|
ملکی یافت نشد.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<Paginator
|
||||||
|
:rows="per_page"
|
||||||
|
:totalRecords="totalCount"
|
||||||
|
:first="(page - 1) * per_page"
|
||||||
|
:rowsPerPageOptions="[20, 40, 80]"
|
||||||
|
@page="onPageChange($event)"
|
||||||
|
class="mt-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right/Side: Sticky Interactive Map -->
|
||||||
|
<div class="properties-map-column">
|
||||||
|
<PropertyMap
|
||||||
|
ref="splitMapRef"
|
||||||
|
:properties="properties"
|
||||||
|
:selectedPropertyId="selectedPropertyId"
|
||||||
|
@select-property="onSelectProperty"
|
||||||
|
@edit-property="editProperty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── VIEW 3: FULL MAP VIEW ── -->
|
||||||
|
<div v-else-if="viewMode === 'map'" class="full-map-container">
|
||||||
|
<PropertyMap
|
||||||
|
ref="mapRef"
|
||||||
|
:properties="properties"
|
||||||
|
:selectedPropertyId="selectedPropertyId"
|
||||||
|
@select-property="onSelectProperty"
|
||||||
|
@edit-property="editProperty"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.properties-page {
|
||||||
|
font-family: 'Estedad', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Split View Layout (Airbnb style) ── */
|
||||||
|
.airbnb-split-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.15fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
min-height: calc(100vh - 220px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.airbnb-split-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-cards-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-height: calc(100vh - 220px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Property Card */
|
||||||
|
.property-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-card.card-selected {
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 2px #6366f1, 0 12px 28px rgba(99, 102, 241, 0.18);
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-img-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
background: #f8fafc;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-card:hover .card-img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-badge-category {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(79, 70, 229, 0.9);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-badge-code {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(15, 23, 42, 0.82);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row.address-row {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-btn-newtab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4f46e5;
|
||||||
|
background: #eef2ff;
|
||||||
|
border: 1px solid #e0e7ff;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-btn-newtab:hover {
|
||||||
|
background: #e0e7ff;
|
||||||
|
color: #4338ca;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-map-column {
|
||||||
|
position: sticky;
|
||||||
|
top: 20px;
|
||||||
|
height: calc(100vh - 220px);
|
||||||
|
min-height: 500px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full Map Container */
|
||||||
|
.full-map-container {
|
||||||
|
height: calc(100vh - 220px);
|
||||||
|
min-height: 550px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dark Mode Adjustments ── */
|
||||||
|
.app-dark .property-card {
|
||||||
|
background: #1e293b;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .property-card:hover {
|
||||||
|
border-color: #475569;
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .property-card.card-selected {
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 2px #6366f1, 0 12px 28px rgba(99, 102, 241, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .card-title {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .card-img-wrap {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .card-actions {
|
||||||
|
border-top-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .card-btn-newtab {
|
||||||
|
background: #1e1b4b;
|
||||||
|
border-color: #312e81;
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .card-btn-newtab:hover {
|
||||||
|
background: #312e81;
|
||||||
|
color: #c7d2fe;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,908 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="mapContainer" class="property-map-container" :class="{ 'map-fullscreen': isFullscreen }">
|
||||||
|
<!-- ── Top Action Buttons ── -->
|
||||||
|
<div class="map-overlay action-buttons">
|
||||||
|
<!-- Fullscreen toggle -->
|
||||||
|
<button class="map-btn" @click="toggleFullscreen" :title="isFullscreen ? 'خروج از تمامصفحه' : 'تمامصفحه'">
|
||||||
|
<svg v-if="isFullscreen" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M8 3v3a2 2 0 0 1-2 2H3"/><path d="M21 8h-3a2 2 0 0 1-2-2V3"/>
|
||||||
|
<path d="M3 16h3a2 2 0 0 1 2 2v3"/><path d="M16 21v-3a2 2 0 0 1 2-2h3"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 7V3h4"/><path d="M21 7V3h-4"/>
|
||||||
|
<path d="M3 17v4h4"/><path d="M21 17v4h-4"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Fit all markers -->
|
||||||
|
<button class="map-btn map-btn-fit" @click="fitBounds" title="نمایش همه املاک روی نقشه" :disabled="propertiesWithCoords.length === 0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Locate me -->
|
||||||
|
<button class="map-btn map-btn-locate" @click="locateMe" title="موقعیت من" :class="{ locating: isLocating }">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
|
||||||
|
<circle cx="12" cy="12" r="7" opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Top-Left: Properties count badge ── -->
|
||||||
|
<div class="map-overlay count-badge">
|
||||||
|
<i class="pi pi-map-marker text-indigo-500"></i>
|
||||||
|
<span>{{ propertiesWithCoords.length }} ملک روی نقشه</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bottom-Left: Coordinate readout ── -->
|
||||||
|
<div v-if="hoverCoords" class="map-overlay coords-readout">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="opacity:0.7;flex-shrink:0">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ hoverCoords.lat.toFixed(5) }}, {{ hoverCoords.lon.toFixed(5) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Leaflet Map ── -->
|
||||||
|
<l-map
|
||||||
|
ref="map"
|
||||||
|
v-model:zoom="zoom"
|
||||||
|
:center="center"
|
||||||
|
:use-global-leaflet="false"
|
||||||
|
style="height: 100%; width: 100%;"
|
||||||
|
@mousemove="handleMouseMove($event)"
|
||||||
|
@mouseout="hoverCoords = null"
|
||||||
|
@ready="onMapReady"
|
||||||
|
>
|
||||||
|
<!-- Layer switcher -->
|
||||||
|
<l-control-layers position="bottomright" />
|
||||||
|
|
||||||
|
<!-- OpenStreetMap -->
|
||||||
|
<l-tile-layer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
layer-type="base"
|
||||||
|
name="نمای نقشهای"
|
||||||
|
attribution="© OpenStreetMap contributors"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Satellite -->
|
||||||
|
<l-tile-layer
|
||||||
|
:visible="false"
|
||||||
|
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
|
||||||
|
layer-type="base"
|
||||||
|
name="نمای ماهوارهای"
|
||||||
|
attribution="Tiles © Esri"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Scale control -->
|
||||||
|
<l-control-scale position="bottomleft" :imperial="false" :maxWidth="150" />
|
||||||
|
|
||||||
|
<!-- Property Markers with Airbnb Popups -->
|
||||||
|
<l-marker
|
||||||
|
v-for="prop in propertiesWithCoords"
|
||||||
|
:key="prop._id || prop.id"
|
||||||
|
:lat-lng="prop.resolvedCoords"
|
||||||
|
:icon="getMarkerIcon(prop)"
|
||||||
|
@click="onMarkerClick(prop)"
|
||||||
|
>
|
||||||
|
<l-popup :options="{ maxWidth: 320, minWidth: 270, className: 'airbnb-custom-popup' }">
|
||||||
|
<div class="airbnb-popup-card">
|
||||||
|
<!-- Image / Header -->
|
||||||
|
<div class="popup-image-container">
|
||||||
|
<img
|
||||||
|
:src="getPropertyImage(prop)"
|
||||||
|
:alt="prop.name"
|
||||||
|
class="popup-image"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
<span v-if="getCategoryName(prop)" class="popup-category-badge">
|
||||||
|
{{ getCategoryName(prop) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="prop.propertyCode" class="popup-code-badge">
|
||||||
|
کد: {{ prop.propertyCode }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Body -->
|
||||||
|
<div class="popup-content-body">
|
||||||
|
<h4 class="popup-title">{{ prop.name || 'ملک بدون نام' }}</h4>
|
||||||
|
|
||||||
|
<div class="popup-info-grid">
|
||||||
|
<div v-if="prop.nationalGeoCode" class="popup-info-item">
|
||||||
|
<span class="info-label">شناسه ملی جغرافیایی:</span>
|
||||||
|
<span class="info-value text-mono">{{ prop.nationalGeoCode }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="prop.dong" class="popup-info-item">
|
||||||
|
<span class="info-label">سهم دانگ:</span>
|
||||||
|
<span class="info-value">{{ prop.dong }} دانگ</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="prop.zoningType" class="popup-info-item">
|
||||||
|
<span class="info-label">کاربری:</span>
|
||||||
|
<span class="info-value">{{ prop.zoningType }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="prop.address" class="popup-info-item full-width">
|
||||||
|
<span class="info-label">آدرس:</span>
|
||||||
|
<span class="info-value">{{ prop.address }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="popup-actions">
|
||||||
|
<a
|
||||||
|
:href="'/property/edit-property/' + (prop._id || prop.id)"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="popup-btn popup-btn-external"
|
||||||
|
title="باز کردن در برگه جدید"
|
||||||
|
>
|
||||||
|
<i class="pi pi-external-link"></i>
|
||||||
|
<span>باز کردن در تب جدید</span>
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="popup-btn popup-btn-primary"
|
||||||
|
@click="onEditClick(prop)"
|
||||||
|
title="ویرایش ملک"
|
||||||
|
>
|
||||||
|
<i class="pi pi-pencil"></i>
|
||||||
|
<span>ویرایش</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</l-popup>
|
||||||
|
</l-marker>
|
||||||
|
</l-map>
|
||||||
|
|
||||||
|
<!-- Locating overlay -->
|
||||||
|
<div v-if="isLocating" class="locating-overlay">
|
||||||
|
<div class="locating-spinner"></div>
|
||||||
|
<span>در حال تعیین موقعیت...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import { LMap, LTileLayer, LMarker, LPopup, LControlLayers, LControlScale } from '@vue-leaflet/vue-leaflet';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { UTMtoLatLon } from '@/utm';
|
||||||
|
|
||||||
|
// Fix Leaflet's default icon path issue with Vite
|
||||||
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
shadowUrl: markerShadow
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
properties: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
selectedPropertyId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['select-property', 'edit-property']);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const map = ref(null);
|
||||||
|
const mapContainer = ref(null);
|
||||||
|
const zoom = ref(6);
|
||||||
|
const center = ref([32.4279, 53.688]); // Iran center
|
||||||
|
const hoverCoords = ref(null);
|
||||||
|
const isLocating = ref(false);
|
||||||
|
const isFullscreen = ref(false);
|
||||||
|
const mapReady = ref(false);
|
||||||
|
|
||||||
|
// Default fallback image
|
||||||
|
const DEFAULT_IMAGE = 'https://cloud.mbmanager.ir/images/item-no-image.png';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses Iran's National Geo Code (JAM UTM code)
|
||||||
|
*/
|
||||||
|
const parseNationalGeoCode = (code) => {
|
||||||
|
if (!code || typeof code !== 'string') return null;
|
||||||
|
const digits = code.replace(/\D/g, '');
|
||||||
|
if (digits.length === 15) {
|
||||||
|
const e = parseInt(digits.slice(0, 6), 10);
|
||||||
|
const n = parseInt(digits.slice(6, 13), 10);
|
||||||
|
const z = parseInt(digits.slice(13, 15), 10);
|
||||||
|
return { easting: e, northing: n, zone: z };
|
||||||
|
}
|
||||||
|
if (digits.length === 13) {
|
||||||
|
const e = parseInt(digits.slice(0, 6), 10);
|
||||||
|
const n = parseInt(digits.slice(6, 13), 10);
|
||||||
|
return { easting: e, northing: n, zone: 39 };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves [lat, lng] coordinates for a property from any available source
|
||||||
|
*/
|
||||||
|
const resolveCoordinates = (prop) => {
|
||||||
|
if (!prop) return null;
|
||||||
|
|
||||||
|
// 1. Direct coordinates array: [val1, val2]
|
||||||
|
const rawCoords = prop.coordinates || prop.location?.coordinates;
|
||||||
|
if (Array.isArray(rawCoords) && rawCoords.length >= 2) {
|
||||||
|
const c0 = parseFloat(rawCoords[0]);
|
||||||
|
const c1 = parseFloat(rawCoords[1]);
|
||||||
|
|
||||||
|
if (!isNaN(c0) && !isNaN(c1) && !(c0 === 0 && c1 === 0)) {
|
||||||
|
// Check orientation: Iran latitude is ~25-40, longitude is ~44-68
|
||||||
|
if (c0 >= 20 && c0 <= 45 && c1 >= 40 && c1 <= 68) {
|
||||||
|
return [c0, c1]; // [lat, lng]
|
||||||
|
}
|
||||||
|
if (c1 >= 20 && c1 <= 45 && c0 >= 40 && c0 <= 68) {
|
||||||
|
return [c1, c0]; // [lng, lat] -> [lat, lng]
|
||||||
|
}
|
||||||
|
// General fallback
|
||||||
|
if (Math.abs(c0) <= 90 && Math.abs(c1) <= 180) {
|
||||||
|
return [c0, c1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Direct lat / lon properties
|
||||||
|
if (prop.lat && prop.lng) {
|
||||||
|
const lat = parseFloat(prop.lat);
|
||||||
|
const lng = parseFloat(prop.lng);
|
||||||
|
if (!isNaN(lat) && !isNaN(lng) && lat !== 0 && lng !== 0) {
|
||||||
|
return [lat, lng];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. National Geo Code (JAM UTM code)
|
||||||
|
if (prop.nationalGeoCode) {
|
||||||
|
const parsed = parseNationalGeoCode(prop.nationalGeoCode);
|
||||||
|
if (parsed) {
|
||||||
|
try {
|
||||||
|
const result = UTMtoLatLon(parsed.easting, parsed.northing, parsed.zone, 'N');
|
||||||
|
if (result && !isNaN(result.latitude) && !isNaN(result.longitude)) {
|
||||||
|
return [result.latitude, result.longitude];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('UTM conversion error for code:', prop.nationalGeoCode, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter and compute properties with valid coordinates
|
||||||
|
const propertiesWithCoords = computed(() => {
|
||||||
|
return props.properties
|
||||||
|
.map((prop) => {
|
||||||
|
const coords = resolveCoordinates(prop);
|
||||||
|
return coords ? { ...prop, resolvedCoords: coords } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions for property fields
|
||||||
|
const getPropertyImage = (prop) => {
|
||||||
|
return prop.imageUrl || prop.mainImageUrls?.[0] || prop.signedMainImageUrls?.[0] || DEFAULT_IMAGE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageError = (e) => {
|
||||||
|
e.target.src = DEFAULT_IMAGE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryName = (prop) => {
|
||||||
|
if (prop.nativeCategory) return prop.nativeCategory;
|
||||||
|
if (typeof prop.category === 'object' && prop.category?.nativeName) return prop.category.nativeName;
|
||||||
|
if (typeof prop.category === 'string') return prop.category;
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates custom Airbnb-style Leaflet pin icon
|
||||||
|
*/
|
||||||
|
const getMarkerIcon = (prop) => {
|
||||||
|
const isSelected = props.selectedPropertyId === (prop._id || prop.id);
|
||||||
|
const code = prop.propertyCode ? `#${prop.propertyCode}` : '';
|
||||||
|
const cat = getCategoryName(prop) || 'ملک';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="airbnb-pin ${isSelected ? 'is-selected' : ''}">
|
||||||
|
<div class="pin-content">
|
||||||
|
<span class="pin-icon">🏠</span>
|
||||||
|
<span class="pin-label">${code || cat}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pin-pointer"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'airbnb-marker-wrapper',
|
||||||
|
html: html,
|
||||||
|
iconSize: [64, 38],
|
||||||
|
iconAnchor: [32, 38],
|
||||||
|
popupAnchor: [0, -38]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMapReady = () => {
|
||||||
|
mapReady.value = true;
|
||||||
|
nextTick(() => {
|
||||||
|
fitBounds();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjusts map bounds to fit all markers
|
||||||
|
*/
|
||||||
|
const fitBounds = () => {
|
||||||
|
if (!map.value?.leafletObject || propertiesWithCoords.value.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bounds = L.latLngBounds(propertiesWithCoords.value.map((p) => p.resolvedCoords));
|
||||||
|
map.value.leafletObject.fitBounds(bounds, {
|
||||||
|
padding: [50, 50],
|
||||||
|
maxZoom: 16,
|
||||||
|
animate: true
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fit bounds error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMarkerClick = (prop) => {
|
||||||
|
emit('select-property', prop._id || prop.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEditClick = (prop) => {
|
||||||
|
const id = prop._id || prop.id;
|
||||||
|
emit('edit-property', id);
|
||||||
|
router.push(`/property/edit-property/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Center map on a specific property
|
||||||
|
*/
|
||||||
|
const flyToProperty = (propId) => {
|
||||||
|
const prop = propertiesWithCoords.value.find((p) => (p._id || p.id) === propId);
|
||||||
|
if (prop && map.value?.leafletObject) {
|
||||||
|
map.value.leafletObject.flyTo(prop.resolvedCoords, 16, {
|
||||||
|
animate: true,
|
||||||
|
duration: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (event) => {
|
||||||
|
if (event?.latlng) {
|
||||||
|
hoverCoords.value = { lat: event.latlng.lat, lon: event.latlng.lng };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const locateMe = () => {
|
||||||
|
if (!navigator.geolocation) return;
|
||||||
|
isLocating.value = true;
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
isLocating.value = false;
|
||||||
|
const newCenter = [pos.coords.latitude, pos.coords.longitude];
|
||||||
|
center.value = newCenter;
|
||||||
|
zoom.value = 14;
|
||||||
|
map.value?.leafletObject?.flyTo(newCenter, 14, { animate: true });
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
isLocating.value = false;
|
||||||
|
},
|
||||||
|
{ timeout: 10000, enableHighAccuracy: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (!isFullscreen.value) {
|
||||||
|
if (mapContainer.value?.requestFullscreen) {
|
||||||
|
mapContainer.value.requestFullscreen();
|
||||||
|
} else if (mapContainer.value?.webkitRequestFullscreen) {
|
||||||
|
mapContainer.value.webkitRequestFullscreen();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.exitFullscreen) document.exitFullscreen();
|
||||||
|
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
isFullscreen.value = !!document.fullscreenElement;
|
||||||
|
nextTick(() => map.value?.leafletObject?.invalidateSize());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for selected property changes to zoom to it
|
||||||
|
watch(
|
||||||
|
() => props.selectedPropertyId,
|
||||||
|
(newId) => {
|
||||||
|
if (newId) {
|
||||||
|
flyToProperty(newId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Watch for property list changes to auto-fit
|
||||||
|
watch(
|
||||||
|
() => propertiesWithCoords.value.length,
|
||||||
|
() => {
|
||||||
|
if (mapReady.value) {
|
||||||
|
nextTick(() => fitBounds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
fitBounds,
|
||||||
|
flyToProperty
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.property-map-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 520px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
font-family: 'Estedad', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-fullscreen {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.leaflet-container) {
|
||||||
|
font-family: 'Estedad', sans-serif !important;
|
||||||
|
border-radius: inherit;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overlays */
|
||||||
|
.map-overlay {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-badge {
|
||||||
|
top: 14px;
|
||||||
|
left: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 30px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: none;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 14px rgba(79, 70, 229, 0.35);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-btn:hover:not(:disabled) {
|
||||||
|
background: #4338ca;
|
||||||
|
box-shadow: 0 6px 20px rgba(67, 56, 202, 0.45);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-btn:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-btn-fit {
|
||||||
|
background: #0ea5e9;
|
||||||
|
box-shadow: 0 4px 14px rgba(14, 165, 233, 0.35);
|
||||||
|
}
|
||||||
|
.map-btn-fit:hover:not(:disabled) {
|
||||||
|
background: #0284c7;
|
||||||
|
box-shadow: 0 6px 20px rgba(2, 132, 199, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-btn-locate {
|
||||||
|
background: #10b981;
|
||||||
|
box-shadow: 0 4px 14px rgba(16, 185, 129, 0.35);
|
||||||
|
}
|
||||||
|
.map-btn-locate:hover:not(:disabled) {
|
||||||
|
background: #059669;
|
||||||
|
box-shadow: 0 6px 20px rgba(5, 150, 105, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-btn.locating {
|
||||||
|
animation: pulse-btn 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-btn {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); }
|
||||||
|
50% { box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords-readout {
|
||||||
|
bottom: 62px;
|
||||||
|
left: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: rgba(15, 23, 42, 0.82);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Locating overlay */
|
||||||
|
.locating-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4f46e5;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locating-spinner {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border: 3px solid rgba(79, 70, 229, 0.2);
|
||||||
|
border-top-color: #4f46e5;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Global styles for Leaflet custom HTML markers and popups -->
|
||||||
|
<style>
|
||||||
|
/* ── Airbnb Style Custom Pin ── */
|
||||||
|
.airbnb-marker-wrapper {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airbnb-pin {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.airbnb-pin:hover {
|
||||||
|
transform: scale(1.15) translateY(-4px);
|
||||||
|
z-index: 1000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airbnb-pin.is-selected {
|
||||||
|
transform: scale(1.2) translateY(-6px);
|
||||||
|
z-index: 1001 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1e293b;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||||
|
border: 1.5px solid #4f46e5;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airbnb-pin:hover .pin-content {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.airbnb-pin.is-selected .pin-content {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #ffffff;
|
||||||
|
border-color: #ffffff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.35), 0 8px 24px rgba(79, 70, 229, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-icon {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-pointer {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 7px solid #4f46e5;
|
||||||
|
margin-top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Airbnb Popup Card ── */
|
||||||
|
.airbnb-custom-popup .leaflet-popup-content-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.22);
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'Estedad', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airbnb-custom-popup .leaflet-popup-content {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.airbnb-custom-popup .leaflet-popup-tip {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.airbnb-popup-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #ffffff;
|
||||||
|
font-family: 'Estedad', sans-serif;
|
||||||
|
color: #1e293b;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-image-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 140px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-image-container:hover .popup-image {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-category-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: rgba(79, 70, 229, 0.9);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-code-badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: rgba(15, 23, 42, 0.8);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-content-body {
|
||||||
|
padding: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-info-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-info-item.full-width {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: #334155;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-mono {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-btn-external {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #4f46e5;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-btn-external:hover {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #bfdbfe;
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-btn-primary {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-btn-primary:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dark Mode Adjustments ── */
|
||||||
|
.app-dark .count-badge {
|
||||||
|
background: rgba(30, 41, 59, 0.92);
|
||||||
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .airbnb-custom-popup .leaflet-popup-content-wrapper {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .airbnb-popup-card {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .popup-title {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .info-value {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .popup-actions {
|
||||||
|
border-top-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .popup-btn-external {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #818cf8;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .popup-btn-external:hover {
|
||||||
|
background: #1e1b4b;
|
||||||
|
border-color: #4f46e5;
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-dark .pin-content {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #f8fafc;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user