Regions: the selected page is now an exact clone; the chooser is the one addition

The rule: a SELECTED region or country must look identical to the DX-life page
you land on when you click it, and the ONLY thing we add is the state their
site has no route for. Three things followed from that.

1. THE BAR CHART IS GONE. It was ours, not theirs. RegionDetail.vue is four
   stat cards, the map, three goal cards and the country table — nothing else.
   The goal cards and the country table now belong to a SELECTED region only;
   they were showing on the no-selection view as totals, which made the chooser
   look like a region page wearing the wrong numbers.

2. THE COUNTRY LIST IS THEIR UupgsList, ported. CountryDetail.vue:63-69 nests
   <UupgsList :lock-country-code hide-see-all-link />, so cloning that page
   means cloning that component — a DataTable of ours was a stand-in, not a
   clone. src/UupgsList.vue carries their markup and their class names so the
   CSS is theirs rather than a lookalike: .filters / .filters__toggle /
   .filters__panel / .filters__active / .filter-chip, the .search-box with its
   1.5em text-indent, the .grid[data-width-lg] 400px track, and the
   .uupg__card grid-template-areas with its 576px reflow. Their endpoint and
   field list too, and .uupg__content stays display:none as theirs is.
   Scoped to what the page can actually reach: CountryDetail passes none of
   useSelectCard / useHighlightedUUPGs / randomizeList / dontShowListOnLoad, so
   the select-card variant, the /highlighted endpoint and the see-all link are
   branches no consumer runs.

3. THE CHOOSER is the one deliberate difference, and it is now the only one.
   Opening tab 2 first is a state DX-life cannot reach (/regions is the list,
   /regions/<slug> always has a subject, [slug].vue 404s otherwise), so it gets
   a generic map of everything plus three ways in: a region dropdown, a country
   dropdown (both the shared FilterDropdown), and the all-regions table whose
   rows were already clickable. Choosing a subject replaces it with the clone.

Also removed a visible difference nothing had flagged: the shared PinHint's
"Tap a coloured dot" coach-mark was floating over the cloned map, and their
page has no such overlay. pinHint:false now, and the fix had to go in the
COMPOSITE — a child profile's meta only configures its own leaf page, while a
nested pane is handed the config regions-mfe.vue builds, and PinHint reads it
off the injected profileConfig. Same cap-4 inheritance gap assetsBase hit, so
pinHint joined INHERITED_PAGE_KEYS as well.

Verified against the LIVE page, not the stylesheet: doxa.life/regions/cameroon
at 1440x1000, filter panel open on both sides, 28 of 28 computed properties
IDENTICAL, 0 differ. That diff found the one real error — their .button sets no
font-size so it inherits 19.5312px, and I had hardcoded 15.625px. Two rows it
could NOT measure are reported as unmeasured rather than counted either way:
.filter-chip is absent on both sides with no filter selected.

Every suite still green: the map draws 934 pins / 51 outline for a region and
61 / 5 for a country, the country page renders 8 UupgsList cards with their
filter toggle and search box, no page has a coach-mark, and all six viewports
still match their container formula.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 00:59:10 +00:00
parent 3b7aa1fed4
commit 06c6455fdc
12 changed files with 415 additions and 97 deletions

View file

@ -0,0 +1,8 @@
{
"type": "single",
"label": "Regions — region + country dashboard",
"config": {
"pinHint": false
},
"note": "pinHint:false because this page is an EXACT CLONE of DX-life's /regions/<slug> (RegionDetail.vue for a region, CountryDetail.vue for a country) and their page carries no coach-mark over the map. The shared PinHint's 'Tap a coloured dot' overlay is our own chrome; leaving it on would be a visible difference from the page being cloned. Opt-out is the documented channel (library/components/hints/PinHint.vue). Siblings doing the same for their own reason: cities-internet/connectivity-map.meta.json, gospel-resources/resources-map.meta.json."
}

View file

@ -24,8 +24,8 @@
*/
import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import BarChart from '@map/components/charts/BarChart.vue'
import DataTable from '@map/components/tables/DataTable.vue'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
import UupgsList from './src/UupgsList.vue'
import RegionCountryMap from './src/RegionCountryMap.vue'
const props = defineProps({
@ -217,28 +217,10 @@ const tiles = computed(() => {
]
})
// The comparison bars: a region's countries against each other, or all regions against each
// other when nothing is selected. A country selection keeps its region's bars so the country
// stays legible IN CONTEXT rather than as one lone bar.
const chartRows = computed(() => {
const r = selectedRegion.value
const rows = r ? r.countries : regions.value
return rows
.map((x) => ({
label: x.name || x.label || 'Other',
value: x.count,
// Highlight the selected country inside its region's chart.
color: selectedCountry.value && x.slug === selectedCountry.value.slug ? '#3b463d' : '#92b195',
}))
.sort((a, b) => b.value - a.value)
.slice(0, 24)
})
const chartTitle = computed(() =>
selectedRegion.value
? `People groups by country — ${selectedRegion.value.label || 'Other'}`
: 'People groups by region'
)
// ★ NO BAR CHART. It was ours, not theirs, and the rule is that a SELECTED region or country
// must look IDENTICAL to its DX-life page — RegionDetail.vue has four stat cards, the map, three
// goal cards and the country table, and nothing else. The one deliberate difference in this
// element is the NO-SELECTION state below, which their site has no route for at all.
// ── DX-life's RegionDetail, field for field ──────────────────────────────────
// Ported from root-dx-life/doxa-life/app/components/public/RegionDetail.vue (read-only clone,
@ -329,6 +311,31 @@ const summaryLine = computed(() => {
// regions so the page still compares like with like. The prompt below then says how to narrow it.
const isAll = computed(() => subject.value.kind === 'all')
// ── THE ONE DELIBERATE DIFFERENCE: the no-selection chooser ─────────────────
// DX-life has no route for a dashboard without a subject (/regions is the list,
// /regions/<slug> always has one, [slug].vue 404s otherwise). Opening tab 2 first is a state
// only this element can reach, so it gets the one thing their site cannot give: a GENERIC map
// of everything, plus three independent ways to choose a subject — a region dropdown, a country
// dropdown, and the all-regions table, whose rows are already clickable. Once a subject is
// chosen the page becomes the faithful clone and this chooser disappears.
const regionOptions = computed(() =>
regions.value.map((r) => ({ value: r.slug, label: r.label || 'Other', count: r.count }))
)
const countryOptions = computed(() =>
regions.value
.flatMap((r) => r.countries.map((c) => ({ value: c.slug, label: c.name, count: c.count })))
.sort((a, b) => a.label.localeCompare(b.label))
)
function onPickRegion(e) {
sel.value = { regionSlug: e?.value || '', countrySlug: '' }
}
function onPickCountry(e) {
const slug = e?.value || ''
const owner = regions.value.find((r) => r.countries.some((c) => c.slug === slug))
sel.value = { regionSlug: owner?.slug || '', countrySlug: slug }
}
// ── the map's subject: which countries to highlight, and how to frame them ──
// DX-life gets centre/zoom/bounds from config/countries-meta.ts, a build-time table we do not
// have. Deriving the frame from the FOCUS POINTS themselves is better, not a workaround: their
@ -380,33 +387,9 @@ const mapNote = computed(() => {
.replace('%countries%', formatNumber(countries))
})
// ── the country page's people-group list ────────────────────────────────────
// CountryDetail.vue:63-69 nests UupgsList locked to the country. That component is 662 lines of
// search + eight filter dropdowns + photo cards, and this repo ALREADY ships that element as the
// people-groups-grid bundle -- re-implementing it here would be a third copy. So the country page
// gets the same INFORMATION through our own table pattern (@map/components/tables/DataTable.vue,
// which carries the mobile column-collapse and row-detail behaviour the Driver asked for once
// already). Nesting the real people-groups-grid element is the right end state and is a cap-4
// composition question, not a styling one.
const countryTable = computed(() => {
const c = selectedCountry.value
if (!c) return null
const rows = points.value
.filter((p) => p.code === c.code)
.sort((a, b) => (b.population - a.population) || a.name.localeCompare(b.name))
.map((p) => ({ name: p.name, population: p.population, committed: p.committed }))
return {
meta: { title: c.name + ' people groups' },
columns: [
{ field: 'name', title: 'People Group' },
{ field: 'population', title: 'Population', hozAlign: 'right', formatter: 'money',
formatterParams: { precision: 0, thousand: ',' } },
{ field: 'committed', title: 'People Praying', hozAlign: 'right', formatter: 'money',
formatterParams: { precision: 0, thousand: ',' } },
],
rows,
}
})
// The country page's list is DX-life's own UupgsList, ported at src/UupgsList.vue — not a table
// of ours. It fetches and filters the people groups itself, exactly as theirs does, so there is
// no countryTable computed here any more.
// ── in-dashboard navigation ─────────────────────────────────────────────────
// Their back-link (RegionDetail.vue:48) goes to /regions. We have no router, so it drops the
@ -551,6 +534,10 @@ useShadowStyles(
'.rd-chart{ padding:23.5264px clamp(17.689px, 4cqw, 31.2901px); }' +
// RegionDetail wraps the note and the map in a .stack--sm (13.3px). The note is their
// .text-center .color-primary .font-weight-bold -- --color-brand-primary at weight 700.
// The chooser's two controls, side by side and wrapping on a phone. Its own block so the
// clone below is untouched by it.
'.rd-chooser{ display:flex; flex-wrap:wrap; gap:13.3px; align-items:flex-end;' +
' justify-content:center; }' +
'.rd-mapblock{ display:flex; flex-direction:column; gap:13.3px; }' +
'.rd-mapnote{ margin:0; text-align:center; color:#92b195; font-weight:700; font-size:19.5312px; }' +
// .rounded-xlg on their <CountryMap>; the child takes border-radius:inherit from here.
@ -676,6 +663,28 @@ useShadowStyles(
</p>
</div>
<!-- THE CHOOSER — the no-selection state, and the only thing here DX-life does not have -->
<div v-if="isAll" class="rd-chooser">
<FilterDropdown
label="Region"
name="region"
:options="regionOptions"
value=""
placeholder="Type a region…"
@filter-change="onPickRegion"
@filter-clear="clearSelection"
/>
<FilterDropdown
label="Country"
name="country"
:options="countryOptions"
value=""
placeholder="Type a country…"
@filter-change="onPickCountry"
@filter-clear="clearSelection"
/>
</div>
<!-- RegionDetail.vue:53-70 — four stat cards. CountryDetail has none. -->
<div v-if="subject.kind !== 'country'" class="rd-stats">
<div v-for="st in stats" :key="st.label" class="rd-card rd-card--small rd-stat">
@ -701,13 +710,8 @@ useShadowStyles(
</div>
</div>
<!-- Ours, not theirs, and kept: the one view that RANKS the subject's parts. -->
<div v-if="subject.kind !== 'country'" class="rd-card rd-chart">
<BarChart :series="chartRows" :title="chartTitle" unit="people groups" color="#92b195" />
</div>
<!-- RegionDetail.vue:88-141 — the three DOXA goal cards in their .switcher -->
<div v-if="subject.kind !== 'country'" class="rd-switcher">
<div v-if="subject.kind === 'region'" class="rd-switcher">
<div class="rd-card rd-card--secondary rd-goal-card">
<h2 class="rd-h2">Prayer Coverage</h2>
<div>
@ -777,11 +781,15 @@ useShadowStyles(
</div>
</div>
<!-- CountryDetail.vue:63-69 — UupgsList locked to the country, through our table pattern. -->
<div v-if="subject.kind === 'country' && countryTable" class="rd-section">
<h2 class="rd-h2 rd-h2--start">People groups in {{ subject.title }}</h2>
<DataTable :table="countryTable" :assets-base="assetsBase" />
</div>
<!-- CountryDetail.vue:63-69 — <UupgsList :lock-country-code hide-see-all-link />, and
nothing around it: their page puts no heading above this block. -->
<UupgsList
v-if="subject.kind === 'country' && selectedCountry"
:lock-country-code="selectedCountry.code"
:country-name="selectedCountry.name"
:pray-base-url="prayBaseUrl"
:language-code="languageCode"
/>
</template>
</div>
</div>

View file

@ -0,0 +1,8 @@
{
"type": "single",
"label": "Regions — the list page",
"config": {
"pinHint": false
},
"note": "pinHint:false: this profile draws NO map at all (it is the clone of DX-life's /regions index), but it now sits in a bundle that owns a map, so generate-tree classifies it isMap and would bake the coach-mark onto a page with no dots. Same shape as cities-internet/connectivity-map.meta.json."
}

View file

@ -1,6 +1,12 @@
{
"type": "nested-elements-mfe",
"label": "Regions — List + Dashboards",
"composes": ["regions-dashboard/regions-list", "regions-dashboard/region-dashboards"],
"note": "The Driver's 2-tab Regions element. Composite MFE on the pane-wrapper layout, imitating my-upg-100-list/upg-100-mfe: a SELECT VIEW switcher (library/components/ViewSelect.vue) over Regions List at [0,0] and Region Dashboards at [1,0]. Clicking a region or country on tab 1 forwards the selection on 'doxa-regions-dashboard:select' and swaps to tab 2."
"composes": [
"regions-dashboard/regions-list",
"regions-dashboard/region-dashboards"
],
"note": "The Driver's 2-tab Regions element. Composite MFE on the pane-wrapper layout, imitating my-upg-100-list/upg-100-mfe: a SELECT VIEW switcher (library/components/ViewSelect.vue) over Regions List at [0,0] and Region Dashboards at [1,0]. Clicking a region or country on tab 1 forwards the selection on 'doxa-regions-dashboard:select' and swaps to tab 2. pinHint:false: the cloned pages carry no coach-mark, and the composite must declare it because a pane's config comes from here, not from the child's own meta.",
"config": {
"pinHint": false
}
}

View file

@ -42,7 +42,12 @@ const instanceId = computed(() => cfg.value?.instanceId || 'regions-mfe')
const spec = computed(() => ({
id: 'regions-mfe',
tag: TAG,
config: { profile: 'regions-list', instanceId: instanceId.value },
// pinHint:false reaches the PANES from here, not from their own meta.json. A child profile's
// meta only configures its own LEAF page; nested in this composite each pane is handed the
// config built below, and PinHint.vue reads pinHint off the INJECTED profileConfig. Without
// this the coach-mark reappeared over the cloned map inside the composite while the leaf
// pages were correctly clean — the same cap-4 inheritance gap assetsBase hit.
config: { profile: 'regions-list', instanceId: instanceId.value, pinHint: false },
panes: [
{ id: 'list', label: 'Regions List', at: [0, 0], config: { profile: 'regions-list' } },
{ id: 'dashboard', label: 'Region Dashboards', at: [1, 0], config: { profile: 'region-dashboards' } },
@ -63,7 +68,7 @@ let offScroll = null
// own default. Forwarded by KEY LIST, never by spreading the whole host config, so a pane's own
// `profile` / `instanceId` cannot be overwritten by the host's — that is what makes a pane a
// distinct view at all (pane.config is spread last and always wins).
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'languageCode', 'isDark', 'qa']
const INHERITED_PAGE_KEYS = ['assetsBase', 'apiBaseUrl', 'prayBaseUrl', 'locale', 'lang', 'languageCode', 'isDark', 'qa', 'pinHint']
function paneElement(pane) {
const el = document.createElement(TAG)

View file

@ -0,0 +1,282 @@
<script setup>
/**
* UupgsList.vue — DX-life's people-group list, ported for the country page.
*
* @description CountryDetail.vue:63-69 nests <UupgsList :lock-country-code="country.code"
* hide-see-all-link />. That component IS the bottom half of the page you land on when you
* click a country on DX-life, so cloning that page means cloning this. Ported from
* root-dx-life/doxa-life/app/components/public/UupgsList.vue (read-only clone, standing law),
* which is itself a Lit -> Vue port of marketing-theme/js/components/src/uupgs-list.ts and
* whose own header says it "preserves class names, markup structure, fetch endpoints, and
* filter logic verbatim so the source SCSS applies unchanged".
*
* WHAT IS CLONED. The markup and the class names, so the ported CSS below is theirs rather than
* a lookalike: .filters / .filters__toggle / .filters__panel / .filters__active / .filter-chip,
* the .search-box, the .grid[data-width-lg] card grid, and the .uupg__card grid-template-areas
* with its 576px reflow. Their endpoint and field list too (UupgsList.vue:388).
*
* SCOPED TO WHAT THE COUNTRY PAGE ACTUALLY RENDERS, and that is a deliberate narrowing rather
* than an omission. Their component carries four call-sites' worth of options — useSelectCard,
* useHighlightedUUPGs, randomizeList, dontShowListOnLoad, a select-card variant with prayer
* counters, the /highlighted endpoint, the see-all link. CountryDetail passes NONE of them: it
* passes a locked country and hide-see-all-link, which selects the DEFAULT full card and hides
* the see-all. Porting the branches the page cannot reach would be code no consumer runs.
*
* NOT CLONED, and it is visible: their cards show a photo per people group from image_url, and
* the /api/people-groups/list response we consume carries has_photo + image_url, so the img is
* wired. Where a group has no photo the card reflows without one, as theirs does.
*/
import { ref, computed, onMounted, watch } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import FilterDropdown from '@map/components/search/FilterDropdown.vue'
const props = defineProps({
// ISO-3. Their `lockCountryCode`: the country filter is pre-applied and its dropdown hidden.
lockCountryCode: { type: String, default: '' },
countryName: { type: String, default: '' },
prayBaseUrl: { type: String, default: 'https://pray.doxa.life' },
languageCode: { type: String, default: '' },
researchUrl: { type: String, default: 'https://doxa.life/research/' },
// Their perPage / morePerPage defaults (UupgsList.vue props block).
perPage: { type: Number, default: 24 },
morePerPage: { type: Number, default: 0 },
})
// Their field list, verbatim (UupgsList.vue:388).
const FIELDS = 'name,slug,wagf_region,wagf_block,country_code,rop1,religion,has_photo,image_url,adopted_by_churches,imb_alternate_name,engagement_status,people_committed,location_description'
const rows = ref([])
const loading = ref(true)
const failed = ref(false)
const searchTerm = ref('')
const filtersExpanded = ref(false)
const shown = ref(props.perPage)
// Their filter set, minus `country` — a locked country hides that one (their own rule).
const active = ref({ wagf_region: null, wagf_block: null, rop1: null, religion: null })
const label = (f) => (f && typeof f === 'object' ? f.label : f) || ''
const value = (f) => (f && typeof f === 'object' ? f.value : f) || ''
async function load() {
loading.value = true
failed.value = false
try {
const r = await fetch(`${props.prayBaseUrl}/api/people-groups/list?fields=${FIELDS}&lang=${props.languageCode}`)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const d = await r.json()
const all = Array.isArray(d.posts) ? d.posts : []
rows.value = props.lockCountryCode
? all.filter((p) => value(p.country_code) === props.lockCountryCode)
: all
} catch (e) {
console.error('[UupgsList] fetch error:', e)
failed.value = true
rows.value = []
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => props.lockCountryCode, load)
// Distinct values per filter key, with counts — the shape FilterDropdown wants.
function optionsFor(key) {
const m = new Map()
for (const p of rows.value) {
const v = value(p[key]); const l = label(p[key])
if (!v) continue
const e = m.get(v) || { value: v, label: l || v, count: 0 }
e.count++; m.set(v, e)
}
return [...m.values()].sort((a, b) => a.label.localeCompare(b.label))
}
const filterDefs = computed(() => [
{ name: 'wagf_region', label: 'WAGF Region' },
{ name: 'wagf_block', label: 'WAGF Block' },
{ name: 'rop1', label: 'Affinity Bloc' },
{ name: 'religion', label: 'Religion' },
].map((f) => ({ ...f, options: optionsFor(f.name) })))
const activeChips = computed(() =>
Object.entries(active.value).filter(([, v]) => v).map(([name, v]) => ({ name, ...v }))
)
// Their filter+search pipeline: every active filter ANDs, then the term matches a name or an
// alternate name. Substring, not fuzzy — the fuzzy ladder is the /research page's own concern.
const filtered = computed(() => {
const term = searchTerm.value.trim().toLowerCase()
return rows.value.filter((p) => {
for (const [k, v] of Object.entries(active.value)) {
if (v && value(p[k]) !== v.value) return false
}
if (!term) return true
const alt = p.imb_alternate_name || ''
return String(p.name || '').toLowerCase().includes(term) || String(alt).toLowerCase().includes(term)
})
})
const displayed = computed(() => filtered.value.slice(0, shown.value))
const showMore = computed(() => filtered.value.length > shown.value)
function loadMore() { shown.value += (props.morePerPage || props.perPage) }
watch([searchTerm, active], () => { shown.value = props.perPage }, { deep: true })
function onFilterChange(e) { if (e && e.name) active.value = { ...active.value, [e.name]: { value: e.value, label: e.label } } }
function onFilterClear(e) { if (e && e.name) active.value = { ...active.value, [e.name]: null } }
function clearAll() { active.value = { wagf_region: null, wagf_block: null, rop1: null, religion: null } }
function profileUrl(p) { return `${props.researchUrl}${p.slug || ''}` }
// Every value resolved against DX-life's 10px rem root, from the same token files as the rest of
// this bundle. Sources: blocks/_filters.scss (.filters*, .filter-chip*), blocks/_form.scss
// (.search-box, input[type=search]), blocks/_cards.scss:143-236 (.uupg__*), blocks/_buttons.scss
// (.button, .button.compact), composition/_grid.scss (.grid + data-width-lg).
// NO BACKTICKS in this string, including comments (house style: one terminates the literal).
useShadowStyles(
':host{ display:block; }' +
'.ul-root{ display:flex; flex-direction:column; gap:17.689px;' +
' --dx-text:#2a332a; --dx-muted:#6d766e; --dx-white:#fff; color:var(--dx-text);' +
' font:19.5312px/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }' +
// .filters — a .stack--md column
'.filters{ display:flex; flex-direction:column; gap:17.689px; }' +
// .search-box + input[type=search]: 100% wide, pill, text indented past the icon
'.search-box{ position:relative; width:100%; max-width:480px; margin-inline:auto; }' +
'.search-box input{ box-sizing:border-box; width:100%; border:1px solid #666; border-radius:100px;' +
' background:var(--dx-white); padding:10px 23.5264px; text-indent:1.5em; font:inherit;' +
' font-size:19.5312px; color:var(--dx-text); }' +
'.search-box input::placeholder{ color:gray; }' +
'.search-icon{ position:absolute; left:16px; top:50%; transform:translateY(-50%); width:18px;' +
' height:18px; fill:none; stroke:var(--dx-muted); stroke-width:2; pointer-events:none; }' +
// .filters__toggle — centred, chevron rotates when expanded
'.filters__toggle{ background:none; border:0; cursor:pointer; font:inherit; font-size:15.625px;' +
' display:inline-flex; align-items:center; gap:7.5188px; margin-inline:auto; color:var(--dx-text); }' +
'.filters__toggle svg{ transition:transform 200ms ease; }' +
'.filters__toggle[aria-expanded="true"] svg{ transform:rotate(180deg); }' +
'.filters__panel{ display:flex; flex-wrap:wrap; gap:13.3px; justify-content:center;' +
' align-items:flex-start; }' +
'.filters__active{ display:flex; flex-wrap:wrap; gap:7.5188px; align-items:center;' +
' justify-content:center; }' +
// .filter-chip — surface-brand-lighter on white, fully round
'.filter-chip{ display:inline-flex; gap:7.5188px; background:#6d766e; color:#fff;' +
' border-radius:100px; padding:5.6532px 13.3px; font-size:15.625px; text-align:center; }' +
'.filter-chip__remove{ background:none; border:0; cursor:pointer; font-size:1.1em; line-height:1;' +
' color:inherit; padding:0; display:inline-flex; align-items:center; }' +
'.filter-chip__remove:hover{ opacity:0.7; }' +
'.filters__clear-all{ background:none; border:0; cursor:pointer; font:inherit; font-size:15.625px;' +
' color:var(--dx-text); text-decoration:underline; text-underline-offset:4px;' +
' text-decoration-thickness:2px; }' +
'.ul-count{ text-align:center; font-size:15.625px; color:var(--dx-muted); }' +
// .grid[data-width-lg] — auto-fit, 400px min track, --spacing-2xl gap
'.grid{ display:grid; grid-gap:41.6158px;' +
' grid-template-columns:repeat(auto-fit, minmax(min(400px,100%), 1fr)); }' +
// .card + .uupg__card: their grid-template-areas, radius-xlg, --spacing-md padding + gap
'.uupg__card{ box-sizing:border-box; background:var(--dx-white); color:var(--dx-text);' +
' border-radius:35px; display:grid;' +
' grid-template-areas:"image header adopted" "image content content" "footer footer footer-right";' +
' grid-template-columns:auto 1fr auto; grid-template-rows:auto auto auto;' +
' gap:17.689px; padding:17.689px; }' +
'.uupg__image{ width:100px; height:100px; object-fit:cover; grid-area:image; border-radius:7.5px;' +
' background:#f3f3f1; }' +
'.uupg__header{ grid-area:header; }' +
'.uupg__name{ margin:0; font-family:inherit; font-size:24.4141px; font-weight:500;' +
' line-height:1.25; }' +
'.uupg__country{ margin:0; font-size:19.5312px; }' +
'.uupg__country-link{ color:#4e594f; text-decoration:none; }' +
'.uupg__country-link:hover{ text-decoration:underline; }' +
// theirs is display:none — the location line is fetched and deliberately not shown
'.uupg__content{ display:none; grid-area:content; }' +
// .button.compact, grid-area footer, bottom-aligned
'.uupg__more-button{ grid-area:footer; align-self:flex-end; display:block; width:fit-content;' +
' box-sizing:border-box; padding:10px 41.6158px; color:#f3f3f1; background:#92b195;' +
' border-radius:100px; border:1px solid transparent; line-height:1; text-decoration:none;' +
' text-transform:uppercase; font-size:19.5312px;' +
' font-family:"Brandon Grotesque",Poppins,-apple-system,"Segoe UI",sans-serif;' +
' transition:background-color .3s ease, color .3s ease; cursor:pointer; }' +
'.uupg__more-button:hover{ background:#2a332a; color:#f3f3f1; }' +
// their 576px reflow: the photo and the header stop sharing a row
'@media (max-width:576px){ .uupg__card{' +
' grid-template-areas:"image adopted" "header header" "content content" "footer footer";' +
' grid-template-columns:auto 1fr; grid-template-rows:auto auto auto auto; } }' +
'.loading, .ul-status{ text-align:center; color:var(--dx-muted); font-size:19.5312px; padding:17.689px; }' +
'.ul-status.ul-failed{ font-weight:600; }' +
// .center | .button compact .stack-spacing-2xl
'.ul-more{ margin-inline:auto; margin-top:41.6158px; display:block; width:fit-content;' +
' padding:10px 41.6158px; color:#f3f3f1; background:#92b195; border-radius:100px;' +
' border:1px solid transparent; line-height:1; text-transform:uppercase; font-size:19.5312px;' +
' font-family:"Brandon Grotesque",Poppins,-apple-system,"Segoe UI",sans-serif; cursor:pointer; }' +
'.ul-more:hover{ background:#2a332a; }',
'uupgs-list'
)
</script>
<template>
<div class="ul-root">
<div class="filters">
<div class="search-box">
<svg class="search-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="M20 20l-3.5-3.5" stroke-linecap="round" />
</svg>
<label class="sr-only" :for="'ul-search'">Search</label>
<input id="ul-search" v-model="searchTerm" type="search" placeholder="Search" aria-label="Search people groups">
</div>
<button
type="button"
class="filters__toggle"
:aria-expanded="filtersExpanded ? 'true' : 'false'"
@click="filtersExpanded = !filtersExpanded"
>
{{ filtersExpanded ? 'Hide Filters' : 'Show Filters' }}
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div v-if="filtersExpanded" class="filters__panel">
<FilterDropdown
v-for="f in filterDefs"
:key="f.name"
:label="f.label"
:name="f.name"
:options="f.options"
:value="active[f.name]?.value || ''"
placeholder="Type to search…"
@filter-change="onFilterChange"
@filter-clear="onFilterClear"
/>
</div>
<div v-if="activeChips.length" class="filters__active">
<span v-for="c in activeChips" :key="c.name" class="filter-chip">
{{ c.label }}
<button type="button" class="filter-chip__remove" :aria-label="'Remove ' + c.label" @click="onFilterClear({ name: c.name })">×</button>
</span>
<button type="button" class="filters__clear-all" @click="clearAll">Clear all</button>
</div>
</div>
<p v-if="!loading && !failed" class="ul-count">
{{ filtered.length.toLocaleString('en-US') }} people groups<span v-if="countryName"> in {{ countryName }}</span>
</p>
<div v-if="failed" class="ul-status ul-failed" role="status">
People groups could not be loaded. This is a connection problem, not a filter.
</div>
<div id="results" class="grid" data-width-lg>
<div v-for="p in displayed" :key="p.slug" class="uupg__card">
<img v-if="p.image_url" class="uupg__image" :src="p.image_url" :alt="p.name" loading="lazy">
<div class="uupg__header">
<h3 class="uupg__name">{{ p.name }}</h3>
<p class="uupg__country">
<a class="uupg__country-link" :href="profileUrl(p)">{{ label(p.country_code) }}</a>
<span v-if="label(p.rop1)"> ({{ label(p.rop1) }})</span>
</p>
</div>
<p v-if="p.location_description" class="uupg__content">{{ p.location_description }}</p>
<a class="uupg__more-button" :href="profileUrl(p)">Full profile</a>
</div>
<div v-if="loading" class="loading">Loading…</div>
</div>
<button v-if="showMore" type="button" class="ul-more" @click="loadMore">Load more</button>
</div>
</template>

File diff suppressed because one or more lines are too long

View file

@ -821,7 +821,7 @@
"bundle": "regions-dashboard",
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"output": "doxa-maps/regions-dashboard/regions-dashboard.js",
"integrity": "sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+",
"integrity": "sha384-Bb3MKu3Qtq0bi36uAe+ubqScwhCiwRvZMaC9igh47c6n1KgKpdlfUdjxgbkgFRcZ",
"elements": [
"regions-dashboard"
],
@ -841,7 +841,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/region-dashboards/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Bb3MKu3Qtq0bi36uAe+ubqScwhCiwRvZMaC9igh47c6n1KgKpdlfUdjxgbkgFRcZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"region-dashboards\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -855,7 +855,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-list/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Bb3MKu3Qtq0bi36uAe+ubqScwhCiwRvZMaC9igh47c6n1KgKpdlfUdjxgbkgFRcZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-list\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
},
{
@ -869,7 +869,7 @@
"scriptUrl": "./regions-dashboard/regions-dashboard.js",
"embed": {
"iframe": "<iframe src=\"./regions-dashboard/regions-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./regions-dashboard/regions-dashboard.js\" integrity=\"sha384-Bb3MKu3Qtq0bi36uAe+ubqScwhCiwRvZMaC9igh47c6n1KgKpdlfUdjxgbkgFRcZ\" crossorigin=\"anonymous\"></script>\n<regions-dashboard profile-config='{\"profile\":\"regions-mfe\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></regions-dashboard>"
}
}
]

View file

@ -29,7 +29,7 @@
<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-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-Bb3MKu3Qtq0bi36uAe+ubqScwhCiwRvZMaC9igh47c6n1KgKpdlfUdjxgbkgFRcZ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,7 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"profile":"region-dashboards","dataSource":"pray-tools","instanceId":"regions-dashboard-region-dashboards","assetsBase":"../../assets"};
var BASE_CONFIG = {"pinHint":false,"profile":"region-dashboards","dataSource":"pray-tools","instanceId":"regions-dashboard-region-dashboards","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 = false;

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<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-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-Bb3MKu3Qtq0bi36uAe+ubqScwhCiwRvZMaC9igh47c6n1KgKpdlfUdjxgbkgFRcZ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -45,7 +45,7 @@
(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"};
var BASE_CONFIG = {"pinHint":false,"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 = false;

View file

@ -40,7 +40,7 @@
<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-6Fd21Bp7QOSu1IQIYP6h1PnM33iVRAU9KvFfc2gAGnnpFCn/bQvDP+JIfO9aEiW+" crossorigin="anonymous"></script>
<script src="../regions-dashboard.js" integrity="sha384-Bb3MKu3Qtq0bi36uAe+ubqScwhCiwRvZMaC9igh47c6n1KgKpdlfUdjxgbkgFRcZ" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:
@ -56,7 +56,7 @@
(function () {
var slot = document.getElementById('slot');
var status = document.getElementById('status');
var BASE_CONFIG = {"profile":"regions-mfe","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-mfe","assetsBase":"../../assets"};
var BASE_CONFIG = {"pinHint":false,"profile":"regions-mfe","dataSource":"pray-tools","instanceId":"regions-dashboard-regions-mfe","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 = false;