db-ministerial/src/app/(frontend)/page.tsx

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>
)
}