@braudypedrosa/bp-listings 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1042 @@
1
+ /**
2
+ * ListingsMap - Airbnb-style Listings + Map Widget
3
+ * A standalone vanilla JavaScript library.
4
+ * Dependencies: Leaflet (loaded automatically if not present)
5
+ *
6
+ * Usage:
7
+ * ListingsMap.init({
8
+ * container: '#my-container',
9
+ * listings: [...],
10
+ * mapOptions: { center: [14.55, 121.03], zoom: 12 },
11
+ * currency: '₱',
12
+ * renderSearchSlot: (containerEl) => {
13
+ * // Render your own search UI (shortcode, library, etc.) into containerEl
14
+ * },
15
+ * onFavorite: (listing, isFavorited) => {},
16
+ * onListingClick: (listing) => {},
17
+ * });
18
+ */
19
+ (function (root, factory) {
20
+ /* UMD: AMD / CommonJS / Browser global */
21
+ if (typeof root["define"] === "function" && root["define"].amd) {
22
+ root["define"]([], factory);
23
+ } else if (typeof root["module"] === "object" && root["module"].exports) {
24
+ root["module"].exports = factory();
25
+ } else {
26
+ root.ListingsMap = factory();
27
+ }
28
+ })(typeof self !== "undefined" ? self : this, function () {
29
+ "use strict";
30
+
31
+ // ==========================================
32
+ // SVG Icons
33
+ // ==========================================
34
+ var ICONS = {
35
+ chevronLeft:
36
+ '<svg viewBox="0 0 16 16"><polyline points="10 3 5 8 10 13"/></svg>',
37
+ chevronRight:
38
+ '<svg viewBox="0 0 16 16"><polyline points="6 3 11 8 6 13"/></svg>',
39
+ heart:
40
+ '<svg viewBox="0 0 24 24" class="lm-heart-outline"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
41
+ star: '<svg viewBox="0 0 16 16"><path d="M8 0l2.47 5.01L16 5.82l-4 3.9.94 5.51L8 12.49 3.06 15.23 4 9.72 0 5.82l5.53-.81z"/></svg>',
42
+ expand:
43
+ '<svg viewBox="0 0 16 16"><polyline points="4 1 1 1 1 4"/><polyline points="12 1 15 1 15 4"/><polyline points="4 15 1 15 1 12"/><polyline points="12 15 15 15 15 12"/></svg>',
44
+ mapPin:
45
+ '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 14s-5-4.58-5-7.5a5 5 0 0 1 10 0C13 9.42 8 14 8 14z"/><circle cx="8" cy="6.5" r="1.5"/></svg>',
46
+ chevronDown:
47
+ '<svg viewBox="0 0 16 16"><polyline points="3 6 8 11 13 6"/></svg>',
48
+ trophy: "&#127942;",
49
+ };
50
+
51
+ // ==========================================
52
+ // Helpers
53
+ // ==========================================
54
+ function el(tag, className, attrs) {
55
+ var node = document.createElement(tag);
56
+ if (className) node.className = className;
57
+ if (attrs) {
58
+ Object.keys(attrs).forEach(function (key) {
59
+ if (key === "html") {
60
+ node.innerHTML = attrs[key];
61
+ } else if (key === "text") {
62
+ node.textContent = attrs[key];
63
+ } else {
64
+ node.setAttribute(key, attrs[key]);
65
+ }
66
+ });
67
+ }
68
+ return node;
69
+ }
70
+
71
+ function formatPrice(amount, currency) {
72
+ if (typeof amount === "string") return currency + amount;
73
+ return (
74
+ currency + amount.toLocaleString("en-US", { maximumFractionDigits: 0 })
75
+ );
76
+ }
77
+
78
+ function loadLeaflet(callback) {
79
+ if (window.L) {
80
+ callback();
81
+ return;
82
+ }
83
+ // Load CSS
84
+ var link = document.createElement("link");
85
+ link.rel = "stylesheet";
86
+ link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
87
+ document.head.appendChild(link);
88
+ // Load JS
89
+ var script = document.createElement("script");
90
+ script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
91
+ script.onload = callback;
92
+ document.head.appendChild(script);
93
+ }
94
+
95
+ // ==========================================
96
+ // Carousel Component
97
+ // ==========================================
98
+ function Carousel(images) {
99
+ var self = this;
100
+ self.index = 0;
101
+ self.images = images || [];
102
+
103
+ self.el = el("div", "lm-carousel");
104
+ self.track = el("div", "lm-carousel-track");
105
+
106
+ self.images.forEach(function (src) {
107
+ var slide = el("div", "lm-carousel-slide");
108
+ var img = el("img", null, { src: src, alt: "Property photo", loading: "lazy" });
109
+ slide.appendChild(img);
110
+ self.track.appendChild(slide);
111
+ });
112
+
113
+ self.el.appendChild(self.track);
114
+
115
+ // Prev / Next buttons
116
+ if (self.images.length > 1) {
117
+ self.btnPrev = el("button", "lm-carousel-btn lm-carousel-btn-prev", {
118
+ html: ICONS.chevronLeft,
119
+ "aria-label": "Previous image",
120
+ });
121
+ self.btnNext = el("button", "lm-carousel-btn lm-carousel-btn-next", {
122
+ html: ICONS.chevronRight,
123
+ "aria-label": "Next image",
124
+ });
125
+
126
+ self.btnPrev.addEventListener("click", function (e) {
127
+ e.stopPropagation();
128
+ self.go(self.index - 1);
129
+ });
130
+ self.btnNext.addEventListener("click", function (e) {
131
+ e.stopPropagation();
132
+ self.go(self.index + 1);
133
+ });
134
+
135
+ self.el.appendChild(self.btnPrev);
136
+ self.el.appendChild(self.btnNext);
137
+
138
+ // Dots
139
+ self.dotsContainer = el("div", "lm-carousel-dots");
140
+ self.dots = [];
141
+ self.images.forEach(function (_, i) {
142
+ var dot = el("button", "lm-carousel-dot" + (i === 0 ? " lm-carousel-dot-active" : ""), {
143
+ "aria-label": "Go to image " + (i + 1),
144
+ });
145
+ dot.addEventListener("click", function (e) {
146
+ e.stopPropagation();
147
+ self.go(i);
148
+ });
149
+ self.dots.push(dot);
150
+ self.dotsContainer.appendChild(dot);
151
+ });
152
+ self.el.appendChild(self.dotsContainer);
153
+ }
154
+
155
+ self.update();
156
+ }
157
+
158
+ Carousel.prototype.go = function (i) {
159
+ if (i < 0 || i >= this.images.length) return;
160
+ this.index = i;
161
+ this.update();
162
+ };
163
+
164
+ Carousel.prototype.update = function () {
165
+ this.track.style.transform = "translateX(-" + this.index * 100 + "%)";
166
+ if (this.btnPrev) {
167
+ this.btnPrev.disabled = this.index === 0;
168
+ }
169
+ if (this.btnNext) {
170
+ this.btnNext.disabled = this.index === this.images.length - 1;
171
+ }
172
+ if (this.dots) {
173
+ var self = this;
174
+ this.dots.forEach(function (dot, i) {
175
+ if (i === self.index) {
176
+ dot.classList.add("lm-carousel-dot-active");
177
+ } else {
178
+ dot.classList.remove("lm-carousel-dot-active");
179
+ }
180
+ });
181
+ }
182
+ };
183
+
184
+ // ==========================================
185
+ // ListingCard Component
186
+ // ==========================================
187
+ function ListingCard(listing, options) {
188
+ var self = this;
189
+ self.listing = listing;
190
+ self.options = options;
191
+ self.favorited = listing.favorited || false;
192
+
193
+ self.el = el("div", "lm-card");
194
+ self.el.setAttribute("data-listing-id", listing.id);
195
+
196
+ // Carousel
197
+ self.carousel = new Carousel(listing.images || []);
198
+ self.el.appendChild(self.carousel.el);
199
+
200
+ // Badge
201
+ if (listing.badge) {
202
+ var badgeClass = "lm-badge ";
203
+ var badgeText = String(listing.badge);
204
+ var isMinStay =
205
+ badgeText.toLowerCase().indexOf("minimum stay") !== -1;
206
+
207
+ if (badgeText === "Guest favorite") {
208
+ badgeClass += "lm-badge-guest-favorite";
209
+ } else if (badgeText === "Superhost") {
210
+ badgeClass += "lm-badge-superhost";
211
+ } else if (isMinStay) {
212
+ badgeClass += "lm-badge-minstay";
213
+ } else {
214
+ badgeClass += "lm-badge-guest-favorite";
215
+ }
216
+
217
+ var badgeContent = "";
218
+ if (badgeText === "Guest favorite") {
219
+ badgeContent =
220
+ '<span class="lm-badge-icon">' +
221
+ ICONS.trophy +
222
+ "</span> " +
223
+ badgeText;
224
+ } else {
225
+ badgeContent = badgeText;
226
+ }
227
+
228
+ var badge = el("div", badgeClass, { html: badgeContent });
229
+ self.carousel.el.appendChild(badge);
230
+ }
231
+
232
+ // Heart button
233
+ self.heartBtn = el(
234
+ "button",
235
+ "lm-heart-btn" + (self.favorited ? " lm-heart-btn-active" : ""),
236
+ { html: ICONS.heart, "aria-label": "Save to wishlist" }
237
+ );
238
+ self.heartBtn.addEventListener("click", function (e) {
239
+ e.stopPropagation();
240
+ self.favorited = !self.favorited;
241
+ if (self.favorited) {
242
+ self.heartBtn.classList.add("lm-heart-btn-active");
243
+ } else {
244
+ self.heartBtn.classList.remove("lm-heart-btn-active");
245
+ }
246
+ if (options.onFavorite) {
247
+ options.onFavorite(listing, self.favorited);
248
+ }
249
+ });
250
+ self.carousel.el.appendChild(self.heartBtn);
251
+
252
+ // Card Info
253
+ var info = el("div", "lm-card-info");
254
+
255
+ // Header row (title + rating)
256
+ var header = el("div", "lm-card-header");
257
+ var title = el("div", "lm-card-title", { text: listing.title || "" });
258
+ header.appendChild(title);
259
+
260
+ if (listing.rating) {
261
+ var rating = el("div", "lm-card-rating");
262
+ rating.innerHTML =
263
+ ICONS.star +
264
+ " " +
265
+ listing.rating +
266
+ (listing.reviewCount
267
+ ? ' <span class="lm-card-rating-count">(' +
268
+ listing.reviewCount +
269
+ ")</span>"
270
+ : "");
271
+ header.appendChild(rating);
272
+ }
273
+ info.appendChild(header);
274
+
275
+ // Subtitle
276
+ if (listing.subtitle) {
277
+ info.appendChild(
278
+ el("div", "lm-card-subtitle", { text: listing.subtitle })
279
+ );
280
+ }
281
+
282
+ // Details (bedrooms, beds)
283
+ if (listing.details) {
284
+ info.appendChild(
285
+ el("div", "lm-card-details", { text: listing.details })
286
+ );
287
+ }
288
+
289
+ // Dates
290
+ if (listing.dates) {
291
+ info.appendChild(el("div", "lm-card-dates", { text: listing.dates }));
292
+ }
293
+
294
+ // Price
295
+ if (listing.price !== undefined) {
296
+ var priceContainer = el("div", "lm-card-price");
297
+ var priceValue = formatPrice(listing.price, options.currency || "");
298
+ priceContainer.innerHTML =
299
+ '<span class="lm-card-price-value">' +
300
+ priceValue +
301
+ "</span>" +
302
+ (listing.pricePeriod
303
+ ? ' <span class="lm-card-price-period">' +
304
+ listing.pricePeriod +
305
+ "</span>"
306
+ : "");
307
+ info.appendChild(priceContainer);
308
+ }
309
+
310
+ // Tag (e.g. "Free cancellation")
311
+ if (listing.tag) {
312
+ info.appendChild(el("div", "lm-card-tag", { text: listing.tag }));
313
+ }
314
+
315
+ self.el.appendChild(info);
316
+
317
+ // Click handler
318
+ self.el.addEventListener("click", function () {
319
+ if (options.onListingClick) {
320
+ options.onListingClick(listing);
321
+ }
322
+ });
323
+ }
324
+
325
+ ListingCard.prototype.setActive = function (active) {
326
+ if (active) {
327
+ this.el.classList.add("lm-card-active");
328
+ } else {
329
+ this.el.classList.remove("lm-card-active");
330
+ }
331
+ };
332
+
333
+ // ==========================================
334
+ // Main Widget
335
+ // ==========================================
336
+ function ListingsMapWidget(config) {
337
+ var self = this;
338
+ self.config = Object.assign(
339
+ {
340
+ container: null,
341
+ listings: [],
342
+ currency: "$",
343
+ mapOptions: {
344
+ center: [14.55, 121.03],
345
+ zoom: 12,
346
+ },
347
+ tileUrl: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
348
+ tileAttribution:
349
+ '&copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',
350
+ showMapToggle: true,
351
+ showSort: true,
352
+ showPagination: true,
353
+ pageSize: 0, // 0 = no pagination
354
+ renderSearchSlot: null, // callback: function(containerEl) { ... } — render your own search UI
355
+ onFavorite: null,
356
+ onListingClick: null,
357
+ onMapMoveEnd: null,
358
+ },
359
+ config
360
+ );
361
+
362
+ self.cards = [];
363
+ self.markers = [];
364
+ self.activeListingId = null;
365
+ self.map = null;
366
+ self._mapVisible = true;
367
+ self._sortOrder = "default";
368
+ self._currentPage = 1;
369
+ self._originalListings = self.config.listings.slice();
370
+
371
+ self._init();
372
+ }
373
+
374
+ ListingsMapWidget.prototype._init = function () {
375
+ var self = this;
376
+ var container =
377
+ typeof self.config.container === "string"
378
+ ? document.querySelector(self.config.container)
379
+ : self.config.container;
380
+
381
+ if (!container) {
382
+ console.error(
383
+ "ListingsMap: Container not found:",
384
+ self.config.container
385
+ );
386
+ return;
387
+ }
388
+
389
+ self.container = container;
390
+ container.innerHTML = "";
391
+ container.classList.add("lm-widget");
392
+
393
+ // Listings panel
394
+ self.listingsPanel = el("div", "lm-listings-panel");
395
+
396
+ // Toolbar
397
+ if (self.config.showMapToggle || self.config.showSort) {
398
+ self._renderToolbar();
399
+ }
400
+
401
+ // Search slot: let consumer render their own search UI
402
+ if (self.config.renderSearchSlot) {
403
+ self._searchSlot = el("div", "lm-search-slot");
404
+ self.listingsPanel.appendChild(self._searchSlot);
405
+ self.config.renderSearchSlot(self._searchSlot);
406
+ }
407
+
408
+ self.listingsGrid = el("div", "lm-listings-grid");
409
+ self.listingsPanel.appendChild(self.listingsGrid);
410
+
411
+ // Pagination container
412
+ self.paginationContainer = el("div", "lm-pagination");
413
+ self.listingsPanel.appendChild(self.paginationContainer);
414
+
415
+ container.appendChild(self.listingsPanel);
416
+
417
+ // Map panel
418
+ self.mapPanel = el("div", "lm-map-panel");
419
+ self.mapContainer = el("div", "lm-map-container");
420
+ self.mapPanel.appendChild(self.mapContainer);
421
+
422
+ // Expand button
423
+ var expandBtn = el("button", "lm-map-expand-btn", {
424
+ html: ICONS.expand,
425
+ "aria-label": "Expand map",
426
+ });
427
+ expandBtn.addEventListener("click", function () {
428
+ self._toggleFullscreenMap();
429
+ });
430
+ self.mapPanel.appendChild(expandBtn);
431
+
432
+ container.appendChild(self.mapPanel);
433
+
434
+ // Render listings
435
+ self._renderListings();
436
+
437
+ // Load Leaflet then init map
438
+ loadLeaflet(function () {
439
+ self._initMap();
440
+ });
441
+ };
442
+
443
+ // ==========================================
444
+ // Toolbar: Map Toggle + Sort
445
+ // ==========================================
446
+ ListingsMapWidget.prototype._renderToolbar = function () {
447
+ var self = this;
448
+ self.toolbar = el("div", "lm-toolbar");
449
+
450
+ // Left side: sort
451
+ var left = el("div", "lm-toolbar-left");
452
+
453
+ if (self.config.showSort) {
454
+ var sortWrap = el("div", "lm-sort-wrapper");
455
+ self.sortSelect = el("select", "lm-sort-select", { "aria-label": "Sort listings" });
456
+ var options = [
457
+ { value: "default", label: "Sort: Default" },
458
+ { value: "price-asc", label: "Price: Low to High" },
459
+ { value: "price-desc", label: "Price: High to Low" },
460
+ ];
461
+ options.forEach(function (opt) {
462
+ var option = el("option", null, { value: opt.value, text: opt.label });
463
+ self.sortSelect.appendChild(option);
464
+ });
465
+ self.sortSelect.addEventListener("change", function () {
466
+ self._sortOrder = self.sortSelect.value;
467
+ self._currentPage = 1;
468
+ self._sortListings();
469
+ self._renderListings();
470
+ });
471
+ sortWrap.appendChild(self.sortSelect);
472
+ left.appendChild(sortWrap);
473
+ }
474
+
475
+ self.toolbar.appendChild(left);
476
+
477
+ // Right side: map toggle
478
+ var right = el("div", "lm-toolbar-right");
479
+
480
+ if (self.config.showMapToggle) {
481
+ self.mapToggleBtn = el("button", "lm-toggle-map-btn", {
482
+ "aria-label": "Toggle map",
483
+ });
484
+ self.mapToggleBtn.innerHTML = ICONS.mapPin + " <span>Show map</span>";
485
+ self._updateToggleLabel();
486
+ self.mapToggleBtn.addEventListener("click", function () {
487
+ self.toggleMap();
488
+ });
489
+ right.appendChild(self.mapToggleBtn);
490
+ }
491
+
492
+ self.toolbar.appendChild(right);
493
+ self.listingsPanel.appendChild(self.toolbar);
494
+ };
495
+
496
+ ListingsMapWidget.prototype._updateToggleLabel = function () {
497
+ if (!this.mapToggleBtn) return;
498
+ var span = this.mapToggleBtn.querySelector("span");
499
+ if (span) {
500
+ span.textContent = this._mapVisible ? "Hide map" : "Show map";
501
+ }
502
+ };
503
+
504
+ // ==========================================
505
+ // Sort Logic
506
+ // ==========================================
507
+ ListingsMapWidget.prototype._sortListings = function () {
508
+ var self = this;
509
+ if (self._sortOrder === "default") {
510
+ self.config.listings = self._originalListings.slice();
511
+ } else if (self._sortOrder === "price-asc") {
512
+ self.config.listings = self._originalListings.slice().sort(function (a, b) {
513
+ return (parseFloat(a.price) || 0) - (parseFloat(b.price) || 0);
514
+ });
515
+ } else if (self._sortOrder === "price-desc") {
516
+ self.config.listings = self._originalListings.slice().sort(function (a, b) {
517
+ return (parseFloat(b.price) || 0) - (parseFloat(a.price) || 0);
518
+ });
519
+ }
520
+ };
521
+
522
+ // ==========================================
523
+ // Pagination Helpers
524
+ // ==========================================
525
+ ListingsMapWidget.prototype._getPagedListings = function () {
526
+ var self = this;
527
+ var all = self.config.listings;
528
+ if (!self.config.pageSize || self.config.pageSize <= 0) return all;
529
+ var start = (self._currentPage - 1) * self.config.pageSize;
530
+ return all.slice(start, start + self.config.pageSize);
531
+ };
532
+
533
+ ListingsMapWidget.prototype._getTotalPages = function () {
534
+ var self = this;
535
+ if (!self.config.pageSize || self.config.pageSize <= 0) return 1;
536
+ return Math.ceil(self.config.listings.length / self.config.pageSize) || 1;
537
+ };
538
+
539
+ ListingsMapWidget.prototype._renderPagination = function () {
540
+ var self = this;
541
+ if (!self.paginationContainer) return;
542
+ self.paginationContainer.innerHTML = "";
543
+
544
+ var totalPages = self._getTotalPages();
545
+ if (!self.config.showPagination || !self.config.pageSize || self.config.pageSize <= 0 || totalPages <= 1) return;
546
+
547
+ var page = self._currentPage;
548
+
549
+ // Prev button
550
+ var prev = el("button", "lm-pagination-btn lm-pagination-btn-arrow", {
551
+ html: ICONS.chevronLeft,
552
+ "aria-label": "Previous page",
553
+ });
554
+ if (page <= 1) prev.disabled = true;
555
+ prev.addEventListener("click", function () {
556
+ if (page > 1) self.goToPage(page - 1);
557
+ });
558
+ self.paginationContainer.appendChild(prev);
559
+
560
+ // Page numbers with ellipsis
561
+ var pages = self._getPageNumbers(page, totalPages);
562
+ pages.forEach(function (p) {
563
+ if (p === "...") {
564
+ var dots = el("span", "lm-pagination-ellipsis", { text: "..." });
565
+ self.paginationContainer.appendChild(dots);
566
+ } else {
567
+ var btn = el("button", "lm-pagination-btn" + (p === page ? " lm-pagination-btn-active" : ""), {
568
+ text: String(p),
569
+ "aria-label": "Page " + p,
570
+ });
571
+ btn.addEventListener("click", function () {
572
+ self.goToPage(p);
573
+ });
574
+ self.paginationContainer.appendChild(btn);
575
+ }
576
+ });
577
+
578
+ // Next button
579
+ var next = el("button", "lm-pagination-btn lm-pagination-btn-arrow", {
580
+ html: ICONS.chevronRight,
581
+ "aria-label": "Next page",
582
+ });
583
+ if (page >= totalPages) next.disabled = true;
584
+ next.addEventListener("click", function () {
585
+ if (page < totalPages) self.goToPage(page + 1);
586
+ });
587
+ self.paginationContainer.appendChild(next);
588
+ };
589
+
590
+ ListingsMapWidget.prototype._getPageNumbers = function (current, total) {
591
+ if (total <= 7) {
592
+ var arr = [];
593
+ for (var i = 1; i <= total; i++) arr.push(i);
594
+ return arr;
595
+ }
596
+ // Always show first, last, and 2 around current
597
+ var pages = [1];
598
+ if (current > 3) pages.push("...");
599
+ for (var j = Math.max(2, current - 1); j <= Math.min(total - 1, current + 1); j++) {
600
+ pages.push(j);
601
+ }
602
+ if (current < total - 2) pages.push("...");
603
+ pages.push(total);
604
+ return pages;
605
+ };
606
+
607
+ // ==========================================
608
+ // Listings Rendering
609
+ // ==========================================
610
+ ListingsMapWidget.prototype._renderListings = function () {
611
+ var self = this;
612
+ self.listingsGrid.innerHTML = "";
613
+ self.cards = [];
614
+
615
+ if (!self.config.listings || self.config.listings.length === 0) {
616
+ var noResults = el("div", "lm-no-results");
617
+ noResults.innerHTML =
618
+ '<div class="lm-no-results-title">No results found</div><div>Try adjusting your search or filters.</div>';
619
+ self.listingsGrid.appendChild(noResults);
620
+ self._renderPagination();
621
+ return;
622
+ }
623
+
624
+ var pagedListings = self._getPagedListings();
625
+
626
+ pagedListings.forEach(function (listing) {
627
+ var card = new ListingCard(listing, {
628
+ currency: self.config.currency,
629
+ onFavorite: self.config.onFavorite,
630
+ onListingClick: function (l) {
631
+ self._highlightListing(l.id);
632
+ if (self.config.onListingClick) {
633
+ self.config.onListingClick(l);
634
+ }
635
+ },
636
+ });
637
+
638
+ // Hover: highlight map marker
639
+ card.el.addEventListener("mouseenter", function () {
640
+ self._highlightMarker(listing.id);
641
+ });
642
+ card.el.addEventListener("mouseleave", function () {
643
+ self._unhighlightMarker(listing.id);
644
+ });
645
+
646
+ self.cards.push(card);
647
+ self.listingsGrid.appendChild(card.el);
648
+ });
649
+
650
+ // Pagination
651
+ self._renderPagination();
652
+ };
653
+
654
+ ListingsMapWidget.prototype._initMap = function () {
655
+ var self = this;
656
+ var L = window.L;
657
+
658
+ self.map = L.map(self.mapContainer, {
659
+ center: self.config.mapOptions.center,
660
+ zoom: self.config.mapOptions.zoom,
661
+ zoomControl: false,
662
+ });
663
+
664
+ L.control.zoom({ position: "topright" }).addTo(self.map);
665
+
666
+ L.tileLayer(self.config.tileUrl, {
667
+ attribution: self.config.tileAttribution,
668
+ maxZoom: 19,
669
+ }).addTo(self.map);
670
+
671
+ // Price markers (use typeof so lat/lng 0 are valid)
672
+ self.markers = [];
673
+ self.config.listings.forEach(function (listing) {
674
+ var hasCoords =
675
+ typeof listing.lat === "number" &&
676
+ typeof listing.lng === "number" &&
677
+ !Number.isNaN(listing.lat) &&
678
+ !Number.isNaN(listing.lng);
679
+ if (hasCoords) {
680
+ var priceLabel = formatPrice(
681
+ listing.price,
682
+ self.config.currency || ""
683
+ );
684
+ var icon = L.divIcon({
685
+ className: "",
686
+ html: '<div class="lm-price-marker" data-listing-id="' + listing.id + '">' + priceLabel + "</div>",
687
+ iconSize: null,
688
+ iconAnchor: [0, 0],
689
+ });
690
+
691
+ var marker = L.marker([listing.lat, listing.lng], { icon: icon }).addTo(
692
+ self.map
693
+ );
694
+
695
+ // Popup
696
+ var popupHtml =
697
+ '<div class="lm-map-popup">' +
698
+ (listing.images && listing.images[0]
699
+ ? '<img class="lm-popup-img" src="' +
700
+ listing.images[0] +
701
+ '" alt="' +
702
+ (listing.title || "") +
703
+ '">'
704
+ : "") +
705
+ '<div class="lm-popup-body">' +
706
+ '<div class="lm-popup-title">' +
707
+ (listing.title || "") +
708
+ "</div>" +
709
+ '<div class="lm-popup-price"><strong>' +
710
+ priceLabel +
711
+ "</strong>" +
712
+ (listing.pricePeriod ? " " + listing.pricePeriod : "") +
713
+ "</div>" +
714
+ "</div></div>";
715
+
716
+ marker.bindPopup(popupHtml, {
717
+ closeButton: true,
718
+ className: "lm-map-popup",
719
+ maxWidth: 270,
720
+ offset: [0, -5],
721
+ });
722
+
723
+ marker.on("click", function () {
724
+ self._highlightListing(listing.id);
725
+ self._scrollToCard(listing.id);
726
+ });
727
+
728
+ // Store reference
729
+ marker._listingId = listing.id;
730
+ self.markers.push(marker);
731
+ }
732
+ });
733
+
734
+ // Fit bounds if we have markers
735
+ if (self.markers.length > 0) {
736
+ var group = L.featureGroup(self.markers);
737
+ self.map.fitBounds(group.getBounds().pad(0.1));
738
+ }
739
+
740
+ // Recompute map size after layout (fixes blank map when container height is from flex/min-height)
741
+ setTimeout(function () {
742
+ if (self.map) self.map.invalidateSize();
743
+ }, 100);
744
+ setTimeout(function () {
745
+ if (self.map) self.map.invalidateSize();
746
+ }, 350);
747
+
748
+ // Map move event
749
+ if (self.config.onMapMoveEnd) {
750
+ self.map.on("moveend", function () {
751
+ var bounds = self.map.getBounds();
752
+ self.config.onMapMoveEnd({
753
+ north: bounds.getNorth(),
754
+ south: bounds.getSouth(),
755
+ east: bounds.getEast(),
756
+ west: bounds.getWest(),
757
+ center: [self.map.getCenter().lat, self.map.getCenter().lng],
758
+ zoom: self.map.getZoom(),
759
+ });
760
+ });
761
+ }
762
+ };
763
+
764
+ ListingsMapWidget.prototype._highlightMarker = function (id) {
765
+ this.markers.forEach(function (m) {
766
+ if (m._listingId === id) {
767
+ var el = m.getElement();
768
+ if (el) {
769
+ var pill = el.querySelector(".lm-price-marker");
770
+ if (pill) pill.classList.add("lm-price-marker-active");
771
+ }
772
+ }
773
+ });
774
+ };
775
+
776
+ ListingsMapWidget.prototype._unhighlightMarker = function (id) {
777
+ this.markers.forEach(function (m) {
778
+ if (m._listingId === id) {
779
+ var el = m.getElement();
780
+ if (el) {
781
+ var pill = el.querySelector(".lm-price-marker");
782
+ if (pill) pill.classList.remove("lm-price-marker-active");
783
+ }
784
+ }
785
+ });
786
+ };
787
+
788
+ ListingsMapWidget.prototype._highlightListing = function (id) {
789
+ this.cards.forEach(function (card) {
790
+ card.setActive(card.listing.id === id);
791
+ });
792
+ this.markers.forEach(function (m) {
793
+ var el = m.getElement();
794
+ if (el) {
795
+ var pill = el.querySelector(".lm-price-marker");
796
+ if (pill) {
797
+ if (m._listingId === id) {
798
+ pill.classList.add("lm-price-marker-active");
799
+ } else {
800
+ pill.classList.remove("lm-price-marker-active");
801
+ }
802
+ }
803
+ }
804
+ });
805
+ };
806
+
807
+ ListingsMapWidget.prototype._scrollToCard = function (id) {
808
+ var self = this;
809
+ // If pagination is active, check if listing is on current page
810
+ if (self.config.pageSize && self.config.pageSize > 0) {
811
+ var idx = -1;
812
+ for (var i = 0; i < self.config.listings.length; i++) {
813
+ if (self.config.listings[i].id === id) { idx = i; break; }
814
+ }
815
+ if (idx >= 0) {
816
+ var targetPage = Math.floor(idx / self.config.pageSize) + 1;
817
+ if (targetPage !== self._currentPage) {
818
+ self._currentPage = targetPage;
819
+ self._renderListings();
820
+ }
821
+ }
822
+ }
823
+ var target = self.listingsGrid.querySelector(
824
+ '[data-listing-id="' + id + '"]'
825
+ );
826
+ if (target) {
827
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
828
+ }
829
+ };
830
+
831
+ ListingsMapWidget.prototype._toggleFullscreenMap = function () {
832
+ var panel = this.mapPanel;
833
+ if (panel.style.position === "fixed") {
834
+ panel.style.position = "";
835
+ panel.style.top = "";
836
+ panel.style.left = "";
837
+ panel.style.right = "";
838
+ panel.style.bottom = "";
839
+ panel.style.zIndex = "";
840
+ panel.style.width = "";
841
+ this.listingsPanel.style.display = "";
842
+ } else {
843
+ panel.style.position = "fixed";
844
+ panel.style.top = "0";
845
+ panel.style.left = "0";
846
+ panel.style.right = "0";
847
+ panel.style.bottom = "0";
848
+ panel.style.zIndex = "9999";
849
+ panel.style.width = "100%";
850
+ this.listingsPanel.style.display = "none";
851
+ }
852
+ if (this.map) {
853
+ var self = this;
854
+ setTimeout(function () {
855
+ self.map.invalidateSize();
856
+ }, 100);
857
+ }
858
+ };
859
+
860
+ // ==========================================
861
+ // Public API
862
+ // ==========================================
863
+ /**
864
+ * Update the listings data and re-render
865
+ */
866
+ ListingsMapWidget.prototype.setListings = function (listings) {
867
+ this._originalListings = listings.slice();
868
+ this._currentPage = 1;
869
+ this._sortOrder = "default";
870
+ if (this.sortSelect) this.sortSelect.value = "default";
871
+ this.config.listings = listings;
872
+ this._renderListings();
873
+
874
+ // Remove old markers
875
+ var self = this;
876
+ if (self.map) {
877
+ self.markers.forEach(function (m) {
878
+ self.map.removeLayer(m);
879
+ });
880
+ self.markers = [];
881
+
882
+ // Re-add markers (use typeof so lat/lng 0 are valid), including popups
883
+ var L = window.L;
884
+ listings.forEach(function (listing) {
885
+ var hasCoords =
886
+ typeof listing.lat === "number" &&
887
+ typeof listing.lng === "number" &&
888
+ !Number.isNaN(listing.lat) &&
889
+ !Number.isNaN(listing.lng);
890
+ if (!hasCoords) {
891
+ return;
892
+ }
893
+
894
+ var priceLabel = formatPrice(
895
+ listing.price,
896
+ self.config.currency || ""
897
+ );
898
+
899
+ var icon = L.divIcon({
900
+ className: "",
901
+ html:
902
+ '<div class="lm-price-marker" data-listing-id="' +
903
+ listing.id +
904
+ '">' +
905
+ priceLabel +
906
+ "</div>",
907
+ iconSize: null,
908
+ iconAnchor: [0, 0],
909
+ });
910
+
911
+ var marker = L.marker([listing.lat, listing.lng], {
912
+ icon: icon,
913
+ }).addTo(self.map);
914
+
915
+ // Popup content mirrors _initMap so behavior is consistent
916
+ var popupHtml =
917
+ '<div class="lm-map-popup">' +
918
+ (listing.images && listing.images[0]
919
+ ? '<img class="lm-popup-img" src="' +
920
+ listing.images[0] +
921
+ '" alt="' +
922
+ (listing.title || "") +
923
+ '">'
924
+ : "") +
925
+ '<div class="lm-popup-body">' +
926
+ '<div class="lm-popup-title">' +
927
+ (listing.title || "") +
928
+ "</div>" +
929
+ '<div class="lm-popup-price"><strong>' +
930
+ priceLabel +
931
+ "</strong>" +
932
+ (listing.pricePeriod ? " " + listing.pricePeriod : "") +
933
+ "</div>" +
934
+ "</div></div>";
935
+
936
+ marker.bindPopup(popupHtml, {
937
+ closeButton: true,
938
+ className: "lm-map-popup",
939
+ maxWidth: 270,
940
+ offset: [0, -5],
941
+ });
942
+
943
+ marker.on("click", function () {
944
+ self._highlightListing(listing.id);
945
+ self._scrollToCard(listing.id);
946
+ });
947
+
948
+ marker._listingId = listing.id;
949
+ self.markers.push(marker);
950
+ });
951
+
952
+ if (self.markers.length > 0) {
953
+ var group = L.featureGroup(self.markers);
954
+ self.map.fitBounds(group.getBounds().pad(0.1));
955
+ }
956
+ }
957
+ };
958
+
959
+ /**
960
+ * Pan the map to a specific listing
961
+ */
962
+ ListingsMapWidget.prototype.panToListing = function (id) {
963
+ var self = this;
964
+ self.markers.forEach(function (m) {
965
+ if (m._listingId === id) {
966
+ self.map.setView(m.getLatLng(), self.map.getZoom(), { animate: true });
967
+ m.openPopup();
968
+ }
969
+ });
970
+ self._highlightListing(id);
971
+ self._scrollToCard(id);
972
+ };
973
+
974
+ /**
975
+ * Toggle map panel visibility
976
+ */
977
+ ListingsMapWidget.prototype.toggleMap = function () {
978
+ var self = this;
979
+ self._mapVisible = !self._mapVisible;
980
+ if (self._mapVisible) {
981
+ self.container.classList.remove("lm-map-hidden");
982
+ } else {
983
+ self.container.classList.add("lm-map-hidden");
984
+ }
985
+ self._updateToggleLabel();
986
+ // Invalidate map size after CSS transition
987
+ if (self.map && self._mapVisible) {
988
+ setTimeout(function () {
989
+ self.map.invalidateSize();
990
+ }, 350);
991
+ }
992
+ };
993
+
994
+ /**
995
+ * Go to a specific page
996
+ */
997
+ ListingsMapWidget.prototype.goToPage = function (n) {
998
+ var self = this;
999
+ var total = self._getTotalPages();
1000
+ if (n < 1) n = 1;
1001
+ if (n > total) n = total;
1002
+ self._currentPage = n;
1003
+ self._renderListings();
1004
+ // Scroll listings panel to top
1005
+ if (self.listingsPanel) {
1006
+ self.listingsPanel.scrollTo({ top: 0, behavior: "smooth" });
1007
+ }
1008
+ };
1009
+
1010
+ /**
1011
+ * Destroy the widget
1012
+ */
1013
+ ListingsMapWidget.prototype.destroy = function () {
1014
+ if (this.map) {
1015
+ this.map.remove();
1016
+ this.map = null;
1017
+ }
1018
+ if (this.container) {
1019
+ this.container.innerHTML = "";
1020
+ this.container.classList.remove("lm-widget");
1021
+ }
1022
+ };
1023
+
1024
+
1025
+
1026
+ // ==========================================
1027
+ // Factory
1028
+ // ==========================================
1029
+ return {
1030
+ /**
1031
+ * Initialize a new ListingsMap widget
1032
+ * @param {Object} config
1033
+ * @returns {ListingsMapWidget}
1034
+ */
1035
+ init: function (config) {
1036
+ return new ListingsMapWidget(config);
1037
+ },
1038
+
1039
+ /** Version */
1040
+ version: "1.0.0",
1041
+ };
1042
+ });