Compare commits
No commits in common. "861f65fd2f502a13f5d27bc837e12b8bcba2bad4" and "0449df38f5427a356bca173ebf1065d0b99f76ad" have entirely different histories.
861f65fd2f
...
0449df38f5
|
|
@ -29,3 +29,5 @@ 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,7 +8,6 @@ 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;
|
||||||
|
|
@ -21,7 +20,6 @@ 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, noindex } = Astro.props;
|
const { title, description, image, url, date } = 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,7 +28,6 @@ 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) => {
|
||||||
|
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
---
|
|
||||||
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,17 +37,6 @@ 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({
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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