Aseer Store Locator
A store locator plugin built with Leaflet.js (Google Map API Support), a REST API backend, marker clustering, live filtering, and CSV bulk import.
by Aseer Time Group · github.com/huuhabn/store-locator · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/huuhabn/store-locator/archive/refs/heads/main.zipA store locator plugin built with Leaflet.js (Google Map API Support), a REST API backend, marker clustering, live filtering, and CSV bulk import.
Features
- Custom post type (
store) for managing store data from wp-admin - 11 store fields: name, brand, country, city, address, lat/lng, phone, email, opening hours, details, directions URL
- CSV importer for bulk loading 500+ stores (create + update in one pass)
- Frontend shortcode
[store_locator]with:- Interactive Leaflet.js map, choice of 4 basemap styles (see Settings)
- Marker clustering, auto-fit bounds, popups, admin-configurable pin color/icon
- Search box with "use my location" + place autocomplete (OpenStreetMap Nominatim)
- Collapsible filter panel: checkbox filters for brand, dropdown filters for country/city
- Nearest-first sorting once the user shares their location
- Store cards with open/closed status, expandable hours, distance, directions button, details modal
- Mobile map/list toggle layout
- Settings page (Store Locator → Settings): marker color/icon, primary + panel colors, map basemap style, default map center/zoom
- Listing detail page: every store gets its own URL (
/store/store-name/) rendering a full detail page — theme-overridable, see below - REST API (
/wp-json/aseer-store-locator/v1/stores,/filters) with pagination - Security: nonces, capability checks, sanitized input, escaped output, prepared queries
- Styling driven entirely by CSS variables so it inherits your theme's look
Listing Detail Page (Overriding Templates)
Every store is a real WordPress page at its own URL (e.g. /store/altamonte-mall/), rendered by templates/single-store.php — a normal WordPress Loop template (title, featured image, address, phone/email, hours, a small map, and a Directions button).
To customize it: copy the file to your theme (or child theme) at
yourtheme/aseer-store-locator/single-store.php
and edit that copy. The plugin always checks the theme first (child theme, then parent theme) and only falls back to its own bundled copy if no theme version exists — a plugin update will never overwrite your customized copy. This is the same override pattern used by WooCommerce, Easy Digital Downloads, etc.
The [store_locator] widget's own template (templates/locator.php) can be overridden the exact same way, at yourtheme/aseer-store-locator/locator.php.
After updating from a version without this feature: visit Settings → Permalinks in wp-admin and click Save Changes once — WordPress only registers the new
/store/...URLs on save/activation, not automatically on plugin update.
Installation
- Zip the
aseer-store-locatorfolder (or use the providedaseer-store-locator.zip). - In WordPress admin, go to Plugins → Add New → Upload Plugin.
- Choose the zip file and click Install Now, then Activate.
- A new Store Locator menu item will appear in the left admin sidebar.
Adding Stores
Manually: Go to Store Locator → Add New Store, fill in the title (store name) and the Store Details meta box fields (brand, country, city, address, latitude/longitude, phone, email, hours, details, directions URL override), then Publish.
Via CSV (recommended for 500+ locations):
- Go to Store Locator → Import CSV.
- Prepare a CSV with this exact column order:
name,store_brand,store_country,address,coordinates,phone,opening_hours,direction_url(
coordinatesis a single"lat,lng"cell, anddirection_urlis an optional external directions link — wrap any value containing a comma in quotes. Seestores-example.csvin this package for a working sample.) - Upload the file and click Import Stores.
- Existing stores are matched by exact Store Name + Brand and updated; unmatched rows are created as new stores. Invalid rows (missing name, out-of-range lat/lng, or a column count that doesn't match the header — usually an un-quoted comma inside a text field) are skipped and reported in the results summary instead of failing the whole import.
Using the Shortcode
Add this to any page or post:
[store_locator]
Optional attributes:
[store_locator height="700px" default_zoom="5"]
| Attribute | Default | Description |
|---|---|---|
height |
650px |
Height of the map/sidebar area (any valid CSS length) |
default_zoom |
(Settings page value) | Initial Leaflet zoom level before stores load |
default_center |
(Settings page value) | Initial map center as "lat,lng", e.g. "24.7,46.6" |
Settings Page
Go to Store Locator → Settings to configure, without touching code:
- Marker Color / Custom Marker Icon URL — the pin color (or a custom PNG/SVG image) used for every store marker on the map
- Primary / Button Color — buttons, links, focus outlines
- Search Panel Background — the search box + results/filter bar background (pink in the default Victoria's Secret–style theme, but any color)
- Map Provider — Leaflet (default: free, no API key, uses OpenStreetMap/CARTO tiles) or Google Maps (requires a Google Cloud API key). See "Map Provider" below for details.
- Map Style — Leaflet only: Standard (OpenStreetMap), Light (Positron), Dark (Dark Matter), or Voyager — all free CARTO/OSM basemaps, no API key needed
- Default Map Center / Zoom — where the map starts before any search runs (a shortcode's own
default_zoom/default_centerattribute always takes priority over this)
Map Provider
The plugin defaults to Leaflet with free OpenStreetMap/CARTO tiles — no account or API key needed, works out of the box.
To switch to Google Maps instead:
- In Google Cloud Console, enable the Maps JavaScript API for a project (requires billing to be enabled on the project — Google's free monthly credit covers typical small/medium traffic).
- Create an API key under APIs & Services → Credentials, and restrict it (HTTP referrers) to your site's domain(s). This is what makes it safe for the key to appear in your page's HTML/JS — that's normal for the Maps JavaScript API, not a leak.
- On Store Locator → Settings, set Map Provider to Google Maps and paste the key into Google Maps API Key.
Notes:
- If Google Maps is selected but no key is set, the frontend automatically falls back to Leaflet (with a warning shown on the Settings page) rather than showing a broken map.
- Marker clustering on Google Maps uses the optional
@googlemaps/markerclustererlibrary (loaded from a CDN, same approach as Leaflet's cluster plugin); if it fails to load for any reason, markers still render individually rather than the map breaking. - Location autocomplete in the search box always uses OpenStreetMap's Nominatim, regardless of which map provider is active — the two are independent (Nominatim just looks up place names/coordinates; it doesn't render the map itself).
- The "Dark Matter" Map Style option applies an approximate dark theme to Google Maps too (Google doesn't have that exact CARTO basemap, so this is a hand-built equivalent, not a pixel-perfect match). All other Map Style choices are Leaflet-only and are ignored when Google Maps is active.
Styling / Branding
Colors set on the Settings page are injected automatically. For anything else (radius, font, muted/border colors), override the CSS variables on .asl-locator from your theme's stylesheet instead of editing the plugin, e.g.:
.asl-locator {
--asl-color-text: #1a1a1a;
--asl-radius: 4px;
--asl-font: "Poppins", sans-serif;
}
Changing the Map Tile Provider
Pick from the 4 built-in styles on the Settings page. To add a different provider entirely (e.g. Mapbox, Maptiler), add an entry to the tile_providers() array in includes/Frontend/Assets.php and to the $options list in field_tile_style() in includes/Admin/Settings.php.
Location Autocomplete
The search box's autocomplete suggestions come from OpenStreetMap's free Nominatim geocoder (same open-data stack as the map tiles — no API key). Nominatim's usage policy caps free public use at roughly 1 request/second per client; the search input is debounced to stay well under that for normal traffic. A site expecting heavy search volume should proxy this through its own server or switch to a commercial geocoder.
REST API Reference
GET /wp-json/aseer-store-locator/v1/stores— params:brand,country,city,search,page,per_page(max 500)GET /wp-json/aseer-store-locator/v1/filters— returns distinct brand/country/city values for populating filter dropdowns
Both endpoints are public/read-only (GET only) and return published stores only.
File Structure
aseer-store-locator/
├── aseer-store-locator.php Plugin bootstrap
├── includes/
│ ├── Plugin.php Wires everything together
│ ├── PostTypes/StorePostType.php
│ ├── Admin/MetaBoxes.php Edit-screen fields, admin list columns/filter
│ ├── Admin/Import.php CSV importer page + handler
│ ├── Admin/Settings.php Marker/color/map settings page
│ ├── Frontend/Shortcode.php [store_locator] shortcode
│ ├── Frontend/Templates.php Theme-override template locator/loader
│ ├── Frontend/TemplateLoader.php Swaps in single-store.php on store URLs
│ ├── Frontend/OpeningHours.php Best-effort "open now" + hours-table parsing
│ ├── Frontend/Assets.php Conditional asset loading + settings → CSS/JS bridge
│ └── Rest/StoreController.php REST endpoints
├── templates/
│ ├── locator.php [store_locator] widget markup (overridable)
│ └── single-store.php Listing detail page (overridable)
├── assets/js/store-locator.js Map + filters + geolocation logic
├── assets/css/store-locator.css Themeable styles
├── templates/locator.php Frontend markup
└── stores-example.csv Sample import file
Requirements
- WordPress 5.8+
- PHP 7.4+
- Outbound access to
unpkg.com(Leaflet CDN),tile.openstreetmap.org/basemaps.cartocdn.com(map tiles), andnominatim.openstreetmap.org(search autocomplete) from visitors' browsers
Notes / Next Steps
- Distance-based sorting and "open now" status are computed client-side from opening-hours free text using a best-effort parser; for exact multi-day schedules consider extending
_asl_opening_hoursto a structured format later. - The importer currently accepts the fixed column order specified above; a future version could support header-based column mapping for flexibility.
Recent Fixes (v1.3.0)
- "Use My Location" now actually zooms in. Previously the map's re-fit-to-all-stores logic ran right after locating the user and silently undid the zoom; the map now stays centered on the user at zoom 14.
- Download Example Template. Store Locator → Import CSV now has a "Download Example Template (CSV)" button that streams a ready-to-edit CSV with the correct header and a few example rows.
- Button styling is theme-resistant. All interactive elements (
.asl-btn, filter/search icon buttons, checkboxes, the mobile list/map toggle, etc.) are now scoped under.asl-locatorwith tag-qualified selectors (a.asl-btn,button.asl-btn, …) and explicit resets (text-decoration,box-shadow,appearance, etc.), so an active theme's own genericbutton/astyles no longer bleed through and override them. - Fixed double-encoded text from the REST API.
get_the_title()HTML-entity-encodes things like apostrophes and ampersands for direct HTML output; since the frontend JS escapes text itself before inserting it into the page, that pre-encoded text was being escaped a second time and showing up as literal’/&instead of the actual characters. The REST API now decodes entities before returning JSON, so exactly one escaping step happens (in the browser).
v1.4.0 — Google Maps support
- New Map Provider setting. Choose Leaflet (default) or Google Maps on the Settings page — see "Map Provider" above. Both the
[store_locator]widget and the single-store detail page's mini map support either provider, including marker icons/colors, clustering, popups, and the "Use My Location" zoom behavior. - Assets now only load the scripts for whichever provider is actually configured (e.g. Leaflet's ~150KB never loads when Google Maps is selected, and vice versa).
v1.5.0
- Fixed: clicking a store in the list didn't reliably pan/center the map. Both Leaflet and Google Maps compute the wrong view if you pan while the map container is still hidden (
display:none) — a classic issue when the map is inside a hidden mobile tab (or, relatedly, a hidden Elementor tab during editing).focusStore()now forces a size recalculation right before centering, fixing this for mobile and any other hidden-container scenario. - Fixed:
[store_locator]mostly not working inside Elementor. Elementor's "Shortcode" widget stores the shortcode text in_elementor_datapostmeta (JSON), not$post->post_content—has_shortcode()never found it there, so the plugin's CSS/JS never loaded (the widget rendered, just completely unstyled/non-interactive). Fixed two ways: (1)should_enqueue()now also checks_elementor_datafor a proactive, in-<head>load; (2) the existingwp_footersafety-net fallback now force-prints its stylesheet (wp_print_styles()) since WordPress only auto-prints late-enqueued scripts, not styles — the fallback was silently missing CSS even when it correctly loaded the JS. - Redesigned the listing detail page to match a Victoria's Secret–style store page: breadcrumb, a three-column info panel (Store Details / Store Hours / Store Services) with vertical dividers, a pink "Get Directions" CTA button, and the map + content moved below the panel.
- New: a real "Open Now"/"Closed" status and a day-by-day-looking hours table, both computed from the existing free-text
_asl_opening_hoursfield (best-effort parsing — seeFrontend/OpeningHours.php) — no new admin fields needed, works with hours already entered in a "Day: hours" per-line format. - Deliberately not included (no matching data source, not faked): a separate "Store Includes" list distinct from "Services" (this plugin has one
servicesfield, shown once), and social media links (no such field exists). Ask if you'd like either added as real fields.
- New: a real "Open Now"/"Closed" status and a day-by-day-looking hours table, both computed from the existing free-text
v1.5.1
- Fixed:
[store_locator]still not working in the Elementor editor. The earlier fix only addressed detecting the shortcode in a page's content; the actual reason it "mostly doesn't work" while editing is that Elementor's Shortcode widget re-renders via an AJAX request straight toadmin-ajax.phpwhenever its content changes — that request type never fireswp_enqueue_scriptsat all (no page/footer to print into), so there's no way to catch it after the fact. Fixed by always loading the plugin's assets on Elementor's preview-iframe page load (detected via itselementor-previewquery var), so they're already present in the iframe before any later AJAX re-render happens. - Added a slim scrollbar to the store list panel (and, for consistency, the search autocomplete dropdown and the details modal) instead of the browser's default scrollbar — styled via
--asl-color-border/--asl-color-muted, works in both Firefox (scrollbar-width/scrollbar-color) and Chromium/Safari (::-webkit-scrollbar).
v1.5.28
- Removed the "Services" field. The per-store services list has been dropped everywhere — the
_asl_servicesmeta box field and registration, the RESTservicesquery param and theservicesvalues in the/storesand/filtersresponses, the "Store Services" column on the single-store detail page (now a two-column Details / Hours panel), and the related CSS. Any previously stored_asl_servicespost meta is simply ignored. - CSV import:
servicescolumn replaced withdirection_url. The importer now maps the last column to the store's external directions URL (_asl_directions_url) instead of services, sanitized as a URL. The downloadable example template andstores-example.csvwere updated to match. Wrap any URL containing a comma (e.g.?q=lat,lng) in quotes so it stays a single CSV field. - Faster
/storesREST endpoint. Eliminated a per-store N+1 query problem in the store list: brand/country now read from the object-term cache (get_the_terms()instead ofwp_get_post_terms()), featured-image attachments are bulk-primed in one batch instead of loaded individually per store, and the query explicitly primes the post-meta/term caches for the whole page. On a 500-store page this cuts roughly ~1,500 extra queries down to a handful. - Faster
/filtersREST endpoint. The brand/country/city filter payload is now cached in a transient (asl_filters_payload) and only rebuilt when a store actually changes, so the endpoint stops re-running its term/meta queries on every locator page load. The cache is busted on store save, delete, and status change (publish/trash/untrash), plus any brand/country term create/edit/delete. Also removed a per-country N+1 by bulk-priming the custom flag-image attachments, and split the method into focused helpers.