Files
mbmanager-dashboard/src/views/pages/Property/PropertyMap.vue
T
2026-08-19 07:08:43 +03:30

927 lines
26 KiB
Vue

<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="&copy; 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 &copy; 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, onBeforeUnmount } 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
},
hoveredPropertyId: {
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 propId = prop._id || prop.id;
const isSelected = props.selectedPropertyId === propId;
const isHovered = props.hoveredPropertyId === propId;
const code = prop.propertyCode ? `#${prop.propertyCode}` : '';
const cat = getCategoryName(prop) || 'ملک';
const html = `
<div class="airbnb-pin ${isSelected ? 'is-selected' : ''} ${isHovered ? 'is-hovered' : ''}">
<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();
}
};
const onFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
nextTick(() => map.value?.leafletObject?.invalidateSize());
};
if (typeof document !== 'undefined') {
document.addEventListener('fullscreenchange', onFullscreenChange);
}
onBeforeUnmount(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('fullscreenchange', onFullscreenChange);
}
});
// 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,
.airbnb-pin.is-hovered {
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,
.airbnb-pin.is-hovered .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>