contador volutanrios
This commit is contained in:
parent
50ba35aba9
commit
861f65fd2f
|
|
@ -8,6 +8,7 @@ const {
|
|||
image = null,
|
||||
url = null,
|
||||
date = null,
|
||||
noindex = false,
|
||||
} = Astro.props;
|
||||
|
||||
const imageUrl = image ? new URL(image, Astro.site).toString() : null;
|
||||
|
|
@ -20,6 +21,7 @@ const canonicalURL = new URL(url || Astro.url.pathname, Astro.site);
|
|||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<GoogleAnalytics id="G-26KM3HWW9J" />
|
||||
|
||||
<title>{title}</title>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import "@fontsource/poppins/700.css";
|
|||
import "@fontsource-variable/kameron";
|
||||
import ShareSticky from "../components/ShareSticky.vue";
|
||||
import { routeTranslations } from "../i18n";
|
||||
const { title, description, image, url, date } = Astro.props;
|
||||
const { title, description, image, url, date, noindex } = Astro.props;
|
||||
|
||||
const currentLocale = Astro.currentLocale ?? "es";
|
||||
const direction = currentLocale === "he" ? "rtl" : "ltr";
|
||||
|
|
@ -28,6 +28,7 @@ const isNewsPage = contentSegments.some((segment) =>
|
|||
image={image}
|
||||
url={url}
|
||||
date={date}
|
||||
noindex={noindex}
|
||||
/>
|
||||
<script>
|
||||
document.addEventListener("contextmenu", (event) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
---
|
||||
import MainLayout from "@/layouts/MainLayout.astro";
|
||||
|
||||
// Frecuencia de consulta al Google Sheet de voluntarios (en milisegundos).
|
||||
// Cambia este valor para ajustar cada cuánto se refresca el contador.
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title="Contador de Voluntarios | Centro del Reino de Paz y Justicia"
|
||||
description="Contador en vivo de voluntarios registrados en el Centro del Reino de Paz y Justicia."
|
||||
noindex={true}
|
||||
>
|
||||
<main
|
||||
class="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#0f3327] px-4 py-12"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,rgba(203,161,106,0.18),transparent_60%)]"
|
||||
>
|
||||
</div>
|
||||
<img
|
||||
src="/img/opacity-logo.png"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 m-auto w-[140%] max-w-none select-none opacity-[0.05]"
|
||||
/>
|
||||
|
||||
<section
|
||||
class="counter-card relative w-full max-w-2xl rounded-3xl border border-white/10 bg-white/5 px-6 py-10 text-center shadow-2xl backdrop-blur-md sm:px-12 sm:py-14"
|
||||
>
|
||||
<img
|
||||
src="/img/logo-metalico.webp"
|
||||
alt="Centro del Reino de Paz y Justicia"
|
||||
class="mx-auto mb-4 h-14 w-auto object-contain sm:h-16"
|
||||
/>
|
||||
<p
|
||||
class="font-primary text-xs font-bold uppercase tracking-[0.3em] text-colorSecondary sm:text-sm"
|
||||
>
|
||||
Centro del Reino de Paz y Justicia
|
||||
</p>
|
||||
<h1
|
||||
class="mt-3 font-secondary text-2xl font-bold text-colorPrimary sm:text-4xl"
|
||||
>
|
||||
Voluntarios Registrados
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="mt-8 flex items-end justify-center gap-1 font-mono text-colorSecondary sm:mt-10"
|
||||
style="text-shadow: 0 0 40px rgba(203,161,106,0.35)"
|
||||
>
|
||||
<span
|
||||
id="group-thousands"
|
||||
class="countdown hidden text-[clamp(3.5rem,16vw,9rem)]"
|
||||
>
|
||||
<span id="digit-thousands" style="--value:0;" aria-hidden="true">0</span>
|
||||
</span>
|
||||
<span class="countdown text-[clamp(3.5rem,16vw,9rem)]">
|
||||
<span id="digit-rest" style="--value:0;" aria-hidden="true">0</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p id="sr-count" class="sr-only" aria-live="polite">
|
||||
Cargando contador de voluntarios…
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-6 flex flex-col items-center gap-2 sm:mt-8 sm:flex-row sm:justify-center sm:gap-6"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-white/70 sm:text-sm"
|
||||
>
|
||||
<span class="relative flex h-2.5 w-2.5">
|
||||
<span
|
||||
id="status-ping"
|
||||
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-amber-400 opacity-75"
|
||||
></span>
|
||||
<span
|
||||
id="status-dot"
|
||||
class="relative inline-flex h-2.5 w-2.5 rounded-full bg-amber-400"
|
||||
></span>
|
||||
</span>
|
||||
<span id="status-text">Conectando…</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 sm:text-sm">
|
||||
Última actualización: <span id="last-updated">—</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.counter-card {
|
||||
animation: counter-fade-in 0.6s ease-out;
|
||||
}
|
||||
.countdown {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pulse-update {
|
||||
animation: counter-pulse 0.5s ease-out;
|
||||
}
|
||||
@keyframes counter-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes counter-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script is:inline define:vars={{ POLL_INTERVAL_MS }}>
|
||||
const ENDPOINT = "/api/voluntarios/count";
|
||||
|
||||
const groupThousands = document.getElementById("group-thousands");
|
||||
const digitThousands = document.getElementById("digit-thousands");
|
||||
const digitRest = document.getElementById("digit-rest");
|
||||
const srCount = document.getElementById("sr-count");
|
||||
const statusDot = document.getElementById("status-dot");
|
||||
const statusPing = document.getElementById("status-ping");
|
||||
const statusText = document.getElementById("status-text");
|
||||
const lastUpdated = document.getElementById("last-updated");
|
||||
const numberWrap = digitRest.closest("div");
|
||||
|
||||
const STATUS_STYLES = {
|
||||
live: { color: "bg-emerald-400", text: "En vivo" },
|
||||
error: { color: "bg-red-400", text: "Reconectando…" },
|
||||
loading: { color: "bg-amber-400", text: "Conectando…" },
|
||||
};
|
||||
|
||||
let currentCount = null;
|
||||
|
||||
function renderCount(count) {
|
||||
const safe = Math.max(0, Math.min(99999, Math.round(count)));
|
||||
const thousands = Math.floor(safe / 1000);
|
||||
const rest = safe % 1000;
|
||||
|
||||
if (thousands > 0) {
|
||||
groupThousands.classList.remove("hidden");
|
||||
digitThousands.style.setProperty("--value", String(thousands));
|
||||
digitThousands.textContent = String(thousands);
|
||||
digitThousands.setAttribute("aria-label", String(thousands));
|
||||
digitRest.style.setProperty("--digits", "3");
|
||||
} else {
|
||||
groupThousands.classList.add("hidden");
|
||||
digitRest.style.removeProperty("--digits");
|
||||
}
|
||||
|
||||
digitRest.style.setProperty("--value", String(rest));
|
||||
digitRest.textContent = String(rest);
|
||||
digitRest.setAttribute("aria-label", String(rest));
|
||||
|
||||
srCount.textContent = `${safe} ${safe === 1 ? "voluntario registrado" : "voluntarios registrados"}`;
|
||||
|
||||
if (currentCount !== null && currentCount !== safe && numberWrap) {
|
||||
numberWrap.classList.remove("pulse-update");
|
||||
void numberWrap.offsetWidth;
|
||||
numberWrap.classList.add("pulse-update");
|
||||
}
|
||||
currentCount = safe;
|
||||
}
|
||||
|
||||
function setStatus(state) {
|
||||
const { color, text } = STATUS_STYLES[state] || STATUS_STYLES.loading;
|
||||
statusDot.className = `relative inline-flex h-2.5 w-2.5 rounded-full ${color}`;
|
||||
statusPing.className = `absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${color}`;
|
||||
statusText.textContent = text;
|
||||
}
|
||||
|
||||
async function fetchCount() {
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.message || "Error desconocido");
|
||||
}
|
||||
|
||||
renderCount(data.count);
|
||||
setStatus("live");
|
||||
lastUpdated.textContent = new Date().toLocaleTimeString("es-ES", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("No se pudo actualizar el contador de voluntarios:", err);
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
fetchCount();
|
||||
setInterval(fetchCount, POLL_INTERVAL_MS);
|
||||
</script>
|
||||
</MainLayout>
|
||||
|
|
@ -37,6 +37,17 @@ export async function emailExists(email: string): Promise<boolean> {
|
|||
}
|
||||
|
||||
|
||||
export async function getVolunteerCount(): Promise<number> {
|
||||
const result = await sheets.spreadsheets.values.get({
|
||||
spreadsheetId: SPREADSHEET_ID,
|
||||
range: 'VOLUNTARIOS!B2:B', // columna nombre_completo, se llena en cada registro
|
||||
valueRenderOption: 'UNFORMATTED_VALUE',
|
||||
});
|
||||
|
||||
const rows = result.data.values || [];
|
||||
return rows.filter((row) => row[0] !== undefined && row[0] !== '').length;
|
||||
}
|
||||
|
||||
export async function appendToSheet(values: any[]) {
|
||||
try {
|
||||
await sheets.spreadsheets.values.append({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import type { APIRoute } from "astro";
|
||||
import { getVolunteerCount } from "../lib/googleSheets";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
try {
|
||||
const count = await getVolunteerCount();
|
||||
|
||||
return Response.json(
|
||||
{ success: true, count },
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error en /api/voluntarios/count:", error);
|
||||
return Response.json(
|
||||
{ success: false, message: "Error al obtener el contador de voluntarios" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue