@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
JavaScript
/**
* 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);
}