doxa-maps-cdn/app/components/public/UupgsList.vue
corsacca eae6143be1
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.
2026-09-07 11:49:24 +01:00

661 lines
23 KiB
Vue

<script setup lang="ts">
// Port of marketing-theme/js/components/src/uupgs-list.ts (Lit → Vue).
// Preserves class names, markup structure, fetch endpoints, and filter
// logic verbatim so the source SCSS
// (blocks/_filters.scss, blocks/_cards.scss) applies unchanged.
import Fuse from 'fuse.js'
import type { Uupg, FilterOption } from '~/types/uupg'
import { countrySlug } from '~~/config/countries-meta'
interface Translations {
search?: string
exact_filter?: string
adopted_filter?: string
engaged_filter?: string
hide_filters?: string
show_filters?: string
type_to_search?: string
wagf_region?: string
wagf_block?: string
country?: string
rop1?: string
religion?: string
clear_all?: string
total?: string
see_all?: string
load_more?: string
loading?: string
prayer_coverage?: string
select?: string
full_profile?: string
adopted?: string
not_adopted?: string
alternate_name?: string
}
const props = withDefaults(defineProps<{
t?: Translations
selectUrl?: string
researchUrl?: string
initialSearchTerm?: string
languageCode?: string
perPage?: number
morePerPage?: number
dontShowListOnLoad?: boolean
useSelectCard?: boolean
useHighlightedUUPGs?: boolean
randomizeList?: boolean
hideSeeAllLink?: boolean
/** When set (ISO-3 code), the list is locked to this country: the country
* filter is pre-applied and its dropdown/chip hidden. Used by the per-country
* pages. Empty string = unlocked (default research-page behaviour). */
lockCountryCode?: string
}>(), {
t: () => ({}),
selectUrl: '',
researchUrl: '/research',
initialSearchTerm: '',
languageCode: '',
perPage: 24,
morePerPage: 0,
dontShowListOnLoad: false,
useSelectCard: false,
useHighlightedUUPGs: false,
randomizeList: false,
hideSeeAllLink: false,
lockCountryCode: ''
})
const config = useRuntimeConfig()
const localePath = useLocalePath()
const prayBaseUrl = config.public.prayBaseUrl as string
const imagesUrl = '/assets/images'
const iconsUrl = '/assets/icons'
const uupgs = ref<Uupg[]>([])
const highlightedUUPGs = ref<Uupg[]>([])
const filteredUUPGs = ref<Uupg[]>([])
const total = ref(0)
const page = ref(1)
const searchTerm = ref('')
const loading = ref(true)
const dontShowListOnLoad = ref(props.dontShowListOnLoad)
const activeFilters = ref<Record<string, { value: string; label: string }>>({})
const filtersExpanded = ref(false)
const filterOptions = ref<Record<string, FilterOption[]>>({})
function debounce<T extends (...args: any[]) => void>(fn: T, ms = 500): T {
let timeout: any
return ((...args: any[]) => {
clearTimeout(timeout)
timeout = setTimeout(() => fn(...args), ms)
}) as T
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
function hasMore() {
if (props.morePerPage > 0) {
return total.value > props.perPage + (page.value - 1) * props.morePerPage
&& !loading.value && filteredUUPGs.value.length > 0
}
return total.value > page.value * props.perPage
&& !loading.value && filteredUUPGs.value.length > 0
}
function loadMore() {
page.value = page.value + 1
}
function getUUPGsToDisplay(): Uupg[] {
if (props.morePerPage > 0) {
return filteredUUPGs.value.slice(0, props.perPage + (page.value - 1) * props.morePerPage)
}
return filteredUUPGs.value.slice(0, page.value * props.perPage)
}
const onSearch = debounce((event: Event) => {
searchTerm.value = (event.target as HTMLInputElement).value
loading.value = true
page.value = 1
total.value = 0
filteredUUPGs.value = []
filterUUPGs()
}, 500)
function toggleFilters() {
filtersExpanded.value = !filtersExpanded.value
}
function onFilterChange(detail: { name: string; value: string; label: string }) {
activeFilters.value = { ...activeFilters.value, [detail.name]: { value: detail.value, label: detail.label } }
page.value = 1
filterUUPGs()
}
function onFilterClear(detail: { name: string }) {
removeFilter(detail.name)
}
function removeFilter(name: string) {
const copy = { ...activeFilters.value }
delete copy[name]
activeFilters.value = copy
page.value = 1
filterUUPGs()
}
function clearAllFilters() {
// When the list is locked to a country, keep that filter applied.
activeFilters.value = props.lockCountryCode && activeFilters.value.country_code
? { country_code: activeFilters.value.country_code }
: {}
page.value = 1
filterUUPGs()
}
// Active filters the user can see/remove — excludes the country filter when the
// list is locked to a country (that one is fixed and has no chip/dropdown).
const visibleFilterCount = computed(() =>
Object.keys(activeFilters.value).filter(
name => !(props.lockCountryCode && name === 'country_code')
).length
)
function toggleAdopted() {
if (activeFilters.value.adopted) return removeFilter('adopted')
activeFilters.value = { ...activeFilters.value, adopted: { value: 'yes', label: props.t.adopted_filter || 'Adopted' } }
page.value = 1
filterUUPGs()
}
function toggleEngaged() {
if (activeFilters.value.engaged) return removeFilter('engaged')
activeFilters.value = { ...activeFilters.value, engaged: { value: 'yes', label: props.t.engaged_filter || 'Engaged' } }
page.value = 1
filterUUPGs()
}
function toggleExact() {
if (activeFilters.value.exact) return removeFilter('exact')
activeFilters.value = { ...activeFilters.value, exact: { value: 'yes', label: props.t.exact_filter || 'Exact Search' } }
page.value = 1
filterUUPGs()
}
function applyDropdownFilters(list: Uupg[]): Uupg[] {
let filtered = list
for (const [field, selection] of Object.entries(activeFilters.value)) {
if (field === 'adopted') {
filtered = filtered.filter(u => (u.adopted_by_churches ?? 0) > 0)
} else if (field === 'engaged') {
filtered = filtered.filter(u => (u.people_praying ?? 0) > 0)
} else if (field === 'exact') {
filtered = filtered
} else {
filtered = filtered.filter((u) => {
const vl = (u as unknown as Record<string, { value: string }>)[field]
return vl?.value === selection.value
})
}
}
return filtered
}
function extractFilterOptions() {
const extract = (field: string): FilterOption[] => {
const counts = new Map<string, { label: string; count: number }>()
for (const u of uupgs.value) {
const vl = (u as unknown as Record<string, { value: string; label: string }>)[field]
if (!vl?.value) continue
const existing = counts.get(vl.value)
if (existing) existing.count++
else counts.set(vl.value, { label: vl.label, count: 1 })
}
return Array.from(counts.entries())
.map(([value, { label, count }]) => ({ value, label, count }))
.sort((a, b) => a.label.localeCompare(b.label))
}
filterOptions.value = {
country_code: extract('country_code'),
rop1: extract('rop1'),
wagf_region: extract('wagf_region'),
wagf_block: extract('wagf_block'),
religion: extract('religion')
}
}
function updateFilterOptionCounts() {
const fields = Object.keys(filterOptions.value)
const next: Record<string, FilterOption[]> = {}
for (const field of fields) {
const filtersWithout: Record<string, { value: string; label: string }> = {}
for (const [key, val] of Object.entries(activeFilters.value)) {
if (key !== field) filtersWithout[key] = val
}
const saved = activeFilters.value
activeFilters.value = filtersWithout
const subset = Object.keys(filtersWithout).length > 0
? applyDropdownFilters(uupgs.value)
: uupgs.value
activeFilters.value = saved
const counts = new Map<string, number>()
for (const u of subset) {
const vl = (u as unknown as Record<string, { value: string }>)[field]
if (!vl?.value) continue
counts.set(vl.value, (counts.get(vl.value) ?? 0) + 1)
}
next[field] = (filterOptions.value[field] ?? []).map(opt => ({
...opt,
count: counts.get(opt.value) ?? 0
}))
}
filterOptions.value = next
}
// Search compares with combining diacritics stripped, so "Mexico" matches "México".
// Safe for highlighting: stripping marks from NFC data preserves string indices.
function stripDiacritics(value: string): string {
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
}
function filterUUPGs() {
// Reset per-item highlighting state
uupgs.value = uupgs.value.map((u) => {
u.matches = []
u.country_label = ''
u.rop1_label = ''
u.wagf_region_label = ''
return u
})
const hasActive = Object.keys(activeFilters.value).length > 0
const preFiltered = hasActive ? applyDropdownFilters(uupgs.value) : uupgs.value
if (hasActive) dontShowListOnLoad.value = false
if (searchTerm.value === '') {
filteredUUPGs.value = preFiltered
total.value = filteredUUPGs.value.length
loading.value = false
updateFilterOptionCounts()
return
}
dontShowListOnLoad.value = false
const fuse = new Fuse(preFiltered, {
useExtendedSearch: true,
includeScore: true,
includeMatches: true,
ignoreLocation: true,
minMatchCharLength: 3,
threshold: activeFilters.value.exact ? 0 : 0.4,
getFn: (obj, path) => {
const value = Fuse.config.getFn(obj, path)
if (typeof value === 'string') return stripDiacritics(value)
if (Array.isArray(value)) return value.map(v => (typeof v === 'string' ? stripDiacritics(v) : v))
return value
},
keys: [
'name',
'imb_alternate_name',
'country_code.label',
'rop1.label',
'religion.label',
'wagf_region.label',
'wagf_block.label'
]
})
const result = fuse.search(stripDiacritics(searchTerm.value))
filteredUUPGs.value = result.map((res) => {
const newItem: Uupg = { ...res.item }
if (!res.matches) return newItem
newItem.matches = []
for (const match of res.matches) {
const matchKey = match.key
if (!matchKey) continue
const key = matchKey as keyof Uupg
let value: any = ''
if (String(key).includes('.')) {
const [parentKey, childKey] = String(key).split('.') as [string, string]
value = (newItem as unknown as Record<string, Record<string, string>>)[parentKey]?.[childKey]
} else {
value = (newItem as unknown as Record<string, string>)[String(key)]
}
if (value && typeof value === 'string') {
let currentIndex = 0
let highlightedValue = ''
match.indices.forEach((idx, i) => {
const start = idx[0]
const end = idx[1]
const isLast = match.indices.length - 1 === i
highlightedValue
+= escapeHtml(value.slice(currentIndex, start))
+ `<span class="search-highlight">${escapeHtml(value.slice(start, end + 1))}</span>`
+ (isLast ? escapeHtml(value.slice(end + 1)) : '')
currentIndex = end + 1
})
if (String(key).includes('imb_alternate_name')) {
newItem.matches!.push({ key: props.t.alternate_name ?? 'Also known as', label: highlightedValue })
} else if (String(key).includes('.')) {
const [parentKey] = String(key).split('.') as [string]
const keyTranslations: Record<string, string | undefined> = {
religion: props.t.religion,
country_code: props.t.country,
rop1: props.t.rop1,
wagf_region: props.t.wagf_region,
wagf_block: props.t.wagf_block
}
if (parentKey === 'wagf_region' && props.useSelectCard) {
newItem.wagf_region_label = highlightedValue
} else if (parentKey === 'rop1' && !props.useSelectCard) {
newItem.rop1_label = highlightedValue
} else if (parentKey === 'country_code') {
newItem.country_label = highlightedValue
} else if (parentKey === 'wagf_region' && !props.useSelectCard) {
// do nothing
} else {
newItem.matches!.push({
key: keyTranslations[parentKey] ?? parentKey,
label: highlightedValue
})
}
} else {
(newItem as unknown as Record<string, unknown>)[String(key)] = highlightedValue
}
}
}
return newItem
})
total.value = filteredUUPGs.value.length
loading.value = false
}
async function getUUPGs() {
const url = `${prayBaseUrl}/api/people-groups/list?fields=name,slug,wagf_region,wagf_block,country_code,rop1,religion,has_photo,image_url,adopted_by_churches,imb_alternate_name,engagement_status,people_committed&lang=${props.languageCode}`
loading.value = true
try {
const response = await fetch(url)
const data = await response.json()
total.value = data.total
uupgs.value = data.posts
extractFilterOptions()
if (props.lockCountryCode) {
const match = uupgs.value.find(u => u.country_code?.value === props.lockCountryCode)
activeFilters.value = {
...activeFilters.value,
country_code: { value: props.lockCountryCode, label: match?.country_code?.label || props.lockCountryCode }
}
}
if (props.randomizeList) {
uupgs.value = uupgs.value.sort(() => Math.random() - 0.5)
}
if (props.useHighlightedUUPGs) {
filteredUUPGs.value = [...filteredUUPGs.value, ...data.posts]
}
if (!dontShowListOnLoad.value && !props.useHighlightedUUPGs) {
filterUUPGs()
}
} catch (error) {
console.error('Error:', error)
} finally {
loading.value = false
}
}
async function getHighlightedUUPGs() {
const url = `${prayBaseUrl}/api/people-groups/highlighted?lang=${props.languageCode}`
try {
const response = await fetch(url)
const data = await response.json()
highlightedUUPGs.value = data.posts
total.value = data.total
filteredUUPGs.value = highlightedUUPGs.value
} catch (error) {
console.error('Error:', error)
} finally {
loading.value = false
}
}
onMounted(async () => {
if (props.initialSearchTerm) {
searchTerm.value = decodeURI(props.initialSearchTerm)
}
if (props.useHighlightedUUPGs) {
await getHighlightedUUPGs()
await getUUPGs()
} else {
await getUUPGs()
}
})
const displayedUUPGs = computed(() => getUUPGsToDisplay())
const showMore = computed(() => hasMore())
</script>
<template>
<div
class="stack stack--md bg-image"
:style="`background-image: url(${imagesUrl}/worldmap.svg); background-size: 60%; background-position: top; min-height: 400px;`"
>
<div id="filters" class="filters">
<div class="search-box | center | max-width-md">
<span class="sr-only">{{ t.search }}</span>
<svg class="search-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<use :href="`${iconsUrl}/search.svg#search`" />
</svg>
<div class="repel">
<input
type="search"
:placeholder="initialSearchTerm ? initialSearchTerm : t.search"
@input="onSearch"
>
<button
v-if="useSelectCard"
class="mx-auto color-brand surface-primary filter-toggle input fit-content"
type="button"
:data-active="activeFilters.exact ? '' : undefined"
@click="toggleExact"
>
{{ t.exact_filter || 'Exact Search' }}
</button>
</div>
</div>
<button
v-if="!useSelectCard"
class="filters__toggle | button compact link"
type="button"
:aria-expanded="filtersExpanded"
@click="toggleFilters"
>
{{ filtersExpanded ? (t.hide_filters || 'Hide Filters') : (t.show_filters || 'Show Filters') }}
<svg width="12" height="12" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<template v-if="filtersExpanded">
<div class="filters__panel">
<FilterDropdown
:label="t.wagf_region || 'WAGF Region'"
name="wagf_region"
:options="filterOptions.wagf_region || []"
:value="activeFilters.wagf_region?.value ?? ''"
:placeholder="t.type_to_search || 'Type to search...'"
@filter-change="onFilterChange"
@filter-clear="onFilterClear"
/>
<FilterDropdown
:label="t.wagf_block || 'WAGF Block'"
name="wagf_block"
:options="filterOptions.wagf_block || []"
:value="activeFilters.wagf_block?.value ?? ''"
:placeholder="t.type_to_search || 'Type to search...'"
@filter-change="onFilterChange"
@filter-clear="onFilterClear"
/>
<FilterDropdown
v-if="!lockCountryCode"
:label="t.country || 'Country'"
name="country_code"
:options="filterOptions.country_code || []"
:value="activeFilters.country_code?.value ?? ''"
:placeholder="t.type_to_search || 'Type to search...'"
@filter-change="onFilterChange"
@filter-clear="onFilterClear"
/>
<FilterDropdown
:label="t.rop1 || 'People Group'"
name="rop1"
:options="filterOptions.rop1 || []"
:value="activeFilters.rop1?.value ?? ''"
:placeholder="t.type_to_search || 'Type to search...'"
@filter-change="onFilterChange"
@filter-clear="onFilterClear"
/>
<FilterDropdown
:label="t.religion || 'Religion'"
name="religion"
:options="filterOptions.religion || []"
:value="activeFilters.religion?.value ?? ''"
:placeholder="t.type_to_search || 'Type to search...'"
@filter-change="onFilterChange"
@filter-clear="onFilterClear"
/>
</div>
<div class="filters__panel">
<button
class="filter-toggle input fit-content"
type="button"
:data-active="activeFilters.adopted ? '' : undefined"
@click="toggleAdopted"
>{{ t.adopted_filter || 'Adopted' }}</button>
<button
class="filter-toggle input fit-content"
type="button"
:data-active="activeFilters.engaged ? '' : undefined"
@click="toggleEngaged"
>{{ t.engaged_filter || 'Engaged' }}</button>
<button
class="filter-toggle input fit-content"
type="button"
:data-active="activeFilters.exact ? '' : undefined"
@click="toggleExact"
>{{ t.exact_filter || 'Exact Search' }}</button>
</div>
</template>
<div v-if="!useSelectCard && visibleFilterCount > 0" class="filters__active">
<template v-for="(filter, name) in activeFilters" :key="name">
<span v-if="!(lockCountryCode && name === 'country_code')" class="filter-chip">
{{ filter.label }}
<button class="filter-chip__remove" type="button" @click="removeFilter(String(name))">&times;</button>
</span>
</template>
<button class="filters__clear-all | button compact link" type="button" @click="clearAllFilters">
{{ t.clear_all || 'Clear All' }}
</button>
</div>
</div>
<div class="stack stack--xs">
<div class="repel">
<div class="font-size-sm">
<template v-if="!dontShowListOnLoad && !loading">
{{ t.total }}: {{ total }}
</template>
<span v-else class="invisible-placeholder">Placeholder</span>
</div>
<a
v-if="!hideSeeAllLink && !dontShowListOnLoad && showMore"
class="light-link"
:href="`${researchUrl}search/${searchTerm}`"
>{{ t.see_all }}</a>
</div>
<div
id="results"
class="grid | uupgs-list"
:class="{ 'gap-md': useSelectCard }"
:data-width-lg="!useSelectCard ? '' : undefined"
:data-width-md="useSelectCard ? '' : undefined"
>
<template v-for="uupg in displayedUUPGs" :key="uupg.slug">
<!-- useSelectCard variant -->
<div v-if="useSelectCard" class="stack stack--sm | card | highlighted-uupg__card">
<div class="repel align-start">
<img :src="uupg.image_url" :alt="uupg.name">
<p class="color-brand-lighter uppercase text-end overflow-wrap-anywhere" v-html="uupg.wagf_region_label ? uupg.wagf_region_label : uupg.wagf_region.label" />
</div>
<div>
<p class="line-height-tight" v-html="uupg.name" />
<p v-if="uupg.country_code" class="uupg__country font-size-sm">
<a
class="uupg__country-link light-link"
: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">
<p v-for="(match, mi) in uupg.matches" :key="mi" class="font-size-sm color-brand-lighter">
<strong>{{ match.key }}</strong>: <span v-html="match.label" />
</p>
</template>
</div>
<div class="repel">
<p class="font-size-sm color-brand-lighter">{{ t.prayer_coverage }}:</p>
<p class="font-size-xl font-button">{{ uupg.people_committed ?? 0 }}/100</p>
</div>
<div class="switcher | text-center" data-width="md">
<a class="highlighted-uupg__prayer-coverage-button button compact" :href="`${selectUrl}${uupg.slug}?source=doxalife`">{{ t.select }}</a>
<a class="highlighted-uupg__more-button button compact link" :href="`${researchUrl}${uupg.slug}`">{{ t.full_profile }}</a>
</div>
</div>
<!-- full card variant -->
<div v-else class="card | uupg__card">
<img class="uupg__image" :src="uupg.image_url" :alt="uupg.name">
<div class="uupg__header">
<h3 class="uupg__name line-height-tight" v-html="uupg.name" />
<p class="uupg__country">
<a
class="uupg__country-link light-link"
: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">
<p v-for="(match, mi) in uupg.matches" :key="mi" class="font-size-sm color-brand-lighter">
<strong>{{ match.key }}</strong>: <span v-html="match.label" />
</p>
</template>
</div>
<p v-if="uupg.location_description" class="uupg__content">{{ uupg.location_description }}</p>
<a class="uupg__more-button button compact" :href="`${researchUrl}${uupg.slug}`">{{ t.full_profile }}</a>
</div>
</template>
<div v-if="!dontShowListOnLoad && loading" class="loading">{{ t.loading }}</div>
</div>
<button
v-if="showMore"
class="center | button compact stack-spacing-2xl"
@click="loadMore"
>{{ t.load_more }}</button>
</div>
</div>
</template>