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