UNPKG

@grdistro/store-locator-widget

Version:

A modular store locator widget with Mapbox integration that can be embedded in any website

341 lines (301 loc) β€’ 11.4 kB
/** * Modular Store Locator Widget * Can be embedded in any website */ class StoreLocator { constructor(config) { this.config = { containerId: 'store-locator', apiEndpoint: config.apiEndpoint || '/api/stores', mapboxToken: config.mapboxToken, stores: config.stores || [], theme: config.theme || 'light', height: config.height || '600px', showSearch: config.showSearch !== false, showFilters: config.showFilters !== false, showMap: config.showMap !== false, ...config }; this.container = null; this.stores = this.config.stores; this.filteredStores = this.config.stores; this.selectedStore = null; this.userLocation = null; this.init(); } init() { this.container = document.getElementById(this.config.containerId); if (!this.container) { console.error(`Container with ID "${this.config.containerId}" not found`); return; } this.render(); this.attachEventListeners(); if (this.config.stores.length === 0 && this.config.apiEndpoint) { this.loadStores(); } } render() { this.container.innerHTML = ` <div class="store-locator-widget" style="height: ${this.config.height}; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;"> ${this.renderHeader()} <div class="sl-content" style="display: flex; height: calc(100% - 80px); gap: 1rem;"> ${this.config.showMap ? this.renderMap() : ''} ${this.renderStoreList()} </div> </div> ${this.renderStyles()} `; } renderHeader() { if (!this.config.showSearch && !this.config.showFilters) return ''; return ` <div class="sl-header" style="padding: 1rem; border-bottom: 1px solid #e2e8f0;"> ${this.config.showSearch ? ` <div class="sl-search" style="margin-bottom: 1rem;"> <input type="text" id="sl-search-input" placeholder="Search stores or enter location..." style="width: 100%; padding: 0.75rem; border: 1px solid #d1d5db; border-radius: 0.5rem; font-size: 1rem;" /> </div> ` : ''} ${this.config.showFilters ? ` <div class="sl-filters" style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> <button class="sl-filter-btn active" data-filter="all" style="padding: 0.5rem 1rem; border: 1px solid #3b82f6; background: #3b82f6; color: white; border-radius: 0.25rem; cursor: pointer;">All Stores</button> <button class="sl-filter-btn" data-filter="open" style="padding: 0.5rem 1rem; border: 1px solid #10b981; background: transparent; color: #10b981; border-radius: 0.25rem; cursor: pointer;">Open Now</button> <button class="sl-filter-btn" data-filter="nearby" style="padding: 0.5rem 1rem; border: 1px solid #f59e0b; background: transparent; color: #f59e0b; border-radius: 0.25rem; cursor: pointer;">Nearby</button> </div> ` : ''} </div> `; } renderMap() { return ` <div class="sl-map-container" style="flex: 2; min-height: 400px;"> <div id="sl-map" style="width: 100%; height: 100%; background: #f8fafc; border-radius: 0.5rem; display: flex; align-items: center; justify-content: center;"> <div style="text-align: center; color: #64748b;"> <div style="font-size: 2rem; margin-bottom: 0.5rem;">πŸ—ΊοΈ</div> <div>Map Loading...</div> </div> </div> </div> `; } renderStoreList() { return ` <div class="sl-store-list" style="flex: 1; min-width: 300px; overflow-y: auto; padding: 1rem; background: #f8fafc; border-radius: 0.5rem;"> <div id="sl-stores-container"> ${this.renderStores()} </div> </div> `; } renderStores() { if (this.filteredStores.length === 0) { return ` <div style="text-align: center; padding: 2rem; color: #64748b;"> <div style="font-size: 1.5rem; margin-bottom: 0.5rem;">πŸͺ</div> <div>No stores found</div> </div> `; } return this.filteredStores.map(store => ` <div class="sl-store-card" data-store-id="${store.id}" style="background: white; border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem; cursor: pointer; border: 1px solid #e2e8f0; transition: all 0.2s;"> <div style="display: flex; justify-content: between; align-items: start; margin-bottom: 0.5rem;"> <h3 style="margin: 0; font-size: 1.1rem; font-weight: 600; color: #1f2937;">${store.name}</h3> <span class="sl-status ${store.isOpen ? 'open' : 'closed'}" style="padding: 0.25rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; ${store.isOpen ? 'background: #dcfce7; color: #166534;' : 'background: #fee2e2; color: #dc2626;'}">${store.isOpen ? 'Open' : 'Closed'}</span> </div> <p style="margin: 0 0 0.5rem 0; color: #6b7280; font-size: 0.875rem;">${store.address}</p> <div style="display: flex; justify-content: between; align-items: center; font-size: 0.875rem; color: #6b7280;"> <span>πŸ“ž ${store.phone}</span> ${store.distance ? `<span>${store.distance.toFixed(1)} mi</span>` : ''} </div> </div> `).join(''); } renderStyles() { return ` <style> .store-locator-widget * { box-sizing: border-box; } .sl-store-card:hover { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); transform: translateY(-1px); } .sl-filter-btn:hover { opacity: 0.8; } .sl-filter-btn.active { background-color: var(--primary-color, #3b82f6) !important; color: white !important; } .sl-search input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); } </style> `; } attachEventListeners() { // Search functionality const searchInput = document.getElementById('sl-search-input'); if (searchInput) { searchInput.addEventListener('input', (e) => { this.handleSearch(e.target.value); }); } // Filter buttons const filterBtns = document.querySelectorAll('.sl-filter-btn'); filterBtns.forEach(btn => { btn.addEventListener('click', (e) => { filterBtns.forEach(b => b.classList.remove('active')); e.target.classList.add('active'); this.handleFilter(e.target.dataset.filter); }); }); // Store card clicks this.container.addEventListener('click', (e) => { const storeCard = e.target.closest('.sl-store-card'); if (storeCard) { const storeId = parseInt(storeCard.dataset.storeId); this.selectStore(storeId); } }); } async loadStores() { try { const response = await fetch(`${this.config.apiEndpoint}/search`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); if (response.ok) { this.stores = await response.json(); this.filteredStores = this.stores; this.updateStoreList(); } } catch (error) { console.error('Failed to load stores:', error); } } handleSearch(query) { if (!query.trim()) { this.filteredStores = this.stores; } else { this.filteredStores = this.stores.filter(store => store.name.toLowerCase().includes(query.toLowerCase()) || store.address.toLowerCase().includes(query.toLowerCase()) ); } this.updateStoreList(); } handleFilter(filter) { switch (filter) { case 'open': this.filteredStores = this.stores.filter(store => store.isOpen); break; case 'nearby': if (this.userLocation) { this.filteredStores = this.stores .filter(store => store.distance && store.distance <= 10) .sort((a, b) => (a.distance || 0) - (b.distance || 0)); } else { this.getUserLocation(); } break; default: this.filteredStores = this.stores; } this.updateStoreList(); } selectStore(storeId) { this.selectedStore = this.stores.find(store => store.id === storeId); // Highlight selected store document.querySelectorAll('.sl-store-card').forEach(card => { card.style.borderColor = '#e2e8f0'; }); const selectedCard = document.querySelector(`[data-store-id="${storeId}"]`); if (selectedCard) { selectedCard.style.borderColor = '#3b82f6'; } // Trigger custom event if (this.config.onStoreSelect) { this.config.onStoreSelect(this.selectedStore); } // Dispatch custom event this.container.dispatchEvent(new CustomEvent('storeSelect', { detail: { store: this.selectedStore } })); } updateStoreList() { const container = document.getElementById('sl-stores-container'); if (container) { container.innerHTML = this.renderStores(); } } getUserLocation() { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { this.userLocation = { latitude: position.coords.latitude, longitude: position.coords.longitude }; this.calculateDistances(); }, (error) => { console.warn('Geolocation access denied:', error); } ); } } calculateDistances() { if (!this.userLocation) return; this.stores.forEach(store => { const distance = this.calculateDistance( this.userLocation.latitude, this.userLocation.longitude, parseFloat(store.latitude), parseFloat(store.longitude) ); store.distance = distance; }); this.updateStoreList(); } calculateDistance(lat1, lon1, lat2, lon2) { const R = 3959; // Earth's radius in miles const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R * c; } // Public API methods addStore(store) { this.stores.push(store); this.filteredStores = this.stores; this.updateStoreList(); } removeStore(storeId) { this.stores = this.stores.filter(store => store.id !== storeId); this.filteredStores = this.filteredStores.filter(store => store.id !== storeId); this.updateStoreList(); } updateStore(storeId, updates) { const storeIndex = this.stores.findIndex(store => store.id === storeId); if (storeIndex !== -1) { this.stores[storeIndex] = { ...this.stores[storeIndex], ...updates }; this.filteredStores = this.stores; this.updateStoreList(); } } getSelectedStore() { return this.selectedStore; } destroy() { if (this.container) { this.container.innerHTML = ''; } } } // Make it available globally window.StoreLocator = StoreLocator; // Auto-initialize if config is provided if (window.storeLocatorConfig) { new StoreLocator(window.storeLocatorConfig); }