staging #80

Merged
esteban merged 35 commits from staging into production 2026-08-03 05:07:52 +00:00
6 changed files with 242 additions and 3 deletions
Showing only changes of commit acf723ac7d - Show all commits

2
.gitignore vendored
View File

@ -29,5 +29,3 @@ prisma/dev.db
# opencode - agentes y scripts locales (no subir a producción) # opencode - agentes y scripts locales (no subir a producción)
opencode/ opencode/
.opencode/ .opencode/
.env
docs/

View File

@ -8,6 +8,7 @@ const {
image = null, image = null,
url = null, url = null,
date = null, date = null,
noindex = false,
} = Astro.props; } = Astro.props;
const imageUrl = image ? new URL(image, Astro.site).toString() : null; 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" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<GoogleAnalytics id="G-26KM3HWW9J" /> <GoogleAnalytics id="G-26KM3HWW9J" />
<title>{title}</title> <title>{title}</title>

View File

@ -8,7 +8,7 @@ import "@fontsource/poppins/700.css";
import "@fontsource-variable/kameron"; import "@fontsource-variable/kameron";
import ShareSticky from "../components/ShareSticky.vue"; import ShareSticky from "../components/ShareSticky.vue";
import { routeTranslations } from "../i18n"; 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 currentLocale = Astro.currentLocale ?? "es";
const direction = currentLocale === "he" ? "rtl" : "ltr"; const direction = currentLocale === "he" ? "rtl" : "ltr";
@ -28,6 +28,7 @@ const isNewsPage = contentSegments.some((segment) =>
image={image} image={image}
url={url} url={url}
date={date} date={date}
noindex={noindex}
/> />
<script> <script>
document.addEventListener("contextmenu", (event) => { document.addEventListener("contextmenu", (event) => {

View File

@ -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>

View File

@ -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[]) { export async function appendToSheet(values: any[]) {
try { try {
await sheets.spreadsheets.values.append({ await sheets.spreadsheets.values.append({

View File

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