/* Shared stroke icons — single component, exported to window */ const ICONS = { search: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14ZM20 20l-3.2-3.2', pin: 'M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11Z|M12 10.5m-2.4 0a2.4 2.4 0 1 0 4.8 0 2.4 2.4 0 1 0-4.8 0', heart: 'M12 20.5 4.6 13a4.6 4.6 0 0 1 6.5-6.5l.9.9.9-.9A4.6 4.6 0 1 1 19.4 13Z', home: 'M4 11.5 12 5l8 6.5M6 10v9h12v-9', bed: 'M3 17v-5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5M3 14h18M3 17v2M21 17v2M7 10V8a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2', bath: 'M5 11V6.5A2.5 2.5 0 0 1 7.5 4 2.5 2.5 0 0 1 10 6.5M4 11h16v2a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5v-2ZM7 18l-1 2M17 18l1 2', ruler: 'M4 8h16v8H4zM8 8v3M12 8v4M16 8v3', filter: 'M4 6h16M7 12h10M10 18h4', bell: 'M6 9a6 6 0 1 1 12 0c0 5 2 6 2 6H4s2-1 2-6ZM9.5 19a2.5 2.5 0 0 0 5 0', user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM5 20a7 7 0 0 1 14 0', mail: 'M4 5h16v14H4z|M4 5l8 7 8-7', plus: 'M12 5v14M5 12h14', chat: 'M4 5h16v11H9l-4 3v-3H4z', grid: 'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z', factory: 'M3 21V11l6 4v-4l6 4v-4l6 4v6H3ZM7 21v-4M12 21v-4M17 21v-4', townhome: 'M3 21V9l5-4 5 4v12M8 21v-4M13 21V11l5-4 5 4v10M18 21v-4', penthouse: 'M6 21V11a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v10M9 21v-5h6v5M12 9V4M9 6l3-2 3 2', warehouse: 'M3 21V10l9-5 9 5v11H3ZM3 21h18M8 21v-7h8v7', list: 'M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01', cam: 'M4 8h3l1.5-2h7L17 8h3v11H4zM12 16a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z', check: 'M5 12.5 10 17l9-10', chevR: 'M9 5l7 7-7 7', chevD: 'M5 9l7 7 7-7', back: 'M15 5l-7 7 7 7', star: 'M12 4l2.4 5 5.6.6-4.2 3.8 1.2 5.6L12 17.5 6.8 19l1.2-5.6L4 9.6 9.6 9Z', shield: 'M12 3l7 3v5c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6Z', scale: 'M12 4v16M7 8h10M7 8l-3 6h6zM17 8l3 6h-6zM6 20h12', doc: 'M7 3h7l5 5v13H7zM14 3v5h5', building: 'M5 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16M15 21V9h2a2 2 0 0 1 2 2v10M8 7h2M8 11h2M8 15h2', trend: 'M4 17l5-5 3 3 7-8M14 7h5v5', wallet: 'M4 7h14a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4zM4 7V6a2 2 0 0 1 2-2h10M17 13h.01', users: 'M9 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM2 20a7 7 0 0 1 13-3M16 5a3.5 3.5 0 0 1 0 7M22 20a6 6 0 0 0-4-5', clock: 'M12 12V7M12 12l3.5 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z', dash: 'M3 13h8V3H3zM13 21h8v-12h-8zM13 3v4h8V3zM3 21h8v-4H3z', pipe: 'M5 4v16M12 4v10M19 4v6M5 7h0M12 17h0M19 13h0', sliders: 'M4 7h10M18 7h2M4 17h2M10 17h10M14 5v4M6 15v4', share: 'M8 12a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM20 6a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM20 18a2 2 0 1 1-4 0 2 2 0 0 1 4 0ZM8.7 11l6.6-3.6M8.7 13l6.6 3.6', globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18', car: 'M5 16l1.5-5h11L19 16M4 16h16v3H4zM7 19v1M17 19v1M7 16v0', wifi: 'M5 12a10 10 0 0 1 14 0M8 15a6 6 0 0 1 8 0M12 18h.01', pool: 'M3 16c1.5 1 2.5 1 4 0s2.5-1 4 0 2.5 1 4 0 2.5-1 4 0M7 14V6a2 2 0 0 1 4 0M13 14V6a2 2 0 0 1 4 0', key: 'M14 8a4 4 0 1 1-3.5 6L4 20.5V17H7v-2.5l3.5-3.5A4 4 0 0 1 14 8Z', phone: 'M5 4h3l2 5-2 1a11 11 0 0 0 5 5l1-2 5 2v3a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2Z', calendar: 'M5 5h14v15H5zM5 9h14M9 3v4M15 3v4', eye: 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z', menu: 'M4 7h16M4 12h16M4 17h16', arrowUp: 'M12 19V5M6 11l6-6 6 6', arrowDn: 'M12 5v14M6 13l6 6 6-6', dots: 'M5 12h.01M12 12h.01M19 12h.01', logout: 'M14 4h4a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-4M10 12H3M6 8l-4 4 4 4', edit: 'M5 19h14M14 5l4 4-9 9H5v-4z', download: 'M12 4v11M8 11l4 4 4-4M5 20h14', x: 'M6 6l12 12M18 6 6 18', }; function Icon({ name, size = 22, stroke = 2, color, fill = "none", style }) { const d = ICONS[name] || ''; const paths = d.split('|'); return ( {paths.map((p, i) => )} ); } /* ---------------- Shared Google Maps location picker ---------------- Used by Customer, Agent, and Ops "Add/List Property" forms. Requires window.GOOGLE_MAPS_API_KEY (set in the shell HTML) to actually render — without one it degrades to a plain notice instead of a broken/blank map, so the rest of the form still works. */ let _svlmGmapsLoadPromise = null; function loadGoogleMaps(apiKey) { if (window.google && window.google.maps && window.google.maps.places) return Promise.resolve(); if (_svlmGmapsLoadPromise) return _svlmGmapsLoadPromise; _svlmGmapsLoadPromise = new Promise((resolve, reject) => { const script = document.createElement('script'); // v=beta + libraries=places: required for PlaceAutocompleteElement — this // key only has "Places API (New)" enabled, not legacy Places API, so the // old google.maps.places.Autocomplete class 404s (confirmed by the team // that set this key up on another SVLM project). script.src = 'https://maps.googleapis.com/maps/api/js?key=' + encodeURIComponent(apiKey) + '&libraries=places&v=beta'; script.async = true; script.onload = () => resolve(); script.onerror = () => { _svlmGmapsLoadPromise = null; reject(new Error('Failed to load Google Maps')); }; document.head.appendChild(script); }); return _svlmGmapsLoadPromise; } function SvlmMapPicker({ lang, pos, onPick }) { const apiKey = window.GOOGLE_MAPS_API_KEY; const mapRef = React.useRef(null); const autocompleteWrapRef = React.useRef(null); const [failed, setFailed] = React.useState(false); React.useEffect(() => { if (!apiKey) return; let cancelled = false; let createdEl = null; let mapInstance = null; let markerInstance = null; loadGoogleMaps(apiKey).then(() => { if (cancelled || !mapRef.current) return; const center = pos || { lat: 13.7563, lng: 100.5018 }; // default: central Bangkok const map = new window.google.maps.Map(mapRef.current, { center, zoom: pos ? 16 : 11, disableDefaultUI: true, zoomControl: true }); const marker = new window.google.maps.Marker({ position: center, map, draggable: true }); mapInstance = map; markerInstance = marker; marker.addListener('dragend', () => { if (cancelled) return; const p = marker.getPosition(); onPick({ lat: p.lat(), lng: p.lng() }); }); map.addListener('click', (e) => { if (cancelled) return; marker.setPosition(e.latLng); onPick({ lat: e.latLng.lat(), lng: e.latLng.lng() }); }); // New Places API web component — replaces the legacy Autocomplete // class, which this key can't use. Appended (never innerHTML-cleared) // and torn down in this effect's own cleanup — clearing a React ref's // children any other way fights React's own DOM bookkeeping and // throws "removeChild: not a child of this node" on unmount. if (autocompleteWrapRef.current && !cancelled && window.google.maps.places.PlaceAutocompleteElement) { const el = new window.google.maps.places.PlaceAutocompleteElement({ componentRestrictions: { country: 'th' } }); el.style.width = '100%'; autocompleteWrapRef.current.appendChild(el); createdEl = el; el.addEventListener('gmp-placeselect', async (e) => { // The place-select fetch is async — guard against it resolving // after this picker already unmounted (e.g. user picked a place // just as they closed the form), or it touches a torn-down map. if (cancelled) return; const place = e.place; if (!place) return; await place.fetchFields({ fields: ['location'] }); if (cancelled) return; const loc = place.location; if (!loc) return; map.setCenter(loc); map.setZoom(16); marker.setPosition(loc); onPick({ lat: loc.lat(), lng: loc.lng() }); }); } }).catch(() => setFailed(true)); return () => { cancelled = true; // Detach every listener before the container unmounts — the Maps SDK // keeps mutating its container asynchronously (tiles, resize) even // after listeners fire once, and React removing the container mid- // mutation is what causes the "removeChild: not a child" crash. if (markerInstance) window.google.maps.event.clearInstanceListeners(markerInstance); if (mapInstance) window.google.maps.event.clearInstanceListeners(mapInstance); if (createdEl && createdEl.parentNode) { try { createdEl.parentNode.removeChild(createdEl); } catch (e) { /* already detached */ } } }; // eslint-disable-next-line }, [apiKey]); if (!apiKey || failed) { return
{lang === 'en' ? 'Map search needs a Google Maps API key — ask an admin to set one.' : 'ต้องใส่ Google Maps API key ก่อนจึงจะค้นหาบนแผนที่ได้ — กรุณาแจ้งแอดมิน'}
; } return (
{pos &&
{pos.lat.toFixed(5)}, {pos.lng.toFixed(5)}
}
); } /* Read-only counterpart to SvlmMapPicker — a fixed pin at a known lat/lng, no dragging/search/click-to-move. Used by the property detail page's Location tab. */ function SvlmMapView({ lat, lng, label }) { const apiKey = window.GOOGLE_MAPS_API_KEY; const mapRef = React.useRef(null); const [failed, setFailed] = React.useState(false); const hasPos = lat != null && lng != null; React.useEffect(() => { if (!apiKey || !hasPos) return; let cancelled = false; let mapInstance = null; let markerInstance = null; loadGoogleMaps(apiKey).then(() => { if (cancelled || !mapRef.current) return; const center = { lat, lng }; const map = new window.google.maps.Map(mapRef.current, { center, zoom: 16, disableDefaultUI: true, zoomControl: true, gestureHandling: 'cooperative' }); const marker = new window.google.maps.Marker({ position: center, map }); mapInstance = map; markerInstance = marker; }).catch(() => setFailed(true)); return () => { cancelled = true; if (markerInstance) window.google.maps.event.clearInstanceListeners(markerInstance); if (mapInstance) window.google.maps.event.clearInstanceListeners(mapInstance); }; // eslint-disable-next-line }, [apiKey, lat, lng]); if (!apiKey || failed || !hasPos) { return
{label}
; } return
; } /* Many-marker map for a search-results view — one pin per listing with a real lat/lng, click to open its detail. Falls back to a plain labeled box (same convention as SvlmMapView) when there's no key or no positioned listings, rather than drawing fake pins at made-up coordinates. */ function SvlmMapMultiView({ pins, onPinClick, emptyLabel }) { const apiKey = window.GOOGLE_MAPS_API_KEY; const mapRef = React.useRef(null); const [failed, setFailed] = React.useState(false); const validPins = (pins || []).filter(p => p.lat != null && p.lng != null); const pinsKey = validPins.map(p => p.id + ':' + p.lat + ':' + p.lng).join('|'); React.useEffect(() => { if (!apiKey || !validPins.length) return; let cancelled = false; let mapInstance = null; let markers = []; loadGoogleMaps(apiKey).then(() => { if (cancelled || !mapRef.current) return; const bounds = new window.google.maps.LatLngBounds(); validPins.forEach(p => bounds.extend({ lat: p.lat, lng: p.lng })); const map = new window.google.maps.Map(mapRef.current, { center: bounds.getCenter(), zoom: 13, disableDefaultUI: true, zoomControl: true, gestureHandling: 'greedy' }); if (validPins.length > 1) map.fitBounds(bounds, 40); markers = validPins.map(p => { const marker = new window.google.maps.Marker({ position: { lat: p.lat, lng: p.lng }, map, title: p.label }); if (onPinClick) marker.addListener('click', () => onPinClick(p.id)); return marker; }); mapInstance = map; }).catch(() => setFailed(true)); return () => { cancelled = true; markers.forEach(m => window.google.maps.event.clearInstanceListeners(m)); if (mapInstance) window.google.maps.event.clearInstanceListeners(mapInstance); }; // eslint-disable-next-line }, [apiKey, pinsKey]); if (!apiKey || failed || !validPins.length) { return
{emptyLabel}
; } return
; } function StatusBar({ dark }) { const c = dark ? "#fff" : "#0e2233"; return (
9:41
); } Object.assign(window, { Icon, StatusBar, SvlmMapPicker });