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:
parent
c27d16cd4b
commit
f7cee9640e
5 changed files with 619 additions and 0 deletions
46
plugin-designs/regions-dashboard/index.html
Normal file
46
plugin-designs/regions-dashboard/index.html
Normal 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>
|
||||
55
plugin-designs/regions-dashboard/index.js
Normal file
55
plugin-designs/regions-dashboard/index.js
Normal 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
|
||||
279
plugin-designs/regions-dashboard/regions-list.vue
Normal file
279
plugin-designs/regions-dashboard/regions-list.vue
Normal 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">›</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>
|
||||
71
public/doxa-maps/regions-dashboard/regions-dashboard.js
generated
Normal file
71
public/doxa-maps/regions-dashboard/regions-dashboard.js
generated
Normal file
File diff suppressed because one or more lines are too long
168
public/doxa-maps/regions-dashboard/regions-list/index.html
generated
Normal file
168
public/doxa-maps/regions-dashboard/regions-list/index.html
generated
Normal 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>
|
||||
Loading…
Reference in a new issue