This commit is contained in:
corsacca 2026-04-21 13:58:07 +01:00
parent d21f4e82c0
commit 7eb566a96b
18 changed files with 2007 additions and 51 deletions

View file

@ -17,3 +17,15 @@ S3_REGION=us-west-004
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_BUCKET_NAME=your-bucket-name
# Prayer Tools integration
NUXT_PRAY_BASE_URL=https://pray.doxa.life
NUXT_PUBLIC_PRAY_BASE_URL=https://pray.doxa.life
FORM_API_KEY=your-form-api-key
# Mapbox (used by the prayer map on /pray)
NUXT_PUBLIC_MAPBOX_TOKEN=your-mapbox-public-token
# Cloudflare Turnstile
NUXT_TURNSTILE_SITE_KEY=your-turnstile-site-key
NUXT_TURNSTILE_SECRET_KEY=your-turnstile-secret-key

View file

@ -0,0 +1,178 @@
<script setup lang="ts">
// Port of marketing-theme/js/components/src/filter-dropdown.ts (Lit → Vue).
// Classes and markup mirror the source exactly so the existing CUBE SCSS
// (blocks/_filters.scss) styles apply unchanged.
import type { FilterOption } from '~/types/uupg'
const props = withDefaults(defineProps<{
label: string
name: string
options: FilterOption[]
value?: string
placeholder?: string
}>(), {
value: '',
placeholder: 'Type to search...'
})
const emit = defineEmits<{
(e: 'filter-change', detail: { name: string; value: string; label: string; type?: 'dropdown' | 'boolean' }): void
(e: 'filter-clear', detail: { name: string }): void
}>()
const isOpen = ref(false)
const searchText = ref('')
const highlightedIndex = ref(-1)
const rootRef = ref<HTMLElement | null>(null)
const searchInputRef = ref<HTMLInputElement | null>(null)
const filteredOptions = computed<FilterOption[]>(() => {
if (!searchText.value) return props.options
const search = searchText.value.toLowerCase()
return props.options.filter(o => o.label.toLowerCase().includes(search))
})
const selectedOption = computed(() => props.options.find(o => o.value === props.value))
function onClickOutside(e: Event) {
const path = (e as any).composedPath?.() ?? []
if (rootRef.value && !path.includes(rootRef.value)) {
close()
}
}
function toggle() {
if (isOpen.value) close()
else open()
}
function open() {
isOpen.value = true
searchText.value = ''
highlightedIndex.value = -1
document.addEventListener('click', onClickOutside)
nextTick(() => searchInputRef.value?.focus())
}
function close() {
isOpen.value = false
searchText.value = ''
highlightedIndex.value = -1
document.removeEventListener('click', onClickOutside)
}
function onSearchInput(e: Event) {
searchText.value = (e.target as HTMLInputElement).value
highlightedIndex.value = -1
}
function onKeydown(e: KeyboardEvent) {
const options = filteredOptions.value
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
highlightedIndex.value = Math.min(highlightedIndex.value + 1, options.length - 1)
scrollHighlightedIntoView()
break
case 'ArrowUp':
e.preventDefault()
highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
scrollHighlightedIntoView()
break
case 'Enter': {
e.preventDefault()
const option = options[highlightedIndex.value]
if (option) select(option)
break
}
case 'Escape':
e.preventDefault()
close()
break
}
}
function scrollHighlightedIntoView() {
nextTick(() => {
const highlighted = rootRef.value?.querySelector('.filter-dropdown__option[data-highlighted]')
highlighted?.scrollIntoView({ block: 'nearest' })
})
}
function select(option: FilterOption) {
emit('filter-change', {
name: props.name,
value: option.value,
label: option.label,
type: option.type
})
close()
}
function clear() {
emit('filter-clear', { name: props.name })
close()
}
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
</script>
<template>
<div ref="rootRef" class="filter-dropdown" :class="{ 'is-open': isOpen }">
<button
class="filter-dropdown__trigger input"
:data-active="value ? '' : undefined"
type="button"
aria-haspopup="listbox"
:aria-expanded="isOpen"
@click="toggle"
>
<span class="filter-dropdown__label">
<template v-if="selectedOption">{{ label }}: <strong>{{ selectedOption.label }}</strong></template>
<template v-else>{{ label }}</template>
</span>
<svg class="filter-dropdown__chevron" 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>
<div v-if="isOpen" class="filter-dropdown__menu" role="listbox">
<input
ref="searchInputRef"
class="filter-dropdown__search"
type="text"
:value="searchText"
:placeholder="placeholder"
@input="onSearchInput"
@keydown="onKeydown"
>
<button
v-if="value"
class="filter-dropdown__clear"
type="button"
@click="clear"
>Clear selection</button>
<div class="filter-dropdown__options">
<template v-if="filteredOptions.length === 0">
<div class="filter-dropdown__no-options">No options found</div>
</template>
<template v-else>
<div
v-for="(option, index) in filteredOptions"
:key="option.value"
class="filter-dropdown__option"
role="option"
:data-highlighted="index === highlightedIndex ? '' : undefined"
:data-selected="option.value === value ? '' : undefined"
:aria-selected="option.value === value"
@click="select(option)"
@mouseenter="highlightedIndex = index"
>
<span>{{ option.label }}</span>
<span class="filter-dropdown__count">{{ option.count }}</span>
</div>
</template>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,126 @@
<script setup lang="ts">
// Port of the `#reel-people-groups` carousel from marketing-theme/js/theme.js.
// Fetches up to 20 random people groups with photos, dedupes multiple "deaf"
// entries, renders them into the `.reel` container, then starts a
// setInterval auto-scroll (20ms tick, RTL-aware). Matches the original
// CUBE classes (`reel`, `reel__item`, `stack stack--sm`, `light-link`,
// `square rounded-md size-md`, etc.) so the existing SCSS applies.
import type { Uupg } from '~/types/uupg'
const props = withDefaults(defineProps<{
researchUrl?: string
languageCode?: string
}>(), {
researchUrl: '/research/',
languageCode: 'en'
})
const config = useRuntimeConfig()
const prayBaseUrl = config.public.prayBaseUrl as string
const NUMBER_OF_PEOPLE_GROUPS = 20
const reelRef = ref<HTMLDivElement | null>(null)
const items = ref<Uupg[]>([])
let tickHandle: ReturnType<typeof setInterval> | null = null
let isFadedIn = false
async function fetchReel(): Promise<Uupg[]> {
const url = `${prayBaseUrl}/api/people-groups/list?lang=${props.languageCode}`
const response = await fetch(url)
const data = await response.json()
const withPhoto: Uupg[] = data.posts.filter((g: Uupg) => g.has_photo)
withPhoto.sort(() => Math.random() - 0.5)
let hasDeaf = false
const filtered = withPhoto.filter((g: Uupg) => {
if (g.name.toLowerCase().includes('deaf')) {
if (hasDeaf) return false
hasDeaf = true
}
return true
})
return filtered.slice(0, NUMBER_OF_PEOPLE_GROUPS)
}
async function waitForImages(reel: HTMLElement) {
const images = Array.from(reel.querySelectorAll('img'))
await Promise.all(images.map(img => new Promise<void>((resolve) => {
if (img.complete) resolve()
else img.onload = () => resolve()
})))
}
function startAutoScroll() {
if (!reelRef.value) return
const reel = reelRef.value
// Apply initial `style.order` to each child (mirrors theme.js)
Array.from(reel.children).forEach((child, index) => {
(child as HTMLElement).style.order = String(index)
})
if (!isFadedIn) {
reel.classList.add('in')
isFadedIn = true
}
const isRtl = document.documentElement.getAttribute('dir') === 'rtl'
const scrollDirection = isRtl ? -1 : 1
tickHandle = setInterval(() => {
const children = Array.from(reel.children) as HTMLElement[]
if (children.length === 0) return
const firstImage = children.reduce((prev, current) =>
Number(prev.style.order) < Number(current.style.order) ? prev : current
)
if (firstImage.offsetWidth < reel.scrollLeft) {
reel.scrollLeft = reel.scrollLeft - firstImage.offsetWidth * scrollDirection
firstImage.style.order = String(reel.children.length)
for (const img of children) {
if (img !== firstImage) img.style.order = String(Number(img.style.order) - 1)
}
} else {
reel.scrollLeft += 1 * scrollDirection
}
}, 20)
}
onMounted(async () => {
items.value = await fetchReel()
await nextTick()
if (!reelRef.value) return
await waitForImages(reelRef.value)
startAutoScroll()
})
onBeforeUnmount(() => {
if (tickHandle) {
clearInterval(tickHandle)
tickHandle = null
}
})
</script>
<template>
<div
ref="reelRef"
class="reel"
id="reel-people-groups"
data-reel-mode="auto-scroll"
:data-research-url="researchUrl"
>
<a
v-for="group in items"
:key="group.slug"
class="stack stack--sm reel__item light-link"
:href="`${researchUrl}${group.slug}`"
target="_blank"
>
<div><img class="square rounded-md size-md" :src="group.image_url" :alt="group.name"></div>
<p class="text-center uppercase width-md">{{ group.name }}</p>
</a>
</div>
</template>

View file

@ -0,0 +1,99 @@
<script setup lang="ts">
// Port of marketing-theme/js/components/src/phone-input.js (Lit → Vue).
// Wraps intl-tel-input. Emits `phone-input` (with the current E.164 number)
// and `invalid` (with a validation message from props.t) per the source.
import intlTelInput from 'intl-tel-input'
import 'intl-tel-input/build/css/intlTelInput.css'
interface PhoneTranslations {
phone_error?: string
phone_error_too_short?: string
phone_error_too_long?: string
}
const props = withDefaults(defineProps<{
value?: string
required?: boolean
placeholder?: string
initialCountry?: string
t?: PhoneTranslations
}>(), {
value: '',
required: false,
placeholder: '',
initialCountry: '',
t: () => ({})
})
const emit = defineEmits<{
(e: 'phone-input', detail: { number: string }): void
(e: 'invalid', detail: { number: string; message: string }): void
}>()
const inputRef = ref<HTMLInputElement | null>(null)
// Keep outside reactive state — intl-tel-input returns an instance with
// DOM-mutating methods; wrapping in a ref triggers unnecessary proxying.
let iti: ReturnType<typeof intlTelInput> | null = null
const number = ref(props.value)
function handleInput() {
if (!iti) return
number.value = iti.getNumber()
emit('phone-input', { number: number.value })
if (!iti.isValidNumber()) {
const error = iti.getValidationError() as unknown as string | number
let message = ''
if (error === 'TOO_SHORT') message = props.t.phone_error_too_short ?? ''
else if (error === 'TOO_LONG') message = props.t.phone_error_too_long ?? ''
else message = props.t.phone_error ?? ''
emit('invalid', { number: number.value, message })
}
}
onMounted(() => {
if (!inputRef.value) return
iti = intlTelInput(inputRef.value, {
initialCountry: (props.initialCountry || undefined) as any,
loadUtils: () => new Promise((resolve) => {
const w = window as any
if (w.intlTelInputUtils) {
resolve({ default: w.intlTelInputUtils })
} else {
const script = document.createElement('script')
script.type = 'module'
script.src = '/assets/js/intl-tel-input-utils-bundle.js'
script.onload = () => resolve({ default: w.intlTelInputUtils })
document.body.appendChild(script)
}
})
})
number.value = props.value
})
watch(() => props.initialCountry, (c) => {
if (iti && c) iti.setCountry(c as any)
})
onBeforeUnmount(() => {
iti?.destroy()
iti = null
})
defineExpose({ getNumber: () => number.value })
</script>
<template>
<input
ref="inputRef"
type="tel"
id="phone"
class="input"
name="phone"
:value="value"
:required="required"
:placeholder="placeholder"
@input="handleInput"
>
</template>

View file

@ -0,0 +1,621 @@
<script setup lang="ts">
// Port of marketing-theme/js/components/src/uupgs-list.ts (Lit → Vue).
// Preserves class names, markup structure, fetch endpoints, search
// algorithm, 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'
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
}>(), {
t: () => ({}),
selectUrl: '',
researchUrl: '/research',
initialSearchTerm: '',
languageCode: '',
perPage: 24,
morePerPage: 0,
dontShowListOnLoad: false,
useSelectCard: false,
useHighlightedUUPGs: false,
randomizeList: false,
hideSeeAllLink: false
})
const config = useRuntimeConfig()
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() {
activeFilters.value = {}
page.value = 1
filterUUPGs()
}
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' } }
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
}
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,
keys: [
'name',
'imb_alternate_name',
'country_code.label',
'rop1.label',
'religion.label',
'wagf_region.label',
'wagf_block.label'
]
})
const result = fuse.search(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' && !props.useSelectCard) {
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&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.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' }}
</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
: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' }}</button>
</div>
</template>
<div v-if="!useSelectCard && Object.keys(activeFilters).length > 0" class="filters__active">
<span v-for="(filter, name) in activeFilters" :key="name" class="filter-chip">
{{ filter.label }}
<button class="filter-chip__remove" type="button" @click="removeFilter(String(name))">&times;</button>
</span>
<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" />
<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 }}/144</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">
<span v-html="uupg.country_label ? uupg.country_label : uupg.country_code.label" /> (<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>
<div class="uupg_adopted">
<div>
<img
:src="(uupg.adopted_by_churches && uupg.adopted_by_churches > 0) ? `${iconsUrl}/Check-GreenCircle.png` : `${iconsUrl}/RedX-Circle.png`"
:alt="(uupg.adopted_by_churches && uupg.adopted_by_churches > 0) ? (t.adopted || '') : (t.not_adopted || '')"
>
<span>{{ (uupg.adopted_by_churches && uupg.adopted_by_churches > 0) ? t.adopted : t.not_adopted }}</span>
</div>
</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>

View file

@ -1,34 +1,33 @@
<script setup lang="ts">
// Port of the video modal in marketing-theme/front-page.php + theme.js.
// Overlay + Vimeo iframe; opens on any element bound to the exposed
// `openVideo()`. Closes on overlay click, close button, Escape, or back
// navigation. Uses `data-state` attributes to match the existing SCSS
// (_video.scss).
// Port of the video modal markup in marketing-theme/front-page.php plus
// the open/close/escape/back behaviour from marketing-theme/js/theme.js.
// The template mirrors the PHP structure verbatim — .video-modal-overlay
// + .video-modal with the 41.89% padding wrapper, <iframe id="vimeo-player">,
// and the whiteX.png close button.
const props = defineProps<{
src: string // Vimeo embed URL (without autoplay param)
src: string // Vimeo embed URL (without autoplay param)
title?: string // <iframe title>
closeLabel?: string // alt text on the close icon
}>()
const state = ref<'open' | 'closed'>('closed')
const iframeRef = ref<HTMLIFrameElement | null>(null)
const baseSrc = computed(() => props.src)
function open() {
state.value = 'open'
if (iframeRef.value) {
// Append autoplay=1 when opening
const sep = baseSrc.value.includes('?') ? '&' : '?'
iframeRef.value.src = `${baseSrc.value}${sep}autoplay=1`
const sep = props.src.includes('?') ? '&' : '?'
iframeRef.value.src = `${props.src}${sep}autoplay=1`
}
if (import.meta.client) history.pushState(null, '', '#playing-video')
}
function close() {
state.value = 'closed'
// Reset src to stop playback
if (iframeRef.value) {
iframeRef.value.src = ''
iframeRef.value.src = baseSrc.value
iframeRef.value.src = props.src
}
}
@ -53,7 +52,6 @@ onBeforeUnmount(() => {
window.removeEventListener('popstate', onPopState)
})
// Expose open/close so parent templates can trigger from the hero button.
defineExpose({ open, close })
</script>
@ -65,22 +63,21 @@ defineExpose({ open, close })
@click="close"
/>
<div class="video-modal" :data-state="state">
<button
type="button"
class="video-modal-close"
aria-label="Close video"
@click="close"
>
×
<div style="padding:41.89% 0 0 0;position:relative;">
<iframe
id="vimeo-player"
ref="iframeRef"
:src="props.src"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share"
referrerpolicy="strict-origin-when-cross-origin"
style="position:absolute;top:0;left:0;width:100%;height:100%;"
:title="title ?? 'Doxa Video'"
/>
</div>
<button type="button" class="video-modal-close icon-button" @click="close">
<img src="/assets/icons/whiteX.png" :alt="closeLabel ?? 'Close video'">
</button>
<iframe
ref="iframeRef"
id="vimeo-player"
:src="baseSrc"
frameborder="0"
allow="autoplay; fullscreen; picture-in-picture"
allowfullscreen
/>
</div>
</div>
</template>

View file

@ -0,0 +1,65 @@
// Port of marketing-theme/js/components/src/main.ts (the
// `getPeopleGroupsStatistics` function). Fetches totals from
// `pray.doxa.life/api/people-groups/statistics` and exposes them as
// reactive refs plus a `reload()` method. Consumers render their own
// counters and progress bars; in the original source the DOM was updated
// imperatively via `#prayer-current-status`, `#adopted-current-status`
// etc. — we leave that imperative side-effect up to the page templates
// to keep the composable side-effect free.
export interface PrayerStatistics {
total_with_prayer: number
total_with_full_prayer: number
total_adopted: number
}
const TOTAL_PEOPLE_GROUPS = 2085
export function usePrayerStatistics() {
const config = useRuntimeConfig()
const prayBaseUrl = config.public.prayBaseUrl as string
const stats = useState<PrayerStatistics>('prayer-stats', () => ({
total_with_prayer: 0,
total_with_full_prayer: 0,
total_adopted: 0
}))
const loading = useState<boolean>('prayer-stats-loading', () => false)
const error = useState<Error | null>('prayer-stats-error', () => null)
async function reload() {
loading.value = true
error.value = null
try {
const response = await fetch(`${prayBaseUrl}/api/people-groups/statistics`)
const data = await response.json()
stats.value = {
total_with_prayer: Number(data.total_with_prayer || 0),
total_with_full_prayer: Number(data.total_with_full_prayer || 0),
total_adopted: Number(data.total_adopted || 0)
}
} catch (e) {
error.value = e as Error
console.error('Error:', e)
} finally {
loading.value = false
}
}
const prayerCoveragePercent = computed(
() => Math.min(100, (stats.value.total_with_full_prayer / TOTAL_PEOPLE_GROUPS) * 100)
)
const adoptedPercent = computed(
() => Math.min(100, (stats.value.total_adopted / TOTAL_PEOPLE_GROUPS) * 100)
)
return {
stats: readonly(stats),
loading: readonly(loading),
error: readonly(error),
prayerCoveragePercent,
adoptedPercent,
reload,
TOTAL_PEOPLE_GROUPS
}
}

View file

@ -16,7 +16,10 @@ export function applyTextHighlights(root: ParentNode = document) {
nodes.forEach((item) => {
if (item.dataset[PROCESSED_FLAG] === 'true') return
const words = (item.textContent ?? '').split(' ')
// Trim leading/trailing whitespace Vue introduces around {{ }} on
// multiline templates, then split on any whitespace run so word
// indices are accurate regardless of source formatting.
const words = (item.textContent ?? '').trim().split(/\s+/)
const highlightIndex = item.dataset.highlightIndex ? parseInt(item.dataset.highlightIndex, 10) - 1 : -1
const highlightLast = 'highlightLast' in item.dataset
const color = item.dataset.highlightColor || 'primary'

View file

@ -1,6 +1,177 @@
<script setup lang="ts">
// Port of marketing-theme/page-contact.php.
// Markup, classes, field names, labels, placeholders, and submit flow
// mirror the source exactly. The Turnstile widget is rendered via the
// @nuxtjs/turnstile module; the form submits to our server proxy at
// /api/contact which verifies the token and forwards to pray.doxa.life.
import { COUNTRIES } from '~/utils/countries'
const { t, locale } = useI18n()
const name = ref('')
const email = ref('')
const honeypot = ref('') // matches the `name="email"` trap in the PHP template
const country = ref('')
const message = ref('')
const consent = ref(false)
const turnstileToken = ref('')
const submitting = ref(false)
const submitDisabled = computed(() => submitting.value || !turnstileToken.value)
const messageEl = ref<HTMLDivElement | null>(null)
const messageText = ref('')
const messageClass = ref<'success' | 'error' | ''>('')
const formRef = ref<HTMLFormElement | null>(null)
const turnstileRef = ref<{ reset: () => void } | null>(null)
async function onSubmit(e: Event) {
e.preventDefault()
if (honeypot.value) return
if (formRef.value && !formRef.value.checkValidity()) {
formRef.value.reportValidity()
return
}
submitting.value = true
messageText.value = ''
messageClass.value = ''
try {
const response = await $fetch<{ status: string; message?: string }>('/api/contact', {
method: 'POST',
body: {
name: name.value,
email: email.value,
country: country.value,
message: message.value,
consent_doxa_general: consent.value,
language: locale.value,
cf_turnstile: turnstileToken.value
}
})
if (response?.status === 'success') {
messageClass.value = 'success'
messageText.value = t('Thank you for your message. We will get back to you soon!')
name.value = ''
email.value = ''
country.value = ''
message.value = ''
consent.value = false
formRef.value?.reset()
} else {
messageClass.value = 'error'
messageText.value = response?.message ?? t('There was an error sending your message. Please try again.')
}
} catch (err: any) {
messageClass.value = 'error'
messageText.value = err?.data?.message ?? t('There was an error sending your message. Please try again.')
} finally {
submitting.value = false
turnstileToken.value = ''
turnstileRef.value?.reset()
}
}
</script>
<template>
<section class="container stack stack--md">
<h1>Contact Us</h1>
<p>The contact form is ported in Phase 3.</p>
</section>
<div class="container page-content">
<h1 class="page-title">{{ t('Contact Us') }}</h1>
<form
id="contact-form"
ref="formRef"
class="stack stack--md max-width-lg center"
@submit="onSubmit"
>
<input type="hidden" name="action" value="contact_us">
<input
v-model="honeypot"
type="email"
name="email"
style="display:none;"
autocomplete="off"
tabindex="-1"
>
<div class="">
<label for="name">{{ t('Name') }}</label>
<input
id="name"
v-model="name"
type="text"
name="name"
required
:placeholder="t('Enter your name')"
>
</div>
<div class="">
<label for="contact_email">{{ t('Email') }}</label>
<input
id="contact_email"
v-model="email"
type="email"
name="contact_email"
required
:placeholder="t('Enter your email')"
>
</div>
<div class="">
<label for="country">{{ t('Your Country (optional)') }}</label>
<select id="country" v-model="country" name="country">
<option value="">{{ t('Select Country') }}</option>
<option v-for="c in COUNTRIES" :key="c.value" :value="c.value">{{ c.label }}</option>
</select>
</div>
<div class="">
<label for="message">{{ t('Message') }}</label>
<textarea
id="message"
v-model="message"
name="message"
rows="5"
required
:placeholder="t('Enter your message')"
/>
</div>
<div class="form-control color-primary-darker">
<input
id="consent-doxa-general"
v-model="consent"
type="checkbox"
name="consent_doxa_general"
>
<label for="consent-doxa-general">{{ t('I would like to receive email updates from the DOXA partnership.') }}</label>
</div>
<NuxtTurnstile
ref="turnstileRef"
v-model="turnstileToken"
:options="{ theme: 'light' }"
/>
<div
id="contact-message"
ref="messageEl"
class="contact-message"
:class="messageClass"
:style="{ display: messageText ? 'block' : 'none' }"
>{{ messageText }}</div>
<button
id="contact-submit"
type="submit"
class="button"
:disabled="submitDisabled"
>
{{ submitting ? t('Submitting...') : t('Submit') }}
</button>
</form>
</div>
</template>

View file

@ -1,17 +1,238 @@
<script setup lang="ts">
// Placeholder home page. Phase 3 replaces this with the full port of
// marketing-theme/front-page.php (hero video, reel, step cards, etc.).
const { t } = useI18n()
// Port of marketing-theme/front-page.php.
// Each <section> mirrors the PHP source in the same order with the same
// CUBE classes, data attributes, asset paths, and strings. Dynamic bits
// (reel, stats counters, video modal) are delegated to the corresponding
// Phase 2 components.
import { getVideoUrl } from '~/utils/videoUrls'
const { t, locale } = useI18n()
const localePath = useLocalePath()
const videoModalRef = ref<{ open: () => void; close: () => void } | null>(null)
const videoUrl = computed(() => getVideoUrl(locale.value))
function openVideo() {
videoModalRef.value?.open()
}
useTextHighlight()
</script>
<template>
<section class="stack stack--md container">
<div>
<h2 class="color-brand">{{ t('Our gift to Jesus') }}:</h2>
<h1 class="color-brand-light">{{ t('Engage every people by 2033') }}</h1>
</div>
<p>
{{ t('The DOXA Vision: Click image to watch the video') }}
</p>
</section>
<div class="front-page">
<section class="stack stack--md container">
<div>
<h2 class="color-brand">{{ t('Our gift to Jesus') }}:</h2>
<h1
class="color-brand-light highlight"
data-highlight-index="1"
data-highlight-last
data-highlight-color="primary"
>{{ t('Engage every people by 2033') }}</h1>
</div>
<div class="video-modal-button" @click="openVideo">
<svg class="icon">
<use href="/assets/icons/play-button.svg#play-button" />
</svg>
<img
class="rounded-xlg"
src="/assets/images/home-01-hero.jpg"
:alt="t('Engage every people by 2033')"
>
</div>
<p class="text-center color-primary uppercase font-button font-weight-medium">
{{ t('The DOXA Vision: Click image to watch the video') }}
</p>
</section>
<section class="stack stack--md | surface-brand-light">
<div class="container stack stack--4xl">
<div class="stack stack--2xl">
<h2 class="highlight" data-highlight-index="1">
{{ t('{0} unengaged people groups', ['2,085']) }}
</h2>
<p class="subtext">
{{ t('Our hope is to see each of them covered in 24-hour prayer, and your church can be part of it.') }}
</p>
</div>
<PeopleGroupReel
:research-url="localePath('/research') + '/'"
:language-code="locale"
/>
<div class="stack stack--2xl">
<h2 class="highlight" data-highlight-last>{{ t('A simple path to faithful obedience') }}</h2>
<div class="switcher | gap-md">
<div class="step-card">
<div class="step-card__number">1</div>
<div class="step-card__content">
<h2 class="step-card__title">{{ t('Pray') }}</h2>
<p>{{ t('Receive daily prayer points and join believers worldwide in prayer for the unengaged peoples.') }}</p>
</div>
<NuxtLink :to="localePath('/pray')" class="button | compact">{{ t('Join') }}</NuxtLink>
</div>
<div class="step-card">
<div class="step-card__number">2</div>
<div class="step-card__content">
<h2 class="step-card__title">{{ t('Adopt') }}</h2>
<p>{{ t('Churches and networks take ownership – praying, giving, and preparing the way for gospel workers.') }}</p>
</div>
<NuxtLink :to="localePath('/adopt')" class="button | compact">{{ t('Commit') }}</NuxtLink>
</div>
<div class="step-card">
<div class="step-card__number">3</div>
<div class="step-card__content">
<h2 class="step-card__title">{{ t('Engage') }}</h2>
<p>{{ t('God raises up men and women to go, serve, and proclaim Christ among the nations.') }}</p>
</div>
<a href="#" class="button | compact invisible-placeholder">{{ t('Commit') }}</a>
</div>
</div>
</div>
</div>
</section>
<section
class="bg-image"
style="background-image: url('/assets/images/home-02-WhoAreTheUnreached-new.jpg');"
>
<h2 class="text-center banner-title invisible-placeholder">{{ t('Who are the unengaged?') }}</h2>
</section>
<section class="overflow-hidden progress-section">
<div class="container stack stack--xl">
<h2 class="color-white text-center progress-section__title">{{ t('Who are the unengaged?') }}</h2>
<div class="concentric-circles | gap-md">
<div class="switcher-item">
<div class="info-card color-brand-dark justify-center">
<div class="stack stack--lg | info-card__content">
<h3 class="color-brand-lighter">{{ t('Unreached') }}</h3>
<span>{{ t('{0} Billion', ['3.9']) }}</span>
<span class="color-brand-lighter">{{ t('{0} People Groups', ['6,602']) }}</span>
</div>
</div>
</div>
<div class="switcher-item">
<div class="info-card color-secondary-very-light justify-center">
<div class="stack stack--lg | info-card__content">
<h3>{{ t('Under-Engaged') }}</h3>
<span class="color-secondary-light">{{ t('{0} Billion', ['3.3']) }}</span>
<span>{{ t('{0} People Groups', ['5,119']) }}</span>
</div>
</div>
</div>
<div class="switcher-item">
<div class="info-card color-secondary-very-light justify-center">
<div class="stack stack--lg | info-card__content">
<h3>{{ t('Frontier People') }}</h3>
<span class="color-secondary-light">{{ t('{0} Billion', ['2']) }}</span>
<span>{{ t('{0} People Groups', ['4,788']) }}</span>
</div>
</div>
</div>
<div class="switcher-item">
<div class="info-card surface-brand-dark justify-center">
<div class="stack stack--lg | info-card__content">
<h3>{{ t('Unengaged') }}</h3>
<span>{{ t('{0} Million', ['202']) }}</span>
<span>{{ t('{0} People Groups', ['2,085']) }}</span>
</div>
</div>
</div>
</div>
<NuxtLink :to="localePath('/about/definitions')" class="with-icon | light-link mx-auto">
{{ t('Learn More') }}
<svg class="icon | rotate-90">
<use href="/assets/icons/arrow-chevron.svg#chevron-up" />
</svg>
</NuxtLink>
</div>
</section>
<section class="surface-white">
<div class="container stack stack--xl">
<h2>{{ t('Vision 2033') }}</h2>
<div class="">
<div class="switcher" data-width="xl">
<div class="switcher-item center grow-none">
<img
class="center"
src="/assets/images/home-03-Vision-2033.jpg"
:alt="t('Vision 2033')"
style="width: clamp(150px, 25vw, 350px);"
>
</div>
<div class="switcher-item align-center justify-center">
<div class="stack stack--xl">
<h3 class="subtext">{{ t('In partnership with the global church, our vision is for...') }}</h3>
<ul class="stack stack--md" data-list-color="primary">
<li>{{ t('Daily 24-hour prayer coverage for all 2,085 unengaged peoples') }}</li>
<li>{{ t('No unengaged people groups by 2033') }}</li>
<li>{{ t('Mobilization of 20,000+ DOXA partnership missionaries') }}</li>
<li>{{ t('Fruitful engagement among frontier peoples and the under-engaged') }}</li>
<li>{{ t('Church planting movements among every unreached people on earth') }}</li>
</ul>
<NuxtLink :to="localePath('/about/vision')" class="with-icon | color-primary-darker ms-auto">
{{ t('More') }}
<svg class="icon | rotate-90">
<use href="/assets/icons/arrow-chevron.svg#chevron-up" />
</svg>
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="surface-brand-light">
<div class="container stack stack--3xl">
<h2 class="highlight" data-highlight-last>{{ t('Engagement starts with prayer') }}</h2>
<div class="switcher | align-center">
<div class="switcher-item center grow-none">
<img
src="/assets/images/home-04-EngagementStartsWithPrayer.jpg"
:alt="t('Engagement starts with prayer')"
>
</div>
<div>
<div class="stack stack--3xl | align-center">
<p class="text-center max-width-md font-size-lg">{{ t('Every movement of the gospel begins with intercession. Cover an unengaged people group in daily prayer and help prepare the way.') }}</p>
<NuxtLink :to="localePath('/pray')" class="button | compact">{{ t('Pray') }}</NuxtLink>
</div>
</div>
</div>
</div>
</section>
<section>
<div class="switcher container | gap-md" data-width="xl">
<div
class="switcher-item card | padding-clamp-2xl bg-image align-center"
style="background-image: url('/assets/images/home-doxa-background.jpg');"
>
<div class="stack stack--md | text-center text-secondary">
<h2>{{ t('What does "DOXA" mean?') }}</h2>
<p class="subtext">{{ t('DOXA is the Greek word for "GLORY".') }}</p>
<p>{{ t('We chose this name because Jesus is worthy of glory from every tribe, tongue, people, and nation. DOXA reminds us that we partner with the whole Church to take the whole gospel to the whole world – until people from every nation are worshipping Jesus and He alone receives all the glory.') }}</p>
</div>
</div>
<div class="switcher-item center grow-none">
<img
class="rounded-xlg"
src="/assets/images/home-05-WhatDoesDoxaMean.jpg"
:alt="t('Engagement starts with prayer')"
>
</div>
</div>
</section>
<VideoModal
ref="videoModalRef"
:src="videoUrl"
:title="t('Engage every people by 2033')"
:close-label="t('Close video')"
/>
</div>
</template>

View file

@ -1,6 +1,34 @@
<script setup lang="ts">
// Port of marketing-theme/page-uupgs.php.
// Matches the PHP markup verbatim: container page-content uupgs-page stack
// stack--3xl + a highlighted h1 + the <UupgsList> component. The
// `initialSearchTerm` is taken from the `uupg_search` query parameter
// (PHP equivalent: `get_query_var('uupg_search')`).
import { buildUupgListTranslations } from '~/utils/uupgListTranslations'
const { t, locale } = useI18n()
const localePath = useLocalePath()
const route = useRoute()
const initialSearchTerm = computed(() =>
(Array.isArray(route.query.uupg_search) ? route.query.uupg_search[0] : route.query.uupg_search) ?? ''
)
const translations = computed(() => buildUupgListTranslations(t))
useTextHighlight()
</script>
<template>
<section class="container stack stack--md">
<h1>Find a UUPG</h1>
<p>People group search is ported in Phase 3.</p>
</section>
<div class="container page-content uupgs-page stack stack--3xl">
<h1 class="text-center highlight" data-highlight-last>{{ t('Find a UUPG') }}</h1>
<UupgsList
:language-code="locale"
:research-url="localePath('/research') + '/'"
:t="translations"
:initial-search-term="initialSearchTerm"
hide-see-all-link
/>
</div>
</template>

45
app/types/uupg.ts Normal file
View file

@ -0,0 +1,45 @@
// Copied verbatim from marketing-theme/js/components/src/types/uupg.ts
// — plus the FilterOption type from filter-dropdown.ts so it lives with
// the data model types.
export interface ValueLabel {
value: string
label: string
description?: string
}
export interface UupgMatch {
key: string
label: any
}
export interface Uupg {
slug: string
name: string
wagf_region: ValueLabel
wagf_region_label?: any
wagf_block: ValueLabel
wagf_member: ValueLabel
country_code: ValueLabel
country_label?: any
rop1: ValueLabel
rop1_label?: any
location_description: string
has_photo: boolean
image_url: string
picture_credit: any
population: number
religion: ValueLabel
adopted_by_churches?: number
people_praying?: number
people_committed?: number
imb_alternate_name?: string
matches?: UupgMatch[]
}
export interface FilterOption {
value: string
label: string
count: number
type?: 'dropdown' | 'boolean'
}

251
app/utils/countries.ts Normal file
View file

@ -0,0 +1,251 @@
// Port of `doxa_get_countries()` from marketing-theme/functions.php.
// Source lists ~240 countries with 3-letter ISO codes. WP sorts by label
// at the end of the function; we pre-sort here so the array is
// immutable at module load.
export interface Country { value: string; label: string }
export const COUNTRIES: Country[] = [
{ value: "AFG", label: "Afghanistan" },
{ value: "ALB", label: "Albania" },
{ value: "DZA", label: "Algeria" },
{ value: "ASM", label: "American Samoa" },
{ value: "AND", label: "Andorra" },
{ value: "AGO", label: "Angola" },
{ value: "AIA", label: "Anguilla" },
{ value: "ATG", label: "Antigua and Barbuda" },
{ value: "ARG", label: "Argentina" },
{ value: "ARM", label: "Armenia" },
{ value: "ABW", label: "Aruba" },
{ value: "AUS", label: "Australia" },
{ value: "AUT", label: "Austria" },
{ value: "AZE", label: "Azerbaijan" },
{ value: "BHR", label: "Bahrain" },
{ value: "BGD", label: "Bangladesh" },
{ value: "BRB", label: "Barbados" },
{ value: "BLR", label: "Belarus" },
{ value: "BEL", label: "Belgium" },
{ value: "BLZ", label: "Belize" },
{ value: "BEN", label: "Benin" },
{ value: "BMU", label: "Bermuda" },
{ value: "BTN", label: "Bhutan" },
{ value: "BOL", label: "Bolivia" },
{ value: "BES", label: "Bonaire, Sint Eustatius, and Saba" },
{ value: "BIH", label: "Bosnia and Herzegovina" },
{ value: "BWA", label: "Botswana" },
{ value: "BRA", label: "Brazil" },
{ value: "BRN", label: "Brunei" },
{ value: "BGR", label: "Bulgaria" },
{ value: "BFA", label: "Burkina Faso" },
{ value: "BDI", label: "Burundi" },
{ value: "CPV", label: "Cabo Verde" },
{ value: "KHM", label: "Cambodia" },
{ value: "CMR", label: "Cameroon" },
{ value: "CAN", label: "Canada" },
{ value: "CYM", label: "Cayman Islands" },
{ value: "CAF", label: "Central African Republic" },
{ value: "TCD", label: "Chad" },
{ value: "CHL", label: "Chile" },
{ value: "CHN", label: "China" },
{ value: "CXR", label: "Christmas Island" },
{ value: "CCK", label: "Cocos (Keeling) Islands" },
{ value: "COL", label: "Colombia" },
{ value: "COM", label: "Comoros" },
{ value: "COK", label: "Cook Islands" },
{ value: "CRI", label: "Costa Rica" },
{ value: "CIV", label: "Côte d’Ivoire" },
{ value: "HRV", label: "Croatia" },
{ value: "CUB", label: "Cuba" },
{ value: "CUW", label: "Curaçao" },
{ value: "CYP", label: "Cyprus" },
{ value: "DNK", label: "Denmark" },
{ value: "DJI", label: "Djibouti" },
{ value: "DMA", label: "Dominica" },
{ value: "DOM", label: "Dominican Republic" },
{ value: "ECU", label: "Ecuador" },
{ value: "EGY", label: "Egypt" },
{ value: "SLV", label: "El Salvador" },
{ value: "GNQ", label: "Equatorial Guinea" },
{ value: "ERI", label: "Eritrea" },
{ value: "EST", label: "Estonia" },
{ value: "SWZ", label: "Eswatini" },
{ value: "ETH", label: "Ethiopia" },
{ value: "FLK", label: "Falkland Islands (Islas Malvinas)" },
{ value: "FRO", label: "Faroe Islands" },
{ value: "FJI", label: "Fiji" },
{ value: "FIN", label: "Finland" },
{ value: "FRA", label: "France" },
{ value: "GUF", label: "French Guiana" },
{ value: "PYF", label: "French Polynesia" },
{ value: "GAB", label: "Gabon" },
{ value: "GEO", label: "Georgia" },
{ value: "DEU", label: "Germany" },
{ value: "GHA", label: "Ghana" },
{ value: "GIB", label: "Gibraltar" },
{ value: "GRC", label: "Greece" },
{ value: "GRL", label: "Greenland" },
{ value: "GRD", label: "Grenada" },
{ value: "GLP", label: "Guadeloupe" },
{ value: "GUM", label: "Guam" },
{ value: "GTM", label: "Guatemala" },
{ value: "GGY", label: "Guernsey" },
{ value: "GIN", label: "Guinea" },
{ value: "GNB", label: "Guinea-Bissau" },
{ value: "GUY", label: "Guyana" },
{ value: "HTI", label: "Haiti" },
{ value: "HND", label: "Honduras" },
{ value: "HKG", label: "Hong Kong" },
{ value: "HUN", label: "Hungary" },
{ value: "ISL", label: "Iceland" },
{ value: "IND", label: "India" },
{ value: "IDN", label: "Indonesia" },
{ value: "IRN", label: "Iran" },
{ value: "IRQ", label: "Iraq" },
{ value: "IRL", label: "Ireland" },
{ value: "IMN", label: "Isle of Man" },
{ value: "ISR", label: "Israel" },
{ value: "ITA", label: "Italy" },
{ value: "JAM", label: "Jamaica" },
{ value: "JPN", label: "Japan" },
{ value: "JEY", label: "Jersey" },
{ value: "JOR", label: "Jordan" },
{ value: "KAZ", label: "Kazakhstan" },
{ value: "KEN", label: "Kenya" },
{ value: "KIR", label: "Kiribati" },
{ value: "KOS", label: "Kosovo" },
{ value: "KWT", label: "Kuwait" },
{ value: "KGZ", label: "Kyrgyzstan" },
{ value: "LAO", label: "Laos" },
{ value: "LVA", label: "Latvia" },
{ value: "LBN", label: "Lebanon" },
{ value: "LSO", label: "Lesotho" },
{ value: "LBR", label: "Liberia" },
{ value: "LBY", label: "Libya" },
{ value: "LIE", label: "Liechtenstein" },
{ value: "LTU", label: "Lithuania" },
{ value: "LUX", label: "Luxembourg" },
{ value: "MAC", label: "Macao" },
{ value: "MDG", label: "Madagascar" },
{ value: "MWI", label: "Malawi" },
{ value: "MYS", label: "Malaysia" },
{ value: "MLI", label: "Mali" },
{ value: "MLT", label: "Malta" },
{ value: "MTQ", label: "Martinique" },
{ value: "MRT", label: "Mauritania" },
{ value: "MUS", label: "Mauritius" },
{ value: "MYT", label: "Mayotte" },
{ value: "MEX", label: "Mexico" },
{ value: "MDA", label: "Moldova" },
{ value: "MCO", label: "Monaco" },
{ value: "MNG", label: "Mongolia" },
{ value: "MNE", label: "Montenegro" },
{ value: "MSR", label: "Montserrat" },
{ value: "MAR", label: "Morocco" },
{ value: "MOZ", label: "Mozambique" },
{ value: "MMR", label: "Myanmar" },
{ value: "NAM", label: "Namibia" },
{ value: "NRU", label: "Nauru" },
{ value: "NPL", label: "Nepal" },
{ value: "NCL", label: "New Caledonia" },
{ value: "NZL", label: "New Zealand" },
{ value: "NIC", label: "Nicaragua" },
{ value: "NER", label: "Niger" },
{ value: "NGA", label: "Nigeria" },
{ value: "NIU", label: "Niue" },
{ value: "NFK", label: "Norfolk Island" },
{ value: "PRK", label: "North Korea" },
{ value: "MKD", label: "North Macedonia" },
{ value: "NOR", label: "Norway" },
{ value: "OMN", label: "Oman" },
{ value: "PAK", label: "Pakistan" },
{ value: "PLW", label: "Palau" },
{ value: "PSE", label: "Palestine" },
{ value: "PAN", label: "Panama" },
{ value: "PNG", label: "Papua New Guinea" },
{ value: "PRY", label: "Paraguay" },
{ value: "PER", label: "Peru" },
{ value: "POL", label: "Poland" },
{ value: "PRT", label: "Portugal" },
{ value: "PRI", label: "Puerto Rico" },
{ value: "QAT", label: "Qatar" },
{ value: "REU", label: "Reunion" },
{ value: "ROU", label: "Romania" },
{ value: "RWA", label: "Rwanda" },
{ value: "BLM", label: "Saint Barthelemy" },
{ value: "SHN", label: "Saint Helena, Ascension, and Tristan da Cunha" },
{ value: "KNA", label: "Saint Kitts and Nevis" },
{ value: "LCA", label: "Saint Lucia" },
{ value: "MAF", label: "Saint Martin" },
{ value: "SPM", label: "Saint Pierre and Miquelon" },
{ value: "VCT", label: "Saint Vincent and the Grenadines" },
{ value: "WSM", label: "Samoa" },
{ value: "SMR", label: "San Marino" },
{ value: "STP", label: "Sao Tome and Principe" },
{ value: "SAU", label: "Saudi Arabia" },
{ value: "SEN", label: "Senegal" },
{ value: "SRB", label: "Serbia" },
{ value: "SYC", label: "Seychelles" },
{ value: "SLE", label: "Sierra Leone" },
{ value: "SGP", label: "Singapore" },
{ value: "SXM", label: "Sint Maarten" },
{ value: "SVK", label: "Slovakia" },
{ value: "SVN", label: "Slovenia" },
{ value: "SOM", label: "Somalia" },
{ value: "ZAF", label: "South Africa" },
{ value: "KOR", label: "South Korea" },
{ value: "SSD", label: "South Sudan" },
{ value: "ESP", label: "Spain" },
{ value: "LKA", label: "Sri Lanka" },
{ value: "SDN", label: "Sudan" },
{ value: "SUR", label: "Suriname" },
{ value: "SJM", label: "Svalbard" },
{ value: "SWE", label: "Sweden" },
{ value: "CHE", label: "Switzerland" },
{ value: "SYR", label: "Syria" },
{ value: "TWN", label: "Taiwan" },
{ value: "TJK", label: "Tajikistan" },
{ value: "TZA", label: "Tanzania" },
{ value: "THA", label: "Thailand" },
{ value: "BHS", label: "The Bahamas" },
{ value: "VGB", label: "the British Virgin Islands" },
{ value: "CZE", label: "the Czech Republic" },
{ value: "COD", label: "the Democratic Republic of the Congo" },
{ value: "FSM", label: "the Federated States of Micronesia" },
{ value: "GMB", label: "The Gambia" },
{ value: "MDV", label: "the Maldives" },
{ value: "MHL", label: "the Marshall Islands" },
{ value: "NLD", label: "the Netherlands" },
{ value: "MNP", label: "the Northern Mariana Islands" },
{ value: "PHL", label: "the Philippines" },
{ value: "PCN", label: "the Pitcairn Islands" },
{ value: "COG", label: "the Republic of the Congo" },
{ value: "RUS", label: "the Russian Federation" },
{ value: "SLB", label: "the Solomon Islands" },
{ value: "TCA", label: "the Turks and Caicos Islands" },
{ value: "ARE", label: "the United Arab Emirates" },
{ value: "GBR", label: "the United Kingdom" },
{ value: "USA", label: "the United States" },
{ value: "VIR", label: "the Virgin Islands" },
{ value: "TLS", label: "Timor-Leste" },
{ value: "TGO", label: "Togo" },
{ value: "TKL", label: "Tokelau" },
{ value: "TON", label: "Tonga" },
{ value: "TTO", label: "Trinidad and Tobago" },
{ value: "TUN", label: "Tunisia" },
{ value: "TUR", label: "Turkiye" },
{ value: "TKM", label: "Turkmenistan" },
{ value: "TUV", label: "Tuvalu" },
{ value: "UGA", label: "Uganda" },
{ value: "UKR", label: "Ukraine" },
{ value: "URY", label: "Uruguay" },
{ value: "UZB", label: "Uzbekistan" },
{ value: "VUT", label: "Vanuatu" },
{ value: "VAT", label: "Vatican City" },
{ value: "VEN", label: "Venezuela" },
{ value: "VNM", label: "Vietnam" },
{ value: "WLF", label: "Wallis and Futuna" },
{ value: "ESH", label: "Western Sahara" },
{ value: "YEM", label: "Yemen" },
{ value: "ZMB", label: "Zambia" },
{ value: "ZWE", label: "Zimbabwe" }
]

View file

@ -0,0 +1,34 @@
// Port of `get_uupg_list_translations()` from marketing-theme/functions.php.
// Returns the `t` object that the <UupgsList> component consumes.
// Each value is localized via the caller-supplied `$t` function.
export type UupgListT = ReturnType<typeof buildUupgListTranslations>
export function buildUupgListTranslations(t: (key: string) => string) {
return {
select: t('Select'),
full_profile: t('Full Profile'),
prayer_coverage: t('Intercessors'),
adopted: t('Adopted'),
not_adopted: t('Not Adopted'),
loading: t('Loading results...'),
load_more: t('Load More'),
total: t('Total'),
search: t('Search names, country/continent, religions...'),
see_all: t('See All'),
country: t('Country'),
religion: t('Religion'),
alternate_name: t('Alternative Names'),
rop1: t('People Group'),
wagf_region: t('WAGF Region'),
wagf_block: t('WAGF Block'),
show_filters: t('Show Filters'),
hide_filters: t('Hide Filters'),
clear_all: t('Clear All'),
adopted_filter: t('Adopted'),
engaged_filter: t('Engaged'),
exact_filter: t('Exact'),
type_to_search: t('Type to search...'),
no_options: t('No options found')
}
}

13
app/utils/videoUrls.ts Normal file
View file

@ -0,0 +1,13 @@
// Port of doxa_get_video_urls() / doxa_get_video_url() from
// marketing-theme/functions.php. Returns the Vimeo embed URL for the
// current language, falling back to English.
const VIDEO_URLS: Record<string, string> = {
en: 'https://player.vimeo.com/video/1143355099?h=39f8c1f131&badge=0&autopause=0&player_id=0&app_id=58479',
es: 'https://player.vimeo.com/video/1183374525?h=003bb3e2c0&badge=0&autopause=0&player_id=0&app_id=58479',
fr: 'https://player.vimeo.com/video/1174779547?h=8c00c1c764&badge=0&autopause=0&player_id=0&app_id=58479'
}
export function getVideoUrl(langCode: string): string {
return VIDEO_URLS[langCode] ?? VIDEO_URLS.en!
}

View file

@ -5,9 +5,14 @@ export default defineNuxtConfig({
modules: [
'@nuxt/eslint',
'@nuxt/ui',
'@nuxtjs/i18n'
'@nuxtjs/i18n',
'@nuxtjs/turnstile'
],
turnstile: {
siteKey: process.env.NUXT_TURNSTILE_SITE_KEY || ''
},
components: [
{ path: '~/components', pathPrefix: false }
],
@ -67,10 +72,13 @@ export default defineNuxtConfig({
s3AccessKeyId: process.env.S3_ACCESS_KEY_ID || '',
s3SecretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
s3BucketName: process.env.S3_BUCKET_NAME || '',
formApiKey: process.env.FORM_API_KEY || '',
prayBaseUrl: process.env.NUXT_PRAY_BASE_URL || 'https://pray.doxa.life',
public: {
appName: process.env.APP_TITLE || 'My App',
nodeEnv: process.env.NODE_ENV || '',
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || ''
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || '',
prayBaseUrl: process.env.NUXT_PUBLIC_PRAY_BASE_URL || 'https://pray.doxa.life'
}
},

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,82 @@
// Port of marketing-theme/functions/contact-rest-api.php.
// Browser POSTs form data + Turnstile token here; we verify the token
// server-side and forward the sanitized payload to the campaigns-sever
// public endpoint at `{prayBaseUrl}/api/contact` with X-API-Key.
import { defineEventHandler, readBody, createError } from 'h3'
interface ContactBody {
name?: string
email?: string
country?: string
message?: string
consent_doxa_general?: boolean
language?: string
cf_turnstile?: string
}
function sanitizeText(s: unknown): string {
if (typeof s !== 'string') return ''
return s.trim().replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').slice(0, 5000)
}
function sanitizeEmail(s: unknown): string {
if (typeof s !== 'string') return ''
return s.trim().toLowerCase().slice(0, 320)
}
export default defineEventHandler(async (event) => {
const body = await readBody<ContactBody>(event)
const config = useRuntimeConfig()
// 1. Turnstile verification (server-side). `verifyTurnstileToken` is
// auto-imported by @nuxtjs/turnstile.
const token = body?.cf_turnstile
if (!token) {
throw createError({ statusCode: 400, statusMessage: 'Invalid token' })
}
const turnstileResult = await verifyTurnstileToken(token)
if (!turnstileResult?.success) {
throw createError({ statusCode: 400, statusMessage: 'Invalid token' })
}
// 2. Sanitize inputs (matches PHP sanitize_text_field / sanitize_email)
const name = sanitizeText(body.name)
const email = sanitizeEmail(body.email)
const country = sanitizeText(body.country)
const message = sanitizeText(body.message)
const consent = Boolean(body.consent_doxa_general)
const language = sanitizeText(body.language) || 'en'
if (!email || !message) {
throw createError({ statusCode: 400, statusMessage: 'Email and message are required' })
}
// 3. Forward to campaigns-sever (mirrors contact-rest-api.php)
if (!config.prayBaseUrl || !config.formApiKey) {
throw createError({ statusCode: 500, statusMessage: 'Prayer Tools integration not configured' })
}
try {
await $fetch(`${String(config.prayBaseUrl).replace(/\/$/, '')}/api/contact`, {
method: 'POST',
headers: {
'X-API-Key': config.formApiKey as string,
'Content-Type': 'application/json'
},
body: {
name,
email,
country,
message,
consent_doxa_general: consent,
language
},
timeout: 15_000
})
} catch {
throw createError({ statusCode: 500, statusMessage: 'Failed to send message. Please try again.' })
}
return { status: 'success' }
})