Add regions-list element: tab 1 of the Regions Dashboard plugin

Scrollable list of WAGF regions and their countries, refactored from the
DX-life /regions page into a standalone custom element. Clicking a region
or country emits a selection event instead of navigating, so a host shell
can drive a second tab from it.
This commit is contained in:
doxa-maps-88655c70 2026-09-23 14:24:04 +00:00
parent c27d16cd4b
commit f7cee9640e
5 changed files with 619 additions and 0 deletions

View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>doxa-maps · regions-dashboard staging</title>
<link rel="icon" href="data:,">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: #0b0f14; color: #e5e7eb; min-height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 32px 20px; }
h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; }
.sub { color: #9ca3af; font-size: 13px; margin: 6px 0 24px; }
.slot { border: 1px solid #1f2937; border-radius: 12px; overflow: hidden; }
.slot .label { font: 12px/1 ui-monospace, monospace; color: #9ca3af; padding: 8px 12px;
background: #111827; border-bottom: 1px solid #1f2937; }
.frame { height: 640px; }
regions-dashboard { display: block; width: 100%; height: 100%; }
</style>
</head>
<body>
<div class="wrap">
<h1>regions-dashboard · staging</h1>
<p class="sub">Tab 1, "Regions List" — an independently scrollable list of WAGF regions and
their countries. Clicking a region or country emits a selection event
(<code>region-selected</code> / <code>country-selected</code>) rather than navigating.</p>
<div class="slot">
<div class="label">profile-config.profile = "regions-list"</div>
<div class="frame">
<regions-dashboard profile-config='{"profile":"regions-list"}'></regions-dashboard>
</div>
</div>
</div>
<script>
window.addEventListener('region-selected', (e) => console.log('region-selected', e.detail))
window.addEventListener('country-selected', (e) => console.log('country-selected', e.detail))
</script>
<!-- Vite multi-page entry. In dev, served transformed; in build, Vite emits
../../../public/doxa-maps/regions-dashboard/regions-dashboard.js and rewrites this tag. -->
<script type="module" src="./index.js"></script>
</body>
</html>

View file

@ -0,0 +1,55 @@
/**
* plugin-designs/regions-dashboard/index.js
*
* Bundle entry — registers the regions-dashboard web component.
*
* This bundle currently ships ONE flat profile owned by this slice — regions-list.vue
* ("Regions List", tab 1 of the Driver's 2-tab Regions Dashboard). A teammate owns tab 2
* ("Region Dashboards") and the shell that hosts both tabs; their profile(s) land as
* additional flat .vue files in this same folder and are picked up automatically by the
* glob below — no change needed here when that lands.
*
* Usage from a host page:
* <script src="/doxa-maps/regions-dashboard/regions-dashboard.js"></script>
* <regions-dashboard profile-config='{"profile":"regions-list"}'></regions-dashboard>
*
* `profile` must match a flat .vue filename in this folder (without the .vue).
* The profile-config attribute is parsed by ProfileLoader (in @map).
*/
import { defineCustomElement } from 'vue'
import { createPinia } from 'pinia'
import ProfileLoader from '@map/ProfileLoader.vue'
import { createAppI18n } from '@map/i18n/index.js'
// ─── Bundle-private profile registry ─────────────────────────────────────────
// import.meta.glob is evaluated by Vite at build time relative to THIS file, so it
// captures only this bundle's profiles — flat .vue files sitting directly here, no
// profiles/ subfolder. Handed to ProfileLoader via app.provide('profileModules', ...).
const profileModules = import.meta.glob('./*.vue')
/**
* Build the defineCustomElement-wrapped class for this bundle.
* Each instance gets its own Pinia + i18n — isolation that matters if multiple
* <regions-dashboard> embeds ever share one page.
*/
function buildRegionsDashboardElement() {
return defineCustomElement(ProfileLoader, {
configureApp(app) {
app.use(createPinia())
app.use(createAppI18n())
app.provide('profileModules', profileModules)
}
})
}
const RegionsDashboardElement = buildRegionsDashboardElement()
// Guard prevents "already registered" errors when this IIFE is loaded twice.
if (typeof customElements !== 'undefined') {
if (!customElements.get('regions-dashboard')) {
customElements.define('regions-dashboard', RegionsDashboardElement)
}
}
export default RegionsDashboardElement

View file

@ -0,0 +1,279 @@
<script setup>
/**
* regions-list.vue — Tab 1 ("Regions List") of the regions-dashboard bundle.
*
* @description A refactor of DX-life's /regions index page (app/pages/regions/index.vue) into
* an owned, self-contained doxa-maps custom element: one card per WAGF region, each holding
* its countries as a responsive list. Independently scrollable (the shell hosts this beside a
* sibling "Region Dashboards" tab and swaps between them). Clicking a region heading or a
* country row does NOT navigate — it EMITS a selection so the shell can switch to tab 2.
*
* SOURCE (READ-ONLY, copy-only — root-dx-life/doxa-life is standing law, zero edits):
* root-dx-life/doxa-life/app/pages/regions/index.vue — the layout being ported
* root-dx-life/doxa-life/server/api/regions.get.ts — the /api/regions contract
* root-dx-life/doxa-life/config/countries-meta.ts — summarizeRegions() + fields
*
* WHY THIS FETCHES AND GROUPS ITSELF rather than calling /api/regions: that endpoint is a Nitro
* server route inside the DX-life Nuxt app and is not reachable from a standalone embed. The
* SAME live people-groups endpoint people-groups-grid.vue already uses (pray.doxa.life) carries
* the same wagf_region / country_code fields the Nuxt route reads, so grouping happens here,
* client-side, using the identical fields (PEOPLE_GROUP_LIST_FIELDS) and the identical rollup
* shape (one row per country, rolled into one card per region) as countries-meta.ts's
* summarizeRegions(). This is a COPY of that grouping logic, not an import — DX-life's file is
* a .ts module with a Nuxt-only COUNTRIES import that cannot resolve outside that app.
*
* EVENT CONTRACT (for the shell that nests this element — see RESULT-regions-list-element.md):
* region-selected { slug, value, label, countryCount, count } — region heading clicked
* country-selected { slug, code, name, region: {slug,value,label}, count } — a country row clicked
* Both are emitted as a Vue `emit` AND as a native bubbling+composed CustomEvent on the host
* element (same double-emit pattern as people-groups-grid.vue's `groups-filtered`), so a plain
* host page hears it standalone and a Vue-nesting shell hears it as a prop-less DOM event.
*/
import { ref, computed, onMounted, getCurrentInstance } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
const props = defineProps({
profileConfig: { type: String, default: '' },
// Same standalone-dep pattern as people-groups-grid.vue: the people-groups API host.
prayBaseUrl: { type: String, default: '' },
languageCode: { type: String, default: '' },
})
const emit = defineEmits(['region-selected', 'country-selected'])
// profile-config JSON can override prayBaseUrl / languageCode (single string attr).
const cfg = computed(() => {
if (!props.profileConfig) return {}
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
})
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
const languageCode = computed(() => props.languageCode || cfg.value.languageCode || '')
// ── state ───────────────────────────────────────────────────────────────────
const regions = ref([])
const loading = ref(true)
const loadFailed = ref(false)
// ── same field list the source page's PEOPLE_GROUP_LIST_FIELDS asks for ─────
const FIELDS = 'country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
function toNumber(v) {
const n = typeof v === 'string' ? parseFloat(v) : (v ?? 0)
return Number.isNaN(n) ? 0 : n
}
function slugify(name) {
return String(name || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '') // strip combining diacritics
.toLowerCase()
.replace(/['\u2019.]/g, '') // drop apostrophes / periods
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
const FULL_PRAYER_COVERAGE_COUNT = 100
function emptyStats() {
return { count: 0, population: 0, adopted: 0, engaged: 0, withPrayer: 0, withFullPrayer: 0, peopleCommitted: 0 }
}
function addToStats(stats, p) {
const committed = toNumber(p.people_committed)
stats.count++
stats.population += toNumber(p.population)
if ((p.adopted_by_churches ?? 0) > 0) stats.adopted++
if (p.engagement_status?.value === 'engaged') stats.engaged++
if (committed > 0) stats.withPrayer++
if (committed >= FULL_PRAYER_COVERAGE_COUNT) stats.withFullPrayer++
stats.peopleCommitted += committed
}
// Group people-group records into one summary per country, then roll countries up into one
// summary per WAGF region — same two-pass shape as countries-meta.ts's summarizeCountries() +
// summarizeRegions(), minus the map bounds/zoom (this element has no map).
function summarizeRegions(posts) {
const countryGroups = new Map()
for (const p of posts) {
const code = p.country_code?.value
if (!code) continue
let g = countryGroups.get(code)
if (!g) {
g = { name: p.country_code.label, region: { value: '', label: '' }, stats: emptyStats() }
countryGroups.set(code, g)
}
if (!g.region.value && p.wagf_region?.value) g.region = p.wagf_region
addToStats(g.stats, p)
}
const countries = []
for (const [code, g] of countryGroups) {
countries.push({ slug: slugify(g.name) || slugify(code) || code.toLowerCase(), code, name: g.name, region: g.region, ...g.stats })
}
countries.sort((a, b) => a.name.localeCompare(b.name))
const regionSlugByCountry = new Map(countries.map((c) => [c.code, slugify(c.region.value) || 'other']))
const regionGroups = new Map()
for (const c of countries) {
const slug = regionSlugByCountry.get(c.code)
let g = regionGroups.get(slug)
if (!g) {
g = { value: c.region.value, label: c.region.label, countries: [], stats: emptyStats() }
regionGroups.set(slug, g)
}
g.countries.push(c)
}
for (const p of posts) {
const code = p.country_code?.value
if (!code) continue
const g = regionGroups.get(regionSlugByCountry.get(code))
if (g) addToStats(g.stats, p)
}
const out = []
for (const [slug, g] of regionGroups) {
out.push({ slug, value: g.value, label: g.label, countryCount: g.countries.length, countries: g.countries, ...g.stats })
}
out.sort((a, b) => (a.label || 'Other').localeCompare(b.label || 'Other'))
return out
}
function formatNumber(n) {
return Number(n || 0).toLocaleString('en-US')
}
function summaryLine(region) {
return `${formatNumber(region.count)} people groups in ${formatNumber(region.countryCount)} countries`
}
// ── emit (Vue + native composed CustomEvent, same pattern as people-groups-grid) ────────────
const inst = getCurrentInstance()
function hostEl() {
const root = inst?.vnode?.el || inst?.proxy?.$el
try { const rn = root && root.getRootNode && root.getRootNode(); if (rn && rn.host) return rn.host } catch (_) {}
return root
}
function dispatch(name, detail) {
emit(name, detail)
const el = hostEl()
if (el && typeof el.dispatchEvent === 'function') {
el.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }))
}
}
function selectRegion(region) {
dispatch('region-selected', {
slug: region.slug, value: region.value, label: region.label || 'Other',
countryCount: region.countryCount, count: region.count,
})
}
function selectCountry(country, region) {
dispatch('country-selected', {
slug: country.slug, code: country.code, name: country.name,
region: { slug: region.slug, value: region.value, label: region.label || 'Other' },
count: country.count,
})
}
// ── data fetch (same live endpoint + fields as people-groups-grid.vue) ──────────────────────
async function loadRegions() {
const url = `${prayBaseUrl.value}/api/people-groups/list?fields=${FIELDS}&lang=${languageCode.value}`
loading.value = true
loadFailed.value = false
try {
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
const posts = Array.isArray(data.posts) ? data.posts : []
regions.value = summarizeRegions(posts)
} catch (error) {
console.error('[regions-list] fetch error:', error)
loadFailed.value = true
regions.value = []
} finally {
loading.value = false
}
}
onMounted(loadRegions)
useShadowStyles(
':host{ display:block; width:100%; height:100%; min-height:320px; }' +
'.rl-root{ position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;' +
' --dx-surface:#f3f3f1; --dx-elevated:#fff; --dx-text:#3b463d; --dx-muted:#6d766e;' +
' --dx-line:#d1d4cd; --dx-accent:#92b195; --dx-accent-weak:rgba(146,177,149,.18);' +
' --dx-accent-strong:#657967;' +
' background:var(--dx-surface); color:var(--dx-text);' +
' font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif; }' +
'.rl-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; -webkit-overflow-scrolling:touch;' +
' padding:16px; display:flex; flex-direction:column; gap:16px; }' +
'.rl-status{ padding:28px; text-align:center; color:var(--dx-muted); font-size:0.9rem; }' +
'.rl-status.rl-failed{ font-weight:600; }' +
'.region-card{ flex:0 0 auto; border-radius:12px; overflow:hidden; background:var(--dx-elevated);' +
' border:1px solid var(--dx-line); }' +
'.region-card__header{ display:flex; flex-direction:column; gap:4px; padding:14px 18px;' +
' background:var(--dx-accent-strong); color:#fff; }' +
'.region-card__link{ appearance:none; background:none; border:0; padding:0; margin:0; text-align:left;' +
' cursor:pointer; color:inherit; font:inherit; font-size:1.1rem; font-weight:700;' +
' display:flex; align-items:center; gap:6px; }' +
'.region-card__link:hover{ text-decoration:underline; }' +
'.region-card__link:focus-visible{ outline:2px solid #fff; outline-offset:2px; }' +
'.region-card__chevron{ font-size:0.85em; opacity:0.85; }' +
'.region-card__summary{ font-size:0.85rem; opacity:0.85; margin:0; }' +
'.region-card__body{ padding:14px 18px 16px; }' +
'.countries-grid{ list-style:none; margin:0; padding:0; display:grid;' +
' grid-template-columns:repeat(auto-fill, minmax(200px, 1fr)); gap:8px 20px; }' +
'.country-link{ appearance:none; background:none; border:0; padding:2px 0; margin:0; text-align:left;' +
' cursor:pointer; color:var(--dx-text); font:inherit; font-size:0.9rem; width:100%; }' +
'.country-link:hover{ text-decoration:underline; text-decoration-color:var(--dx-accent); }' +
'.country-link:focus-visible{ outline:2px solid var(--dx-accent); outline-offset:2px; border-radius:4px; }' +
'.country-link__count{ color:var(--dx-muted); }',
'regions-list'
)
</script>
<template>
<div class="rl-root">
<div class="rl-scroll">
<div v-if="loading" class="rl-status">Loading regions…</div>
<div v-else-if="loadFailed" class="rl-status rl-failed" role="status">
Region data could not be loaded. This is a connection problem, not a filter.
</div>
<div v-else-if="!regions.length" class="rl-status">No regions found.</div>
<template v-else>
<div
v-for="region in regions"
:key="region.slug"
class="region-card"
>
<div class="region-card__header">
<button
type="button"
class="region-card__link"
@click="selectRegion(region)"
>
{{ region.label || 'Other' }}
<span class="region-card__chevron" aria-hidden="true">&rsaquo;</span>
</button>
<p class="region-card__summary">{{ summaryLine(region) }}</p>
</div>
<div class="region-card__body">
<ul class="countries-grid">
<li v-for="c in region.countries" :key="c.slug">
<button
type="button"
class="country-link"
@click="selectCountry(c, region)"
>
{{ c.name }} <span class="country-link__count">({{ c.count }})</span>
</button>
</li>
</ul>
</div>
</div>
</template>
</div>
</div>
</template>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>regions-list · regions-dashboard</title>
<link rel="icon" href="data:,">
<!-- NON-MAP leaf (app-shell / composite / grid / card / chart-table): NO map peer.
This element draws no map tiles, so it needs no Mapbox/MapLibre engine and MUST NOT
gate on a token. Any nested MAP (in a composite/shell) renders in its own iframe leaf,
which loads its own map peer + token. A peer here would be dead weight + imply a gate. -->
<style>
/* BARE full-page map — no chrome. The custom element IS the page, so the
browser URL shares the map itself. */
html, body { height: 100%; margin: 0; background: #0a0e15; }
#slot, regions-dashboard { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
#status { position: fixed; inset: 0; z-index: 2; display: grid; place-items: center;
padding: 24px; text-align: center; color: #9aa7b8;
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
#status.err { color: #f87171; }
</style>
</head>
<body>
<div id="slot"></div>
<div id="status">loading…</div>
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <regions-dashboard> custom element. -->
<script src="../regions-dashboard.js" integrity="sha384-kHO7akUTZkhcmGUHvirbAvMdkAzR2x5/kY0q8fU1RirNgum32XhESod5SxhWvCFK" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
// 1. prop — a token hardcoded into this page's profile-config (config.tk)
// 2. ?tk= URL param — token carried in the shared link
// 3. baked token — opt-in literal token inlined at build time (build-config.token)
// A token from rungs 1-3 is used DIRECTLY (no network). Otherwise a token
// *endpoint* is resolved and fetched:
// 4. window.MAP_TOKEN_URL override 5. configured TOKEN_SRC URL 6. same-origin /api/maps/token
// TOKEN_SRC is treated as DATA, never eval'd: the literal "window.MAP_TOKEN_URL"
// means "read that global"; anything else is used as a URL — so both a global
// name and a plain URL are safe (no SyntaxError footgun).
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"profile":"regions-list","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-list","assetsBase":"../../assets"};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
var TOKEN_FREE = true;
var TOKEN_SRC = "window.MAP_TOKEN_URL";
var BAKED_TOKEN = "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ";
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
function fail(msg) { if (status) { status.className = 'err'; status.textContent = '⚠ ' + msg; } }
// No token anywhere on the ladder → ask ONCE, store locally, remount. No dead ends.
function askToken() {
if (!status) return;
status.className = 'err';
status.innerHTML = 'map needs a Mapbox token — ' +
'<input id="tkIn" type="password" placeholder="paste pk.… (stays in this browser)" ' +
'style="font:12px ui-monospace,monospace;padding:6px 8px;border-radius:6px;border:1px solid #223047;background:#0e1623;color:#eef2f7;width:min(320px,70vw)"> ' +
'<button id="tkGo" style="font:700 11px ui-monospace,monospace;padding:7px 12px;border-radius:999px;border:0;cursor:pointer;background:linear-gradient(90deg,#46d4ff,#b9a6f5);color:#0a0e15">use it</button>';
var go = document.getElementById('tkGo');
if (go) go.addEventListener('click', function () {
var v = (document.getElementById('tkIn').value || '').trim();
if (!v) return;
try { localStorage.setItem('MAPS_STAGING_TK', v); } catch (e) {}
mount(v);
});
}
/* ★ RELEASE THE LOAD BACKDROP WHEN THE ELEMENT MOUNTS (BUG-GOSPEL-MAP-BLACK-RECTANGLE).
html/body carry background:#0a0e15 so the light #status text (#9aa7b8) is readable while the
bundle loads. That backdrop is LOAD-STATE CHROME, but it outlived its moment: any gap the
mounted element does not cover shows it through, because every layer above body is
transparent. On gospel-resources/resources-map that produced a 352x126 DARK RECTANGLE between
the legend card and the detail panel — MEASURED rgb(8,12,18), i.e. the page background, NOT an
unpainted canvas as first supposed. Clearing it here (the one place we already know the
element is up) is a one-shot style write — no resize, no repaint loop. The Driver is
PHOTOSENSITIVE and a 60fps resize-strobe was already fixed this session; do NOT turn this into
a per-frame or per-event handler. */
function done() {
if (status && status.parentNode) { status.parentNode.removeChild(status); }
document.documentElement.style.background = '#f3f3f1';
document.body.style.background = '#f3f3f1';
}
function mount(token) {
if (!token && !TOKEN_FREE) { return askToken(); }
try {
var cfg = token ? Object.assign({}, BASE_CONFIG, { tk: token }) : BASE_CONFIG;
var el = document.createElement("regions-dashboard");
el.setAttribute('profile-config', JSON.stringify(cfg));
slot.innerHTML = '';
slot.appendChild(el);
done();
} catch (e) { fail(e && e.message ? e.message : String(e)); }
}
function qp(name) {
try { return new URLSearchParams(location.search).get(name) || ''; }
catch (e) { return ''; }
}
function extractToken(data) {
if (typeof data === 'string') return data.trim();
if (data && typeof data === 'object') return data.token || data.tk || data.accessToken || '';
return '';
}
// Rungs 1-3b: an actual token → mount directly, no fetch.
// 2b = localStorage: the paste-once store shared with the staging pages
// (MAPS_STAGING_TK) — paste a token ONCE anywhere in the tree, every page uses it.
var stored = '';
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
if (TOKEN_FREE) { return mount(''); }
if (directToken) { return mount(directToken); }
// Rungs 4-6: resolve the token ENDPOINT candidates and try them IN ORDER — the first
// one that answers with a token wins. Order: window.MAP_TOKEN_URL override → the
// configured TOKEN_SRC (an ABSOLUTE url, so a tree hosted on a CDN away from the API
// still finds it) → same-origin /api/maps/token (dev, or the site hosting both).
// A single hard-coded pick used to kill the page when its one url 404'd or was
// blocked by CORS; a shared link must work wherever the tree is hosted.
var candidates = [];
function addCandidate(u) { if (u && candidates.indexOf(u) === -1) candidates.push(u); }
addCandidate(window.MAP_TOKEN_URL);
addCandidate(TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL' ? TOKEN_SRC : '');
addCandidate(DEFAULT_TOKEN_URL);
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
askToken();
if (status) {
var d = document.createElement('div');
d.textContent = '⚠ Token fetch failed from ' + candidates.join(', ') + ' — set window.MAP_TOKEN_URL or pass ?tk=. (' + errors.join(' | ') + ')';
status.insertBefore(d, status.firstChild);
}
return;
}
var url = candidates[i];
return fetch(url)
.then(function (r) {
if (!r.ok) throw new Error(url + ' → HTTP ' + r.status);
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') !== -1 ? r.json() : r.text();
})
.then(function (data) {
var tk = extractToken(data);
if (!tk) throw new Error(url + ' → no token in response');
mount(tk);
})
.catch(function (e) {
errors.push(e && e.message ? e.message : String(e));
return fetchToken(i + 1, errors);
});
}
fetchToken(0, []);
})();
</script>
</body>
</html>