188 lines
7.2 KiB
TypeScript
188 lines
7.2 KiB
TypeScript
import { getPayload, type Where } from 'payload'
|
|
import React from 'react'
|
|
|
|
import config from '@/payload.config'
|
|
import './styles.css'
|
|
import { PastorsTable, type PastorRow } from './PastorsTable'
|
|
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
const PAGE_SIZE = 25
|
|
|
|
// Idioma usado para mostrar los códigos ISO de país como nombres legibles.
|
|
const DISPLAY_LOCALE = 'es'
|
|
const regionNames = new Intl.DisplayNames([DISPLAY_LOCALE], { type: 'region' })
|
|
|
|
const localizedCountry = (code?: string | null): string | null => {
|
|
if (!code) return null
|
|
try {
|
|
return regionNames.of(code.toUpperCase()) ?? code
|
|
} catch {
|
|
return code
|
|
}
|
|
}
|
|
|
|
// Código ISO alpha-2 -> emoji de bandera (símbolos indicadores regionales).
|
|
const flag = (code?: string | null): string => {
|
|
if (!code || code.length !== 2) return ''
|
|
const base = 0x1f1e6
|
|
const cc = code.toUpperCase()
|
|
return String.fromCodePoint(base + (cc.charCodeAt(0) - 65), base + (cc.charCodeAt(1) - 65))
|
|
}
|
|
|
|
// Accent/case-insensitive normalizer for search matching ("méxico" ~ "mexico").
|
|
const normalize = (s: string): string =>
|
|
s
|
|
.normalize('NFD')
|
|
.replace(/\p{Diacritic}/gu, '')
|
|
.toLowerCase()
|
|
.trim()
|
|
|
|
// All ISO 3166-1 alpha-2 codes, so a country typed by name resolves to its code
|
|
// (country is stored as the ISO code, e.g. "MX" for México).
|
|
// prettier-ignore
|
|
const ALPHA2 = ['AD','AE','AF','AG','AI','AL','AM','AO','AQ','AR','AS','AT','AU','AW','AX','AZ','BA','BB','BD','BE','BF','BG','BH','BI','BJ','BL','BM','BN','BO','BQ','BR','BS','BT','BV','BW','BY','BZ','CA','CC','CD','CF','CG','CH','CI','CK','CL','CM','CN','CO','CR','CU','CV','CW','CX','CY','CZ','DE','DJ','DK','DM','DO','DZ','EC','EE','EG','EH','ER','ES','ET','FI','FJ','FK','FM','FO','FR','GA','GB','GD','GE','GF','GG','GH','GI','GL','GM','GN','GP','GQ','GR','GS','GT','GU','GW','GY','HK','HM','HN','HR','HT','HU','ID','IE','IL','IM','IN','IO','IQ','IR','IS','IT','JE','JM','JO','JP','KE','KG','KH','KI','KM','KN','KP','KR','KW','KY','KZ','LA','LB','LC','LI','LK','LR','LS','LT','LU','LV','LY','MA','MC','MD','ME','MF','MG','MH','MK','ML','MM','MN','MO','MP','MQ','MR','MS','MT','MU','MV','MW','MX','MY','MZ','NA','NC','NE','NF','NG','NI','NL','NO','NP','NR','NU','NZ','OM','PA','PE','PF','PG','PH','PK','PL','PM','PN','PR','PS','PT','PW','PY','QA','RE','RO','RS','RU','RW','SA','SB','SC','SD','SE','SG','SH','SI','SJ','SK','SL','SM','SN','SO','SR','SS','ST','SV','SX','SY','SZ','TC','TD','TF','TG','TH','TJ','TK','TL','TM','TN','TO','TR','TT','TV','TW','TZ','UA','UG','UM','US','UY','UZ','VA','VC','VE','VG','VI','VN','VU','WF','WS','YE','YT','ZA','ZM','ZW']
|
|
|
|
// Precomputed [normalizedName, code] pairs in the display locale.
|
|
const COUNTRY_NAME_INDEX: [string, string][] = ALPHA2.map((code) => [
|
|
normalize(localizedCountry(code) ?? code),
|
|
code,
|
|
])
|
|
|
|
// Country codes whose localized name contains the search term.
|
|
const countryCodesMatching = (term: string): string[] => {
|
|
const t = normalize(term)
|
|
if (!t) return []
|
|
return COUNTRY_NAME_INDEX.filter(([name]) => name.includes(t)).map(([, code]) => code)
|
|
}
|
|
|
|
export default async function HomePage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ q?: string; page?: string }>
|
|
}) {
|
|
const { q = '', page = '1' } = await searchParams
|
|
const currentPage = Math.max(1, parseInt(page, 10) || 1)
|
|
const search = q.trim()
|
|
|
|
const payload = await getPayload({ config: await config })
|
|
|
|
// Country is stored as an ISO code, so resolve any country *name* in the
|
|
// search term to its code(s) and match those too.
|
|
const countryCodes = countryCodesMatching(search)
|
|
const where: Where = search
|
|
? {
|
|
or: [
|
|
{ name: { like: search } },
|
|
{ email: { like: search } },
|
|
{ city: { like: search } },
|
|
{ country: { like: search } },
|
|
...(countryCodes.length ? [{ country: { in: countryCodes } }] : []),
|
|
],
|
|
}
|
|
: {}
|
|
|
|
const result = await payload.find({
|
|
collection: 'pastors',
|
|
where,
|
|
sort: 'name',
|
|
depth: 1,
|
|
page: currentPage,
|
|
limit: PAGE_SIZE,
|
|
})
|
|
|
|
// Running total of impacted people across ALL matching records (not just the
|
|
// current page). Only the number field is selected to keep this light.
|
|
const impactedAgg = await payload.find({
|
|
collection: 'pastors',
|
|
where,
|
|
pagination: false,
|
|
depth: 0,
|
|
select: { impactedPeople: true },
|
|
})
|
|
const totalImpacted = impactedAgg.docs.reduce((sum, d) => sum + (d.impactedPeople ?? 0), 0)
|
|
|
|
const rows: PastorRow[] = result.docs.map((p) => {
|
|
const letter = p.letter && typeof p.letter === 'object' ? p.letter : null
|
|
return {
|
|
id: p.id,
|
|
name: p.name,
|
|
churchName: p.churchName ?? null,
|
|
email: p.email ?? null,
|
|
telephone: Array.isArray(p.telephone) ? p.telephone.filter(Boolean) : [],
|
|
place: p.place ?? null,
|
|
city: p.city ?? null,
|
|
state: p.state ?? null,
|
|
country: p.country ?? null,
|
|
countryName: localizedCountry(p.country),
|
|
countryFlag: flag(p.country),
|
|
impactedPeople: p.impactedPeople ?? null,
|
|
churchRegistered: Boolean(p.churchRegistered),
|
|
registrationNumber: p.registrationNumber ?? null,
|
|
inTelegram: Boolean(p.inTelegram),
|
|
notes: p.notes ?? null,
|
|
letterUrl: letter?.url ?? null,
|
|
letterName: letter?.filename ?? null,
|
|
}
|
|
})
|
|
|
|
return (
|
|
<section className="max-w-7xl mx-auto w-full px-4 py-10">
|
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Pastores</h1>
|
|
<p className="text-base-content/60 text-sm mt-1">
|
|
{result.totalDocs.toLocaleString('es')} registros
|
|
<span className="mx-2 opacity-40">·</span>
|
|
<span className="font-medium text-base-content/80">
|
|
{totalImpacted.toLocaleString('es')}
|
|
</span>{' '}
|
|
personas impactadas
|
|
</p>
|
|
</div>
|
|
<form method="get" className="join">
|
|
<input
|
|
type="text"
|
|
name="q"
|
|
defaultValue={search}
|
|
placeholder="Buscar nombre, correo, ciudad, país…"
|
|
className="input input-bordered join-item w-64 max-w-full"
|
|
/>
|
|
<button type="submit" className="btn btn-primary join-item">
|
|
Buscar
|
|
</button>
|
|
{search && (
|
|
<a href="/" className="btn btn-ghost join-item">
|
|
Limpiar
|
|
</a>
|
|
)}
|
|
</form>
|
|
</div>
|
|
|
|
<PastorsTable rows={rows} />
|
|
|
|
<div className="flex items-center justify-between mt-6">
|
|
<p className="text-sm text-base-content/60">
|
|
Página {result.page} de {result.totalPages}
|
|
</p>
|
|
<div className="join">
|
|
<a
|
|
href={`/?${new URLSearchParams({ ...(search ? { q: search } : {}), page: String(currentPage - 1) })}`}
|
|
className={`btn join-item ${result.hasPrevPage ? '' : 'btn-disabled'}`}
|
|
aria-disabled={!result.hasPrevPage}
|
|
>
|
|
« Anterior
|
|
</a>
|
|
<a
|
|
href={`/?${new URLSearchParams({ ...(search ? { q: search } : {}), page: String(currentPage + 1) })}`}
|
|
className={`btn join-item ${result.hasNextPage ? '' : 'btn-disabled'}`}
|
|
aria-disabled={!result.hasNextPage}
|
|
>
|
|
Siguiente »
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|