Store Locator (ProMap) · Developer guide
Build a custom store detail page (deep-link), reusing the Store Locator map
Show a custom detail layout for a single store right on the Store Locator page, opened via a link like ?store=<id>. It reuses the data & map the app already renders — no separate API key, no server.
In this guide
Overview & limitations to know
This block reads store data from the global JS object SCASLSetting that the Store Locator app injects, picks the store matching ?store=<id> in the URL, and renders a custom detail layout — while focusing the app's map on that store.
Limitations — read first
No SEO. Content is JS-rendered behind a query param, so Google canonicals back to the base page and will not index one page per store. If you need real per-store SEO URLs, that's a different architecture (Shopify Metaobjects).
Query param, not a path. Shopify Pages don't allow sub-paths, so
/pages/store-locator/store-namereturns 404. Use?store=123only.Only one opening-hours set exists in the app data. A second block (e.g. "Trade Centre") can't come from the data — hardcode it in the theme (see Step 4).
SCASLSettingis the app's internal structure, not a guaranteed public API — it can change between releases. This block is owned & maintained by the merchant.
How it works (keep the app's map)
The detail page is a two-column layout: left = store info, right = the actual Store Locator map (full markers + info window). Mechanism:
Focus the map on the store: each location-list item has a
data-markeridattribute. The block finds the item matching the store and callstrigger("click")→ the app pans the map and opens that marker's info window.Reflow the layout: hide the app's search form, location list and title, keep the map container intact, move it into the right column, and render the detail panel on the left.
The map uses the app's existing Google Maps key → no separate key needed.
Verified liveThis was verified working on ProMap's own demo store (rpstorelocator.app): the map renders fully and the info window auto-opens on the selected store. See the screenshot under Expected result.
1 Get a store id to test
Open the Store Locator page in a browser, open the Console (F12), and run this to list each store's id and name:
JSON.parse(SCASLSetting.locationsRaw).map(s => ({ id: s.id, name: s.name }))Note one id for testing (e.g. 18683934).
2 Add the block to your theme
Pick one of the two methods below. Keep the existing Store Locator app block on the page — the custom block reuses its data & map.
Method A — Custom Liquid section (quick, no theme file edits)
Admin → Online Store → Themes → Customize → open the
store-locatorpage template.Add section → Custom Liquid, placed right below the Store Locator block.
Paste the code from Step 3 into the Custom Liquid box → Save.
Theme Customizer → Add section → Custom Liquid, on the Store Locator page template.
Method B — Dedicated snippet (cleaner, easier to maintain — recommended)
Admin → Online Store → Themes → Edit code.
Snippets → Add a new snippet, name it
rp-store-detail.liquid, and paste the Step 3 code.In the page's section/template, add this line after the app block renders:
{% render 'rp-store-detail' %}Edit code → Snippets → Add a new snippet for the detail block.
3 The code block (verified working)
Adjust the CSS/HTML inside left.innerHTML to match your mockup. The map-focus and reflow logic is verified — leave it as is.
snippets/rp-store-detail.liquid (or a Custom Liquid section)
<div id="rp-store-detail-host"></div> <script> (function () { var id = new URLSearchParams(location.search).get("store"); if (!id) return; // không có param → để Store Locator chạy bình thường function getStores(cb) { if (typeof SCASLSetting !== "undefined" && SCASLSetting.locationsRaw) { try { return cb(JSON.parse(SCASLSetting.locationsRaw)); } catch (e) {} } if (typeof SCASLSetting !== "undefined" && SCASLSetting.locationsUrl) { // dataset lớn → CDN return fetch(SCASLSetting.locationsUrl).then(function (r) { return r.json(); }).then(cb).catch(function () {}); } return setTimeout(function () { getStores(cb); }, 200); } function whenReady(cb, n) { n = n || 0; var mapOk = !!document.querySelector(".gm-style"); var listOk = !!document.querySelector(".bh-sl-loc-list li[data-markerid]"); if (mapOk && listOk) return cb(); if (n > 60) return cb(); setTimeout(function () { whenReady(cb, n + 1); }, 200); } getStores(function (stores) { var idx = (stores || []).findIndex(function (x) { return String(x.id) === id; }); if (idx < 0) return; var s = stores[idx]; var pm = document.getElementById("scasl-app-container"); if (pm) pm.scrollIntoView({ block: "center" }); // kích hoạt lazy-init map whenReady(function () { // 1) Focus map: click item list khớp store (pan map + mở info window) var items = [].slice.call(document.querySelectorAll(".bh-sl-loc-list li[data-markerid]")); var li = items.filter(function (el) { return (el.innerText || "").toLowerCase().indexOf((s.name || "").toLowerCase()) >= 0; })[0] || document.querySelector('.bh-sl-loc-list li[data-markerid="' + idx + '"]'); if (li) { try { (window.jQuerySCASL ? jQuerySCASL(li).trigger("click") : li.click()); } catch (e) {} } // 2) Ẩn UI mặc định, GIỮ map [".bh-sl-form-container", ".bh-sl-loc-list", "#scasl-tabs", ".bh-sl-title"].forEach(function (q) { var el = document.querySelector("#scasl-app-container " + q); if (el) el.style.display = "none"; }); // 3) Layout 2 cột: trái = chi tiết, phải = container app (map còn nguyên bên trong) var wrap = document.createElement("div"); wrap.style.cssText = "display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:32px;max-width:1120px;margin:24px auto;align-items:start"; pm.parentNode.insertBefore(wrap, pm); var left = document.createElement("div"); left.style.cssText = "min-width:0;line-height:1.6"; wrap.appendChild(left); wrap.appendChild(pm); // đưa cả container (chứa map) sang cột phải pm.style.cssText = "min-width:0;width:100%;margin:0"; var mapNode = document.querySelector("#bh-sl-map-container, .bh-sl-map-container"); if (mapNode) mapNode.style.cssText = "height:520px;width:100%"; // services badges var badges = (s.tags || []).map(function (t) { return '<span style="display:inline-block;padding:4px 12px;margin:3px 4px 0 0;border:1px solid #c00;border-radius:16px;font-size:13px;color:#c00">' + t.tag + "</span>"; }).join(""); // giờ Retail Showroom (operating_hours là chuỗi JSON, 1 bộ) var hrs = ""; try { var oh = JSON.parse(s.operating_hours || "{}"); hrs = Object.keys(oh).map(function (k) { var d = oh[k]; var slot = (d.slot && d.slot[0]) ? (d.slot[0].from + "–" + d.slot[0].to) : "Closed"; return "<div><b>" + d.name + ":</b> " + (d.status ? slot : "Closed") + "</div>"; }).join(""); } catch (e) {} // giờ Trade Centre — KHÔNG có trong data app → hardcode theo id (xem Bước 4) var tradeHours = { // "18683934": "Mon–Fri: 06:00–17:00<br>Sat: 06:00–14:00<br>Sun: CLOSED", }; var trade = tradeHours[id] || ""; left.innerHTML = '<a href="/pages/store-locator" style="color:#c00;text-decoration:none">← Back to Stores</a>' + "<h1>" + (s.name || "") + "</h1>" + "<p>" + (s.description || "") + "</p>" + '<div style="margin:14px 0">' + '<a href="#" style="background:#111;color:#fff;padding:12px 18px;text-decoration:none;margin-right:10px;display:inline-block">SELECT AT MY STORE</a>' + '<a href="mailto:' + (s.email || "") + '" style="border:1px solid #111;color:#111;padding:12px 18px;text-decoration:none;display:inline-block">SEND AN ENQUIRY</a>' + "</div>" + "<p>" + [s.address, s.city, s.state, s.postal].filter(Boolean).join(", ") + "</p>" + "<p>" + (s.shareable_link ? '<a href="' + s.shareable_link + '" target="_blank" style="color:#c00">View on Map</a>' : "") + (s.phone ? ' | Phone <a href="tel:' + s.phone + '">' + s.phone + "</a>" : "") + "</p>" + '<div style="display:flex;gap:40px;margin:14px 0">' + "<div><b>Retail Showroom</b>" + hrs + "</div>" + (trade ? "<div><b>Trade Centre</b><div>" + trade + "</div></div>" : "") + "</div>" + (badges ? "<p><b>Services available:</b></p><div>" + badges + "</div>" : "") + (s.store_image ? '<img src="' + s.store_image + '" style="max-width:100%;margin-top:16px">' : ""); // map vừa đổi layout → nhắc app tính lại kích thước if (window.google && google.maps && google.maps.event) google.maps.event.trigger(window, "resize"); }); }); })(); </script>Why wait for .gm-style & scroll into view?
The app lazy-initialises the map when the widget scrolls into the viewport (IntersectionObserver). The block must scrollIntoView to trigger it, then wait for .gm-style (map rendered) before reflowing — otherwise the map stays blank.
4 Second hours block ("Trade Centre")
The app returns only one operating_hours set per store (= Retail Showroom). The "Trade Centre" column in the mockup can't come from the data, so you have two options:
(a) Drop the Trade Centre column and show a single hours block.
(b) Hardcode Trade Centre hours via an id lookup (the
tradeHoursvariable is already in the code) — just fill it in:
var tradeHours = { "18683934": "Mon–Fri: 06:00–17:00<br>Sat: 06:00–14:00<br>Sun: CLOSED", // "<store id>": "<Trade Centre hours for that store>" };Confirm with the merchant Decide between (a) and (b) before finalising the layout — it changes the left column's structure.
Expected result
Two-column layout running live on the demo store: left = store detail (name, description, two CTAs, address, View on Map, Opening Hours, service badges, image); right = the Store Locator map with the info window auto-opened on the selected store.
Important notes before go-live
Always test on the merchant's actual production store
Most stores handle ?store= fine (verified on ProMap's demo store plus a merchant store). However, some app builds/configs fail: when the URL has a query/hash, the Google Maps script is not injected and the widget hangs on the spinner. Test directly on the target store before committing.
Fallback if the target store hangs with a param
Strip the param before the app initialises: add an inline script in the theme <head> (running before the app embed) that reads ?store= → saves it to sessionStorage → calls history.replaceState to a clean URL; the detail block then reads the id from sessionStorage. (Stripping after load does not work — the app already skipped loading Maps during page load.)
Matching the list item
The code matches the list item by store name (then falls back to index). Reason: if the app has autogeocode/distance sorting enabled, the list order (data-markerid) may differ from the data order, so matching by name is safer.
FAQ
Do I need a separate Google Maps API key?
Do I need a separate Google Maps API key?
No. The block reuses window.google.maps already loaded by the app (the same Store Locator key). The map renders on any domain where the app's own map already works.
What about large datasets (thousands of stores)?
What about large datasets (thousands of stores)?
In that case the app serves data via SCASLSetting.locationsUrl (a JSON file on a CDN) instead of inline locationsRaw. The code already includes a fetch(locationsUrl) fallback branch.
Can the?store=link be shared / bookmarked?
Can the?store=link be shared / bookmarked?
Yes — it's a normal deep-link. Just note it isn't an SEO URL (Google won't index it as a separate page).
What if the app ships an update that changes the data structure?
What if the app ships an update that changes the data structure?
This block relies on internal structures (SCASLSetting, the .bh-sl-* classes), so it may need adjusting. The block is maintained by the merchant's team.
Store Locator (ProMap) — Developer guide
