Replace country pages with region dashboards (#23)
* replace country pages with region dashboards - move /countries to /regions, with 301 redirects from the old URLs in every locale - add a region dashboard: a map of the region's countries with pins coloured by prayer coverage, headline figures, and prayer, adoption and engagement progress against the DOXA goals, plus a per-country table - roll up progress stats per country and region in the shared summary and serve them from /api/regions - frame region maps with antimeridian-aware bounds - add Regions to the hamburger menu and translate the new strings in all eight locales * put each region on the regions index in a card with a header band The region name links to its dashboard with the site's chevron icon, and the people-group and country counts sit centred beside it on a brand-green header; the country list is the padded white card body.
This commit is contained in:
parent
fa819d3bda
commit
eae6143be1
25 changed files with 1141 additions and 362 deletions
69
app/components/public/CountryDetail.vue
Normal file
69
app/components/public/CountryDetail.vue
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<script setup lang="ts">
|
||||
// Per-country page body: a map that highlights the country and plots its
|
||||
// people groups (plus surrounding ones, muted), alongside the people-group list
|
||||
// locked to this country. The summary (name, code, count, region, map
|
||||
// center/zoom) comes from /api/regions via the /regions/[slug] page.
|
||||
|
||||
import { buildUupgListTranslations } from '~/utils/uupgListTranslations'
|
||||
import { regionSlug, type CountrySummary } from '~~/config/countries-meta'
|
||||
|
||||
const props = defineProps<{ country: CountrySummary }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const translations = computed(() => buildUupgListTranslations(t))
|
||||
|
||||
const regionLink = computed(() => localePath(`/regions/${regionSlug(props.country.region.value)}`))
|
||||
const regionLabel = computed(() => props.country.region.label || t('Other'))
|
||||
|
||||
// Map note. Placeholders are substituted manually with %count%/%country%
|
||||
// tokens rather than vue-i18n's {0}/{1} slots: vue-i18n empties brace slots
|
||||
// when the key resolves with no args (the English source), and leaves them
|
||||
// literal when the key is missing (untranslated locales fall back to the
|
||||
// English source) — neither path interpolates correctly. Non-brace tokens are
|
||||
// passed through untouched in every locale, so a single manual replace works
|
||||
// for English now and for Weblate translations later (which keep the tokens).
|
||||
const mapNote = computed(() =>
|
||||
t('There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.')
|
||||
.replace('%count%', String(props.country.count))
|
||||
.replace('%country%', props.country.name)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container page-content uupgs-page stack stack--3xl">
|
||||
<div class="stack stack--xs text-center">
|
||||
<NuxtLink class="light-link" :to="localePath('/regions')">← {{ t('All Regions') }}</NuxtLink>
|
||||
<h1 class="highlight" data-highlight-last>{{ country.name }}</h1>
|
||||
<p class="color-brand-lighter">
|
||||
{{ country.count }} {{ t('People Groups') }} ·
|
||||
<NuxtLink class="light-link" :to="regionLink">{{ regionLabel }}</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack stack--sm">
|
||||
<p class="country-map-note | text-center color-primary font-weight-bold">
|
||||
{{ mapNote }}
|
||||
</p>
|
||||
<ClientOnly>
|
||||
<CountryMap
|
||||
:country-codes="[country.code]"
|
||||
:center="country.center"
|
||||
:zoom="country.zoom"
|
||||
:language-code="locale"
|
||||
:research-url="localePath('/research') + '/'"
|
||||
class="rounded-xlg"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<UupgsList
|
||||
:language-code="locale"
|
||||
:research-url="localePath('/research') + '/'"
|
||||
:t="translations"
|
||||
:lock-country-code="country.code"
|
||||
hide-see-all-link
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,33 +1,42 @@
|
|||
<script setup lang="ts">
|
||||
// Dedicated map for a country page. Highlights the focus country (filled +
|
||||
// outlined polygon from Mapbox's country-boundaries tileset) and plots every
|
||||
// people group as a pin — the focus country's groups in a bright brand green,
|
||||
// groups in surrounding countries muted — so the viewer sees both the country's
|
||||
// people groups and the general area around it. Clicking a pin opens a popup
|
||||
// with that people group's details (photo, country, population, religion,
|
||||
// description) plus links to its full profile and prayer page, mirroring the
|
||||
// research map's detail panel.
|
||||
// Map for the region and country pages. Highlights the focus countries (filled
|
||||
// + outlined polygons from Mapbox's country-boundaries tileset) and plots every
|
||||
// people group as a pin — groups inside the focus countries in a bright brand
|
||||
// green, groups elsewhere muted — so the viewer sees both the area's people
|
||||
// groups and its surroundings. With `colorByPrayer`, pins inside the focus
|
||||
// countries are instead coloured by prayer coverage (100+ committed, 1+, none)
|
||||
// using the same colours as the prayer-coverage card, and a legend explains
|
||||
// them. Clicking a pin opens a popup with that people group's details (photo,
|
||||
// country, population, religion, intercessors, description) plus links to its
|
||||
// full profile and prayer page, mirroring the research map's detail panel.
|
||||
//
|
||||
// Self-contained (uses the npm mapbox-gl directly, not the shared embed bundle):
|
||||
// the country page is the only map on the page, so there's no second copy to
|
||||
// clash with. mapbox-gl is loaded client-side only.
|
||||
// this is the only map on the page, so there's no second copy to clash with.
|
||||
// mapbox-gl is loaded client-side only.
|
||||
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import { FULL_PRAYER_COVERAGE_COUNT, type MapBounds } from '~~/config/countries-meta'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** ISO-3 code of the country to highlight (e.g. "IND"). */
|
||||
countryCode: string
|
||||
/** Initial map center [lng, lat]. */
|
||||
/** ISO-3 codes of the countries to highlight (e.g. ["IND"]). */
|
||||
countryCodes: string[]
|
||||
/** Initial map center [lng, lat]; used when no bounds are given. */
|
||||
center: [number, number]
|
||||
/** Initial map zoom. */
|
||||
/** Initial map zoom; used when no bounds are given. */
|
||||
zoom: number
|
||||
/** Bounds [[west, south], [east, north]] to frame with padding instead of center/zoom. */
|
||||
bounds?: MapBounds | null
|
||||
/** Language for people-group names in pin popups. */
|
||||
languageCode?: string
|
||||
/** Base path for "view profile" links, e.g. "/research/". */
|
||||
researchUrl?: string
|
||||
/** Colour pins inside the focus countries by prayer coverage and show a legend. */
|
||||
colorByPrayer?: boolean
|
||||
}>(), {
|
||||
bounds: null,
|
||||
languageCode: 'en',
|
||||
researchUrl: '/research/'
|
||||
researchUrl: '/research/',
|
||||
colorByPrayer: false
|
||||
})
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
|
|
@ -40,30 +49,50 @@ const container = ref<HTMLElement | null>(null)
|
|||
let map: any = null
|
||||
|
||||
// Map colours:
|
||||
// - brand: dark brand green (#3b463d) — the highlighted country's fill + outline.
|
||||
// - brand: dark brand green (#3b463d) — the highlighted countries' fill + outline.
|
||||
// - inCountry: brighter brand green (#73A17F, green-100) — pins inside the focus
|
||||
// country, kept distinct from the dark outline and the muted neighbour pins.
|
||||
// countries, kept distinct from the dark outline and the muted neighbour pins.
|
||||
// - muted: grey-green — pins in surrounding countries.
|
||||
// brand/muted resolve from the design tokens (theme-aware); the pin accent is
|
||||
// fixed so it always reads as a clear, non-dark green against the country fill.
|
||||
// - prayerFull / prayerPartial / prayerNone: the prayer-coverage card's colours
|
||||
// (brand primary, yellow, brand light) for pins inside the focus countries
|
||||
// when colouring by prayer.
|
||||
// - outside: warm grey (brand secondary dark) — pins in surrounding countries
|
||||
// when colouring by prayer, so they don't compete with the dark "no one
|
||||
// praying" pins.
|
||||
// Token-backed colours resolve from the design tokens (theme-aware); the fixed
|
||||
// accents always read clearly against the country fill.
|
||||
const FALLBACK_COLORS = {
|
||||
brand: '#3b463d',
|
||||
muted: '#b8bdb9',
|
||||
inCountry: '#73A17F',
|
||||
prayerFull: '#92b195',
|
||||
prayerPartial: '#f0c64a',
|
||||
prayerNone: '#4e594f',
|
||||
outside: '#a59d92'
|
||||
}
|
||||
const colors = ref({ ...FALLBACK_COLORS })
|
||||
|
||||
function mapColors() {
|
||||
const fallback = { brand: '#3b463d', muted: '#b8bdb9' }
|
||||
const inCountry = '#73A17F'
|
||||
if (typeof document === 'undefined') return { ...fallback, inCountry }
|
||||
if (typeof document === 'undefined') return { ...FALLBACK_COLORS }
|
||||
const s = getComputedStyle(document.documentElement)
|
||||
const token = (name: string, fallback: string) => s.getPropertyValue(name).trim() || fallback
|
||||
return {
|
||||
brand: s.getPropertyValue('--color-brand').trim() || fallback.brand,
|
||||
muted: s.getPropertyValue('--color-brand-lighter').trim() || fallback.muted,
|
||||
inCountry
|
||||
...FALLBACK_COLORS,
|
||||
brand: token('--color-brand', FALLBACK_COLORS.brand),
|
||||
muted: token('--color-brand-lighter', FALLBACK_COLORS.muted),
|
||||
prayerFull: token('--color-brand-primary', FALLBACK_COLORS.prayerFull),
|
||||
prayerNone: token('--color-brand-light', FALLBACK_COLORS.prayerNone),
|
||||
outside: token('--color-brand-secondary-dark', FALLBACK_COLORS.outside)
|
||||
}
|
||||
}
|
||||
|
||||
const PIN_FIELDS = 'name,slug,country_code,rop1,religion,population,location_description,image_url,has_photo,latitude,longitude'
|
||||
const PIN_FIELDS = 'name,slug,country_code,rop1,religion,population,location_description,image_url,has_photo,latitude,longitude,people_committed'
|
||||
|
||||
async function loadPins() {
|
||||
const url = `${prayBaseUrl}/api/people-groups/list?fields=${PIN_FIELDS}&lang=${props.languageCode}`
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
const focus = new Set(props.countryCodes)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const features = (data.posts ?? [])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
|
@ -76,7 +105,8 @@ async function loadPins() {
|
|||
properties: {
|
||||
name: p.name,
|
||||
slug: p.slug,
|
||||
inCountry: p.country_code?.value === props.countryCode,
|
||||
inCountry: focus.has(p.country_code?.value),
|
||||
committed: Number(p.people_committed) || 0,
|
||||
country: p.country_code?.label ?? '',
|
||||
rop1: p.rop1?.label ?? '',
|
||||
religion: p.religion?.label ?? '',
|
||||
|
|
@ -100,7 +130,9 @@ function formatPopulation(pop: number): string {
|
|||
|
||||
// Build the pin popup's inner HTML from a feature's properties. Mirrors the
|
||||
// research map's detail panel: photo, name, country · people-group, the
|
||||
// population/religion stats, a short description, and Pray / Full Profile links.
|
||||
// population/religion stats and intercessor count (out of the full-coverage
|
||||
// goal, as on the people-group cards), a short description, and Pray / Full
|
||||
// Profile links.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function popupHtml(p: any): string {
|
||||
const name = escapeHtml(p.name || '')
|
||||
|
|
@ -112,6 +144,7 @@ function popupHtml(p: any): string {
|
|||
const rows: string[] = []
|
||||
if (pop) rows.push(`<div><dt>${escapeHtml(t('Population'))}</dt><dd>${pop}</dd></div>`)
|
||||
if (p.religion) rows.push(`<div><dt>${escapeHtml(t('Religion'))}</dt><dd>${escapeHtml(p.religion)}</dd></div>`)
|
||||
rows.push(`<div><dt>${escapeHtml(t('Intercessors'))}</dt><dd>${Number(p.committed) || 0}/${FULL_PRAYER_COVERAGE_COUNT}</dd></div>`)
|
||||
|
||||
return `<div class="cm-popup">
|
||||
${p.imageUrl ? `<img class="cm-popup__img" src="${escapeHtml(p.imageUrl)}" alt="${name}" loading="lazy">` : ''}
|
||||
|
|
@ -134,13 +167,16 @@ async function initMap() {
|
|||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
mapboxgl.accessToken = mapboxToken
|
||||
|
||||
const { brand, muted, inCountry } = mapColors()
|
||||
const { brand, muted, inCountry, prayerFull, prayerPartial, prayerNone, outside } = colors.value
|
||||
|
||||
map = new mapboxgl.Map({
|
||||
container: container.value,
|
||||
style: 'mapbox://styles/mapbox/light-v11',
|
||||
center: props.center,
|
||||
zoom: props.zoom
|
||||
zoom: props.zoom,
|
||||
// Bounds take precedence over center/zoom so the frame fits the real
|
||||
// viewport rather than the build-time approximation.
|
||||
...(props.bounds ? { bounds: props.bounds, fitBoundsOptions: { padding: 40, maxZoom: 6 } } : {})
|
||||
})
|
||||
map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
|
||||
map.addControl(new mapboxgl.FullscreenControl(), 'top-right')
|
||||
|
|
@ -151,7 +187,7 @@ async function initMap() {
|
|||
// to a single worldview so the highlight isn't drawn two or three times.
|
||||
const countryFilter = [
|
||||
'all',
|
||||
['==', ['get', 'iso_3166_1_alpha_3'], props.countryCode],
|
||||
['in', ['get', 'iso_3166_1_alpha_3'], ['literal', props.countryCodes]],
|
||||
['any', ['==', ['get', 'worldview'], 'all'], ['in', 'US', ['get', 'worldview']]]
|
||||
]
|
||||
map.addSource('country-boundaries', { type: 'vector', url: 'mapbox://mapbox.country-boundaries-v1' })
|
||||
|
|
@ -173,19 +209,30 @@ async function initMap() {
|
|||
})
|
||||
|
||||
// ── People-group pins ──────────────────────────────────────────────────
|
||||
// Prayer mode colours focus-country pins by committed intercessors (full
|
||||
// coverage, some, none) and draws the better-covered pins on top; the
|
||||
// default mode is a single focus colour.
|
||||
const outsideFocus = ['!', ['get', 'inCountry']]
|
||||
const fullCoverage = ['>=', ['get', 'committed'], FULL_PRAYER_COVERAGE_COUNT]
|
||||
const someCoverage = ['>', ['get', 'committed'], 0]
|
||||
const pinColor = props.colorByPrayer
|
||||
? ['case', outsideFocus, outside, fullCoverage, prayerFull, someCoverage, prayerPartial, prayerNone]
|
||||
: ['case', ['get', 'inCountry'], inCountry, muted]
|
||||
const pinSortKey = props.colorByPrayer
|
||||
? ['case', outsideFocus, 0, fullCoverage, 3, someCoverage, 2, 1]
|
||||
: ['case', ['get', 'inCountry'], 1, 0]
|
||||
const geojson = await loadPins()
|
||||
map.addSource('people-groups', { type: 'geojson', data: geojson })
|
||||
map.addLayer({
|
||||
id: 'people-group-pins',
|
||||
type: 'circle',
|
||||
source: 'people-groups',
|
||||
// Focus-country pins (sort key 1) draw on top of the muted ones (0).
|
||||
layout: { 'circle-sort-key': ['case', ['get', 'inCountry'], 1, 0] },
|
||||
layout: { 'circle-sort-key': pinSortKey },
|
||||
paint: {
|
||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 2, 3, 6, 6.5],
|
||||
'circle-color': ['case', ['get', 'inCountry'], inCountry, muted],
|
||||
'circle-color': pinColor,
|
||||
// Surrounding/border people groups stay visible (so they can be seen on
|
||||
// the map) but a touch dimmer than the focus country's groups.
|
||||
// the map) but a touch dimmer than the focus countries' groups.
|
||||
'circle-opacity': ['case', ['get', 'inCountry'], 1, 0.75],
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
|
|
@ -207,12 +254,23 @@ async function initMap() {
|
|||
})
|
||||
}
|
||||
|
||||
onMounted(() => { initMap() })
|
||||
onMounted(() => {
|
||||
colors.value = mapColors()
|
||||
initMap()
|
||||
})
|
||||
onBeforeUnmount(() => { if (map) { map.remove(); map = null } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="country-map" />
|
||||
<div class="country-map">
|
||||
<div ref="container" class="country-map__canvas" />
|
||||
<div v-if="colorByPrayer" class="country-map__legend">
|
||||
<span><i :style="{ background: colors.prayerFull }" />{{ t('100+ People Praying') }}</span>
|
||||
<span><i :style="{ background: colors.prayerPartial }" />{{ t('1+ People Praying') }}</span>
|
||||
<span><i :style="{ background: colors.prayerNone }" />{{ t('No One Praying') }}</span>
|
||||
<span><i :style="{ background: colors.outside }" />{{ t('Outside this region') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -232,6 +290,45 @@ onBeforeUnmount(() => { if (map) { map.remove(); map = null } })
|
|||
aspect-ratio: 1 / 2;
|
||||
}
|
||||
}
|
||||
|
||||
.country-map__canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Colour key, overlaid on the map's bottom-left corner above the canvas. */
|
||||
.country-map__legend {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 28px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.country-map__legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.country-map__legend i {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 100px;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
|
|
|||
285
app/components/public/RegionDetail.vue
Normal file
285
app/components/public/RegionDetail.vue
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<script setup lang="ts">
|
||||
// Region dashboard body: headline figures, a map of the region's countries
|
||||
// with its people groups coloured by prayer coverage, progress toward the DOXA
|
||||
// goals — prayer coverage, adoption, engagement — and a per-country breakdown
|
||||
// linking to each country page. The summary comes from /api/regions via the
|
||||
// /regions/[slug] page.
|
||||
|
||||
import type { RegionSummary } from '~~/config/countries-meta'
|
||||
|
||||
const props = defineProps<{ region: RegionSummary }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
// Locale grouping separators with Latin digits, matching the rest of the site.
|
||||
const formatNumber = (n: number) => n.toLocaleString(`${locale.value}-u-nu-latn`)
|
||||
|
||||
const label = computed(() => props.region.label || t('Other'))
|
||||
const countryCodes = computed(() => props.region.countries.map(c => c.code))
|
||||
|
||||
// Prayer coverage split, mirroring the pray page: fully covered (100+
|
||||
// committed), partially covered (1+), and no one committed yet.
|
||||
const partialPrayerCount = computed(() => Math.max(0, props.region.withPrayer - props.region.withFullPrayer))
|
||||
const noPrayerCount = computed(() => Math.max(0, props.region.count - props.region.withPrayer))
|
||||
|
||||
function percentOfRegion(count: number): number {
|
||||
return props.region.count ? Math.min(100, (count / props.region.count) * 100) : 0
|
||||
}
|
||||
|
||||
// Placeholders are %tokens% substituted manually (see the map note on the
|
||||
// country page for why vue-i18n's {0} slots aren't used here).
|
||||
const summaryLine = computed(() =>
|
||||
t('%count% people groups in %countries% countries')
|
||||
.replace('%count%', formatNumber(props.region.count))
|
||||
.replace('%countries%', formatNumber(props.region.countryCount))
|
||||
)
|
||||
const mapNote = computed(() =>
|
||||
t('%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.')
|
||||
.replace('%region%', label.value)
|
||||
.replace('%count%', formatNumber(props.region.count))
|
||||
.replace('%countries%', formatNumber(props.region.countryCount))
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container page-content uupgs-page stack stack--3xl">
|
||||
<div class="stack stack--xs text-center">
|
||||
<NuxtLink class="light-link" :to="localePath('/regions')">← {{ t('All Regions') }}</NuxtLink>
|
||||
<h1 class="highlight" data-highlight-last>{{ label }}</h1>
|
||||
<p class="color-brand-lighter">{{ summaryLine }}</p>
|
||||
</div>
|
||||
|
||||
<div class="region-stats">
|
||||
<div class="region-stat | card" padding-small>
|
||||
<p class="region-stat__value">{{ formatNumber(region.count) }}</p>
|
||||
<p class="region-stat__label">{{ t('People Groups') }}</p>
|
||||
</div>
|
||||
<div class="region-stat | card" padding-small>
|
||||
<p class="region-stat__value">{{ formatNumber(region.countryCount) }}</p>
|
||||
<p class="region-stat__label">{{ t('Countries') }}</p>
|
||||
</div>
|
||||
<div class="region-stat | card" padding-small>
|
||||
<p class="region-stat__value">{{ formatNumber(region.population) }}</p>
|
||||
<p class="region-stat__label">{{ t('Population') }}</p>
|
||||
</div>
|
||||
<div class="region-stat | card" padding-small>
|
||||
<p class="region-stat__value">{{ formatNumber(region.count - region.engaged) }}</p>
|
||||
<p class="region-stat__label">{{ t('Unengaged people groups') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack stack--sm">
|
||||
<p class="text-center color-primary font-weight-bold">{{ mapNote }}</p>
|
||||
<ClientOnly>
|
||||
<CountryMap
|
||||
:country-codes="countryCodes"
|
||||
:center="region.center"
|
||||
:zoom="region.zoom"
|
||||
:bounds="region.bounds"
|
||||
:language-code="locale"
|
||||
:research-url="localePath('/research') + '/'"
|
||||
color-by-prayer
|
||||
class="rounded-xlg"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<div class="switcher" data-width="xl">
|
||||
<div class="stack stack--lg | card | text-center" data-variant="secondary">
|
||||
<h2>{{ t('Prayer Coverage') }}</h2>
|
||||
<div>
|
||||
<p class="font-size-4xl font-weight-medium">{{ formatNumber(region.peopleCommitted) }}</p>
|
||||
<p class="font-size-lg">{{ t('People committed to praying') }}</p>
|
||||
</div>
|
||||
<div class="stack stack--xs">
|
||||
<div
|
||||
class="progress-bar prayer-coverage-segments"
|
||||
data-size="md"
|
||||
:aria-label="t('Prayer coverage progress')"
|
||||
>
|
||||
<div class="prayer-coverage-segment prayer-coverage-segment--full" :style="{ width: `${percentOfRegion(region.withFullPrayer)}%` }" />
|
||||
<div class="prayer-coverage-segment prayer-coverage-segment--partial" :style="{ width: `${percentOfRegion(partialPrayerCount)}%` }" />
|
||||
<div class="prayer-coverage-segment prayer-coverage-segment--none" :style="{ width: `${percentOfRegion(noPrayerCount)}%` }" />
|
||||
</div>
|
||||
<div class="prayer-coverage-legend">
|
||||
<span><i class="prayer-coverage-key prayer-coverage-key--full" />{{ t('100+ People Praying') }}: {{ formatNumber(region.withFullPrayer) }}</span>
|
||||
<span><i class="prayer-coverage-key prayer-coverage-key--partial" />{{ t('1+ People Praying') }}: {{ formatNumber(partialPrayerCount) }}</span>
|
||||
<span><i class="prayer-coverage-key prayer-coverage-key--none" />{{ t('No One Praying') }}: {{ formatNumber(noPrayerCount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="region-goal">
|
||||
<strong>{{ t('Goal') }}:</strong> {{ t('Mobilize 100+ people praying daily for each DOXA people group') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack stack--lg | card | text-center" data-variant="primary">
|
||||
<h2>{{ t('Adoption Progress') }}</h2>
|
||||
<div>
|
||||
<p class="font-size-4xl font-weight-medium">{{ formatNumber(region.adopted) }} / {{ formatNumber(region.count) }}</p>
|
||||
<p class="font-size-lg">{{ t('people groups adopted') }}</p>
|
||||
</div>
|
||||
<div class="progress-bar" data-size="md">
|
||||
<div class="progress-bar__slider" :style="{ width: `${percentOfRegion(region.adopted)}%` }" />
|
||||
</div>
|
||||
<p class="region-goal">
|
||||
<strong>{{ t('Goal') }}:</strong> {{ t('Every unengaged people group adopted by a church committed to prayer, giving, and sending') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack stack--lg | card | text-center" data-variant="primary">
|
||||
<h2>{{ t('Engagement Progress') }}</h2>
|
||||
<div>
|
||||
<p class="font-size-4xl font-weight-medium">{{ formatNumber(region.engaged) }} / {{ formatNumber(region.count) }}</p>
|
||||
<p class="font-size-lg">{{ t('people groups engaged') }}</p>
|
||||
</div>
|
||||
<div class="progress-bar" data-size="md">
|
||||
<div class="progress-bar__slider" :style="{ width: `${percentOfRegion(region.engaged)}%` }" />
|
||||
</div>
|
||||
<p class="region-goal">
|
||||
<strong>{{ t('Goal') }}:</strong> {{ t('No unengaged people groups by 2033') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="stack stack--md">
|
||||
<h2>{{ t('Countries in this region') }}</h2>
|
||||
<div class="table-scroll">
|
||||
<table class="region-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('Country') }}</th>
|
||||
<th>{{ t('People Groups') }}</th>
|
||||
<th>{{ t('1+ People Praying') }}</th>
|
||||
<th>{{ t('Adopted') }}</th>
|
||||
<th>{{ t('Engaged') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="c in region.countries" :key="c.slug">
|
||||
<td><NuxtLink :to="localePath(`/regions/${c.slug}`)">{{ c.name }}</NuxtLink></td>
|
||||
<td>{{ formatNumber(c.count) }}</td>
|
||||
<td>{{ formatNumber(c.withPrayer) }}</td>
|
||||
<td>{{ formatNumber(c.adopted) }}</td>
|
||||
<td>{{ formatNumber(c.engaged) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.region-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.region-stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.region-stat__value {
|
||||
font-family: var(--font-family-heading);
|
||||
font-size: var(--font-size-3xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.region-stat__label {
|
||||
margin-top: var(--spacing-2xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-brand-lighter);
|
||||
}
|
||||
|
||||
.region-goal {
|
||||
margin-top: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.prayer-coverage-segments {
|
||||
display: flex;
|
||||
height: clamp(15px, 6vw, 25px);
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.prayer-coverage-segment {
|
||||
height: 100%;
|
||||
transition: width 1s ease-out;
|
||||
}
|
||||
|
||||
.prayer-coverage-segment--none {
|
||||
background: var(--color-brand-light);
|
||||
}
|
||||
|
||||
.prayer-coverage-segment--partial {
|
||||
background: #f0c64a;
|
||||
}
|
||||
|
||||
.prayer-coverage-segment--full {
|
||||
background: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.prayer-coverage-legend {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.prayer-coverage-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.prayer-coverage-key {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.prayer-coverage-key--none {
|
||||
background: var(--color-brand-light);
|
||||
}
|
||||
|
||||
.prayer-coverage-key--partial {
|
||||
background: #f0c64a;
|
||||
}
|
||||
|
||||
.prayer-coverage-key--full {
|
||||
background: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.region-table {
|
||||
width: 100%;
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.region-table th,
|
||||
.region-table td {
|
||||
padding: var(--spacing-2xs) var(--spacing-sm);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.region-table th:not(:first-child),
|
||||
.region-table td:not(:first-child) {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.region-table th {
|
||||
background-color: var(--color-surface-brand-lighter);
|
||||
color: var(--color-text-on-brand-lighter);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -607,7 +607,7 @@ const showMore = computed(() => hasMore())
|
|||
<p v-if="uupg.country_code" class="uupg__country font-size-sm">
|
||||
<a
|
||||
class="uupg__country-link light-link"
|
||||
:href="localePath(`/countries/${countrySlug(uupg.country_code.value, uupg.country_code.label)}`)"
|
||||
:href="localePath(`/regions/${countrySlug(uupg.country_code.value, uupg.country_code.label)}`)"
|
||||
><span v-html="uupg.country_label ? uupg.country_label : uupg.country_code.label" /></a>
|
||||
</p>
|
||||
<template v-if="uupg.matches">
|
||||
|
|
@ -634,7 +634,7 @@ const showMore = computed(() => hasMore())
|
|||
<p class="uupg__country">
|
||||
<a
|
||||
class="uupg__country-link light-link"
|
||||
:href="localePath(`/countries/${countrySlug(uupg.country_code.value, uupg.country_code.label)}`)"
|
||||
:href="localePath(`/regions/${countrySlug(uupg.country_code.value, uupg.country_code.label)}`)"
|
||||
><span v-html="uupg.country_label ? uupg.country_label : uupg.country_code.label" /></a> (<span v-html="uupg.rop1_label ? uupg.rop1_label : uupg.rop1.label" />)
|
||||
</p>
|
||||
<template v-if="uupg.matches">
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
// Per-country page: a dedicated map that highlights the country and plots its
|
||||
// people groups (plus surrounding ones, muted), alongside the people-group list
|
||||
// locked to this country. The country summary (name, code, count, map
|
||||
// center/zoom) comes from /api/countries, which derives it from the prayer API —
|
||||
// see config/countries-meta.ts and server/api/countries.get.ts.
|
||||
|
||||
import { buildUupgListTranslations } from '~/utils/uupgListTranslations'
|
||||
import type { CountrySummary } from '~~/config/countries-meta'
|
||||
|
||||
const route = useRoute()
|
||||
const { t, locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const slug = computed(() => String(route.params.country || ''))
|
||||
|
||||
const { data: country } = await useAsyncData(
|
||||
`country-${slug.value}-${locale.value}`,
|
||||
async () => {
|
||||
const res = await $fetch<{ countries: CountrySummary[] }>('/api/countries', {
|
||||
query: { lang: locale.value }
|
||||
})
|
||||
return res.countries.find(c => c.slug === slug.value) ?? null
|
||||
},
|
||||
{ watch: [slug, locale] }
|
||||
)
|
||||
|
||||
if (!country.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: t('Country Not Found') })
|
||||
}
|
||||
|
||||
const translations = computed(() => buildUupgListTranslations(t))
|
||||
|
||||
// Map note. Placeholders are substituted manually with %count%/%country%
|
||||
// tokens rather than vue-i18n's {0}/{1} slots: vue-i18n empties brace slots
|
||||
// when the key resolves with no args (the English source), and leaves them
|
||||
// literal when the key is missing (untranslated locales fall back to the
|
||||
// English source) — neither path interpolates correctly. Non-brace tokens are
|
||||
// passed through untouched in every locale, so a single manual replace works
|
||||
// for English now and for Weblate translations later (which keep the tokens).
|
||||
const mapNote = computed(() =>
|
||||
t('There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.')
|
||||
.replace('%count%', String(country.value?.count ?? 0))
|
||||
.replace('%country%', country.value?.name ?? '')
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => `${country.value?.name} — Doxa`,
|
||||
description: () => `${country.value?.name}: ${country.value?.count} ${t('People Groups')}`,
|
||||
ogTitle: () => `${country.value?.name} — Doxa`,
|
||||
ogDescription: () => `${country.value?.name}: ${country.value?.count} ${t('People Groups')}`
|
||||
})
|
||||
|
||||
useTextHighlight()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container page-content uupgs-page stack stack--3xl">
|
||||
<div class="stack stack--xs text-center">
|
||||
<NuxtLink class="light-link" :to="localePath('/countries')">← {{ t('All Countries') }}</NuxtLink>
|
||||
<h1 class="highlight" data-highlight-last>{{ country?.name }}</h1>
|
||||
<p class="color-brand-lighter">{{ country?.count }} {{ t('People Groups') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="stack stack--sm">
|
||||
<p v-if="country" class="country-map-note | text-center color-primary font-weight-bold">
|
||||
{{ mapNote }}
|
||||
</p>
|
||||
<ClientOnly>
|
||||
<CountryMap
|
||||
v-if="country"
|
||||
:country-code="country.code"
|
||||
:center="country.center"
|
||||
:zoom="country.zoom"
|
||||
:language-code="locale"
|
||||
:research-url="localePath('/research') + '/'"
|
||||
class="rounded-xlg"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<UupgsList
|
||||
:language-code="locale"
|
||||
:research-url="localePath('/research') + '/'"
|
||||
:t="translations"
|
||||
:lock-country-code="country?.code"
|
||||
hide-see-all-link
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
// Index of every country that has people groups, grouped by WAGF region.
|
||||
// Data comes from /api/countries (derived from the prayer API).
|
||||
|
||||
import type { CountrySummary } from '~~/config/countries-meta'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const { data: countries } = await useAsyncData(
|
||||
`countries-index-${locale.value}`,
|
||||
async () => {
|
||||
const res = await $fetch<{ countries: CountrySummary[] }>('/api/countries', {
|
||||
query: { lang: locale.value }
|
||||
})
|
||||
return res.countries
|
||||
},
|
||||
{ watch: [locale], default: () => [] as CountrySummary[] }
|
||||
)
|
||||
|
||||
// Group by WAGF region; regions sorted by label, countries already name-sorted
|
||||
// by the API.
|
||||
const regions = computed(() => {
|
||||
const byRegion = new Map<string, { label: string, items: CountrySummary[] }>()
|
||||
for (const c of countries.value ?? []) {
|
||||
const key = c.region?.value || 'other'
|
||||
const label = c.region?.label || t('Other')
|
||||
if (!byRegion.has(key)) byRegion.set(key, { label, items: [] })
|
||||
byRegion.get(key)!.items.push(c)
|
||||
}
|
||||
return Array.from(byRegion.values()).sort((a, b) => a.label.localeCompare(b.label))
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: () => `${t('Countries')} — Doxa`,
|
||||
description: () => t('Find unreached people groups by country.')
|
||||
})
|
||||
|
||||
useTextHighlight()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container page-content stack stack--3xl">
|
||||
<div class="stack stack--xs text-center">
|
||||
<h1 class="highlight" data-highlight-last>{{ t('Countries') }}</h1>
|
||||
<p class="color-brand-lighter">{{ t('Find unreached people groups by country.') }}</p>
|
||||
</div>
|
||||
|
||||
<section v-for="region in regions" :key="region.label" class="stack stack--sm">
|
||||
<h2>{{ region.label }}</h2>
|
||||
<ul class="countries-grid">
|
||||
<li v-for="c in region.items" :key="c.slug">
|
||||
<NuxtLink :to="localePath(`/countries/${c.slug}`)">
|
||||
{{ c.name }} <span class="color-brand-lighter">({{ c.count }})</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.countries-grid {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.countries-grid a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.countries-grid a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
65
app/pages/regions/[slug].vue
Normal file
65
app/pages/regions/[slug].vue
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script setup lang="ts">
|
||||
// One route serves both the region dashboards (/regions/africa) and the
|
||||
// per-country pages (/regions/india): the slug is matched against region slugs
|
||||
// first, then country slugs. Both summaries come from /api/regions, which
|
||||
// derives them from the prayer API — see config/countries-meta.ts.
|
||||
|
||||
import type { CountrySummary, RegionSummary } from '~~/config/countries-meta'
|
||||
|
||||
const route = useRoute()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const slug = computed(() => String(route.params.slug || ''))
|
||||
|
||||
const { data, refresh } = await useAsyncData(
|
||||
`region-page-${slug.value}-${locale.value}`,
|
||||
async () => {
|
||||
const res = await $fetch<{ regions: RegionSummary[] }>('/api/regions', {
|
||||
query: { lang: locale.value }
|
||||
})
|
||||
const region = res.regions.find(r => r.slug === slug.value) ?? null
|
||||
const country: CountrySummary | null = region
|
||||
? null
|
||||
: res.regions.flatMap(r => r.countries).find(c => c.slug === slug.value) ?? null
|
||||
return { region, country }
|
||||
},
|
||||
{ watch: [slug, locale] }
|
||||
)
|
||||
|
||||
if (!data.value?.region && !data.value?.country) {
|
||||
throw createError({ statusCode: 404, statusMessage: t('Page Not Found') })
|
||||
}
|
||||
|
||||
// Prerendered dashboards carry build-time figures; refetch on the client so
|
||||
// the progress shown is current.
|
||||
onMounted(() => {
|
||||
if (data.value?.region) refresh()
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
const region = data.value?.region
|
||||
if (region) return region.label || t('Other')
|
||||
return data.value?.country?.name ?? ''
|
||||
})
|
||||
const count = computed(() => data.value?.region?.count ?? data.value?.country?.count ?? 0)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => `${title.value} — Doxa`,
|
||||
description: () => `${title.value}: ${count.value} ${t('People Groups')}`,
|
||||
ogTitle: () => `${title.value} — Doxa`,
|
||||
ogDescription: () => `${title.value}: ${count.value} ${t('People Groups')}`
|
||||
})
|
||||
|
||||
useTextHighlight()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RegionDetail
|
||||
v-if="data?.region"
|
||||
:region="data.region"
|
||||
/>
|
||||
<CountryDetail
|
||||
v-else-if="data?.country"
|
||||
:country="data.country"
|
||||
/>
|
||||
</template>
|
||||
156
app/pages/regions/index.vue
Normal file
156
app/pages/regions/index.vue
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
<script setup lang="ts">
|
||||
// Index of every WAGF region with the countries that have people groups in it.
|
||||
// A region heading opens that region's dashboard (/regions/<region>); a country
|
||||
// opens its own page (/regions/<country>). Data comes from /api/regions
|
||||
// (derived from the prayer API).
|
||||
|
||||
import type { RegionSummary } from '~~/config/countries-meta'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const { data: regions } = await useAsyncData(
|
||||
`regions-index-${locale.value}`,
|
||||
async () => {
|
||||
const res = await $fetch<{ regions: RegionSummary[] }>('/api/regions', {
|
||||
query: { lang: locale.value }
|
||||
})
|
||||
return res.regions
|
||||
},
|
||||
{ watch: [locale], default: () => [] as RegionSummary[] }
|
||||
)
|
||||
|
||||
// Locale grouping separators with Latin digits, matching the rest of the site.
|
||||
const formatNumber = (n: number) => n.toLocaleString(`${locale.value}-u-nu-latn`)
|
||||
|
||||
// Placeholders are %tokens% substituted manually (see the map note on the
|
||||
// country page for why vue-i18n's {0} slots aren't used here).
|
||||
function summaryLine(region: RegionSummary): string {
|
||||
return t('%count% people groups in %countries% countries')
|
||||
.replace('%count%', formatNumber(region.count))
|
||||
.replace('%countries%', formatNumber(region.countryCount))
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: () => `${t('Regions')} — Doxa`,
|
||||
description: () => t('Find unreached people groups by region and country.')
|
||||
})
|
||||
|
||||
useTextHighlight()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container page-content stack stack--3xl">
|
||||
<div class="stack stack--xs text-center">
|
||||
<h1
|
||||
class="highlight"
|
||||
data-highlight-last
|
||||
>
|
||||
{{ t('Regions') }}
|
||||
</h1>
|
||||
<p class="color-brand-lighter">
|
||||
{{ t('Find unreached people groups by region and country.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stack stack--lg">
|
||||
<div
|
||||
v-for="region in regions"
|
||||
:key="region.slug"
|
||||
class="region-card"
|
||||
>
|
||||
<div class="region-card__header">
|
||||
<h2 class="region-card__title">
|
||||
<NuxtLink
|
||||
:to="localePath(`/regions/${region.slug}`)"
|
||||
class="region-card__link | with-icon"
|
||||
>
|
||||
{{ region.label || t('Other') }}
|
||||
<svg
|
||||
class="icon | rotate-90 right"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/assets/icons/arrow-chevron.svg#chevron-up" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</h2>
|
||||
<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"
|
||||
>
|
||||
<NuxtLink :to="localePath(`/regions/${c.slug}`)">
|
||||
{{ c.name }} <span class="color-brand-lighter">({{ c.count }})</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Card with a brand-coloured header band (region name + count) above the
|
||||
white country list, following the site's two-tone card pattern. Plain divs
|
||||
rather than <section>, which carries the page-level section padding. */
|
||||
.region-card {
|
||||
border-radius: var(--border-radius-xlg);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-surface-white);
|
||||
}
|
||||
|
||||
.region-card__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs) var(--spacing-lg);
|
||||
padding: var(--spacing-md) clamp(var(--spacing-md), 4vw, var(--spacing-2xl));
|
||||
background-color: var(--color-surface-brand);
|
||||
color: var(--color-text-on-brand);
|
||||
}
|
||||
|
||||
.region-card__title {
|
||||
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-3xl));
|
||||
}
|
||||
|
||||
.region-card__link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.region-card__link:hover {
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.region-card__summary {
|
||||
font-size: var(--font-size-md);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.region-card__body {
|
||||
padding: var(--spacing-lg) clamp(var(--spacing-md), 4vw, var(--spacing-2xl)) var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* Keeps the site's list bullets (global ul padding + ::before dot) and lays
|
||||
the countries out in responsive columns. */
|
||||
.countries-grid {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--spacing-xs) var(--spacing-xl);
|
||||
}
|
||||
|
||||
.countries-grid a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.countries-grid a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -48,7 +48,7 @@ useTextHighlight()
|
|||
<div class="container page-content uupgs-page stack stack--3xl">
|
||||
<div class="stack stack--xs text-center">
|
||||
<h1 class="highlight" data-highlight-last>{{ t('Find a UUPG') }}</h1>
|
||||
<p><NuxtLink class="light-link" :to="localePath('/countries')">{{ t('By Country') }} →</NuxtLink></p>
|
||||
<p><NuxtLink class="light-link" :to="localePath('/regions')">{{ t('By Region') }} →</NuxtLink></p>
|
||||
</div>
|
||||
|
||||
<!-- Research map embed — sits directly under the H1, above the UUPG
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export const FOOTER_NAV: NavItem[] = [
|
|||
export const SECONDARY_NAV: NavItem[] = [
|
||||
{ key: 'About', to: '/about' },
|
||||
{ key: 'Research', to: '/research' },
|
||||
{ key: 'Regions', to: '/regions' },
|
||||
{ key: 'Pray', to: '/pray' },
|
||||
{ key: 'Adopt', to: '/adopt' },
|
||||
{ key: 'Resources', to: '/resources' },
|
||||
|
|
|
|||
|
|
@ -1,47 +1,105 @@
|
|||
// Shared, framework-agnostic helpers for the per-country pages (/countries/*).
|
||||
// Imported by app pages, the /api/countries server route, and the build-time
|
||||
// prerender module (modules/country-routes.ts), so it must stay free of any
|
||||
// Nuxt/Nitro runtime imports.
|
||||
// Shared, framework-agnostic helpers for the region and country pages
|
||||
// (/regions, /regions/<region>, /regions/<country>). Imported by app pages, the
|
||||
// /api/countries and /api/regions server routes, and the build-time prerender
|
||||
// module (modules/region-routes.ts), so it must stay free of any Nuxt/Nitro
|
||||
// runtime imports.
|
||||
//
|
||||
// People-group records carry an ISO-3 country code (e.g. "IND") plus a
|
||||
// localized country label and lat/lng. We group records by country to produce
|
||||
// one CountrySummary per country: a stable English-name slug for the URL, the
|
||||
// localized display name, the WAGF region (for the index grouping), a count,
|
||||
// and a map view (center + zoom) framed on the country's pins with a little
|
||||
// breathing room so neighbouring countries stay visible.
|
||||
// localized country label, a WAGF region, lat/lng, and the progress fields the
|
||||
// dashboards report on (adoption, prayer commitments, engagement status,
|
||||
// population). Records are grouped by country to produce one CountrySummary per
|
||||
// country: a stable English-name slug for the URL, the localized display name,
|
||||
// the WAGF region, progress stats, and a map view (center + zoom) framed on the
|
||||
// country's pins with a little breathing room so neighbouring countries stay
|
||||
// visible. Countries are then grouped by WAGF region to produce one
|
||||
// RegionSummary per region with the same progress stats rolled up.
|
||||
|
||||
import { COUNTRIES } from '../app/utils/countries'
|
||||
|
||||
/** Fields requested from the prayer API's people-groups list for these summaries. */
|
||||
export const PEOPLE_GROUP_LIST_FIELDS = 'country_code,wagf_region,latitude,longitude,population,adopted_by_churches,people_committed,engagement_status'
|
||||
|
||||
/** A people group counts as fully covered once this many people commit to daily prayer. */
|
||||
export const FULL_PRAYER_COVERAGE_COUNT = 100
|
||||
|
||||
export interface PeopleGroupLite {
|
||||
country_code?: { value: string, label: string } | null
|
||||
wagf_region?: { value: string, label: string } | null
|
||||
latitude?: string | number | null
|
||||
longitude?: string | number | null
|
||||
population?: string | number | null
|
||||
adopted_by_churches?: number | null
|
||||
people_committed?: string | number | null
|
||||
engagement_status?: { value: string, label: string } | null
|
||||
}
|
||||
|
||||
export interface CountrySummary {
|
||||
/**
|
||||
* Map bounds [[west, south], [east, north]] framing a set of pins. East may
|
||||
* exceed 180 when the box crosses the antimeridian (e.g. Oceania), which Mapbox
|
||||
* accepts as a continuous longitude range.
|
||||
*/
|
||||
export type MapBounds = [[number, number], [number, number]]
|
||||
|
||||
/** Progress figures shared by country and region summaries. */
|
||||
export interface ProgressStats {
|
||||
/** Number of people groups. */
|
||||
count: number
|
||||
/** Combined population of the people groups. */
|
||||
population: number
|
||||
/** People groups adopted by at least one church. */
|
||||
adopted: number
|
||||
/** People groups whose engagement status is "engaged". */
|
||||
engaged: number
|
||||
/** People groups with at least one person committed to daily prayer. */
|
||||
withPrayer: number
|
||||
/** People groups with FULL_PRAYER_COVERAGE_COUNT or more people committed. */
|
||||
withFullPrayer: number
|
||||
/** Total people committed to daily prayer across the people groups. */
|
||||
peopleCommitted: number
|
||||
}
|
||||
|
||||
export interface CountrySummary extends ProgressStats {
|
||||
/** English-name slug used in the URL, e.g. "papua-new-guinea". */
|
||||
slug: string
|
||||
/** ISO-3 country code, e.g. "PNG". */
|
||||
code: string
|
||||
/** Localized display name (from the people-groups API `lang` param). */
|
||||
name: string
|
||||
/** WAGF region, used to group countries on the index page. */
|
||||
/** WAGF region the country belongs to. */
|
||||
region: { value: string, label: string }
|
||||
/** Number of people groups in this country. */
|
||||
count: number
|
||||
/** Map center [lng, lat]. */
|
||||
center: [number, number]
|
||||
/** Map zoom level. */
|
||||
zoom: number
|
||||
/** Bounds of the country's pins; null when no record has coordinates. */
|
||||
bounds: MapBounds | null
|
||||
}
|
||||
|
||||
export interface RegionSummary extends ProgressStats {
|
||||
/** Slug derived from the region's stable value, e.g. "middle-east". */
|
||||
slug: string
|
||||
/** Raw WAGF region value, e.g. "middle_east". Empty when records carry no region. */
|
||||
value: string
|
||||
/** Localized region label. Empty when records carry no region. */
|
||||
label: string
|
||||
/** Number of countries with people groups in this region. */
|
||||
countryCount: number
|
||||
/** Map center [lng, lat]. */
|
||||
center: [number, number]
|
||||
/** Map zoom level. */
|
||||
zoom: number
|
||||
/** Bounds of the region's pins; null when no record has coordinates. */
|
||||
bounds: MapBounds | null
|
||||
/** Countries in the region, sorted by name. */
|
||||
countries: CountrySummary[]
|
||||
}
|
||||
|
||||
// ISO-3 → canonical English name, the basis for slugs. Sourced from the same
|
||||
// country list the rest of the app uses so slugs stay consistent and locale-
|
||||
// independent (a French page is still /fr/countries/india, not /fr/countries/inde).
|
||||
// independent (a French page is still /fr/regions/india, not /fr/regions/inde).
|
||||
const ENGLISH_NAME_BY_CODE = new Map(COUNTRIES.map(c => [c.value, c.label]))
|
||||
|
||||
export function slugifyCountry(name: string): string {
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '') // strip diacritics
|
||||
.toLowerCase()
|
||||
|
|
@ -54,7 +112,47 @@ export function slugifyCountry(name: string): string {
|
|||
// Slug for a country code. Prefers the canonical English name; falls back to a
|
||||
// provided label (and finally the raw code) for any code not in the master list.
|
||||
export function countrySlug(code: string, fallbackLabel?: string): string {
|
||||
return slugifyCountry(ENGLISH_NAME_BY_CODE.get(code) || fallbackLabel || code)
|
||||
return slugify(ENGLISH_NAME_BY_CODE.get(code) || fallbackLabel || code)
|
||||
}
|
||||
|
||||
// Slug for a WAGF region. Derived from the stable value rather than the
|
||||
// localized label so every locale shares one URL (e.g. "middle_east" →
|
||||
// "middle-east", "latin_america_&_caribbean" → "latin-america-and-caribbean").
|
||||
// Records with no region fall under "other".
|
||||
export function regionSlug(value: string): string {
|
||||
return slugify(value) || 'other'
|
||||
}
|
||||
|
||||
function toNumber(value: string | number | null | undefined): number {
|
||||
const n = typeof value === 'string' ? parseFloat(value) : (value ?? 0)
|
||||
return Number.isNaN(n) ? 0 : n
|
||||
}
|
||||
|
||||
function emptyStats(): ProgressStats {
|
||||
return { count: 0, population: 0, adopted: 0, engaged: 0, withPrayer: 0, withFullPrayer: 0, peopleCommitted: 0 }
|
||||
}
|
||||
|
||||
function addToStats(stats: ProgressStats, p: PeopleGroupLite): void {
|
||||
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
|
||||
}
|
||||
|
||||
// Pin coordinates collected for a fit-bounds map view.
|
||||
interface PinBounds { lats: number[], lngs: number[] }
|
||||
|
||||
function addPin(bounds: PinBounds, p: PeopleGroupLite): void {
|
||||
const lat = typeof p.latitude === 'string' ? parseFloat(p.latitude) : p.latitude
|
||||
const lng = typeof p.longitude === 'string' ? parseFloat(p.longitude) : p.longitude
|
||||
if (lat != null && lng != null && !Number.isNaN(lat) && !Number.isNaN(lng)) {
|
||||
bounds.lats.push(lat)
|
||||
bounds.lngs.push(lng)
|
||||
}
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
|
|
@ -71,7 +169,7 @@ function latRad(lat: number): number {
|
|||
// slightly beyond a tight fit so surrounding countries remain in view (the
|
||||
// whole point of the country page's map). The viewport dimensions are an
|
||||
// approximation of the map slot; exact framing isn't important because the
|
||||
// research-map keeps its own pan/zoom controls.
|
||||
// map keeps its own pan/zoom controls.
|
||||
function fitView(minLng: number, minLat: number, maxLng: number, maxLat: number): { center: [number, number], zoom: number } {
|
||||
const WORLD_DIM = 512
|
||||
const VIEW_W = 1000
|
||||
|
|
@ -97,17 +195,47 @@ function fitView(minLng: number, minLat: number, maxLng: number, maxLat: number)
|
|||
}
|
||||
}
|
||||
|
||||
// Default whole-world view for a country whose records have no coordinates.
|
||||
const WORLD_VIEW: { center: [number, number], zoom: number } = { center: [20, 10], zoom: 1.8 }
|
||||
// Longitude extent covering all pins along the shortest arc: sorts the
|
||||
// longitudes, finds the largest gap between neighbours (including the
|
||||
// wrap-around gap past 180), and spans everything else. When the extent
|
||||
// crosses the antimeridian the east edge exceeds 180 so the range stays
|
||||
// continuous.
|
||||
function lngExtent(lngs: number[]): [number, number] {
|
||||
const sorted = [...lngs].sort((a, b) => a - b)
|
||||
const last = sorted.length - 1
|
||||
let gapStart = last
|
||||
let maxGap = sorted[0]! + 360 - sorted[last]!
|
||||
for (let i = 0; i < last; i++) {
|
||||
const gap = sorted[i + 1]! - sorted[i]!
|
||||
if (gap > maxGap) {
|
||||
maxGap = gap
|
||||
gapStart = i
|
||||
}
|
||||
}
|
||||
if (gapStart === last) return [sorted[0]!, sorted[last]!]
|
||||
return [sorted[gapStart + 1]!, sorted[gapStart]! + 360]
|
||||
}
|
||||
|
||||
// Default whole-world view for a set of records with no coordinates.
|
||||
const WORLD_VIEW: MapView = { center: [20, 10], zoom: 1.8, bounds: null }
|
||||
|
||||
interface MapView { center: [number, number], zoom: number, bounds: MapBounds | null }
|
||||
|
||||
function viewFor(pins: PinBounds): MapView {
|
||||
if (pins.lats.length === 0) return WORLD_VIEW
|
||||
const [west, east] = lngExtent(pins.lngs)
|
||||
const south = Math.min(...pins.lats)
|
||||
const north = Math.max(...pins.lats)
|
||||
return { ...fitView(west, south, east, north), bounds: [[west, south], [east, north]] }
|
||||
}
|
||||
|
||||
// Group people-group records into one summary per country, sorted by name.
|
||||
export function summarizeCountries(posts: PeopleGroupLite[]): CountrySummary[] {
|
||||
const groups = new Map<string, {
|
||||
name: string
|
||||
region: { value: string, label: string }
|
||||
lats: number[]
|
||||
lngs: number[]
|
||||
count: number
|
||||
pins: PinBounds
|
||||
stats: ProgressStats
|
||||
}>()
|
||||
|
||||
for (const p of posts) {
|
||||
|
|
@ -116,41 +244,83 @@ export function summarizeCountries(posts: PeopleGroupLite[]): CountrySummary[] {
|
|||
|
||||
let g = groups.get(code)
|
||||
if (!g) {
|
||||
g = {
|
||||
name: p.country_code!.label,
|
||||
region: p.wagf_region ?? { value: '', label: '' },
|
||||
lats: [],
|
||||
lngs: [],
|
||||
count: 0
|
||||
}
|
||||
g = { name: p.country_code!.label, region: { value: '', label: '' }, pins: { lats: [], lngs: [] }, stats: emptyStats() }
|
||||
groups.set(code, g)
|
||||
}
|
||||
g.count++
|
||||
|
||||
const lat = typeof p.latitude === 'string' ? parseFloat(p.latitude) : p.latitude
|
||||
const lng = typeof p.longitude === 'string' ? parseFloat(p.longitude) : p.longitude
|
||||
if (lat != null && lng != null && !Number.isNaN(lat) && !Number.isNaN(lng)) {
|
||||
g.lats.push(lat)
|
||||
g.lngs.push(lng)
|
||||
}
|
||||
// A few records carry no region; the country takes the first region seen.
|
||||
if (!g.region.value && p.wagf_region?.value) g.region = p.wagf_region
|
||||
addToStats(g.stats, p)
|
||||
addPin(g.pins, p)
|
||||
}
|
||||
|
||||
const out: CountrySummary[] = []
|
||||
for (const [code, g] of groups) {
|
||||
const view = g.lats.length > 0
|
||||
? fitView(Math.min(...g.lngs), Math.min(...g.lats), Math.max(...g.lngs), Math.max(...g.lats))
|
||||
: WORLD_VIEW
|
||||
const view = viewFor(g.pins)
|
||||
out.push({
|
||||
slug: countrySlug(code, g.name),
|
||||
code,
|
||||
name: g.name,
|
||||
region: g.region,
|
||||
count: g.count,
|
||||
center: view.center,
|
||||
zoom: view.zoom
|
||||
zoom: view.zoom,
|
||||
bounds: view.bounds,
|
||||
...g.stats
|
||||
})
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return out
|
||||
}
|
||||
|
||||
// Group people-group records into one summary per WAGF region, sorted by label.
|
||||
// Each people group rolls up under its country's region, so the region totals,
|
||||
// the per-country rows and the map's in-region pins all agree.
|
||||
export function summarizeRegions(posts: PeopleGroupLite[]): RegionSummary[] {
|
||||
const countries = summarizeCountries(posts)
|
||||
const regionSlugByCountry = new Map(countries.map(c => [c.code, regionSlug(c.region.value)]))
|
||||
|
||||
const groups = new Map<string, {
|
||||
value: string
|
||||
label: string
|
||||
countries: CountrySummary[]
|
||||
pins: PinBounds
|
||||
stats: ProgressStats
|
||||
}>()
|
||||
|
||||
for (const c of countries) {
|
||||
const slug = regionSlugByCountry.get(c.code)!
|
||||
let g = groups.get(slug)
|
||||
if (!g) {
|
||||
g = { value: c.region.value, label: c.region.label, countries: [], pins: { lats: [], lngs: [] }, stats: emptyStats() }
|
||||
groups.set(slug, g)
|
||||
}
|
||||
g.countries.push(c)
|
||||
}
|
||||
|
||||
for (const p of posts) {
|
||||
const code = p.country_code?.value
|
||||
if (!code) continue
|
||||
const g = groups.get(regionSlugByCountry.get(code)!)!
|
||||
addToStats(g.stats, p)
|
||||
addPin(g.pins, p)
|
||||
}
|
||||
|
||||
const out: RegionSummary[] = []
|
||||
for (const [slug, g] of groups) {
|
||||
const view = viewFor(g.pins)
|
||||
out.push({
|
||||
slug,
|
||||
value: g.value,
|
||||
label: g.label,
|
||||
countryCount: g.countries.length,
|
||||
center: view.center,
|
||||
zoom: view.zoom,
|
||||
bounds: view.bounds,
|
||||
countries: g.countries,
|
||||
...g.stats
|
||||
})
|
||||
}
|
||||
|
||||
out.sort((a, b) => a.label.localeCompare(b.label))
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
{
|
||||
"Daily Prayer Coverage": "تغطية صلاة يومية",
|
||||
"All Countries": "جميع الدول",
|
||||
"By Country": "حسب الدولة",
|
||||
"%count% people groups in %countries% countries": "%count% مجموعة بشرية في %countries% دولة",
|
||||
"%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.": "تضم %region% %count% مجموعة بشرية في %countries% دولة. يشير لون كل نقطة إلى عدد الأشخاص الذين يصلّون من أجل تلك المجموعة البشرية.",
|
||||
"All Regions": "جميع المناطق",
|
||||
"By Region": "حسب المنطقة",
|
||||
"Countries": "الدول",
|
||||
"Country Not Found": "الدولة غير موجودة",
|
||||
"Find unreached people groups by country.": "ابحث عن المجموعات البشرية غير المُبلَّغة حسب الدولة.",
|
||||
"Countries in this region": "الدول في هذه المنطقة",
|
||||
"Engagement Progress": "تقدُّم الانخراط",
|
||||
"Find unreached people groups by region and country.": "ابحث عن المجموعات البشرية غير المُبلَّغة حسب المنطقة والدولة.",
|
||||
"Goal": "الهدف",
|
||||
"Other": "أخرى",
|
||||
"Outside this region": "خارج هذه المنطقة",
|
||||
"People Groups": "مجموعات بشرية",
|
||||
"people groups engaged": "مجموعات بشرية مُنخرِطة",
|
||||
"Regions": "المناطق",
|
||||
"There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.": "توجد %count% مجموعات بشرية في %country%. تحقق من الخريطة لمعرفة المجموعات البشرية الواقعة على حدود هذه الدولة أيضًا.",
|
||||
"A church-led commitment to pray, give, and send so that gospel access begins": "الكنيسة تقودها روح الإلتزام بالصلاة والعطاء والإرسال لتفتح أبواب الوصول بالإنجيل",
|
||||
"A clean, elegant theme for DOXA Life - Engaging every people group by 2033": "تصميم أنيق وبسيط لمنصة حياة دوكسا - للوصول إلى جميع الناس بحلول عام 2033",
|
||||
|
|
@ -244,6 +251,7 @@
|
|||
"Tools to mobilize prayer and participation across your congregation": "أدوات عملية لحشد الصلاة والمشاركة بين أعضاء كنيستك",
|
||||
"Total": "المجموع",
|
||||
"Type to search...": "اكتب ما تريد البحث عنه...",
|
||||
"Unengaged people groups": "الشعوب الغير متفاعلة",
|
||||
"UUPG Detail": "التفاصيل عن مجموعة بشرية غير مبشّرة وغير مخدومة روحيا",
|
||||
"UUPG Photo": "صورة لمجموعة بشرية غير مبشّرة وغير مخدومة روحيا",
|
||||
"UUPGS": "مجموعة بشرية غير مبشّرة وغير مخدومة روحيا",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
{
|
||||
"Daily Prayer Coverage": "Tägliche Gebetsabdeckung",
|
||||
"All Countries": "Alle Länder",
|
||||
"By Country": "Nach Land",
|
||||
"%count% people groups in %countries% countries": "%count% Volksgruppen in %countries% Ländern",
|
||||
"%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.": "%region% hat %count% Volksgruppen in %countries% Ländern. Die Farbe jedes Punktes zeigt, wie viele Menschen für diese Volksgruppe beten.",
|
||||
"All Regions": "Alle Regionen",
|
||||
"By Region": "Nach Region",
|
||||
"Countries": "Länder",
|
||||
"Country Not Found": "Land nicht gefunden",
|
||||
"Find unreached people groups by country.": "Finde unerreichte Volksgruppen nach Land.",
|
||||
"Countries in this region": "Länder in dieser Region",
|
||||
"Engagement Progress": "Engagement-Fortschritt",
|
||||
"Find unreached people groups by region and country.": "Finde unerreichte Volksgruppen nach Region und Land.",
|
||||
"Goal": "Ziel",
|
||||
"Other": "Andere",
|
||||
"Outside this region": "Außerhalb dieser Region",
|
||||
"People Groups": "Volksgruppen",
|
||||
"people groups engaged": "engagierte Volksgruppen",
|
||||
"Regions": "Regionen",
|
||||
"There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.": "In %country% gibt es %count% Volksgruppen. Sieh auch auf der Karte nach den Volksgruppen an der Grenze dieses Landes.",
|
||||
"A church-led commitment to pray, give, and send so that gospel access begins": "Verpflichtung einer Ortsgemeinde zum Beten, Geben und Senden, damit ein Volk Zugang zum Evangelium erhält",
|
||||
"A clean, elegant theme for DOXA Life - Engaging every people group by 2033": "Ein klares, elegantes Thema für DOXA Life – Bis 2033 alle Volksgruppen erreichen",
|
||||
|
|
@ -244,6 +251,7 @@
|
|||
"Tools to mobilize prayer and participation across your congregation": "Werkzeuge, um viele in deiner Gemeinde zu mobilisieren",
|
||||
"Total": "Gesamt",
|
||||
"Type to search...": "Geben Sie etwas ein, um danach zu suchen",
|
||||
"Unengaged people groups": "Völlig unerreichte Volksgruppen",
|
||||
"UUPG Detail": "UUPG-Detail",
|
||||
"UUPG Photo": "Foto der UUPG",
|
||||
"UUPGS": "UUPGS",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
{
|
||||
"Daily Prayer Coverage": "Daily Prayer Coverage",
|
||||
"%count% people groups in %countries% countries": "%count% people groups in %countries% countries",
|
||||
"%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.": "%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.",
|
||||
"A church-led commitment to pray, give, and send so that gospel access begins": "A church-led commitment to pray, give, and send so that gospel access begins",
|
||||
"A clean, elegant theme for DOXA Life - Engaging every people group by 2033": "A clean, elegant theme for DOXA Life - Engaging every people group by 2033",
|
||||
"A simple path to faithful obedience": "A simple path to faithful obedience",
|
||||
|
|
@ -20,12 +22,14 @@
|
|||
"Adoption invites the local church into meaningful participation - praying, giving, and sending – so that a people group moves from unengaged to engaged, and ultimately becomes a worshipping community for Jesus.": "Adoption invites the local church into meaningful participation - praying, giving, and sending – so that a people group moves from unengaged to engaged, and ultimately becomes a worshipping community for Jesus.",
|
||||
"Adoption is a much bigger commitment than signing up to pray. If you only wish to pray, click the button below. Please remember to also sign up to pray after you fill out the adoption form.": "Adoption is a much bigger commitment than signing up to pray. If you only wish to pray, click the button below. Please remember to also sign up to pray after you fill out the adoption form.",
|
||||
"Adoption mobilizes 100+ daily intercessors whose prayer prepares the soil, breaks spiritual barriers, and supports workers who go. Every gospel movement begins with prayer.": "Adoption mobilizes 100+ daily intercessors whose prayer prepares the soil, breaks spiritual barriers, and supports workers who go. Every gospel movement begins with prayer.",
|
||||
"All Regions": "All Regions",
|
||||
"All rights reserved.": "All rights reserved.",
|
||||
"Alternate Names": "Alternate Names",
|
||||
"Audio Scripture": "Audio Scripture",
|
||||
"Back": "Back",
|
||||
"Bible Stories": "Bible Stories",
|
||||
"Bible Translation": "Bible Translation",
|
||||
"By Region": "By Region",
|
||||
"Champion Details": "Champion Details",
|
||||
"Champion Tips": "Champion Tips",
|
||||
"Choose": "Choose",
|
||||
|
|
@ -42,17 +46,18 @@
|
|||
"Commit": "Commit",
|
||||
"100+ People Praying": "100+ People Praying",
|
||||
"1+ People Praying": "1+ People Praying",
|
||||
"Countries in this region": "Countries in this region",
|
||||
"Engagement Progress": "Engagement Progress",
|
||||
"Find unreached people groups by region and country.": "Find unreached people groups by region and country.",
|
||||
"Goal": "Goal",
|
||||
"No One Praying": "No One Praying",
|
||||
"Contact": "Contact",
|
||||
"All Countries": "All Countries",
|
||||
"By Country": "By Country",
|
||||
"Contact Page": "Contact Page",
|
||||
"Contact Us": "Contact Us",
|
||||
"Countries": "Countries",
|
||||
"Country": "Country",
|
||||
"Country Not Found": "Country Not Found",
|
||||
"Find unreached people groups by country.": "Find unreached people groups by country.",
|
||||
"Other": "Other",
|
||||
"Outside this region": "Outside this region",
|
||||
"People Groups": "People Groups",
|
||||
"Cross-cultural workers present": "Cross-cultural workers present",
|
||||
"Current Status": "Current Status",
|
||||
|
|
@ -160,6 +165,7 @@
|
|||
"People Group": "People Group",
|
||||
"People Group Not Found": "People Group Not Found",
|
||||
"People committed to praying": "People committed to praying",
|
||||
"people groups engaged": "people groups engaged",
|
||||
"People groups with complete daily prayer coverage": "People groups with complete daily prayer coverage",
|
||||
"Per Page": "Per Page",
|
||||
"Phone": "Phone",
|
||||
|
|
@ -199,6 +205,7 @@
|
|||
"Raise up 100+ daily intercessors to pray for this people group.": "Raise up 100+ daily intercessors to pray for this people group.",
|
||||
"Real prayer needs from the field": "Real prayer needs from the field",
|
||||
"Receive daily prayer points and join believers worldwide in prayer for the unengaged peoples.": "Receive daily prayer points and join believers worldwide in prayer for the unengaged peoples.",
|
||||
"Regions": "Regions",
|
||||
"Religion": "Religion",
|
||||
"Religious Practices": "Religious Practices",
|
||||
"Research": "Research",
|
||||
|
|
@ -245,6 +252,7 @@
|
|||
"Tools to mobilize prayer and participation across your congregation": "Tools to mobilize prayer and participation across your congregation",
|
||||
"Total": "Total",
|
||||
"Type to search...": "Type to search...",
|
||||
"Unengaged people groups": "Unengaged people groups",
|
||||
"UUPG Detail": "UUPG Detail",
|
||||
"UUPG Photo": "UUPG Photo",
|
||||
"UUPGS": "UUPGS",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
{
|
||||
"Daily Prayer Coverage": "Cobertura diaria de oración",
|
||||
"All Countries": "Todos los Países",
|
||||
"By Country": "Por país",
|
||||
"%count% people groups in %countries% countries": "%count% grupos étnicos en %countries% países",
|
||||
"%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.": "%region% tiene %count% grupos étnicos en %countries% países. El color de cada punto indica cuántas personas oran por ese grupo étnico.",
|
||||
"All Regions": "Todas las regiones",
|
||||
"By Region": "Por región",
|
||||
"Countries": "Países",
|
||||
"Country Not Found": "País no encontrado",
|
||||
"Find unreached people groups by country.": "Encuentra grupos étnicos no alcanzados por país.",
|
||||
"Countries in this region": "Países de esta región",
|
||||
"Engagement Progress": "Progreso del compromiso",
|
||||
"Find unreached people groups by region and country.": "Encuentra grupos étnicos no alcanzados por región y país.",
|
||||
"Goal": "Meta",
|
||||
"Other": "Otros",
|
||||
"Outside this region": "Fuera de esta región",
|
||||
"People Groups": "Grupos Étnicos",
|
||||
"people groups engaged": "grupos étnicos comprometidos",
|
||||
"Regions": "Regiones",
|
||||
"There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.": "Hay %count% grupos étnicos en %country%. Consulta también el mapa para ver los grupos étnicos en la frontera de este país.",
|
||||
"A church-led commitment to pray, give, and send so that gospel access begins": "Un compromiso impulsado por la iglesia para orar, dar y enviar, con el fin de que el evangelio sea accesible",
|
||||
"A clean, elegant theme for DOXA Life - Engaging every people group by 2033": "Un tema limpio y elegante para DOXA Life: involucrar a todos los grupos de personas para 2033",
|
||||
|
|
@ -244,6 +251,7 @@
|
|||
"Tools to mobilize prayer and participation across your congregation": "Herramientas para movilizar la oración y la participación en toda su congregación",
|
||||
"Total": "Total",
|
||||
"Type to search...": "Escribe para buscar...",
|
||||
"Unengaged people groups": "Grupos étnicos no comprometidos",
|
||||
"UUPG Detail": "Detalle PNANC",
|
||||
"UUPG Photo": "Foto de la UUPG",
|
||||
"UUPGS": "UUPGS",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
{
|
||||
"Daily Prayer Coverage": "Couverture de prière quotidienne",
|
||||
"All Countries": "Tous les pays",
|
||||
"By Country": "Par pays",
|
||||
"%count% people groups in %countries% countries": "%count% peuples dans %countries% pays",
|
||||
"%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.": "%region% compte %count% peuples répartis dans %countries% pays. La couleur de chaque point indique combien de personnes prient pour ce peuple.",
|
||||
"All Regions": "Toutes les régions",
|
||||
"By Region": "Par région",
|
||||
"Countries": "Pays",
|
||||
"Country Not Found": "Pays introuvable",
|
||||
"Find unreached people groups by country.": "Trouvez des peuples non atteints par pays.",
|
||||
"Countries in this region": "Pays de cette région",
|
||||
"Engagement Progress": "Progrès de l'engagement",
|
||||
"Find unreached people groups by region and country.": "Trouvez des peuples non atteints par région et par pays.",
|
||||
"Goal": "Objectif",
|
||||
"Other": "Autre",
|
||||
"Outside this region": "Hors de cette région",
|
||||
"People Groups": "Peuples",
|
||||
"people groups engaged": "peuples engagés",
|
||||
"Regions": "Régions",
|
||||
"There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.": "Il y a %count% peuples en %country%. Consultez la carte pour découvrir également les peuples situés à la frontière de ce pays.",
|
||||
"A church-led commitment to pray, give, and send so that gospel access begins": "Un engagement d'église locale pour prier, donner et envoyer afin que l'accès à l'Évangile commence",
|
||||
"A clean, elegant theme for DOXA Life - Engaging every people group by 2033": "Un thème épuré et élégant pour DOXA Life - Engager chaque peuple d'ici 2033",
|
||||
|
|
@ -244,6 +251,7 @@
|
|||
"Tools to mobilize prayer and participation across your congregation": "Outils pour mobiliser la prière et la participation au sein de votre congrégation",
|
||||
"Total": "Total",
|
||||
"Type to search...": "Entrer votre recherche",
|
||||
"Unengaged people groups": "Peuples non engagés",
|
||||
"UUPG Detail": "Détails UUPG",
|
||||
"UUPG Photo": "Photo de l'UUPG",
|
||||
"UUPGS": "UUPG",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
{
|
||||
"Daily Prayer Coverage": "दैनिक प्रार्थना कवरेज",
|
||||
"All Countries": "सभी देश",
|
||||
"By Country": "देश के अनुसार",
|
||||
"%count% people groups in %countries% countries": "%countries% देशों में %count% लोगों के समूह",
|
||||
"%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.": "%region% में %countries% देशों में %count% लोगों के समूह हैं। प्रत्येक बिंदु का रंग दर्शाता है कि उस लोगों के समूह के लिए कितने लोग प्रार्थना कर रहे हैं।",
|
||||
"All Regions": "सभी क्षेत्र",
|
||||
"By Region": "क्षेत्र के अनुसार",
|
||||
"Countries": "देश",
|
||||
"Country Not Found": "देश नहीं मिला",
|
||||
"Find unreached people groups by country.": "देश के अनुसार अगम्य लोगों के समूह खोजें।",
|
||||
"Countries in this region": "इस क्षेत्र के देश",
|
||||
"Engagement Progress": "प्रवेश की प्रगति",
|
||||
"Find unreached people groups by region and country.": "क्षेत्र और देश के अनुसार अगम्य लोगों के समूह खोजें।",
|
||||
"Goal": "लक्ष्य",
|
||||
"Other": "अन्य",
|
||||
"Outside this region": "इस क्षेत्र के बाहर",
|
||||
"People Groups": "लोगों के समूह",
|
||||
"people groups engaged": "प्रवेशित लोगों के समूह",
|
||||
"Regions": "क्षेत्र",
|
||||
"There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.": "%country% में %count% लोगों के समूह हैं। इस देश की सीमा पर स्थित लोगों के समूहों के लिए मानचित्र भी देखें।",
|
||||
"A church-led commitment to pray, give, and send so that gospel access begins": "सुसमाचार की पहुँच शुरू हो सके, इसके लिए चर्च के नेतृत्व में प्रार्थना करने, दान देने और भेजने की प्रतिबद्धता।",
|
||||
"A clean, elegant theme for DOXA Life - Engaging every people group by 2033": "DOXA Life के लिए एक स्वच्छ, सुरुचिपूर्ण थीम - 2033 तक प्रत्येक जनसमूह को संलग्न करना",
|
||||
|
|
@ -244,6 +251,7 @@
|
|||
"Tools to mobilize prayer and participation across your congregation": "आपकी संपूर्ण मंडली में प्रार्थना और सहभागिता को सक्रिय करने के उपकरण",
|
||||
"Total": "संपूर्ण",
|
||||
"Type to search...": "खोजने के लिए टाइप करें..।",
|
||||
"Unengaged people groups": "अनलग्न लोगों के समूह",
|
||||
"UUPG Detail": "यूयूपीजी विवरण",
|
||||
"UUPG Photo": "यूयूपीजी फोटो",
|
||||
"UUPGS": "यूयूपीजीएस",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
{
|
||||
"Daily Prayer Coverage": "Cobertura diária de oração",
|
||||
"All Countries": "Todos os países",
|
||||
"By Country": "Por país",
|
||||
"%count% people groups in %countries% countries": "%count% grupos étnicos em %countries% países",
|
||||
"%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.": "%region% tem %count% grupos étnicos em %countries% países. A cor de cada ponto indica quantas pessoas oram por esse grupo étnico.",
|
||||
"All Regions": "Todas as regiões",
|
||||
"By Region": "Por região",
|
||||
"Countries": "Países",
|
||||
"Country Not Found": "País não encontrado",
|
||||
"Find unreached people groups by country.": "Encontre grupos étnicos não alcançados por país.",
|
||||
"Countries in this region": "Países desta região",
|
||||
"Engagement Progress": "Progresso do engajamento",
|
||||
"Find unreached people groups by region and country.": "Encontre grupos étnicos não alcançados por região e país.",
|
||||
"Goal": "Meta",
|
||||
"Other": "Outros",
|
||||
"Outside this region": "Fora desta região",
|
||||
"People Groups": "Grupos étnicos",
|
||||
"people groups engaged": "grupos étnicos engajados",
|
||||
"Regions": "Regiões",
|
||||
"There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.": "Existem %count% grupos étnicos em %country%. Verifique no mapa também os grupos étnicos na fronteira deste país.",
|
||||
"A church-led commitment to pray, give, and send so that gospel access begins": "Um compromisso liderado pela igreja para orar, doar e enviar, para que o evangelho comece a se tornar acessível",
|
||||
"A clean, elegant theme for DOXA Life - Engaging every people group by 2033": "Um tema limpo e elegante para a DOXA Life - Envolvendo todos os grupos étnicos até 2033",
|
||||
|
|
@ -244,6 +251,7 @@
|
|||
"Tools to mobilize prayer and participation across your congregation": "Ferramentas para mobilizar a oração e a participação em toda a sua congregação",
|
||||
"Total": "Total",
|
||||
"Type to search...": "Digite para pesquisar...",
|
||||
"Unengaged people groups": "Grupos étnicos não engajados",
|
||||
"UUPG Detail": "Detalhes da UUPG",
|
||||
"UUPG Photo": "Foto da UUPG",
|
||||
"UUPGS": "UUPGS",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
{
|
||||
"Daily Prayer Coverage": "Ежедневная молитвенная поддержка",
|
||||
"All Countries": "Все страны",
|
||||
"By Country": "По странам",
|
||||
"%count% people groups in %countries% countries": "%count% групп людей в %countries% странах",
|
||||
"%region% has %count% people groups across %countries% countries. Each pin is coloured by how many people are praying for that people group.": "В регионе %region% — %count% групп людей в %countries% странах. Цвет каждой точки показывает, сколько человек молится за эту группу людей.",
|
||||
"All Regions": "Все регионы",
|
||||
"By Region": "По регионам",
|
||||
"Countries": "Страны",
|
||||
"Country Not Found": "Страна не найдена",
|
||||
"Find unreached people groups by country.": "Найдите недостигнутые группы людей по странам.",
|
||||
"Countries in this region": "Страны этого региона",
|
||||
"Engagement Progress": "Прогресс вовлечения",
|
||||
"Find unreached people groups by region and country.": "Найдите недостигнутые группы людей по регионам и странам.",
|
||||
"Goal": "Цель",
|
||||
"Other": "Другое",
|
||||
"Outside this region": "За пределами этого региона",
|
||||
"People Groups": "Группы людей",
|
||||
"people groups engaged": "вовлечённые группы людей",
|
||||
"Regions": "Регионы",
|
||||
"There are %count% people groups in %country%. Check the map for the people groups on the border of this country as well.": "Страна %country% насчитывает %count% групп людей. Проверьте также на карте группы людей, расположенные вдоль границ этой страны.",
|
||||
"A church-led commitment to pray, give, and send so that gospel access begins": "Обязательство церкви молиться, жертвовать и посылать миссионеров, чтобы Евангелие стало доступным",
|
||||
"A clean, elegant theme for DOXA Life - Engaging every people group by 2033": "Чистая, элегантная тема для DOXA Life — привлечение всех народов к 2033 году",
|
||||
|
|
@ -244,6 +251,7 @@
|
|||
"Tools to mobilize prayer and participation across your congregation": "Инструменты для мобилизации молитвы и участия всей вашей общины",
|
||||
"Total": "Итого",
|
||||
"Type to search...": "Введите текст для поиска...",
|
||||
"Unengaged people groups": "Невовлечённые группы людей",
|
||||
"UUPG Detail": "Подробная информация об UUPG",
|
||||
"UUPG Photo": "Фотография UUPG",
|
||||
"UUPGS": "UUPGS",
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
// Build-time module that enumerates the per-country pages for prerendering.
|
||||
//
|
||||
// Crawling is disabled (nitro.prerender.crawlLinks = false), and the country
|
||||
// set is data-driven (derived from the prayer API), so the routes can't be a
|
||||
// static list like the other marketing pages. At build we fetch the people-
|
||||
// groups list once, derive the country slugs, and queue /countries plus
|
||||
// /countries/<slug> for every enabled locale.
|
||||
//
|
||||
// If the prayer API is unreachable at build time, no country pages are
|
||||
// prerendered (the build still succeeds); they then resolve live on request.
|
||||
import { defineNuxtModule } from '@nuxt/kit'
|
||||
import { summarizeCountries } from '../config/countries-meta'
|
||||
import { ENABLED_LANGUAGE_CODES } from '../config/languages'
|
||||
|
||||
export default defineNuxtModule({
|
||||
meta: { name: 'country-routes' },
|
||||
async setup(_options, nuxt) {
|
||||
// Only relevant for the production build's prerender pass.
|
||||
if (nuxt.options.dev) return
|
||||
|
||||
const base = process.env.NUXT_PRAY_BASE_URL || 'https://pray.doxa.life'
|
||||
let slugs: string[]
|
||||
try {
|
||||
const res = await fetch(`${base}/api/people-groups/list?fields=country_code,wagf_region,latitude,longitude&lang=en`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json() as { posts?: unknown[] }
|
||||
slugs = summarizeCountries((data.posts ?? []) as never[]).map(c => c.slug)
|
||||
} catch (err) {
|
||||
console.warn('[country-routes] could not fetch people-groups list; country pages will not be prerendered:', (err as Error).message)
|
||||
return
|
||||
}
|
||||
|
||||
// '' = default locale (no prefix); others are /es, /fr, …
|
||||
const prefixes = ['', ...ENABLED_LANGUAGE_CODES.filter(code => code !== 'en').map(code => `/${code}`)]
|
||||
const routes: string[] = []
|
||||
for (const prefix of prefixes) {
|
||||
routes.push(`${prefix}/countries`)
|
||||
for (const slug of slugs) routes.push(`${prefix}/countries/${slug}`)
|
||||
}
|
||||
|
||||
nuxt.options.nitro.prerender ||= {}
|
||||
nuxt.options.nitro.prerender.routes ||= []
|
||||
nuxt.options.nitro.prerender.routes.push(...routes)
|
||||
|
||||
console.log(`[country-routes] queued ${routes.length} routes for prerender (${slugs.length} countries × ${prefixes.length} locales)`)
|
||||
}
|
||||
})
|
||||
49
modules/region-routes.ts
Normal file
49
modules/region-routes.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Build-time module that enumerates the region and country pages for
|
||||
// prerendering.
|
||||
//
|
||||
// Crawling is disabled (nitro.prerender.crawlLinks = false), and the region and
|
||||
// country sets are data-driven (derived from the prayer API), so the routes
|
||||
// can't be a static list like the other marketing pages. At build we fetch the
|
||||
// people-groups list once, derive the region and country slugs, and queue
|
||||
// /regions, /regions/<region> and /regions/<country> for every enabled locale.
|
||||
//
|
||||
// If the prayer API is unreachable at build time, no region pages are
|
||||
// prerendered (the build still succeeds); they then resolve live on request.
|
||||
import { defineNuxtModule } from '@nuxt/kit'
|
||||
import { PEOPLE_GROUP_LIST_FIELDS, summarizeRegions } from '../config/countries-meta'
|
||||
import { ENABLED_LANGUAGE_CODES } from '../config/languages'
|
||||
|
||||
export default defineNuxtModule({
|
||||
meta: { name: 'region-routes' },
|
||||
async setup(_options, nuxt) {
|
||||
// Only relevant for the production build's prerender pass.
|
||||
if (nuxt.options.dev) return
|
||||
|
||||
const base = process.env.NUXT_PRAY_BASE_URL || 'https://pray.doxa.life'
|
||||
let slugs: string[]
|
||||
try {
|
||||
const res = await fetch(`${base}/api/people-groups/list?fields=${PEOPLE_GROUP_LIST_FIELDS}&lang=en`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json() as { posts?: unknown[] }
|
||||
slugs = summarizeRegions((data.posts ?? []) as never[])
|
||||
.flatMap(region => [region.slug, ...region.countries.map(c => c.slug)])
|
||||
} catch (err) {
|
||||
console.warn('[region-routes] could not fetch people-groups list; region pages will not be prerendered:', (err as Error).message)
|
||||
return
|
||||
}
|
||||
|
||||
// '' = default locale (no prefix); others are /es, /fr, …
|
||||
const prefixes = ['', ...ENABLED_LANGUAGE_CODES.filter(code => code !== 'en').map(code => `/${code}`)]
|
||||
const routes: string[] = []
|
||||
for (const prefix of prefixes) {
|
||||
routes.push(`${prefix}/regions`)
|
||||
for (const slug of slugs) routes.push(`${prefix}/regions/${slug}`)
|
||||
}
|
||||
|
||||
nuxt.options.nitro.prerender ||= {}
|
||||
nuxt.options.nitro.prerender.routes ||= []
|
||||
nuxt.options.nitro.prerender.routes.push(...routes)
|
||||
|
||||
console.log(`[region-routes] queued ${routes.length} routes for prerender (${slugs.length} regions + countries × ${prefixes.length} locales)`)
|
||||
}
|
||||
})
|
||||
|
|
@ -38,14 +38,18 @@ const marketingRouteRules = Object.fromEntries(
|
|||
)
|
||||
)
|
||||
|
||||
// Cache headers for the per-country pages (/countries and /countries/<slug>).
|
||||
// The actual prerender route list is enumerated in modules/country-routes.ts
|
||||
// (the country set is data-driven, so it can't be a static glob); these rules
|
||||
// just apply the marketing HTML cache policy across all enabled locales.
|
||||
const countryRouteRules = Object.fromEntries(
|
||||
// Cache headers for the region and country pages (/regions, /regions/<region>,
|
||||
// /regions/<country>). The actual prerender route list is enumerated in
|
||||
// modules/region-routes.ts (the region/country set is data-driven, so it can't
|
||||
// be a static glob); these rules just apply the marketing HTML cache policy
|
||||
// across all enabled locales. The former /countries URLs redirect permanently
|
||||
// to their /regions equivalents (same slugs) so old links keep working.
|
||||
const regionRouteRules = Object.fromEntries(
|
||||
MARKETING_LOCALE_PREFIXES.flatMap(prefix => [
|
||||
[`${prefix}/countries`, { headers: { 'cache-control': MARKETING_HTML_CACHE } }],
|
||||
[`${prefix}/countries/**`, { headers: { 'cache-control': MARKETING_HTML_CACHE } }]
|
||||
[`${prefix}/regions`, { headers: { 'cache-control': MARKETING_HTML_CACHE } }],
|
||||
[`${prefix}/regions/**`, { headers: { 'cache-control': MARKETING_HTML_CACHE } }],
|
||||
[`${prefix}/countries`, { redirect: { to: `${prefix}/regions`, statusCode: 301 } }],
|
||||
[`${prefix}/countries/**`, { redirect: { to: `${prefix}/regions/**`, statusCode: 301 } }]
|
||||
])
|
||||
)
|
||||
|
||||
|
|
@ -84,7 +88,7 @@ export default defineNuxtConfig({
|
|||
|
||||
modules: [
|
||||
'./modules/migrations',
|
||||
'./modules/country-routes',
|
||||
'./modules/region-routes',
|
||||
'@nuxt/eslint',
|
||||
'@nuxt/ui',
|
||||
'@nuxtjs/i18n',
|
||||
|
|
@ -157,8 +161,8 @@ export default defineNuxtConfig({
|
|||
'/assets/**': { headers: { 'cache-control': 'public, max-age=604800' } },
|
||||
// Prerendered marketing pages (all enabled locales) — edge-cacheable HTML.
|
||||
...marketingRouteRules,
|
||||
// Per-country pages — same cache policy; routes enumerated at build time.
|
||||
...countryRouteRules,
|
||||
// Region + country pages — same cache policy; routes enumerated at build time.
|
||||
...regionRouteRules,
|
||||
'/login': { ssr: false, prerender: false },
|
||||
'/register': { ssr: false, prerender: false },
|
||||
'/reset-password': { ssr: false, prerender: false },
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
/**
|
||||
* GET /api/countries
|
||||
* Returns one summary per country that has people groups (slug, code, localized
|
||||
* name, WAGF region, count, and a map center/zoom framed on the country).
|
||||
* Supports translated names via ?lang=.
|
||||
*
|
||||
* Powers the /countries index and each /countries/[country] page.
|
||||
* name, WAGF region, progress stats, and a map center/zoom framed on the
|
||||
* country). Supports translated names via ?lang=. The region pages read the
|
||||
* same countries nested under /api/regions.
|
||||
*/
|
||||
import { getCountriesSummary, resolveLang } from '../utils/countries'
|
||||
|
||||
|
|
|
|||
23
server/api/regions.get.ts
Normal file
23
server/api/regions.get.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* GET /api/regions
|
||||
* Returns one summary per WAGF region that has people groups: slug, localized
|
||||
* label, progress stats (people groups, population, adopted, engaged, prayer
|
||||
* coverage), a map center/zoom framed on the region, and its countries (each
|
||||
* with the same stats). Supports translated labels via ?lang=.
|
||||
*
|
||||
* Powers the /regions index, each /regions/[region] dashboard and each
|
||||
* /regions/[country] page.
|
||||
*/
|
||||
import { getRegionsSummary, resolveLang } from '../utils/countries'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const lang = resolveLang(getQuery(event).lang)
|
||||
const regions = await getRegionsSummary(lang)
|
||||
|
||||
// Shorter edge TTL than the marketing HTML so the dashboards' client-side
|
||||
// refresh picks up progress within the hour; stale-while-revalidate keeps
|
||||
// responses instant while the edge refreshes.
|
||||
setResponseHeader(event, 'cache-control', 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400')
|
||||
|
||||
return { regions, total: regions.length }
|
||||
})
|
||||
|
|
@ -1,35 +1,48 @@
|
|||
// Server-side accessor for the per-country summary used by /api/countries.
|
||||
// Fetches the full people-groups list from the prayer API and reduces it to one
|
||||
// CountrySummary per country (see config/countries-meta.ts).
|
||||
// Server-side accessor for the per-country and per-region summaries used by
|
||||
// /api/countries and /api/regions. Fetches the full people-groups list from the
|
||||
// prayer API and reduces it with config/countries-meta.ts.
|
||||
//
|
||||
// The result is cached per language for an hour: every prerendered country page
|
||||
// (175 countries × 8 locales) calls this during build, and the upstream list is
|
||||
// ~2,100 rows. Without caching each page would re-fetch the whole list.
|
||||
// The list is cached per language for an hour: every prerendered region and
|
||||
// country page (~185 pages × 8 locales) calls this during build, and the
|
||||
// upstream list is ~2,100 rows. Without caching each page would re-fetch it.
|
||||
|
||||
import { summarizeCountries, type CountrySummary } from '~~/config/countries-meta'
|
||||
import {
|
||||
PEOPLE_GROUP_LIST_FIELDS,
|
||||
summarizeCountries,
|
||||
summarizeRegions,
|
||||
type CountrySummary,
|
||||
type PeopleGroupLite,
|
||||
type RegionSummary
|
||||
} from '~~/config/countries-meta'
|
||||
import { LANGUAGE_CODES } from '~~/config/languages'
|
||||
|
||||
const LIST_FIELDS = 'country_code,wagf_region,latitude,longitude'
|
||||
|
||||
async function fetchCountriesSummary(lang: string): Promise<CountrySummary[]> {
|
||||
async function fetchPeopleGroups(lang: string): Promise<PeopleGroupLite[]> {
|
||||
const base = useRuntimeConfig().prayBaseUrl as string
|
||||
const data = await $fetch<{ posts?: unknown[] }>(`${base}/api/people-groups/list`, {
|
||||
query: { fields: LIST_FIELDS, lang }
|
||||
const data = await $fetch<{ posts?: PeopleGroupLite[] }>(`${base}/api/people-groups/list`, {
|
||||
query: { fields: PEOPLE_GROUP_LIST_FIELDS, lang }
|
||||
})
|
||||
return summarizeCountries((data?.posts ?? []) as never[])
|
||||
return data?.posts ?? []
|
||||
}
|
||||
|
||||
// In dev, skip caching so data changes show up immediately. In production/build
|
||||
// the per-language result is cached for an hour.
|
||||
export const getCountriesSummary: (lang: string) => Promise<CountrySummary[]>
|
||||
// the per-language list is cached for an hour.
|
||||
const getPeopleGroups: (lang: string) => Promise<PeopleGroupLite[]>
|
||||
= import.meta.dev
|
||||
? fetchCountriesSummary
|
||||
: defineCachedFunction(fetchCountriesSummary, {
|
||||
? fetchPeopleGroups
|
||||
: defineCachedFunction(fetchPeopleGroups, {
|
||||
maxAge: 60 * 60,
|
||||
name: 'countries-summary',
|
||||
name: 'people-groups-list',
|
||||
getKey: (lang: string) => lang
|
||||
})
|
||||
|
||||
export async function getCountriesSummary(lang: string): Promise<CountrySummary[]> {
|
||||
return summarizeCountries(await getPeopleGroups(lang))
|
||||
}
|
||||
|
||||
export async function getRegionsSummary(lang: string): Promise<RegionSummary[]> {
|
||||
return summarizeRegions(await getPeopleGroups(lang))
|
||||
}
|
||||
|
||||
// Validate a query `lang` against the known languages, defaulting to English.
|
||||
export function resolveLang(input: unknown): string {
|
||||
return LANGUAGE_CODES.includes(input as string) ? (input as string) : 'en'
|
||||
|
|
|
|||
Loading…
Reference in a new issue