The Regions tab handed the sheet generator its localised card, so an Arabic reader's poster read "Pray for السودان" and Administrative areas "السودان" beside an English "Countries: Sudan". The poster is English by recorded decision; the generator now gets the data's own record by id, as the wall does.
606 lines
40 KiB
Vue
606 lines
40 KiB
Vue
<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().
|
|
*
|
|
* ★ AND IT IS NOW ONE IMPLEMENTATION, NOT A COPY. This paragraph used to say the grouping was a
|
|
* COPY of countries-meta.ts "not an import", because DX-life's file is a .ts module with a
|
|
* Nuxt-only COUNTRIES import that cannot resolve outside that app. That was true when it was
|
|
* written and it stopped being true when the file was ported to
|
|
* library/utils/countries-meta.js: BOTH tabs now import summarizeRegions and
|
|
* PEOPLE_GROUP_LIST_FIELDS from there, and grep finds exactly one definition in the repo.
|
|
* Corrected because a comment that still describes the old shape is worse than no comment — the
|
|
* next reader would have duplicated the rollup a third time on its authority. When a comment and
|
|
* the code disagree, the comment is the one to distrust.
|
|
*
|
|
* 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 { inject, ref, computed, onMounted, watch, getCurrentInstance } from 'vue'
|
|
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
|
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
|
|
import { SHADOW_BOX_SIZING_RESET } from './src/shadowReset.js'
|
|
import { splitLastWord } from './src/useLastWordHighlight.js'
|
|
import { useRegionsI18n } from '@map/i18n/useRegionsI18n.js'
|
|
import { countryName } from '@map/utils/countryName.js'
|
|
import { useOptionalT } from '@map/composables/useOptionalT.js'
|
|
// ar renders right-to-left. RTL_LOCALES is the shared set and :dir on our own root is the
|
|
// house pattern (doxa-simple-map.vue:18, :246, :1346) — an EMBEDDED element must not reach for
|
|
// document.documentElement.dir, which belongs to the host page.
|
|
import { RTL_LOCALES, localeFromUrl } from '@map/i18n/index.js'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useDocumentFont, BEBAS_NEUE_FACES, POPPINS_FACES, FIGTREE_FACES, OSWALD_FACES } from '@map/composables/useDocumentFont.js'
|
|
import { summarizeRegions, PEOPLE_GROUP_LIST_FIELDS } from '@map/utils/countries-meta.js'
|
|
// DX-life's spacing/type scales under THEIR token names. Prepended to this
|
|
// component's shadow CSS so every var() below resolves inside the shadow root.
|
|
import { DXLIFE_TOKENS } from './dxlife-tokens.js'
|
|
// THE COUNTRY-POSTER DROPDOWN — the THIRD sanctioned addition, after the SELECT VIEW tab strip
|
|
// and the no-selection chooser. The requirement: a toggle at the bottom of each region section
|
|
// that opens that region's country posters.
|
|
//
|
|
// NOTHING HERE IS REBUILT. PosterWall is already the shared, fully parameterised grid that
|
|
// my-upg-100-list/poster-wall.vue and country-posters/country-poster-wall.vue both render, and
|
|
// it already owns the card layout, the "Show on map" header button, the Print Options footer
|
|
// and the client-side raster. This file supplies only data and a toggle — the same division of
|
|
// labour country-poster-wall.vue states in its own header ("this profile supplies only its
|
|
// data and taxonomy").
|
|
import PosterWall from '@map/components/poster/PosterWall.vue'
|
|
import RegionSectionCard from '@map/components/regions/RegionSectionCard.vue'
|
|
import posterData from '@map/data/country-posters.json'
|
|
import { createPeopleGroupRecords } from '@map/lists/peopleGroupRecords.js'
|
|
import { makeCountrySheet } from '@map/packet/countrySheet.js'
|
|
import { resolveAssetsBase } from '@map/utils/embedBase.js'
|
|
import { onBeforeUnmount } from 'vue'
|
|
import { cyrillicHeading } from '@map/styles/portedGlobals.js'
|
|
import { hostElement } from '@map/utils/hostElement.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).
|
|
// ★ THE CONFIG ARRIVES BY INJECT, NOT AS A PROP (measured 2026-09-23).
|
|
// The custom element this bundle registers IS ProfileLoader (index.js:41), which parses the
|
|
// profile-config ATTRIBUTE itself and hands the parsed object to its descendants via
|
|
// provide('profileConfig', config) (ProfileLoader.vue:195). So a profile component's own
|
|
// `profileConfig` STRING PROP is always empty, and every value read off it silently fell back
|
|
// to a default: prayBaseUrl, languageCode, instanceId and assetsBase alike. Nothing looked
|
|
// broken because the defaults happened to be right -- until assetsBase mattered, and the
|
|
// vendored Tabulator 404'd twice with no error while the table just never drew.
|
|
// Inject first, fall back to the prop so a direct <regions-dashboard profile-config='...'>
|
|
// outside ProfileLoader still works.
|
|
const injectedCfg = inject('profileConfig', null)
|
|
const cfg = computed(() => {
|
|
const inj = injectedCfg && (injectedCfg.value !== undefined ? injectedCfg.value : injectedCfg)
|
|
if (inj && typeof inj === 'object') return inj
|
|
if (!props.profileConfig) return {}
|
|
try { return JSON.parse(props.profileConfig) || {} } catch { return {} }
|
|
})
|
|
const prayBaseUrl = computed(() => props.prayBaseUrl || cfg.value.prayBaseUrl || 'https://pray.doxa.life')
|
|
// The prayer API localises its LABELS from ?lang= — country names, region names, religion
|
|
// names. MEASURED: with locale=ar the UI translated but every country in the table stayed
|
|
// English, because this read only cfg.languageCode while the locale arrives as
|
|
// cfg.locale (ProfileLoader's contract, ProfileLoader.vue:156 accepts locale OR lang).
|
|
// Falling through to the same keys means translating the chrome also translates the data.
|
|
// ★ AND THE URL, LAST — the same fix region-dashboards.vue got, on the leaf that did not get it.
|
|
// I fixed the dashboard leaf and stopped, and the ENGLISH census could not see what was left:
|
|
// its ours-vs-theirs sets matched because both sides were in English. The ARABIC census found it
|
|
// on the first run — our h1 and subtitle rendered "المناطق" while all 41 country names in the
|
|
// table stayed "Angola", "Benin", "Cameroon". A shared /regions-list/?lang=ar link translated the
|
|
// chrome and then asked the prayer API in English, which is the defect this comment block already
|
|
// describes; it was only ever half fixed.
|
|
const languageCode = computed(() =>
|
|
props.languageCode || cfg.value.languageCode || cfg.value.locale || cfg.value.lang || localeFromUrl() || '')
|
|
// Where the shared asset tree is served from: the generator bakes it per page depth and the
|
|
// composite forwards it via INHERITED_PAGE_KEYS. Needed for the vendored @font-face path below.
|
|
const assetsBase = computed(() => cfg.value.assetsBase || './assets')
|
|
const { locale: i18nLocale } = useI18n()
|
|
const isRTL = computed(() => RTL_LOCALES.has(i18nLocale.value))
|
|
|
|
// ★ THEIR PAGE HEAD, WHICH THIS PROFILE DID NOT HAVE AT ALL.
|
|
// pages/regions/index.vue:47-58 opens with a centred .stack--xs of
|
|
// <h1 class="highlight" data-highlight-last>Regions</h1> and a .color-brand-lighter subtitle.
|
|
// We rendered only the region cards, so the standalone leaf page had no title and no strapline —
|
|
// invisible inside the composite, where the tab strip names the view, and plainly missing on
|
|
// /doxa-maps/regions-dashboard/regions-list/index.html, which is a page in its own right.
|
|
const { t, tf } = useRegionsI18n()
|
|
// ★ A SECOND TRANSLATOR, DELIBERATELY. useRegionsI18n's t() resolves DX-life's English sentences as
|
|
// keys inside the `regions` namespace only, which is what this page is cloned from. A dotted key in
|
|
// another area — messages.countryPosterTooMany — misses there and would render the literal string
|
|
// "messages.countryPosterTooMany" on the wall. tKey is vue-i18n's own lookup, for keys that live
|
|
// outside this page's namespace.
|
|
const tKey = useOptionalT('')
|
|
const headTitle = computed(() => splitLastWord(t('Regions')))
|
|
|
|
// ★ REGISTER BEBAS NEUE AT DOCUMENT LEVEL, not in the shadow root: @font-face is document-scoped
|
|
// and a face declared inside a shadow root is ignored. This profile registers it too, and not
|
|
// only tab 2, because its own leaf page (regions-list/index.html) is a standalone surface where
|
|
// tab 2 is not present — relying on a sibling would leave THIS page on the fallback face.
|
|
// Idempotent per document, so the composite still registers exactly one.
|
|
// ★ THE FONT BASE MUST BE ABSOLUTE, and this is the one place where that is not merely safer.
|
|
// @font-face is DOCUMENT-scoped: useDocumentFont injects a <style> into the HOST document, so a
|
|
// RELATIVE src resolves against the HOST PAGE, not against this bundle. On our own leaf that is
|
|
// invisible — the leaf's generated config says '../../assets' and the leaf really is two
|
|
// directories deep, so it resolves correctly. On a THIRD-PARTY PAGE it resolves against the
|
|
// partner's URL, the file is not there, and every face fails.
|
|
// MEASURED, which is the only reason I know: the cross-origin embed probe reported every
|
|
// FontFace in the partner document as status 'error' while the woff2 responses were 200 and the
|
|
// asset host was sending both access-control-allow-origin:* and content-type: font/woff2. Two
|
|
// wrong hypotheses died first — a missing CORS header (the server already sends it) and a check
|
|
// made before document.fonts.ready (fixed, still error). The URL was the answer.
|
|
// resolveAssetsBase exists for exactly this and its own header documents this bug class; the
|
|
// poster previews next door have used it all along. The font loader was the last consumer still
|
|
// taking the raw value.
|
|
const fontAssetsBase = resolveAssetsBase(cfg.value)
|
|
useDocumentFont('Bebas Neue', BEBAS_NEUE_FACES, fontAssetsBase)
|
|
// Poppins is their --font-family-base — the face of every line of BODY text on these
|
|
// pages. It was the larger of the two typography gaps and completely invisible to a
|
|
// computed-style diff, which read 68/68 while our body text was a system fallback 5.5%
|
|
// wider per glyph. Registered the same way and for the same reason as Bebas.
|
|
useDocumentFont('Poppins', POPPINS_FACES, fontAssetsBase)
|
|
// Figtree stands in for Brandon Grotesque, which is commercial and must not ship in a public
|
|
// giveaway. @font-face is DOCUMENT-scoped, so it registers here beside
|
|
// the other two rather than inside the shadow root that uses it.
|
|
useDocumentFont('Figtree', FIGTREE_FACES, fontAssetsBase)
|
|
// Oswald, for CYRILLIC HEADINGS ONLY. base/_globals.scss:35-44 switches every h1/h2/h3 to
|
|
// --font-family-cyrillic under [lang="ru-RU"], because BEBAS NEUE HAS NO CYRILLIC GLYPHS — their
|
|
// heading face cannot render Russian at all, so without the switch the heading leaves the family.
|
|
// We ship a `ru` locale and had neither the rule nor the font, so every Russian heading here was
|
|
// rendering in whatever condensed system face the browser picked.
|
|
useDocumentFont('Oswald', OSWALD_FACES, fontAssetsBase)
|
|
|
|
// ── 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 ─────
|
|
// Their PEOPLE_GROUP_LIST_FIELDS, imported rather than restated — one list, one source.
|
|
// ★ THIS PAGE ASKS FOR TWO FIELDS, NOT EIGHT. The list page was reported slow, with the posters
|
|
// suspected; the posters are behind a v-if and never load until a region is opened. The cost
|
|
// was this fetch: it requested the full PEOPLE_GROUP_LIST_FIELDS for EVERY people group, then
|
|
// summarizeRegions computed population, adoption, engagement, commitment and pin lat/lngs from them —
|
|
// and this page renders NONE of that. It shows a region's label, its group count, its country count
|
|
// and its country names, all of which come from country_code and wagf_region alone. Verified by
|
|
// running summarizeRegions on two-field rows: the four values this page binds all come out.
|
|
// The shared PEOPLE_GROUP_LIST_FIELDS is left alone — the DASHBOARD tab genuinely needs all eight for
|
|
// its map pins and its stat cards, so this is a narrower request for a narrower page, not a change to
|
|
// what the list means.
|
|
const FIELDS = 'country_code,wagf_region'
|
|
|
|
function formatNumber(n) {
|
|
// Theirs, RegionDetail.vue:16: n.toLocaleString(`${locale}-u-nu-latn`) — locale grouping
|
|
// separators with Latin digits. Hardcoding en-US made de/fr/es wrong (they group with '.'
|
|
// or a thin space). Same fix already applied in region-dashboards.vue.
|
|
const v = Number(n || 0)
|
|
try { return v.toLocaleString(`${i18nLocale.value || 'en'}-u-nu-latn`) } catch (_) { return v.toLocaleString('en-US') }
|
|
}
|
|
|
|
function summaryLine(region) {
|
|
// THEIRS, pages/regions/index.vue:29-31:
|
|
// t('%count% people groups in %countries% countries')
|
|
// .replace('%count%', formatNumber(region.count))
|
|
// .replace('%countries%', formatNumber(region.countryCount))
|
|
// This was a hardcoded English template, so /ru/regions rendered "315 people groups in 41
|
|
// countries" while theirs reads "315 групп людей в 41 странах". The ru string was ALREADY in
|
|
// library/i18n/locales/ru/regions.json — copied, never wired. Caught by reading the Russian
|
|
// screenshot, not by any assertion.
|
|
return tf('%count% people groups in %countries% countries', {
|
|
count: formatNumber(region.count),
|
|
countries: formatNumber(region.countryCount),
|
|
})
|
|
}
|
|
|
|
// ── emit (Vue + native composed CustomEvent, same pattern as people-groups-grid) ────────────
|
|
const inst = getCurrentInstance()
|
|
const hostEl = () => hostElement(inst)
|
|
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 || t('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 || t('Other') },
|
|
count: country.count,
|
|
})
|
|
}
|
|
|
|
// "See Africa dashboard" / "See Asia dashboard" — same wording pattern as DX-life's
|
|
// dashboardLink() in app/pages/regions/index.vue (t('See %region% dashboard')).
|
|
function dashboardLink(region) {
|
|
// THEIRS: t('See %region% dashboard') — ru renders "Открыть панель: %region%", which is not
|
|
// even the same word order, so a hardcoded English template could never be patched into it.
|
|
// The translation was already catalogued; only the wiring was missing.
|
|
return tf('See %region% dashboard', { region: region.label || t('Other') })
|
|
}
|
|
|
|
// ── 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()
|
|
// Only the groups of this page's people-group LIST (library/lists/).
|
|
const posts = await applyPeopleGroupList(Array.isArray(data.posts) ? data.posts : [], injectedCfg)
|
|
regions.value = summarizeRegions(posts)
|
|
} catch (error) {
|
|
console.error('[regions-list] fetch error:', error)
|
|
loadFailed.value = true
|
|
regions.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// ─── COUNTRY POSTERS, per region ─────────────────────────────────────────────────────────
|
|
// One wall per region, opened by the footer toggle. instanceId is suffixed with the region
|
|
// slug because PosterWall scopes its CustomEvent by instanceId so several embeds on one page
|
|
// never cross-talk (PosterWall.vue:40) — and here there genuinely are several.
|
|
const POSTER_EVENT = 'doxa-regions:show-country-on-map'
|
|
// Read off the injected config the same way every other value in this file is (see the note
|
|
// above cfg) — a bare default here would make two embeds on one page answer each other's
|
|
// poster events.
|
|
const instanceId = computed(() => cfg.value.instanceId || 'regions-list')
|
|
const posterAssetsBase = computed(() => resolveAssetsBase(cfg.value))
|
|
|
|
// ★ THE PRINT ROWS, AND THE SHEET DRAWN FROM THEM. Supplying `records` is what makes the Prayer
|
|
// List, Card Deck and Prayer Packet buttons appear on each country (PosterWall hides them when a
|
|
// wall cannot supply rows, rather than failing at click time). One fetch per language, cached in
|
|
// the provider and shared by every country in every region, so opening five regions is still one
|
|
// request. The same provider feeds the sheet, so the printed map and the printed list can never
|
|
// disagree about which groups are in a country.
|
|
const countryRecords = createPeopleGroupRecords({
|
|
apiBase: () => prayBaseUrl.value,
|
|
lang: () => languageCode.value,
|
|
profileConfig: injectedCfg,
|
|
})
|
|
// Page 1 of a country packet, and the country poster, drawn at the click. Nothing is stored: no
|
|
// country sheet ships, and the packet's default lift would ask for posters/country-<iso3>.html and
|
|
// 404 on all 175.
|
|
const sheetForRecord = makeCountrySheet({
|
|
records: countryRecords,
|
|
assetsBase: () => posterAssetsBase.value,
|
|
// The same list the Country Posters wall computes its series from, so this tab's printed
|
|
// "map N of M" agrees with the wall's print and with the country's thumbnail.
|
|
countries: posterData.countries || [],
|
|
})
|
|
// ★ THE POSTER IS ENGLISH BY RECORDED DECISION (series.json english_only_note), so the generator gets
|
|
// the DATA's own record, never the card: the card's label is localised (localisedPoster below), and
|
|
// handing it over printed "Pray for السودان" beside an English "Countries: Sudan" in Arabic. The
|
|
// Country Posters wall resolves the record by id the same way (country-poster-wall.vue, byId).
|
|
const POSTER_BY_ID = new Map((posterData.countries || []).map(c => [c.id, c]))
|
|
const countrySheet = (card, format) => sheetForRecord(POSTER_BY_ID.get(card && card.id) || card, format)
|
|
const openPosters = ref(new Set())
|
|
|
|
function togglePosters(region) {
|
|
const next = new Set(openPosters.value)
|
|
next.has(region.slug) ? next.delete(region.slug) : next.add(region.slug)
|
|
openPosters.value = next // a new Set, so the computed template actually re-renders
|
|
}
|
|
const postersOpen = (region) => openPosters.value.has(region.slug)
|
|
|
|
// The poster dataset is keyed by ISO-3 (`iso3`); the regions rollup keys by `code`. Same
|
|
// codes, so the join is direct.
|
|
const POSTER_BY_ISO3 = new Map((posterData.countries || []).map(c => [String(c.iso3 || '').toUpperCase(), c]))
|
|
|
|
// Every country in the region gets a card, in PosterWall's own shape. A country with no
|
|
// generated poster is NOT dropped — it is passed with a reason, which is what the wall's
|
|
// `unsupported` slot is for, so the grid never silently loses a country.
|
|
// ★ EACH CARD NAMED IN THE READER'S LANGUAGE. item.label is the card's accessible name, the print
|
|
// HUD's title, and the title on the printed list, deck and packet — and country-posters.json carries
|
|
// it in English only, so every card was English in every locale. Same fix as the country-posters
|
|
// wall, and the same two caveats travel with it:
|
|
// · ENGLISH KEEPS THE DATA'S OWN NAMES. Intl's English differs from them for 32 of these 175
|
|
// countries, and countryName.js records that swap as a product decision rather than a refactor.
|
|
// · THE SHEET GENERATOR STILL GETS THE ORIGINAL RECORD. The poster itself is English by recorded
|
|
// decision, so only the card's label is localised — postersFor's result feeds the wall, while
|
|
// countrySheet (above) swaps the card back for the json's own record, by id, before generating.
|
|
// The i18n locale REF is read, not a snapshot, so a runtime language switch renames the cards.
|
|
function localisedPoster(rec) {
|
|
const locale = i18nLocale.value || 'en'
|
|
if (/^en\b/i.test(locale)) return rec
|
|
const name = countryName(rec.iso3, locale)
|
|
return name && name !== rec.iso3 ? { ...rec, label: name } : rec
|
|
}
|
|
|
|
function postersFor(region) {
|
|
return (region.countries || []).map((c) => {
|
|
const rec = POSTER_BY_ISO3.get(String(c.code || '').toUpperCase())
|
|
if (rec) return localisedPoster(rec)
|
|
return {
|
|
id: `country-${String(c.code || '').toLowerCase()}`,
|
|
iso3: c.code,
|
|
label: c.name,
|
|
count: c.count,
|
|
unsupportedReason: t('No poster has been generated for this country yet.'),
|
|
}
|
|
})
|
|
}
|
|
|
|
// ★ THE REASON IS TRANSLATED, THE PRESENCE OF ONE IS STILL THE DATA'S. The record carries its own
|
|
// English note ("India has 855 people groups — too many…"), and returning it put one untranslated
|
|
// sentence on the wall in every locale. The record still DECIDES whether a country is unsupported;
|
|
// only the wording comes from the catalogue now. messages.countryPosterTooMany exists in all
|
|
// eleven, and needs no number in it: the card already shows the count directly above.
|
|
// India is the only country in the dataset carrying one (855 people groups, no sheet); China is
|
|
// fine at 3x4. My own not-yet reason, for a country with no record at all, is already through t().
|
|
const posterUnsupported = (c) => (c.unsupportedReason ? tKey('messages.countryPosterTooMany') : null)
|
|
|
|
// One region's wall is already inside a region section, so it needs no second level of
|
|
// collapsing and no grouping: everything in it is that one region.
|
|
const posterGroupBy = () => 'countries'
|
|
|
|
// PosterWall's "Show on map" dispatches a window CustomEvent carrying listId (the poster id,
|
|
// e.g. "country-chn") or null when the user toggles the same card off. Mapping that id back to
|
|
// the country record and reusing selectCountry() is what makes the button open the dashboard
|
|
// tab for that country: the requirement is that the view-on-map button opens the country
|
|
// dashboard page for that country.
|
|
function onPosterShowOnMap(ev) {
|
|
const d = ev?.detail || {}
|
|
if (!String(d.instanceId || '').startsWith(instanceId.value)) return
|
|
if (!d.listId) return // a deselect is not a navigation
|
|
const iso3 = String(d.listId).replace(/^country-/, '').toUpperCase()
|
|
for (const region of regions.value) {
|
|
const hit = (region.countries || []).find(c => String(c.code || '').toUpperCase() === iso3)
|
|
if (hit) { selectCountry(hit, region); return }
|
|
}
|
|
}
|
|
|
|
// A LOCALE CHANGE MUST REFETCH, not just re-render. The region and country LABELS come from
|
|
// the API's ?lang=, not from the i18n catalogue — MEASURED on their live site, which serves
|
|
// "Азия"/"Африка"/"Азербайджан (3)" on /ru/regions against "Africa"/"Angola" on /regions. With
|
|
// no watcher this tab kept whatever labels the first fetch returned, so switching to Russian
|
|
// translated the chrome and left every region and country name in English. region-dashboards.vue
|
|
// already had this watcher (line 541); this tab was simply missed.
|
|
watch(languageCode, () => { loadRegions() })
|
|
|
|
onMounted(() => { if (typeof window !== 'undefined') window.addEventListener(POSTER_EVENT, onPosterShowOnMap) })
|
|
onBeforeUnmount(() => { if (typeof window !== 'undefined') window.removeEventListener(POSTER_EVENT, onPosterShowOnMap) })
|
|
|
|
onMounted(loadRegions)
|
|
|
|
// Design values are a DIRECT PORT of DX-life's live /regions page (root-dx-life/doxa-life —
|
|
// read-only, standing law), not our own judgement. DX-life's <html> sets
|
|
// `font-size: var(--font-size-base)` = 10px as its rem root (app/assets/styles/base/_globals.scss),
|
|
// so every rem token below is resolved against a 10px root, NOT the browser's 16px default — e.g.
|
|
// their --border-radius-xlg: 3.5rem is 35px, not 56px. Values were cross-checked two ways: hand-
|
|
// resolving the SCSS token files (tokens/semantic/_spacing.scss, _shapes.scss, _colors.scss,
|
|
// _typography.scss) AND reading getComputedStyle() live on https://doxa.life/regions — both agree.
|
|
// Colours/fonts/radii are an intentional exception to this repo's material-light chrome tokens —
|
|
// an exact clone of DX-life's OWN design is required here, so the clone wins.
|
|
useShadowStyles(
|
|
// Their _reset.scss:3-8 FIRST -- see src/shadowReset.js.
|
|
SHADOW_BOX_SIZING_RESET +
|
|
DXLIFE_TOKENS +
|
|
':host{ display:block; position:relative; width:100%; height:100%; min-height:320px; }' +
|
|
'.rl-root{ position:absolute; inset:0; display:flex; flex-direction:column; min-height:0;' +
|
|
// --dx-surface = --color-surface-default ($color-beige-50); --dx-elevated = --color-surface-white
|
|
// --dx-text = --color-text-on-default ($color-green-600 #2a332a) -- body copy on the light page bg
|
|
// --dx-header-bg = --color-surface-brand ($color-green-500 #3b463d) -- the card header band
|
|
// --dx-header-text = --color-text-on-brand (#fff); --dx-link = --color-brand-light ($color-green-400 #4e594f)
|
|
// --dx-muted = --color-brand-lighter (colors.scss redeclares this twice; 2nd wins: $color-green-300 #6d766e)
|
|
// --dx-line = --color-brand-secondary-light ($color-beige-200 #d1d4cd)
|
|
' --dx-surface:#f3f3f1; --dx-elevated:#fff; --dx-text:#2a332a; --dx-header-bg:#3b463d;' +
|
|
' --dx-header-text:#fff; --dx-link:#4e594f; --dx-muted:#6d766e; --dx-line:#d1d4cd;' +
|
|
' --dx-radius:35px;' +
|
|
' background:var(--dx-surface); color:var(--dx-text);' +
|
|
' font:var(--font-size-md)/1.5 Poppins,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,sans-serif; }' +
|
|
// container-type:inline-size makes this pane the query container for the cqw units below.
|
|
// DX-life sizes those with 4vw (the VIEWPORT), which is right for a full-page route and wrong
|
|
// for us: this element is also one pane of the regions-mfe composite, where the viewport is not
|
|
// the width available. cqw honours their DECISION (scale with the width you actually have)
|
|
// rather than transcribing a unit that only works in their layout. Same primitive already used
|
|
// in research-dashboard.vue:284. Safe here because nothing in this element is position:fixed --
|
|
// inline-size containment is what made a fixed scrim pane-relative in check:scrim-viewport.
|
|
// Side padding is deliberately 0: the .container formula below supplies the gutter, as theirs does.
|
|
// ★ scrollbar-gutter: stable — ported from their base/_globals.scss html rule, and it is
|
|
// LOAD-BEARING for layout, not cosmetic. MEASURED at 390px: their container is 353.5px
|
|
// and ours was 367px, because their html reserves the scrollbar gutter always and our
|
|
// scroller did not. 13.5px, invisible at 1440 where both cap at 1200 — which is exactly
|
|
// why a desktop-only diff read 68/68 while every mobile width was wrong.
|
|
// Worse, it CASCADED: at 768 our container came out 707.2px and theirs 693.7px, and
|
|
// their .switcher flips at 700px of container — so their three goal cards stacked and
|
|
// ours stayed three-across. One missing property, a different layout.
|
|
// We cannot set this on the host document from inside a shadow root, and should not;
|
|
// this element's own scroller is the box every child measures against.
|
|
'.rl-scroll{ flex:1 1 auto; min-height:0; overflow-y:auto; scrollbar-gutter:stable; -webkit-overflow-scrolling:touch;' +
|
|
' padding:20px 0; display:flex; flex-direction:column; gap:var(--spacing-lg);' +
|
|
' container-type:inline-size; }' +
|
|
// ★ THE DESKTOP MAX-WIDTH, ported verbatim from DX-life's .container
|
|
// (app/assets/styles/composition/_container.scss), which BOTH /regions and /regions/<slug> use:
|
|
// --container-max-width:1200px; --screen-min-width:320px; --min-margin:8px;
|
|
// width: calc(90% + (10/100 * 320px) - (2 * 8px)) -> calc(90% + 16px)
|
|
// max-width: 1200px; margin-left/right: auto
|
|
// So it is FLUID at 90%+16px until the viewport reaches 1315.625px, then pinned at 1200px centred.
|
|
// Our cards previously stretched to the full pane at every width -- there was no cap at all.
|
|
// box-sizing:border-box so .rl-status's own padding (--spacing-xl) stays inside the cap.
|
|
'.rl-scroll > *{ width:calc(90% + 16px); max-width:1200px;' +
|
|
' margin-left:auto; margin-right:auto; }' +
|
|
// Their head block: .stack--xs (10px) gap, centred. h1 is _globals' clamp(--font-size-3xl,
|
|
// 7vw, --font-size-5xl) and the subtitle is --font-size-md in --color-brand-lighter.
|
|
// cqw not vw, for the same composite-pane reason as every other clamp here.
|
|
// ★ THEIR REDUCED-MOTION RESET, which a shadow boundary had been swallowing.
|
|
// base/_reset.scss:66-79 is `@media (prefers-reduced-motion: reduce) { *, *::before, *::after {
|
|
// animation-duration:.01ms!important; animation-iteration-count:1!important;
|
|
// transition-duration:.01ms!important; scroll-behavior:auto!important } }` -- a DOCUMENT-level
|
|
// universal selector, so it styles nothing inside a shadow root. The same boundary swallowed
|
|
// their box-sizing reset.
|
|
// It matters because we ported their animation faithfully: their RegionDetail.vue:209 and our
|
|
// .rd-seg / .rd-bar__slider both carry `transition: width 1s ease-out`. MEASURED with
|
|
// emulateMedia on the segment that actually carries it -- theirs goes 1s -> 1e-05s under reduce,
|
|
// ours stayed 1s -> 1s. So a vestibular visitor got a full one-second sliding bar from us that
|
|
// their own site suppresses: a clone difference and an accessibility regression in one.
|
|
// Their `html:focus-within{scroll-behavior:auto}` half is deliberately NOT ported -- a shadow
|
|
// root cannot style the host document's html element, and the leaf page owns that.
|
|
'@media (prefers-reduced-motion: reduce){' +
|
|
' .rl-root, .rl-root *, .rl-root *::before, .rl-root *::after{' +
|
|
' animation-duration:0.01ms !important; animation-iteration-count:1 !important;' +
|
|
' transition-duration:0.01ms !important; scroll-behavior:auto !important; } }' +
|
|
'.rl-head{ display:flex; flex-direction:column; gap:10px; text-align:center; }' +
|
|
// NO text-transform. MEASURED on their live h1, both locales: text-transform is `none`
|
|
// (/regions and /ru/regions alike). This rule used to force uppercase, which is INVISIBLE in
|
|
// English because Bebas Neue is an all-caps face — and wrong the moment the text is not Latin:
|
|
// Bebas carries no Cyrillic, so /ru falls back to a normal system face and their heading reads
|
|
// "Регионы" while ours read "РЕГИОНЫ". The same conclusion is already written out for
|
|
// .region-card__title below ("DX-life applies NO transform on top of the font"); this rule was
|
|
// simply missed, and no check caught it because the vs-live property diff covers the DASHBOARD
|
|
// tab's .rd-title, never this one. _probe-list-vs-live.mjs now covers it.
|
|
'.rl-title{ margin:0; font-size:clamp(var(--font-size-3xl), 7cqw, var(--font-size-5xl)); line-height:1;' +
|
|
' font-family:"Bebas Neue","Arial Narrow",BlinkMacSystemFont,"Segoe UI","Helvetica Neue",Arial,sans-serif;' +
|
|
' font-weight:400; }' +
|
|
'.rl-title__last{ color:#92b195; }' +
|
|
'.rl-summary{ margin:0; color:var(--dx-muted); font-size:var(--font-size-md); }' +
|
|
'.rl-status{ padding:var(--spacing-xl); text-align:center; color:var(--dx-muted); font-size:var(--font-size-md); }' +
|
|
'.rl-status.rl-failed{ font-weight:600; }' +
|
|
// ★ CYRILLIC HEADINGS SWITCH FACE, as theirs do. base/_globals.scss:35-44:
|
|
// h1,h2,h3 { [lang="ru-RU"] & { font-family: var(--font-family-cyrillic); letter-spacing: -0.02em } }
|
|
// and --font-family-cyrillic is Oswald. The switch is not decoration: BEBAS NEUE HAS NO CYRILLIC
|
|
// GLYPHS, so a Russian heading in it leaves the family entirely and lands on whatever condensed
|
|
// system face the browser picks. We ship a `ru` locale and had neither the rule nor the font.
|
|
// ONLY the headings, exactly as theirs — their .region-stat__value is a <p> and keeps Bebas even in
|
|
// Russian, which is their behaviour and so is ours.
|
|
// BOTH TAG FORMS, deliberately: their site tags the document ru-RU while our i18n locale is `ru`,
|
|
// and a host page embedding this element may set either. A selector that matched only one would
|
|
// work on their site and fail on ours, or the reverse, depending on who set the attribute.
|
|
// ★ THIS RULE WAS INERT AND ATE THE STYLE-ID. It used to end with a trailing comma and no
|
|
// declaration block, so the concatenation emitted
|
|
// [lang="ru"] .rl-title, [lang="ru-RU"] .rl-title,regions-list
|
|
// — a selector list with nothing to apply, so the Russian heading font never applied, and
|
|
// useShadowStyles' style-id argument was consumed as CSS, leaving the sheet unlabelled. Braces
|
|
// stayed balanced, so nothing caught it for weeks. check:shadow-css-parses now has an arm that
|
|
// does (SELECTOR WITH NO BLOCK), and this file is its poison test.
|
|
cyrillicHeading('.rl-title'),
|
|
'regions-list'
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<!-- :lang is here for the SAME REASON :dir is: a [lang=] selector in this shadow root cannot
|
|
see the host document's attribute, so the switch their global sheet performs on
|
|
[lang="ru-RU"] can only fire if the attribute is INSIDE the root. It is also simply
|
|
correct HTML — screen readers and hyphenation read it. -->
|
|
<div class="rl-root" :lang="i18nLocale" :dir="isRTL ? 'rtl' : 'ltr'">
|
|
<!-- THEIR ICON, DELIVERED THE WAY THEIR MARKUP DELIVERS IT. Theirs is
|
|
<svg class="icon | rotate-90 right"><use href="/assets/icons/arrow-chevron.svg#chevron-up"/></svg>
|
|
— one <use> reference, not a path repeated per call site.
|
|
MEASURED BEFORE CHOOSING, because I assumed the platform forced the inline copy and it
|
|
does not: an external <use href="file.svg#id"> renders in Chromium, renders INSIDE a
|
|
shadow root, and a local <symbol> + <use href="#id"> renders too — bbox 410x234 in all
|
|
three cases.
|
|
WHAT IS ACTUALLY FORCED IS THE EXTERNAL HREF, not the mechanism. A dropped bundle
|
|
resolves assets against whatever tree it sits in, and generate-tree stages an allowlist
|
|
with no icons/ dir, so an external href would 404 SILENTLY and the chevron would simply
|
|
vanish. Same reason worldmap.svg arrives through an imagesBase prop instead of their bare
|
|
`const imagesUrl = '/assets/images'`.
|
|
So: their 454-byte file's path verbatim, as a <symbol> declared ONCE, referenced by
|
|
<use href="#chevron-up"> exactly as their markup does. No external fetch, and the path is
|
|
no longer copied three times. -->
|
|
<svg width="0" height="0" aria-hidden="true" focusable="false" style="position:absolute">
|
|
<symbol id="chevron-up" viewBox="0 0 489.67 289.877">
|
|
<path d="M439.017,211.678L263.258,35.919c-3.9-3.9-8.635-6.454-13.63-7.665-9.539-2.376-20.051.161-27.509,7.619L46.361,211.632c-11.311,11.311-11.311,29.65,0,40.961h0c11.311,11.311,29.65,11.311,40.961,0L242.667,97.248l155.39,155.39c11.311,11.311,29.65,11.311,40.961,0h0c11.311-11.311,11.311-29.65,0-40.961Z" />
|
|
</symbol>
|
|
</svg>
|
|
<div class="rl-scroll">
|
|
<!-- pages/regions/index.vue:47-58 — their centred head: the title with its last word
|
|
in brand primary, and the strapline underneath. -->
|
|
<div class="rl-head">
|
|
<h1 class="rl-title">{{ headTitle.head ? headTitle.head + ' ' : '' }}<span class="rl-title__last">{{ headTitle.last }}</span></h1>
|
|
<p class="rl-summary">{{ t('Find unreached people groups by region and country.') }}</p>
|
|
</div>
|
|
<!-- THEIR string, not a new one: 'Loading results...' is in their common.json and so is
|
|
already translated in all eight locales. Their /regions is server-rendered and has no
|
|
loading state of its own, but reusing their wording beats inventing English here. -->
|
|
<div v-if="loading" class="rl-status">{{ t('Loading results...') }}</div>
|
|
<div v-else-if="loadFailed" class="rl-status rl-failed" role="status">{{ t('Region data could not be loaded. This is a connection problem, not a filter.') }}</div>
|
|
<div v-else-if="!regions.length" class="rl-status">{{ t('No regions found.') }}</div>
|
|
|
|
<template v-else>
|
|
<RegionSectionCard
|
|
v-for="region in regions"
|
|
:key="region.slug"
|
|
:title="region.label || t('Other')"
|
|
:summary="summaryLine(region)"
|
|
:countries="region.countries"
|
|
:footer-label="dashboardLink(region)"
|
|
:disclosure-label="postersOpen(region) ? t('Hide country posters') : t('View country posters')"
|
|
:disclosure-open="postersOpen(region)"
|
|
:disclosure-id="`posters-${region.slug}`"
|
|
@toggle-disclosure="togglePosters(region)"
|
|
@select="selectRegion(region)"
|
|
@select-country="(c) => selectCountry(c, region)"
|
|
>
|
|
<template #extra>
|
|
<PosterWall
|
|
:items="postersFor(region)"
|
|
:group-by="posterGroupBy"
|
|
:instance-id="`${instanceId}-${region.slug}`"
|
|
:assets-base="posterAssetsBase"
|
|
:event-name="POSTER_EVENT"
|
|
:heading="''"
|
|
:intro="''"
|
|
unit="UPG"
|
|
:item-noun="t('Country')"
|
|
:min-card-px="150"
|
|
:item-noun-plural="t('Countries')"
|
|
item-count-key="regions.countryCount"
|
|
:records="countryRecords ? (item) => countryRecords({ type: 'country', values: [item.iso3] }) : null"
|
|
:sheet="countrySheet"
|
|
:card-stocks="true"
|
|
:collapsible="false"
|
|
:embedded="true"
|
|
:section-heads="false"
|
|
:columns="2"
|
|
:columns-control="true"
|
|
:unsupported="posterUnsupported"
|
|
download-prefix="doxa-country"
|
|
poster-action="print"
|
|
:prayer-list-soon-message="t('Coming soon — a prayer list will be generated for each country, naming every unengaged people group within it.')"
|
|
/>
|
|
</template>
|
|
</RegionSectionCard>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|