phase 3
This commit is contained in:
parent
d21f4e82c0
commit
7eb566a96b
18 changed files with 2007 additions and 51 deletions
12
.env.example
12
.env.example
|
|
@ -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
|
||||
|
|
|
|||
178
app/components/public/FilterDropdown.vue
Normal file
178
app/components/public/FilterDropdown.vue
Normal 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>
|
||||
126
app/components/public/PeopleGroupReel.vue
Normal file
126
app/components/public/PeopleGroupReel.vue
Normal 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>
|
||||
99
app/components/public/PhoneInput.vue
Normal file
99
app/components/public/PhoneInput.vue
Normal 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>
|
||||
621
app/components/public/UupgsList.vue
Normal file
621
app/components/public/UupgsList.vue
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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))">×</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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
65
app/composables/usePrayerStatistics.ts
Normal file
65
app/composables/usePrayerStatistics.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
45
app/types/uupg.ts
Normal 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
251
app/utils/countries.ts
Normal 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" }
|
||||
]
|
||||
34
app/utils/uupgListTranslations.ts
Normal file
34
app/utils/uupgListTranslations.ts
Normal 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
13
app/utils/videoUrls.ts
Normal 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!
|
||||
}
|
||||
|
|
@ -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'
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
2
public/assets/js/intl-tel-input-utils-bundle.js
Normal file
2
public/assets/js/intl-tel-input-utils-bundle.js
Normal file
File diff suppressed because one or more lines are too long
82
server/api/contact.post.ts
Normal file
82
server/api/contact.post.ts
Normal 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' }
|
||||
})
|
||||
Loading…
Reference in a new issue