Add Keystatic CMS and refresh landing experience

This commit is contained in:
2026-04-16 00:11:22 -04:00
parent 2e0b742e49
commit e779907ff4
80 changed files with 8187 additions and 1402 deletions

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
# Credenciales del panel de administración
# Copia este archivo como .env y rellena con tus valores reales
ADMIN_USER=admin
ADMIN_PASS=tu_contrasena_segura_aqui

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
# Claude context
CLAUDE.md

View File

@@ -1,6 +1,8 @@
// @ts-check
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
import keystatic from '@keystatic/astro';
// https://astro.build/config
export default defineConfig({
@@ -8,4 +10,5 @@ export default defineConfig({
adapter: node({
mode: 'standalone',
}),
integrations: [react(), keystatic()],
});

142
keystatic.config.ts Normal file
View File

@@ -0,0 +1,142 @@
import { config, collection, fields } from '@keystatic/core';
export default config({
storage: {
kind: 'local',
},
ui: {
brand: {
name: 'Admin Renacer',
},
},
collections: {
programas: collection({
label: 'Programas',
slugField: 'nombre',
path: 'src/content/programas/*',
format: { data: 'yaml' },
schema: {
nombre: fields.slug({
name: { label: 'Nombre del programa' },
slug: { label: 'Slug (URL)' },
}),
// ── Tarjeta en el landing ─────────────────────────────────────
estado: fields.select({
label: 'Estado',
options: [
{ label: 'Activo', value: 'Activo' },
{ label: 'Vacaciones', value: 'Vacaciones' },
{ label: 'Anual', value: 'Anual' },
{ label: 'Temporada', value: 'Temporada' },
{ label: 'En planificación', value: 'En planificación' },
],
defaultValue: 'Activo',
}),
bajada: fields.text({
label: 'Resumen corto (opcional)',
description: 'Campo editorial secundario. Ya no se muestra en la tarjeta pública.',
}),
detalle: fields.text({
label: 'Descripción breve',
description: 'Texto que sí aparece en la tarjeta del landing, bajo el título.',
multiline: true,
validation: { isRequired: true },
}),
icono: fields.select({
label: 'Icono de la tarjeta',
description: 'Se muestra dentro del círculo de la tarjeta del programa.',
options: [
{ label: 'Manos y apoyo', value: 'HeartHandshake' },
{ label: 'Alimentación', value: 'UtensilsCrossed' },
{ label: 'Canasta / apoyo', value: 'Package' },
{ label: 'Vestimenta', value: 'Shirt' },
{ label: 'Naturaleza', value: 'Sprout' },
{ label: 'Regalo / campaña', value: 'Gift' },
{ label: 'Juego e infancia', value: 'Puzzle' },
{ label: 'Comunidad', value: 'Users' },
{ label: 'Inclusión', value: 'HandHelping' },
],
defaultValue: 'HeartHandshake',
}),
orden: fields.integer({
label: 'Orden en landing',
description: 'Número de posición (menor = primero). Ej: 1, 2, 3…',
defaultValue: 99,
validation: { isRequired: true },
}),
// ── Configuración de página ───────────────────────────────────
tienePagina: fields.checkbox({
label: '¿Tiene página propia?',
description: 'Desmarcar para programas en planificación sin página',
defaultValue: true,
}),
externalHref: fields.text({
label: 'URL externa o personalizada (opcional)',
description: 'Déjalo vacío para usar automáticamente /programas/slug/.',
}),
// ── Contenido de la página ────────────────────────────────────
kicker: fields.text({
label: 'Kicker (nombre corto para la página)',
description: 'Aparece sobre el título principal',
}),
titulo: fields.text({
label: 'Título de la página',
description: 'Título largo y descriptivo',
}),
lead: fields.text({
label: 'Párrafo introductorio (lead)',
description: 'Párrafo destacado que aparece bajo el título',
multiline: true,
}),
parrafos: fields.array(
fields.text({ label: 'Párrafo', multiline: true }),
{
label: 'Párrafos del cuerpo',
description: 'Agrega los párrafos del contenido principal',
itemLabel: (props) => {
const val = props.value ?? '';
return val.length > 60 ? val.slice(0, 60) + '…' : val || 'Párrafo vacío';
},
}
),
stats: fields.array(
fields.object({
valor: fields.text({ label: 'Valor o cifra' }),
etiqueta: fields.text({ label: 'Descripción' }),
}),
{
label: 'Estadísticas (idealmente 4)',
description: 'Datos clave del programa en formato cifra + descripción',
itemLabel: (props) => props.fields.valor.value || 'Estadística',
}
),
textoApoyo: fields.text({
label: 'Texto de apoyo / cómo colaborar',
description: 'Texto que aparece en la sección final de la página',
multiline: true,
}),
imagen: fields.image({
label: 'Imagen principal (opcional)',
description: 'Portada del hero para la página del programa',
directory: 'public/uploads/programas',
publicPath: '/uploads/programas/',
}),
galeria: fields.array(
fields.image({
label: 'Imagen de galería',
directory: 'public/uploads/programas',
publicPath: '/uploads/programas/',
}),
{
label: 'Galería (opcional)',
description: 'Imágenes secundarias para mostrar debajo del contenido',
itemLabel: (props) => props.value?.split('/').pop() ?? 'Imagen',
}
),
},
}),
},
});

4252
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,13 @@
},
"dependencies": {
"@astrojs/node": "^9.5.1",
"astro": "^5.16.7"
},
"devDependencies": {
"@astrojs/react": "^5.0.3",
"@keystatic/astro": "^5.0.6",
"@keystatic/core": "^0.5.50",
"astro": "^5.16.7",
"image-size": "^2.0.2",
"lucide": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View File

@@ -1,299 +0,0 @@
---
import { Image } from 'astro:assets';
import canastaImagenPrincipal from '../assets/canasta-familiar/imagen-principal.jpg';
import canastaEntrega from '../assets/canasta-familiar/entrega-canasta.jpeg';
const canastaStats = [
{ valor: '2005', etiqueta: 'año desde el que se desarrolla este programa' },
{ valor: 'Semanal', etiqueta: 'frecuencia actual de entregas a familias' },
{ valor: 'Hogar', etiqueta: 'mercadería, frutas, verduras, higiene y aseo' },
{ valor: 'Flexible', etiqueta: 'entrega a domicilio o con opción de retiro' },
];
---
<main class="canasta-page">
<section class="canasta-hero">
<div class="hero-copy">
<a href="/" class="back-link">← Volver a Renacer</a>
<p class="section-kicker">Canasta Familiar</p>
<h1>Apoyo directo para sostener la mesa y el hogar de muchas familias.</h1>
<p class="hero-lead">
El Programa Canasta Familiar de Renacer entrega apoyo directo a familias que enfrentan
dificultades económicas, a través de alimentos y productos esenciales para el hogar.
</p>
</div>
<div class="hero-visual">
<Image
src={canastaImagenPrincipal}
alt="Canasta Familiar de Renacer preparada para apoyo comunitario."
widths={[720, 1080]}
sizes="(max-width: 960px) 100vw, 46vw"
/>
</div>
</section>
<section class="canasta-content">
<div class="content-copy">
<p>
Las canastas incluyen mercadería, frutas, verduras, artículos de higiene personal y productos
de aseo. En algunos casos también se incorporan elementos específicos, como pañales, según
las necesidades de cada familia.
</p>
<p>
Las entregas se realizan tanto a domicilio como con opción de retiro, adaptándose a cada
situación. Este programa se desarrolla desde el año 2005, acompañando principalmente a
familias vinculadas a niños, niñas y jóvenes que participan en los espacios y talleres de
Renacer.
</p>
<p>
En el contexto actual, donde el costo de la vida ha aumentado de forma sostenida, muchas
familias enfrentan dificultades para cubrir necesidades básicas. A través de este programa,
buscamos ser un apoyo concreto que alivie, en parte, esa carga diaria.
</p>
<p>
Este trabajo es posible gracias a la colaboración de personas, aportes solidarios y el apoyo
del Ministerio Cristiano Renacer en Cristo. Más allá de la entrega de alimentos, buscamos
acompañar con respeto y cercanía la realidad que muchas familias viven hoy.
</p>
</div>
<div class="stats-grid">
{canastaStats.map((stat) => (
<article class="stat-card">
<strong>{stat.valor}</strong>
<span>{stat.etiqueta}</span>
</article>
))}
</div>
</section>
<section class="gallery-section">
<div class="gallery-copy">
<p class="section-kicker">Apoyo concreto</p>
<h2>Una red de ayuda que responde a necesidades básicas con cercanía y continuidad.</h2>
<p>
Si deseas conocer más o ser parte de esta iniciativa, puedes hacerlo a través de nuestros
canales. Toda colaboración permite que este apoyo continúe llegando a quienes lo necesitan.
</p>
</div>
<div class="gallery-grid">
<div class="gallery-card">
<Image
src={canastaEntrega}
alt="Entrega de apoyo del programa Canasta Familiar de Renacer."
widths={[520, 800]}
sizes="(max-width: 960px) 100vw, 30vw"
/>
</div>
<div class="gallery-note">
<h3>Cómo apoyar</h3>
<p>
Puedes colaborar de distintas maneras: aportando económicamente, difundiendo la iniciativa
o coordinando apoyo directo para familias que hoy necesitan un alivio real en sus gastos
básicos.
</p>
<div class="note-actions">
<a class="primary-link" href="/colaboradormensual/">Colaborar mensualmente</a>
<a class="secondary-link" href="mailto:contacto@familiarenacer.cl">Contactar a Renacer</a>
</div>
</div>
</div>
</section>
</main>
<style>
.canasta-page {
width: min(100%, 1240px);
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
.canasta-hero,
.canasta-content,
.gallery-grid {
display: grid;
gap: 1.5rem;
}
.canasta-hero {
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
align-items: center;
}
.back-link {
display: inline-flex;
margin-bottom: 1rem;
color: #52637a;
font-weight: 700;
}
.section-kicker {
margin: 0 0 0.9rem;
color: var(--brand);
font-size: 0.86rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1,
h2,
h3 {
margin: 0;
font-family: 'Sora', sans-serif;
letter-spacing: -0.05em;
color: #1b2740;
}
h1 {
font-size: clamp(2.8rem, 6vw, 4.8rem);
line-height: 0.98;
}
.hero-lead,
.content-copy p,
.gallery-copy p,
.gallery-note p {
color: #5b6a82;
line-height: 1.8;
}
.hero-lead {
margin-top: 1.2rem;
font-size: 1.08rem;
}
.hero-visual,
.gallery-card,
.gallery-note,
.stat-card {
overflow: hidden;
border-radius: 1.6rem;
border: 1px solid rgba(29, 39, 56, 0.08);
background: white;
box-shadow: 0 18px 48px rgba(34, 54, 73, 0.08);
}
.hero-visual :global(img),
.gallery-card :global(img) {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.hero-visual {
min-height: 32rem;
}
.canasta-content {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
align-items: start;
margin-top: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.stat-card {
padding: 1.25rem;
}
.stat-card strong {
display: block;
color: var(--brand-dark);
font-family: 'Sora', sans-serif;
font-size: 2rem;
}
.stat-card span {
display: block;
margin-top: 0.35rem;
color: #5b6a82;
line-height: 1.5;
}
.gallery-section {
margin-top: 4rem;
}
.gallery-copy {
max-width: 48rem;
margin-bottom: 1.6rem;
}
.gallery-copy h2 {
font-size: clamp(2rem, 4vw, 3.4rem);
}
.gallery-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr);
align-items: stretch;
}
.gallery-card {
min-height: 26rem;
}
.gallery-note {
padding: 1.8rem;
background: linear-gradient(180deg, #ffffff 0%, #f7f9ff 100%);
}
.gallery-note h3 {
font-size: 1.6rem;
}
.note-actions {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
margin-top: 1.5rem;
}
.primary-link,
.secondary-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1rem 1.25rem;
border-radius: 1rem;
font-weight: 800;
}
.primary-link {
background: linear-gradient(180deg, #1b49d6 0%, #0834b7 100%);
color: white;
}
.secondary-link {
background: rgba(8, 52, 183, 0.06);
color: var(--brand-dark);
border: 1px solid rgba(8, 52, 183, 0.14);
}
@media (max-width: 960px) {
.canasta-hero,
.canasta-content,
.gallery-grid,
.stats-grid {
grid-template-columns: 1fr;
}
.hero-visual,
.gallery-card {
min-height: 0;
}
}
@media (max-width: 720px) {
.canasta-page {
padding: 1.25rem 1rem 3rem;
}
}
</style>

View File

@@ -1,232 +1,360 @@
---
import LucideIcon from './LucideIcon.astro';
const donationLinks = [
{
name: 'Gesto Solidario',
amount: 'CLP 1.000 a CLP 4.000',
description: 'Un pequeño gesto que también ayuda a sembrar esperanza.',
emoji: '🤍',
icon: 'HeartHandshake',
href: 'https://app.reveniu.com/checkout-custom-link/tDRHQvX1T0JL1gdx8A7S0mM4RFqV25AR',
},
{
name: 'Semilla Solidaria',
amount: '$5.000',
description: 'Inicia la ayuda y permite sembrar nuevas oportunidades.',
emoji: '🌱',
icon: 'Sprout',
href: 'https://app.reveniu.com/checkout-custom-link/95bH9li3iYcdJKxuugTd1NV2s7n5sglH',
},
{
name: 'Brote de Esperanza',
amount: '$10.000',
description: 'Ayuda a que nuevos proyectos comiencen a crecer.',
emoji: '🌿',
icon: 'Leaf',
href: 'https://app.reveniu.com/checkout-custom-link/jwtYOw0vQOk5QyWXg8Zps9t6HMBfksmY',
},
{
name: 'Árbol de Apoyo',
amount: '$20.000',
description: 'Fortalece el acompañamiento a niños y familias.',
emoji: '🌳',
icon: 'Users',
href: 'https://app.reveniu.com/checkout-custom-link/dZEYJhCPUUQwOU8zqfYtsIIkGLpPPFsD',
},
{
name: 'Sol Solidario',
amount: '$30.000',
description: 'Entrega la energía que permite que todo siga creciendo.',
emoji: '🌞',
icon: 'CircleDollarSign',
href: 'https://app.reveniu.com/checkout-custom-link/ayAQljPMkfiwbl8FOEl9SIBXU1u8wRhK',
},
{
name: 'Raíz Fundadora',
amount: '$40.000',
description: 'Sostiene y da estabilidad al proyecto.',
emoji: '✨',
icon: 'BadgeCheck',
href: 'https://app.reveniu.com/checkout-custom-link/kX0o2VEFugphajKBGUDhQEUew9YqYDtt',
},
{
name: 'Guardián de Renacer',
amount: '$50.000 o más',
description: 'Protege y cuida el futuro del centro y de quienes lo necesitan.',
emoji: '🛡️',
icon: 'ShieldCheck',
href: 'https://app.reveniu.com/checkout-custom-link/feN8IRS5PRiC2gC0XKqhwyBTus342V0d',
},
];
---
<main class="linktree-page">
<section class="linktree-card">
<main class="donations-page">
<div class="donations-shell">
<aside class="donations-aside">
<a href="/" class="back-link">← Volver a Renacer</a>
<div class="brand-badge">Renacer</div>
<h1>Colaborador mensual</h1>
<p class="intro">
Elige una forma de aportar mes a mes y ayúdanos a sostener el trabajo con niños, familias y
comunidad.
Elige un nivel de aporte mensual y abre el checkout seguro para sostener el trabajo con
niños, familias y comunidad.
</p>
<div class="links-list">
<div class="aside-panel">
<div class="aside-stat">
<LucideIcon name="CircleDollarSign" size={18} />
<div>
<strong>Aporte recurrente</strong>
<span>Ayuda a dar continuidad a programas y campañas.</span>
</div>
</div>
<div class="aside-stat">
<LucideIcon name="ShieldCheck" size={18} />
<div>
<strong>Checkout externo seguro</strong>
<span>Los enlaces abren la plataforma protegida para completar tu aporte.</span>
</div>
</div>
<div class="aside-stat">
<LucideIcon name="HandHelping" size={18} />
<div>
<strong>Impacto real</strong>
<span>Tu colaboración ayuda a sostener alimentación, infancia y apoyo familiar.</span>
</div>
</div>
</div>
</aside>
<section class="donations-content">
<div class="content-heading">
<p class="eyebrow">Formas de aportar</p>
<h2>Escoge la opción que mejor se ajuste a tu compromiso.</h2>
</div>
<div class="links-grid">
{donationLinks.map((item) => (
<a
class="donation-link"
class="donation-card"
href={item.href}
target="_blank"
rel="nofollow sponsored noopener noreferrer"
>
<span class="link-emoji" aria-hidden="true">{item.emoji}</span>
<span class="link-copy">
<strong>
{item.name} <span>{item.amount}</span>
</strong>
<div class="card-icon">
<LucideIcon name={item.icon} size={20} />
</div>
<div class="card-copy">
<strong>{item.name}</strong>
<span class="card-amount">{item.amount}</span>
<small>{item.description}</small>
</div>
<span class="card-arrow">
<LucideIcon name="ArrowUpRight" size={18} />
</span>
<span class="link-arrow" aria-hidden="true">↗</span>
</a>
))}
</div>
<p class="footnote">Los enlaces de apoyo abren una plataforma externa y segura para completar tu aporte.</p>
<p class="footnote">
Si tienes dudas sobre aportes, coordinación institucional o donaciones directas, escríbenos
a `contacto@familiarenacer.cl`.
</p>
</section>
</div>
</main>
<style>
.linktree-page {
.donations-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.25rem;
padding: 1.5rem;
background:
radial-gradient(circle at top, rgba(29, 107, 87, 0.12), transparent 28%),
linear-gradient(180deg, #f8f4ec 0%, #f2ede3 100%);
radial-gradient(circle at 10% 10%, rgba(8, 52, 183, 0.14), transparent 20%),
radial-gradient(circle at 88% 18%, rgba(60, 118, 255, 0.12), transparent 18%),
linear-gradient(180deg, #eef4ff 0%, #f7faff 40%, #eef4ff 100%);
}
.linktree-card {
width: min(100%, 32rem);
.donations-shell {
width: min(100%, 1220px);
margin: 0 auto;
display: grid;
grid-template-columns: minmax(280px, 0.88fr) minmax(0, 1.12fr);
gap: 1.25rem;
align-items: start;
}
.donations-aside,
.donations-content {
border-radius: 1.7rem;
border: 1px solid rgba(18, 63, 184, 0.12);
background: rgba(255, 255, 255, 0.84);
backdrop-filter: blur(14px);
box-shadow: 0 18px 42px rgba(16, 46, 120, 0.08);
}
.donations-aside {
position: sticky;
top: 1.5rem;
padding: 1.5rem;
border: 1px solid rgba(31, 39, 37, 0.1);
border-radius: 1.75rem;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(12px);
box-shadow: 0 18px 45px rgba(35, 44, 41, 0.09);
}
.donations-content {
padding: 1.45rem;
}
.back-link {
display: inline-flex;
margin-bottom: 1rem;
color: #47625b;
color: #47649a;
font-weight: 700;
text-decoration: none;
}
.brand-badge {
.brand-badge,
.eyebrow {
display: inline-flex;
width: fit-content;
margin: 0 auto 0.9rem;
padding: 0.45rem 0.8rem;
border-radius: 999px;
background: rgba(8, 52, 183, 0.08);
color: #0834b7;
font-size: 0.78rem;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.08em;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h1,
h2,
strong {
font-family: 'Sora', sans-serif;
letter-spacing: -0.04em;
color: #122756;
}
h1 {
text-align: center;
font-size: clamp(2rem, 5vw, 2.8rem);
line-height: 1;
margin: 1rem 0 0;
font-size: clamp(2.2rem, 4vw, 3.4rem);
line-height: 0.96;
}
.intro,
.footnote {
margin: 0.85rem auto 0;
max-width: 32ch;
text-align: center;
color: #5f6966;
line-height: 1.6;
.footnote,
.aside-stat span,
.card-copy small {
color: #5e6f8d;
line-height: 1.65;
}
.links-list {
.intro {
margin: 1rem 0 0;
max-width: 34ch;
font-size: 1rem;
}
.aside-panel {
display: grid;
gap: 0.85rem;
margin-top: 1.6rem;
margin-top: 1.4rem;
}
.donation-link {
.aside-stat {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.8rem;
align-items: start;
padding: 0.95rem 1rem;
border-radius: 1.1rem;
background: linear-gradient(180deg, rgba(244, 248, 255, 0.95), rgba(236, 243, 255, 0.95));
border: 1px solid rgba(18, 63, 184, 0.1);
}
.aside-stat :global(svg) {
color: #0834b7;
}
.aside-stat strong {
display: block;
font-size: 0.96rem;
}
.aside-stat span {
display: block;
margin-top: 0.2rem;
font-size: 0.88rem;
}
.content-heading h2 {
margin-top: 0.7rem;
font-size: clamp(1.55rem, 3vw, 2.3rem);
line-height: 1.02;
}
.links-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
margin-top: 1.35rem;
}
.donation-card {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.9rem;
padding: 1rem 1rem 1rem 0.95rem;
border: 1px solid rgba(31, 39, 37, 0.1);
align-items: start;
gap: 0.85rem;
padding: 1rem;
border-radius: 1.2rem;
background: #ffffff;
box-shadow: 0 10px 24px rgba(35, 44, 41, 0.05);
border: 1px solid rgba(18, 63, 184, 0.1);
background: linear-gradient(180deg, #ffffff 0%, #f4f8ff 100%);
box-shadow: 0 12px 28px rgba(18, 54, 144, 0.06);
text-decoration: none;
transition:
transform 180ms ease,
border-color 180ms ease,
box-shadow 180ms ease;
box-shadow 180ms ease,
background 180ms ease;
}
.donation-link:hover {
.donation-card:hover {
transform: translateY(-2px);
border-color: rgba(8, 52, 183, 0.22);
box-shadow: 0 14px 30px rgba(8, 52, 183, 0.1);
background: linear-gradient(180deg, #ffffff 0%, #eaf1ff 100%);
box-shadow: 0 18px 34px rgba(8, 52, 183, 0.12);
}
.link-emoji {
display: inline-grid;
place-items: center;
width: 2.75rem;
height: 2.75rem;
.card-icon {
width: 2.8rem;
height: 2.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(8, 52, 183, 0.08);
font-size: 1.25rem;
background: rgba(8, 52, 183, 0.09);
color: #0834b7;
}
.link-copy {
.card-copy {
display: grid;
gap: 0.22rem;
}
.link-copy strong {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
.card-copy strong {
font-size: 1rem;
line-height: 1.35;
}
.link-copy strong span,
.link-arrow {
.card-amount {
color: #0834b7;
font-weight: 800;
font-size: 0.94rem;
}
.card-copy small {
font-size: 0.88rem;
}
.card-arrow {
color: #0834b7;
}
.link-copy small {
color: #5f6966;
font-size: 0.92rem;
line-height: 1.45;
}
.link-arrow {
font-size: 1.15rem;
font-weight: 700;
}
.footnote {
font-size: 0.9rem;
margin-top: 1.2rem;
margin: 1.2rem 0 0;
font-size: 0.92rem;
}
@media (max-width: 980px) {
.donations-shell {
grid-template-columns: 1fr;
}
.donations-aside {
position: static;
}
.links-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.linktree-page {
.donations-page {
padding: 0.85rem;
}
.linktree-card {
padding: 1.15rem;
.donations-aside,
.donations-content {
padding: 1.1rem;
border-radius: 1.35rem;
}
.donation-link {
padding: 0.9rem;
gap: 0.75rem;
.donation-card {
grid-template-columns: auto 1fr;
}
.card-arrow {
display: none;
}
}
</style>

View File

@@ -1,301 +0,0 @@
---
import { Image } from 'astro:assets';
import comedorImagenPrincipal from '../assets/comedor-solidario/imagen1.jpg';
import comedorImagenAccion from '../assets/comedor-solidario/comedor-accion.jpeg';
const comedorStats = [
{ valor: '+10', etiqueta: 'sectores de la comuna alcanzados' },
{ valor: '60', etiqueta: 'almuerzos preparados y distribuidos actualmente' },
{ valor: '3', etiqueta: 'jornadas de entrega cada semana' },
{ valor: '2017', etiqueta: 'año en que comenzó este trabajo territorial' },
];
---
<main class="comedor-page">
<section class="comedor-hero">
<div class="hero-copy">
<a href="/" class="back-link">← Volver a Renacer</a>
<p class="section-kicker">Comedor Solidario</p>
<h1>Almuerzos calientes, cercanía y apoyo concreto en Quillota.</h1>
<p class="hero-lead">
El Programa Comedor Solidario de Renacer entrega almuerzos calientes durante la semana a
personas y familias que atraviesan situaciones complejas, como cesantía, dificultades de
movilidad, discapacidad o falta de acceso regular a alimentación.
</p>
</div>
<div class="hero-visual">
<Image
src={comedorImagenPrincipal}
alt="Preparación y servicio del Comedor Solidario de Renacer en Quillota."
widths={[720, 1080]}
sizes="(max-width: 960px) 100vw, 46vw"
/>
</div>
</section>
<section class="comedor-content">
<div class="content-copy">
<p>
Actualmente, el programa se desarrolla en más de 10 sectores de la comuna, acercando
alimento preparado a quienes lo necesitan en su propio entorno. También llegamos a personas
mayores y a quienes se encuentran en situación de calle.
</p>
<p>
Durante el verano de 2026, el comedor entregó almuerzos calientes a niños y niñas durante el
período de vacaciones, considerando que en muchos casos no cuentan con la presencia
permanente de sus padres en el hogar. Esta iniciativa fue un apoyo concreto tanto en la
alimentación diaria como en la economía de las familias.
</p>
<p>
En la actualidad se preparan y distribuyen alrededor de 60 almuerzos, tres veces por
semana. Este trabajo comenzó el año 2017, inicialmente enfocado en acompañar a personas
migrantes, especialmente de la comunidad haitiana, y con el tiempo se adaptó a las nuevas
necesidades del territorio.
</p>
<p>
Más allá de la comida, este espacio busca transmitir dignidad, cercanía y acompañamiento.
Nuestro trabajo se inspira en valores cristianos, pero el apoyo se entrega con respeto a
cada persona, sin distinción de creencias.
</p>
</div>
<div class="stats-grid">
{comedorStats.map((stat) => (
<article class="stat-card">
<strong>{stat.valor}</strong>
<span>{stat.etiqueta}</span>
</article>
))}
</div>
</section>
<section class="gallery-section">
<div class="gallery-copy">
<p class="section-kicker">Acompañamiento real</p>
<h2>Una ayuda sostenida que combina alimentación, cuidado y presencia comunitaria.</h2>
<p>
Sabemos que muchas veces las dificultades económicas, sociales o de salud afectan lo más
básico: la posibilidad de acceder a una comida diaria. Por eso buscamos sostener este apoyo
de manera constante y en condiciones adecuadas de higiene y cuidado.
</p>
</div>
<div class="gallery-grid">
<div class="gallery-card">
<Image
src={comedorImagenAccion}
alt="Actividad del Comedor Solidario junto a la comunidad."
widths={[520, 800]}
sizes="(max-width: 960px) 100vw, 30vw"
/>
</div>
<div class="gallery-note">
<h3>Cómo apoyar</h3>
<p>
Si deseas conocer más o ser parte de este programa, puedes hacerlo a través de nuestros
canales. Toda colaboración permite que este apoyo continúe llegando a quienes lo necesitan.
</p>
<div class="note-actions">
<a class="primary-link" href="/colaboradormensual/">Colaborar mensualmente</a>
<a class="secondary-link" href="mailto:contacto@familiarenacer.cl">Contactar a Renacer</a>
</div>
</div>
</div>
</section>
</main>
<style>
.comedor-page {
width: min(100%, 1240px);
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
.comedor-hero,
.comedor-content,
.gallery-grid {
display: grid;
gap: 1.5rem;
}
.comedor-hero {
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
align-items: center;
}
.back-link {
display: inline-flex;
margin-bottom: 1rem;
color: #52637a;
font-weight: 700;
}
.section-kicker {
margin: 0 0 0.9rem;
color: var(--brand);
font-size: 0.86rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1,
h2,
h3 {
margin: 0;
font-family: 'Sora', sans-serif;
letter-spacing: -0.05em;
color: #1b2740;
}
h1 {
font-size: clamp(2.8rem, 6vw, 4.8rem);
line-height: 0.98;
}
.hero-lead,
.content-copy p,
.gallery-copy p,
.gallery-note p {
color: #5b6a82;
line-height: 1.8;
}
.hero-lead {
margin-top: 1.2rem;
font-size: 1.08rem;
}
.hero-visual,
.gallery-card,
.gallery-note,
.stat-card {
overflow: hidden;
border-radius: 1.6rem;
border: 1px solid rgba(29, 39, 56, 0.08);
background: white;
box-shadow: 0 18px 48px rgba(34, 54, 73, 0.08);
}
.hero-visual :global(img),
.gallery-card :global(img) {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.hero-visual {
min-height: 32rem;
}
.comedor-content {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
align-items: start;
margin-top: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.stat-card {
padding: 1.25rem;
}
.stat-card strong {
display: block;
color: var(--brand-dark);
font-family: 'Sora', sans-serif;
font-size: 2rem;
}
.stat-card span {
display: block;
margin-top: 0.35rem;
color: #5b6a82;
line-height: 1.5;
}
.gallery-section {
margin-top: 4rem;
}
.gallery-copy {
max-width: 48rem;
margin-bottom: 1.6rem;
}
.gallery-copy h2 {
font-size: clamp(2rem, 4vw, 3.4rem);
}
.gallery-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr);
align-items: stretch;
}
.gallery-card {
min-height: 26rem;
}
.gallery-note {
padding: 1.8rem;
background: linear-gradient(180deg, #ffffff 0%, #f7f9ff 100%);
}
.gallery-note h3 {
font-size: 1.6rem;
}
.note-actions {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
margin-top: 1.5rem;
}
.primary-link,
.secondary-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1rem 1.25rem;
border-radius: 1rem;
font-weight: 800;
}
.primary-link {
background: linear-gradient(180deg, #1b49d6 0%, #0834b7 100%);
color: white;
}
.secondary-link {
background: rgba(8, 52, 183, 0.06);
color: var(--brand-dark);
border: 1px solid rgba(8, 52, 183, 0.14);
}
@media (max-width: 960px) {
.comedor-hero,
.comedor-content,
.gallery-grid,
.stats-grid {
grid-template-columns: 1fr;
}
.hero-visual,
.gallery-card {
min-height: 0;
}
}
@media (max-width: 720px) {
.comedor-page {
padding: 1.25rem 1rem 3rem;
}
}
</style>

View File

@@ -0,0 +1,44 @@
---
import { icons } from 'lucide';
interface Props {
name: string;
size?: number;
strokeWidth?: number;
class?: string;
}
const {
name,
size = 20,
strokeWidth = 1.8,
class: className = '',
} = Astro.props;
const icon = icons[name];
if (!icon) {
throw new Error(`Lucide icon "${name}" is not available.`);
}
const markup = icon.map(([tag, attrs]) => {
const attrString = Object.entries(attrs)
.map(([key, value]) => `${key}="${String(value).replace(/"/g, '&quot;')}"`)
.join(' ');
return `<${tag} ${attrString}></${tag}>`;
}).join('');
---
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class:list={['lucide-icon', className]}
set:html={markup}
/>

View File

@@ -1,9 +1,17 @@
---
import { Image } from 'astro:assets';
interface Stat {
valor: string;
etiqueta: string;
}
interface ProgramImage {
src: string;
width: number;
height: number;
}
interface Props {
backHref?: string;
kicker: string;
@@ -11,8 +19,8 @@ interface Props {
lead: string;
paragraphs: string[];
stats: Stat[];
supportTitle?: string;
supportText: string;
images?: ProgramImage[];
}
const {
@@ -22,250 +30,575 @@ const {
lead,
paragraphs,
stats,
supportTitle = 'Cómo apoyar',
supportText,
images = [],
} = Astro.props;
const hasImages = images.length > 0;
const heroImage = images[0];
const galleryImages = images.slice(1, 3);
---
<main class="program-page">
<section class="program-hero">
<div class="hero-copy">
<!-- ── Hero ───────────────────────────────────────── -->
<section class={`program-hero ${hasImages ? 'has-image' : ''}`}>
<div class="hero-backdrop"></div>
<div class="hero-inner">
<div class="hero-text">
<a href={backHref} class="back-link">← Volver a Renacer</a>
<p class="section-kicker">{kicker}</p>
<h1>{title}</h1>
<p class="kicker">{kicker}</p>
<h1 class="hero-title">{title}</h1>
<p class="hero-lead">{lead}</p>
</div>
<div class="hero-panel">
<div class="panel-badge">Programa Renacer</div>
<p>
Una iniciativa comunitaria que combina apoyo concreto, cercanía y participación de redes
solidarias para responder a necesidades reales.
</p>
{hasImages && heroImage && (
<div class="hero-visual">
<Image
src={heroImage.src}
alt={`Foto del programa ${kicker}`}
width={heroImage.width}
height={heroImage.height}
widths={[720, 1080]}
sizes="(max-width: 960px) 100vw, 46vw"
priority={true}
/>
</div>
)}
</div>
</section>
<section class="program-content">
<div class="content-copy">
{paragraphs.map((paragraph) => <p>{paragraph}</p>)}
<!-- ── Contenido + Stats ──────────────────────────── -->
<section class="content-section">
<div class="content-inner">
<div class="text-col">
{paragraphs.map((p) => (
<p data-reveal>{p}</p>
))}
</div>
<aside class="stats-col">
<div class="stats-grid">
{stats.map((stat) => (
<article class="stat-card">
<strong>{stat.valor}</strong>
<span>{stat.etiqueta}</span>
<article class="stat-card" data-reveal>
<strong class="stat-valor" data-valor={stat.valor}>{stat.valor}</strong>
<span class="stat-label">{stat.etiqueta}</span>
</article>
))}
</div>
</section>
<section class="support-section">
<div class="support-copy">
<p class="section-kicker">Apoyo y comunidad</p>
<h2>{supportTitle}</h2>
<p>{supportText}</p>
<div class="note-actions">
<a class="primary-link" href="/colaboradormensual/">Colaborar mensualmente</a>
<a class="secondary-link" href="mailto:contacto@familiarenacer.cl">Contactar a Renacer</a>
</div>
</div>
<aside class="support-panel">
<h3>Red de colaboración</h3>
<p>
Estos programas se sostienen con voluntariado, donaciones, campañas y trabajo comunitario
constante. Cada aporte ayuda a mantener el acompañamiento en el tiempo.
</p>
</aside>
</div>
</section>
<!-- ── Galería (si hay imágenes secundarias) ─────── -->
{galleryImages.length > 0 && (
<section class="gallery-section">
<div class="gallery-inner">
{galleryImages.map((img, i) => (
<div class="gallery-item" data-reveal>
<Image
src={img.src}
alt={`Foto del programa ${kicker} ${i + 2}`}
width={img.width}
height={img.height}
widths={[520, 800]}
sizes="(max-width: 960px) 100vw, 48vw"
/>
</div>
))}
</div>
</section>
)}
<!-- ── Apoyo ──────────────────────────────────────── -->
<section class="support-section" data-reveal>
<div class="support-card">
<div class="support-text-col">
<p class="support-kicker">Apoyo y comunidad</p>
<p class="support-text">{supportText}</p>
<div class="support-actions">
<a class="btn-primary" href="/colaboradormensual/">Colaborar mensualmente</a>
<a class="btn-secondary" href="mailto:contacto@familiarenacer.cl">Contactar a Renacer</a>
</div>
</div>
<div class="support-deco" aria-hidden="true">
<div class="deco-ring deco-ring-1"></div>
<div class="deco-ring deco-ring-2"></div>
<span class="deco-icon">🤝</span>
</div>
</div>
</section>
</main>
<style>
/* ── Base ─────────────────────────────────────────── */
.program-page {
width: min(100%, 1240px);
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
width: 100%;
overflow-x: hidden;
}
.program-hero,
.program-content,
.support-section {
display: grid;
gap: 1.5rem;
/* ── Reveal ──────────────────────────────────────── */
[data-reveal] {
opacity: 0;
transform: translateY(22px);
transition:
opacity 600ms ease,
transform 600ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-reveal].visible {
opacity: 1;
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
[data-reveal] { opacity: 1; transform: none; transition: none; }
}
/* ── Hero ─────────────────────────────────────────── */
.program-hero {
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
align-items: stretch;
position: relative;
padding: clamp(2rem, 4svh, 3.5rem) 1.5rem clamp(2.5rem, 5svh, 4rem);
}
.hero-backdrop {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 70% 60% at 15% 20%, rgba(8, 52, 183, 0.15), transparent),
radial-gradient(ellipse 50% 70% at 85% 80%, rgba(73, 121, 255, 0.1), transparent),
linear-gradient(160deg, #edf2ff 0%, #f0f5ff 40%, #e8f0fe 100%);
border-bottom-left-radius: 1.5rem;
border-bottom-right-radius: 1.5rem;
}
.hero-inner {
position: relative;
z-index: 1;
width: min(100%, 1120px);
margin: 0 auto;
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
align-items: center;
}
.has-image .hero-inner {
grid-template-columns: 0.95fr 1.05fr;
}
.hero-text {
max-width: 600px;
}
.back-link {
display: inline-flex;
margin-bottom: 1rem;
color: #52637a;
font-size: 0.85rem;
font-weight: 700;
text-decoration: none;
transition: color 150ms;
}
.back-link:hover { color: var(--brand); }
.section-kicker {
margin: 0 0 0.9rem;
.kicker {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.85rem;
border-radius: 999px;
background: rgba(8, 52, 183, 0.1);
color: var(--brand);
font-size: 0.86rem;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 1rem;
}
h1,
h2,
h3 {
margin: 0;
.hero-title {
font-family: 'Sora', sans-serif;
letter-spacing: -0.05em;
font-size: clamp(2.2rem, 4.5vw, 3.6rem);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.04em;
color: #1b2740;
}
h1 {
font-size: clamp(2.8rem, 6vw, 4.8rem);
line-height: 0.98;
}
.hero-lead,
.content-copy p,
.support-copy p,
.hero-panel p,
.support-panel p {
color: #5b6a82;
line-height: 1.8;
margin: 0 0 1rem;
max-width: 20ch;
}
.hero-lead {
margin-top: 1.2rem;
font-size: 1.08rem;
font-size: clamp(0.97rem, 1.4vw, 1.1rem);
color: #4a5c73;
line-height: 1.7;
max-width: 52ch;
margin: 0;
}
.hero-panel,
.support-panel,
.stat-card {
border-radius: 1.6rem;
border: 1px solid rgba(29, 39, 56, 0.08);
background: white;
box-shadow: 0 18px 48px rgba(34, 54, 73, 0.08);
.hero-visual {
border-radius: 1.25rem;
overflow: hidden;
aspect-ratio: 4 / 3;
box-shadow: 0 20px 50px rgba(8, 52, 183, 0.14);
}
.hero-panel {
.hero-visual img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
/* ── Contenido + Stats ────────────────────────────── */
.content-section {
width: min(100%, 1120px);
margin: 0 auto;
padding: 2.5rem 1.5rem 2rem;
}
.content-inner {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 3rem;
align-items: start;
}
.text-col {
display: flex;
flex-direction: column;
justify-content: end;
min-height: 20rem;
padding: 1.8rem;
background:
radial-gradient(circle at top left, rgba(8, 52, 183, 0.11), transparent 28%),
linear-gradient(180deg, #ffffff 0%, #f7f9ff 100%);
gap: 1.1rem;
}
.panel-badge {
width: fit-content;
margin-bottom: 0.9rem;
padding: 0.5rem 0.8rem;
border-radius: 999px;
background: rgba(8, 52, 183, 0.08);
color: var(--brand-dark);
font-size: 0.8rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
.text-col p {
color: #4a5568;
line-height: 1.8;
font-size: 1rem;
margin: 0;
}
.program-content {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
align-items: start;
margin-top: 2rem;
/* ── Stats ────────────────────────────────────────── */
.stats-col {
position: sticky;
top: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.stat-card {
padding: 1.25rem;
padding: 1.25rem 1.1rem;
border-radius: 1.25rem;
background: white;
border: 1px solid rgba(29, 39, 56, 0.07);
box-shadow: 0 8px 28px rgba(8, 52, 183, 0.08);
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.stat-card strong {
.stat-valor {
display: block;
color: var(--brand-dark);
font-family: 'Sora', sans-serif;
font-size: 2rem;
font-size: clamp(1.5rem, 2.4vw, 2.1rem);
font-weight: 800;
letter-spacing: -0.04em;
color: var(--brand);
line-height: 1;
transform-origin: left center;
}
.stat-card span {
.stat-label {
display: block;
margin-top: 0.35rem;
color: #5b6a82;
line-height: 1.5;
font-size: 0.78rem;
line-height: 1.4;
}
/* ── Galería ──────────────────────────────────────── */
.gallery-section {
width: min(100%, 1120px);
margin: 0 auto;
padding: 0 1.5rem 2rem;
}
.gallery-inner {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
border-radius: 1.25rem;
overflow: hidden;
}
.gallery-item {
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 1rem;
}
.gallery-item img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
transition: transform 400ms ease;
}
.gallery-item:hover img {
transform: scale(1.03);
}
/* ── Apoyo ────────────────────────────────────────── */
.support-section {
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.9fr);
align-items: stretch;
margin-top: 4rem;
width: min(100%, 1120px);
margin: 0 auto 4rem;
padding: 0 1.5rem;
}
.support-copy h2 {
font-size: clamp(2rem, 4vw, 3.4rem);
.support-card {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 2rem;
padding: 2.25rem 2.25rem;
border-radius: 1.75rem;
background: linear-gradient(135deg, #101b33 0%, #15223c 100%);
box-shadow: 0 20px 50px rgba(17, 27, 51, 0.2);
overflow: hidden;
position: relative;
}
.support-panel {
padding: 1.8rem;
background: linear-gradient(180deg, #ffffff 0%, #f7f9ff 100%);
.support-kicker {
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #7b96c8;
margin: 0 0 0.6rem;
}
.support-panel h3 {
font-size: 1.6rem;
.support-text {
color: #c7d4e8;
line-height: 1.7;
font-size: 0.98rem;
margin: 0 0 1.25rem;
}
.note-actions {
.support-actions {
display: flex;
flex-wrap: wrap;
gap: 0.9rem;
margin-top: 1.5rem;
gap: 0.65rem;
}
.primary-link,
.secondary-link {
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1rem 1.25rem;
border-radius: 1rem;
padding: 0.8rem 1.25rem;
border-radius: 0.9rem;
font-weight: 800;
font-size: 0.88rem;
text-decoration: none;
transition: transform 160ms ease, box-shadow 160ms ease;
}
.btn-primary:hover,
.btn-secondary:hover { transform: translateY(-2px); }
.primary-link {
background: linear-gradient(180deg, #1b49d6 0%, #0834b7 100%);
.btn-primary {
background: linear-gradient(180deg, #2a5ae3 0%, var(--brand) 100%);
color: white;
box-shadow: 0 8px 20px rgba(8, 52, 183, 0.35);
}
.secondary-link {
background: rgba(8, 52, 183, 0.06);
color: var(--brand-dark);
border: 1px solid rgba(8, 52, 183, 0.14);
.btn-secondary {
background: rgba(255, 255, 255, 0.07);
color: #d0daf0;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.support-deco {
position: relative;
width: 90px;
height: 90px;
flex-shrink: 0;
}
.deco-ring {
position: absolute;
border-radius: 50%;
border: 1.5px solid rgba(255, 255, 255, 0.1);
}
.deco-ring-1 { inset: 0; }
.deco-ring-2 { inset: 12px; border-color: rgba(255,255,255,0.07); }
.deco-icon {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 2.1rem;
}
/* ── Responsive ───────────────────────────────────── */
@media (max-width: 960px) {
.program-hero,
.program-content,
.support-section,
.stats-grid {
.has-image .hero-inner {
grid-template-columns: 1fr;
}
.hero-visual {
aspect-ratio: 16 / 9;
}
.content-inner {
grid-template-columns: 1fr;
gap: 2rem;
}
.stats-col {
position: static;
}
.support-deco { display: none; }
}
@media (max-width: 720px) {
.program-page {
padding: 1.25rem 1rem 3rem;
@media (max-width: 600px) {
.hero-title { font-size: 2rem; }
.stats-grid {
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.stat-card { padding: 1rem 0.9rem; }
.support-card {
padding: 1.5rem 1.25rem;
grid-template-columns: 1fr;
}
.gallery-inner {
grid-template-columns: 1fr;
}
.text-col p { font-size: 0.95rem; }
}
</style>
<script>
// ── Reveal scroll ──────────────────────────────────────────────
const initReveal = () => {
const items = document.querySelectorAll('[data-reveal]');
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
items.forEach(el => el.classList.add('visible'));
return;
}
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const siblings = [...(entry.target.parentElement?.children ?? [])];
const order = siblings.indexOf(entry.target);
const delay = order * 60;
setTimeout(() => entry.target.classList.add('visible'), delay);
io.unobserve(entry.target);
}
});
},
{ threshold: 0.08, rootMargin: '0px 0px -4% 0px' }
);
items.forEach(el => io.observe(el));
};
// ── Contador de estadísticas ──────────────────────────────────
function parseValor(raw: string): { prefix: string; num: number; decimals: number; useDot: boolean; suffix: string } | null {
const m = raw.trim().match(/^([+\-]?)(\d[\d.,]*)(.*)$/);
if (!m) return null;
const prefix = m[1];
const numStr = m[2];
const suffix = m[3];
const useDot = /\.\d{3}$/.test(numStr) || /\.\d{3}[.,]/.test(numStr);
const clean = numStr.replace(/\./g, '').replace(/,/g, '.');
const num = parseFloat(clean);
if (isNaN(num)) return null;
const decimals = (clean.split('.')[1] ?? '').length;
return { prefix, num, decimals, useDot, suffix };
}
function formatNum(n: number, parsed: ReturnType<typeof parseValor>): string {
if (!parsed) return String(n);
const { prefix, decimals, useDot, suffix } = parsed;
let s: string;
if (decimals > 0) {
s = n.toFixed(decimals);
} else {
s = String(Math.round(n));
}
if (useDot && Math.round(n) >= 1000) {
s = Math.round(n).toLocaleString('de-DE');
}
return prefix + s + suffix;
}
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function animateStat(el: Element) {
const raw = (el as HTMLElement).dataset.valor ?? el.textContent ?? '';
const parsed = parseValor(raw);
if (!parsed || parsed.num === 0) return;
const duration = 1400;
const start = performance.now();
const target = parsed.num;
el.textContent = formatNum(0, parsed);
function tick(now: number) {
const progress = Math.min((now - start) / duration, 1);
const eased = easeOutCubic(progress);
el.textContent = formatNum(eased * target, parsed);
if (progress < 1) {
requestAnimationFrame(tick);
} else {
el.textContent = formatNum(target, parsed);
(el as HTMLElement).style.transition = 'transform 120ms ease-out';
(el as HTMLElement).style.transform = 'scale(1.1)';
setTimeout(() => {
(el as HTMLElement).style.transition = 'transform 380ms cubic-bezier(0.34, 1.56, 0.64, 1)';
(el as HTMLElement).style.transform = 'scale(1)';
}, 130);
}
}
requestAnimationFrame(tick);
}
const initCounters = () => {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const statCards = document.querySelectorAll('.stat-card');
const io = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const valorEl = entry.target.querySelector('.stat-valor');
if (valorEl) animateStat(valorEl);
io.unobserve(entry.target);
}
});
},
{ threshold: 0.4 }
);
statCards.forEach(card => io.observe(card));
};
requestAnimationFrame(() => requestAnimationFrame(() => {
initReveal();
initCounters();
}));
</script>

File diff suppressed because it is too large Load Diff

35
src/content.config.ts Normal file
View File

@@ -0,0 +1,35 @@
import { defineCollection, z } from 'astro:content';
const programas = defineCollection({
type: 'data',
schema: z.object({
nombre: z.string(),
estado: z.string(),
bajada: z.string(),
detalle: z.string(),
icono: z.string().optional().default('HeartHandshake'),
orden: z.number(),
tienePagina: z.boolean(),
externalHref: z.string().optional().default(''),
kicker: z.string().optional().default(''),
titulo: z.string().optional().default(''),
lead: z.string().optional().default(''),
parrafos: z.array(z.string()).optional().default([]),
stats: z
.array(
z.object({
valor: z.string(),
etiqueta: z.string(),
})
)
.optional()
.default([]),
textoApoyo: z.string().optional().default(''),
imagen: z.string().optional().default(''),
galeria: z.array(z.string()).optional().default([]),
}),
});
export const collections = {
programas,
};

View File

@@ -0,0 +1,51 @@
nombre: Canasta Familiar
estado: Activo
bajada: Apoyo alimentario y productos esenciales
detalle: >-
Apoyo directo con alimentos, frutas, verduras e insumos esenciales para sostener la
mesa y el hogar.
icono: Package
orden: 2
tienePagina: true
externalHref: ''
kicker: Canasta Familiar
titulo: Apoyo directo para sostener la mesa y el hogar de muchas familias.
lead: >-
El Programa Canasta Familiar de Renacer entrega apoyo directo a familias que enfrentan
dificultades económicas, a través de alimentos y productos esenciales para el hogar.
parrafos:
- >-
Las canastas incluyen mercadería, frutas, verduras, artículos de higiene personal y
productos de aseo. En algunos casos también se incorporan elementos específicos,
como pañales, según las necesidades de cada familia.
- >-
Las entregas se realizan tanto a domicilio como con opción de retiro, adaptándose a
cada situación. Este programa se desarrolla desde el año 2005, acompañando
principalmente a familias vinculadas a niños, niñas y jóvenes que participan en los
espacios y talleres de Renacer.
- >-
En el contexto actual, donde el costo de la vida ha aumentado de forma sostenida,
muchas familias enfrentan dificultades para cubrir necesidades básicas. A través de
este programa, buscamos ser un apoyo concreto que alivie, en parte, esa carga
diaria.
- >-
Este trabajo es posible gracias a la colaboración de personas, aportes solidarios y
el apoyo del Ministerio Cristiano Renacer en Cristo. Más allá de la entrega de
alimentos, buscamos acompañar con respeto y cercanía la realidad que muchas familias
viven hoy.
stats:
- valor: '2005'
etiqueta: año desde el que se desarrolla este programa
- valor: Semanal
etiqueta: frecuencia actual de entregas a familias
- valor: Hogar
etiqueta: mercadería, frutas, verduras, higiene y aseo
- valor: Flexible
etiqueta: entrega a domicilio o con opción de retiro
textoApoyo: >-
Puedes colaborar de distintas maneras: aportando económicamente, difundiendo la
iniciativa o coordinando apoyo directo para familias que hoy necesitan un alivio real
en sus gastos básicos.
imagen: /uploads/programas/canasta-familiar/imagen-principal.jpg
galeria:
- /uploads/programas/canasta-familiar/entrega-canasta.jpeg

View File

@@ -0,0 +1,48 @@
nombre: Cena Navideña
estado: Anual
bajada: Encuentro y compañía en Noche Buena
detalle: >-
Cada 24 de diciembre compartimos una cena especial con personas y familias que viven
esta fecha con dificultad o soledad.
icono: UtensilsCrossed
orden: 5
tienePagina: true
externalHref: ''
kicker: Cena Navideña
titulo: Una noche de encuentro, compañía y celebración compartida.
lead: >-
La Cena Navideña de Renacer se realiza cada 24 de diciembre y está pensada
especialmente para personas y familias que atraviesan dificultades económicas o que
viven la Navidad en soledad.
parrafos:
- >-
Sabemos que para muchas personas esta fecha puede ser compleja: a veces no alcanza
el dinero para una cena especial, y en otros casos, la falta de compañía hace que la
celebración pierda su sentido. Por eso abrimos este espacio como una oportunidad
para compartir, acompañar y vivir una noche distinta.
- >-
Desde el año 2006, este programa ha sido parte de nuestra comunidad, creciendo
gracias al apoyo de voluntarios y colaboradores. En su versión más reciente, durante
la Navidad 2025, se lograron entregar 100 cenas navideñas.
- >-
La cena consiste en un menú de tres tiempos preparado gracias a las colaboraciones
de personas que aportan con alimentos, recursos o tiempo. Además de la atención
presencial, el programa contempla la entrega a domicilio para personas con
enfermedades, movilidad reducida o adultos mayores.
- >-
Más que una cena, este programa busca generar un espacio donde las personas puedan
sentirse acompañadas, valoradas y parte de una comunidad en una fecha significativa.
stats:
- valor: 24 dic
etiqueta: fecha en que se realiza cada año
- valor: '100'
etiqueta: cenas entregadas en Navidad 2025
- valor: '2006'
etiqueta: año desde el que forma parte de Renacer
- valor: 3 tiempos
etiqueta: estructura del menú compartido
textoApoyo: >-
Si deseas colaborar con este programa, puedes hacerlo a través de aportes,
voluntariado o difusión para que más personas puedan vivir una Noche Buena acompañada.
imagen: ''
galeria: []

View File

@@ -0,0 +1,51 @@
nombre: Comedor Solidario
estado: Activo
bajada: Almuerzos calientes y acompañamiento
detalle: >-
Almuerzos calientes, cercanía y apoyo concreto para personas y familias en situación
de vulnerabilidad en Quillota.
icono: UtensilsCrossed
orden: 1
tienePagina: true
externalHref: ''
kicker: Comedor Solidario
titulo: Almuerzos calientes, cercanía y apoyo concreto en Quillota.
lead: >-
El Programa Comedor Solidario de Renacer entrega almuerzos calientes durante la semana
a personas y familias que atraviesan situaciones complejas, como cesantía,
dificultades de movilidad, discapacidad o falta de acceso regular a alimentación.
parrafos:
- >-
Actualmente, el programa se desarrolla en más de 10 sectores de la comuna, acercando
alimento preparado a quienes lo necesitan en su propio entorno. También llegamos a
personas mayores y a quienes se encuentran en situación de calle.
- >-
Durante el verano de 2026, el comedor entregó almuerzos calientes a niños y niñas
durante el período de vacaciones, considerando que en muchos casos no cuentan con la
presencia permanente de sus padres en el hogar. Esta iniciativa fue un apoyo
concreto tanto en la alimentación diaria como en la economía de las familias.
- >-
En la actualidad se preparan y distribuyen alrededor de 60 almuerzos, tres veces por
semana. Este trabajo comenzó el año 2017, inicialmente enfocado en acompañar a
personas migrantes, especialmente de la comunidad haitiana, y con el tiempo se
adaptó a las nuevas necesidades del territorio.
- >-
Más allá de la comida, este espacio busca transmitir dignidad, cercanía y
acompañamiento. Nuestro trabajo se inspira en valores cristianos, pero el apoyo se
entrega con respeto a cada persona, sin distinción de creencias.
stats:
- valor: '+10'
etiqueta: sectores de la comuna alcanzados
- valor: '60'
etiqueta: almuerzos preparados y distribuidos actualmente
- valor: '3'
etiqueta: jornadas de entrega cada semana
- valor: '2017'
etiqueta: año en que comenzó este trabajo territorial
textoApoyo: >-
Si deseas conocer más o ser parte de este programa, puedes hacerlo a través de
nuestros canales. Toda colaboración permite que este apoyo continúe llegando a quienes
lo necesitan.
imagen: /uploads/programas/comedor-solidario/imagen1.jpg
galeria:
- /uploads/programas/comedor-solidario/comedor-accion.jpeg

View File

@@ -0,0 +1,55 @@
nombre: Fiestas Patrias
estado: Anual
bajada: Tradición, juego y comunidad
detalle: >-
Desde 2004, una celebración familiar con más de 20 juegos tradicionales, libre de
alcohol y orientada al encuentro comunitario.
icono: Users
orden: 10
tienePagina: true
externalHref: ''
kicker: Fiestas Patrias
titulo: 'Tradición, juego y comunidad: una celebración familiar y libre de alcohol.'
lead: >-
Desde el año 2004, Renacer celebra Fiestas Patrias como una instancia orientada a
fortalecer la identidad, las tradiciones y el sentido de pertenencia en la comunidad,
ofreciendo un espacio familiar, seguro y libre de alcohol para niños, niñas y sus
familias.
parrafos:
- >-
En los últimos años, muchas expresiones culturales propias del país — juegos
tradicionales, comida típica y símbolos patrios — han ido perdiendo presencia en la
vida cotidiana de niños y niñas. Frente a esto, Renacer promueve un espacio
participativo donde las familias puedan reencontrarse con estas prácticas.
- >-
La actividad ofrece más de 20 juegos, juguetes y competencias tradicionales: palo
encebado, tiro al blanco, la rana, trompos, emboque, carreras de saco, carrera de
tres pies y la carrera del huevo con cuchara, además de concursos, payas y bailes
típicos donde niños y familias participan activamente.
- >-
Durante la jornada se comparte en torno a la comida con preparaciones como helados,
choripanes y bebidas, generando un espacio de convivencia y comunidad. Renacer se
propone como alternativa a las celebraciones donde predomina el consumo de alcohol,
poniendo en el centro el encuentro, el juego y el vínculo familiar.
- >-
Esta actividad se sostiene gracias al apoyo de voluntarios que participan en la
implementación de los stands, acompañan a los niños, enseñan los juegos y promueven
la participación. Las colaboraciones también permiten costear premios, materiales y
alimentos.
stats:
- valor: '2004'
etiqueta: año desde el que se realiza esta celebración comunitaria
- valor: '+20'
etiqueta: juegos y competencias tradicionales disponibles
- valor: Alcohol free
etiqueta: espacio familiar y seguro sin consumo de alcohol
- valor: Voluntarios
etiqueta: participan activamente en los stands y actividades
textoApoyo: >-
Si deseas ser parte de esta experiencia como voluntario en la organización,
acompañamiento en los juegos, o colaborar con premios y materiales, puedes
contactarnos a través de nuestros canales.
imagen: /uploads/programas/fiestas-patrias/imagen1.jpeg
galeria:
- /uploads/programas/fiestas-patrias/imagen2.jpeg
- /uploads/programas/fiestas-patrias/imagen3.jpeg

View File

@@ -0,0 +1,54 @@
nombre: Germinando Sueños
estado: Activo
bajada: Invernaderos y agricultura sustentable
detalle: >-
Dos invernaderos que cultivan hortalizas y aromáticas para el Comedor Solidario, con
educación ambiental y siembra comunitaria.
icono: Sprout
orden: 8
tienePagina: true
externalHref: ''
kicker: Germinando Sueños
titulo: Agricultura sustentable que alimenta la comunidad y cuida el entorno.
lead: >-
"Germinando Sueños" es una iniciativa de Renacer que promueve la agricultura
sustentable como una forma concreta de aportar a la alimentación y bienestar de la
comunidad. Los alimentos cultivados se destinan principalmente al Comedor Solidario,
mejorando la calidad, frescura y valor nutricional de las comidas entregadas.
parrafos:
- >-
El programa cuenta con dos espacios complementarios: el Invernadero 1 (Almaciguera),
donde se siembran las semillas y se desarrollan las plántulas, y el Invernadero 2
(Huerta), donde las plantas son trasplantadas para su crecimiento y cosecha. Ambos
espacios están conectados por un sistema de sanitización que resguarda la salud de
los cultivos.
- >-
Trabajamos bajo principios de agricultura sustentable: reducción del uso de
químicos, promoción de la biodiversidad, uso eficiente del agua y recolección de
aguas lluvias para riego.
- >-
Entre los cultivos se encuentran hojas verdes como espinaca, rúcula, mizuna y
acelga; variedades de lechuga; hierbas aromáticas como cilantro, perejil y albahaca;
hortalizas como zanahoria, zapallo italiano, coliflor y pimentón; y otros cultivos
como arvejas, ají, choclo y tomate cherry.
- >-
El programa también tiene un fuerte componente educativo. Niños, niñas y comunidad
pueden visitar los invernaderos y aprender sobre siembra, repique, riego y cosecha,
promoviendo la conciencia ecológica y el valor del autosustento.
stats:
- valor: '2'
etiqueta: 'invernaderos complementarios: almaciguera y huerta'
- valor: '+10'
etiqueta: variedades de hortalizas y aromáticas cultivadas
- valor: Comedor
etiqueta: principal destino de los alimentos cosechados
- valor: Educativo
etiqueta: visitas y aprendizaje para niños y comunidad
textoApoyo: >-
Si deseas conocer más o ser parte de esta iniciativa, puedes hacerlo a través de
nuestros canales. Toda colaboración permite que este programa continúe creciendo y
aportando a la comunidad.
imagen: /uploads/programas/germinando-suenos/imagen1.jpeg
galeria:
- /uploads/programas/germinando-suenos/imagen2.jpeg
- /uploads/programas/germinando-suenos/imagen3.jpeg

View File

@@ -0,0 +1,53 @@
nombre: Sala JuegoTetra
estado: Activo
bajada: Reciclaje y educación ambiental
detalle: >-
Un espacio construido en un 90% con materiales reutilizados, incluyendo más de 5.000
cajas tetra pak, que enseña las 3R de forma práctica.
icono: Puzzle
orden: 9
tienePagina: true
externalHref: ''
kicker: Sala JuegoTetra
titulo: Un espacio construido con reciclaje que transforma la forma de entender los residuos.
lead: >-
La Sala JuegoTetra es un espacio educativo y experiencial que enseña, de manera
práctica, el valor del reciclaje, la reutilización y el cuidado del medio ambiente a
través de una experiencia guiada donde niños, niñas y adultos participan activamente.
parrafos:
- >-
La experiencia comienza abordando la problemática de la contaminación y el impacto
de los residuos en el entorno, para luego introducir los conceptos de las 3R:
reducir, reutilizar y reciclar. A partir de ello, los participantes conocen procesos
reales de transformación de materiales y elaboran sus propios objetos utilizando
desechos.
- >-
La sala está construida en un 90% con materiales reutilizados: más de 5.000 cajas
tetra pak rellenas con residuos plásticos, cerámica reciclada en el suelo, botellas
reutilizadas, techos de envases y mobiliario fabricado a partir de desechos. La sala
no solo habla del reciclaje: lo materializa en cada uno de sus elementos.
- >-
Los participantes interactúan con el espacio, manipulan materiales, juegan y se
llevan un objeto creado durante la experiencia, reforzando el aprendizaje desde lo
vivencial.
- >-
La Sala JuegoTetra recibe visitas de establecimientos educacionales y estudiantes
universitarios, especialmente de carreras de pedagogía, quienes encuentran aquí una
instancia de aprendizaje práctico y observación de metodologías educativas
aplicadas.
stats:
- valor: '+5.000'
etiqueta: cajas tetra pak utilizadas en la construcción
- valor: 90%
etiqueta: de la sala construida con materiales reutilizados
- valor: 3R
etiqueta: reducir, reutilizar y reciclar como eje educativo
- valor: Escuelas
etiqueta: establecimientos y universidades que visitan el espacio
textoApoyo: >-
Si deseas visitar la Sala JuegoTetra o coordinar una experiencia para tu
establecimiento educacional, puedes contactarnos a través de nuestros canales.
imagen: /uploads/programas/juegotetra/imagen1.jpg
galeria:
- /uploads/programas/juegotetra/imagen2.jpg
- /uploads/programas/juegotetra/imagen3.jpg

View File

@@ -0,0 +1,39 @@
nombre: Mi Rincón Seguro
estado: En planificación
bajada: Salas de Contención TEA
detalle: >-
Proyecto en alianza con SECPLAN para habilitar espacios adaptados sensorialmente para
niños y niñas del espectro autista.
icono: HandHelping
orden: 11
tienePagina: false
externalHref: ''
kicker: Mi Rincón Seguro
titulo: Espacios sensorialmente adaptados para niños y niñas del espectro autista.
lead: >-
Mi Rincón Seguro es un proyecto en desarrollo que busca habilitar salas de contención
especialmente adaptadas para niños y niñas con Trastorno del Espectro Autista (TEA),
en alianza con SECPLAN y el municipio de Quillota.
parrafos:
- >-
Este proyecto surge de la necesidad concreta de contar con espacios diseñados
específicamente para acompañar a niños y niñas neurodivergentes en momentos de
desregulación sensorial o emocional.
- >-
Las salas de contención TEA están diseñadas con materiales, colores, texturas y
niveles de estimulación pensados para facilitar la autorregulación y el bienestar de
los niños y niñas.
stats:
- valor: TEA
etiqueta: diseño específico para el espectro autista
- valor: Sensorial
etiqueta: materiales y ambientes adaptados para autorregulación
- valor: SECPLAN
etiqueta: proyecto en alianza con planificación municipal
- valor: '2026'
etiqueta: año proyectado de implementación
textoApoyo: >-
Si deseas conocer más sobre este proyecto o tienes interés en colaborar, contáctanos a
través de nuestros canales.
imagen: ''
galeria: []

View File

@@ -0,0 +1,48 @@
nombre: Navidad Solidaria
estado: Temporada
bajada: Apadrinamiento y celebración comunitaria
detalle: >-
Una campaña de diciembre que acompaña a niños y niñas con regalos, dulces y una
experiencia significativa.
icono: Gift
orden: 4
tienePagina: true
externalHref: ''
kicker: Navidad Solidaria
titulo: Una celebración comunitaria que acompaña a niños y niñas con esperanza y afecto.
lead: >-
Navidad Solidaria de Renacer se desarrolla durante diciembre para acompañar a niños y
niñas en una celebración significativa, entregando alegría y contención a través de
una experiencia comunitaria.
parrafos:
- >-
Está dirigida a niños y niñas desde 1 mes hasta los 15 años, pertenecientes a
familias vinculadas a los programas de Renacer.
- >-
A través de una campaña de apadrinamiento, personas de la comunidad pueden colaborar
entregando un regalo a un niño o niña, de forma presencial o a distancia. Cada
padrino o madrina recibe información básica de su ahijado o ahijada para que el
regalo tenga un sentido más personal.
- >-
Además del regalo, cada niño o niña recibe una bolsa de dulces y un presente
preparado por Renacer, buscando transmitir el sentido de la Navidad desde una mirada
de esperanza, amor y comunidad.
- >-
La entrega se realiza en una celebración organizada por Renacer, con actividades
recreativas, juegos y momentos compartidos. Más allá de lo material, este programa
busca generar una experiencia significativa y aliviar una carga económica adicional
para muchas familias.
stats:
- valor: '167'
etiqueta: niños y niñas acompañados en la celebración
- valor: '83'
etiqueta: madrinas participantes
- valor: '39'
etiqueta: padrinos participantes
- valor: Diciembre
etiqueta: mes en que se activa esta campaña comunitaria
textoApoyo: >-
Si deseas conocer más o ser parte de esta iniciativa, puedes hacerlo a través de
nuestros canales y sumarte como padrino, madrina, colaborador o voluntario.
imagen: ''
galeria: []

View File

@@ -0,0 +1,52 @@
nombre: Ropero Solidario
estado: Activo
bajada: Vestimenta digna y economía circular
detalle: >-
Recibimos, seleccionamos y entregamos ropa en buen estado para responder con respeto a
necesidades reales.
icono: Shirt
orden: 3
tienePagina: true
externalHref: ''
kicker: Ropero Solidario
titulo: Vestimenta digna, rescate textil y apoyo concreto para la comunidad.
lead: >-
El programa Ropero Solidario de Renacer apoya a familias que enfrentan dificultades
para acceder a vestimenta, entregando ropa en buen estado y artículos esenciales según
sus necesidades.
parrafos:
- >-
Este espacio funciona gracias a la donación de ropa por parte de la comunidad, la
cual es recibida, clasificada, seleccionada, lavada y organizada para luego ser
entregada de manera digna y respetuosa a quienes lo necesitan.
- >-
El programa se desarrolla durante todo el año, adaptándose a las distintas
temporadas. En invierno, se refuerza la entrega de abrigo para personas en situación
de calle, familias e inmigrantes, mientras que en verano se continúa apoyando con
vestimenta adecuada a la estación.
- >-
A través de este programa también promovemos una lógica de economía circular, donde
las familias no solo pueden recibir apoyo, sino también donar ropa que ya no
utilizan, generando una red de colaboración dentro de la misma comunidad y
fomentando la reutilización y el cuidado del entorno.
- >-
Más allá de la entrega de ropa, este espacio busca ser un apoyo concreto,
resguardando la dignidad de cada persona y ofreciendo un entorno de respeto y
cercanía.
stats:
- valor: Todo el año
etiqueta: funcionamiento continuo según temporada
- valor: Abrigo
etiqueta: refuerzo especial en invierno para calle, familias e inmigrantes
- valor: Circular
etiqueta: reutilización y rescate textil desde la comunidad
- valor: Digno
etiqueta: clasificación, lavado y entrega respetuosa
textoApoyo: >-
Si deseas conocer más o ser parte de esta iniciativa, puedes hacerlo a través de
nuestros canales. Toda colaboración permite que este apoyo continúe llegando a quienes
lo necesitan.
imagen: /uploads/programas/ropero-solidario/imagen1.jpeg
galeria:
- /uploads/programas/ropero-solidario/imagen2.jpeg
- /uploads/programas/ropero-solidario/imagen3.jpeg

View File

@@ -0,0 +1,48 @@
nombre: Sala del Juguete
estado: Activo
bajada: Juego, infancia y reutilización
detalle: >-
Un espacio para rescatar el valor del juego y dar nueva vida a juguetes donados por la
comunidad.
icono: Puzzle
orden: 6
tienePagina: true
externalHref: ''
kicker: Sala del Juguete
titulo: Un espacio para rescatar el juego, la imaginación y la infancia.
lead: >-
La Sala del Juguete de Renacer está pensada para devolver al juego el lugar que merece
en la infancia, ofreciendo a niños y niñas la oportunidad de explorar, imaginar y
compartir más allá de las pantallas.
parrafos:
- >-
Hoy sabemos que el juego no es solo una forma de entretención, sino una parte
fundamental del desarrollo. A través del juego, los niños fortalecen su creatividad,
habilidades sociales, expresión emocional y aprendizaje.
- >-
Este espacio busca recuperar ese tiempo de juego que muchas veces se ve limitado,
especialmente en contextos donde predominan las pantallas o donde no siempre existen
las condiciones para acceder a juguetes.
- >-
La Sala del Juguete funciona también como un espacio de economía circular, donde se
reciben donaciones de juguetes nuevos y usados en buen estado, los cuales son
seleccionados y puestos a disposición de los niños para su uso dentro del espacio o
para ser entregados en fechas especiales.
- >-
Más allá de los objetos, este programa promueve el juego, la reutilización, el
cuidado del entorno y la colaboración entre familias como parte del bienestar
comunitario.
stats:
- valor: '2.410'
etiqueta: juguetes disponibles para uso dentro del espacio
- valor: '+4.000'
etiqueta: juguetes entregados a niños y niñas, dándoles una segunda vida
- valor: Circular
etiqueta: revisión, clasificación y reutilización de cada juguete
- valor: Comunidad
etiqueta: apoyo a celebraciones y actividades durante el año
textoApoyo: >-
Si deseas colaborar con la donación de juguetes o conocer más sobre este espacio,
puedes hacerlo a través de nuestros canales y campañas comunitarias.
imagen: ''
galeria: []

View File

@@ -0,0 +1,48 @@
nombre: Tardes Recreativas
estado: Vacaciones
bajada: Cuidado y actividades en verano e invierno
detalle: >-
Jornadas seguras con recreación, alimentación y acompañamiento para niños y niñas
durante vacaciones.
icono: Users
orden: 7
tienePagina: true
externalHref: ''
kicker: Tardes Recreativas
titulo: Vacaciones con cuidado, juego y actividades seguras para niños y niñas.
lead: >-
El programa Tardes Recreativas de Renacer ofrece un espacio seguro, acompañado y
entretenido para niños y niñas durante las vacaciones de verano e invierno.
parrafos:
- >-
Está pensado especialmente para familias donde los adultos deben trabajar en estos
períodos y no siempre cuentan con redes de apoyo para el cuidado de sus hijos.
- >-
Durante estas jornadas, los niños participan en actividades recreativas, artísticas
y de convivencia en un entorno supervisado por monitores, donde pueden compartir,
aprender y disfrutar su tiempo de manera segura.
- >-
Además, el programa incluye almuerzo y colación, asegurando que los niños cuenten
con una alimentación adecuada durante su permanencia. En verano se realizan juegos
con agua, actividades al aire libre y talleres de cocina; en invierno, cine, juegos
grupales, manualidades y momentos de relajación.
- >-
Más allá de las actividades, este espacio busca entregar cuidado, contención y un
entorno donde los niños se sientan acompañados, especialmente en períodos donde
pueden pasar más tiempo solos.
stats:
- valor: '60'
etiqueta: niños recibidos en el programa
- valor: Verano
etiqueta: juegos con agua, aire libre y talleres de cocina
- valor: Invierno
etiqueta: cine, manualidades y juegos en espacios cerrados
- valor: Cuidado
etiqueta: incluye almuerzo y colación durante la jornada
textoApoyo: >-
Si deseas conocer más o ser parte de esta iniciativa, puedes colaborar con recursos,
voluntariado o difusión para fortalecer estos espacios de cuidado.
imagen: /uploads/programas/tardes-recreativas/imagen1.jpeg
galeria:
- /uploads/programas/tardes-recreativas/imagen2.jpeg
- /uploads/programas/tardes-recreativas/imagen3.jpeg

99
src/lib/programs.ts Normal file
View File

@@ -0,0 +1,99 @@
import { createReader } from '@keystatic/core/reader';
import { imageSize } from 'image-size';
import keystaticConfig from '../../keystatic.config';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const reader = createReader(process.cwd(), keystaticConfig);
type RawProgram = NonNullable<Awaited<ReturnType<typeof reader.collections.programas.read>>>;
type ProgramImage = { src: string; width: number; height: number };
function normalizeName(nombre: RawProgram['nombre']) {
return typeof nombre === 'string' ? nombre : nombre.name;
}
function getExternalHref(program: RawProgram) {
return typeof program.externalHref === 'string' ? program.externalHref.trim() : '';
}
function getImageMetadata(src: string): ProgramImage | null {
if (!src.startsWith('/uploads/')) return null;
const filePath = join(process.cwd(), 'public', src.replace(/^\/+/, ''));
if (!existsSync(filePath)) return null;
const dimensions = imageSize(readFileSync(filePath));
if (!dimensions.width || !dimensions.height) return null;
return {
src,
width: dimensions.width,
height: dimensions.height,
};
}
function getImages(program: RawProgram): ProgramImage[] {
const primary =
typeof program.imagen === 'string' && program.imagen.trim().length > 0
? [program.imagen.trim()]
: [];
const gallery = Array.isArray(program.galeria)
? program.galeria.filter(
(image): image is string => typeof image === 'string' && image.trim().length > 0
)
: [];
return [...primary, ...gallery]
.map((image) => getImageMetadata(image))
.filter((image): image is ProgramImage => Boolean(image));
}
function getIcon(program: RawProgram) {
return typeof program.icono === 'string' && program.icono.trim().length > 0
? program.icono.trim()
: 'HeartHandshake';
}
export async function listPrograms() {
const slugs = await reader.collections.programas.list();
const programs = await Promise.all(
slugs.map(async (slug) => {
const program = await reader.collections.programas.read(slug);
if (!program) return null;
const dynamicHref = `/programas/${slug}/`;
const externalHref = getExternalHref(program);
const href = program.tienePagina ? externalHref || dynamicHref : undefined;
return {
slug,
...program,
icono: getIcon(program),
nombreNormalizado: normalizeName(program.nombre),
dynamicHref,
href,
imagenes: getImages(program),
};
})
);
return programs
.filter((program): program is NonNullable<typeof program> => Boolean(program))
.sort((a, b) => (a.orden ?? 99) - (b.orden ?? 99));
}
export async function getProgramBySlug(slug: string) {
const program = await reader.collections.programas.read(slug);
if (!program) return null;
return {
slug,
...program,
icono: getIcon(program),
nombreNormalizado: normalizeName(program.nombre),
dynamicHref: `/programas/${slug}/`,
externalHrefNormalizado: getExternalHref(program),
imagenes: getImages(program),
};
}

35
src/middleware.ts Normal file
View File

@@ -0,0 +1,35 @@
import { defineMiddleware } from 'astro:middleware';
const SESSION_COOKIE = 'renacer_admin';
function getExpectedToken() {
const user = import.meta.env.ADMIN_USER ?? 'admin';
const pass = import.meta.env.ADMIN_PASS ?? '';
return `${user}::${pass}`;
}
export const onRequest = defineMiddleware(async (context, next) => {
const { url, cookies } = context;
const path = url.pathname;
// Solo protege rutas del panel admin y su API
// Excluye /admin-login y /admin-logout (páginas públicas de auth)
const isProtected =
path.startsWith('/keystatic') ||
path.startsWith('/api/keystatic') ||
(path.startsWith('/admin') && !path.startsWith('/admin-'));
if (!isProtected) {
return next();
}
const session = cookies.get(SESSION_COOKIE);
if (session?.value === getExpectedToken()) {
return next();
}
// Sin sesión válida → redirige al login
const loginUrl = new URL('/admin-login', url.origin);
loginUrl.searchParams.set('redirect', path);
return context.redirect(loginUrl.toString());
});

185
src/pages/admin-login.astro Normal file
View File

@@ -0,0 +1,185 @@
---
const SESSION_COOKIE = 'renacer_admin';
const adminUser = import.meta.env.ADMIN_USER ?? 'admin';
const adminPass = import.meta.env.ADMIN_PASS ?? '';
const redirectTo = Astro.url.searchParams.get('redirect') ?? '/admin/';
let error = '';
if (Astro.request.method === 'POST') {
const form = await Astro.request.formData();
const user = form.get('user')?.toString() ?? '';
const pass = form.get('pass')?.toString() ?? '';
if (user === adminUser && pass === adminPass) {
Astro.cookies.set(SESSION_COOKIE, `${user}::${pass}`, {
path: '/',
httpOnly: true,
secure: import.meta.env.PROD,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7, // 7 días
});
return Astro.redirect(redirectTo);
} else {
error = 'Usuario o contraseña incorrectos.';
}
}
---
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin | Renacer</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;700;800&family=Sora:wght@600;700;800&display=swap"
rel="stylesheet"
/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Manrope', sans-serif;
background: linear-gradient(135deg, #eef4ff 0%, #f5f8ff 60%, #e8f0fe 100%);
min-height: 100svh;
display: grid;
place-items: center;
padding: 1.5rem;
}
.card {
background: white;
border-radius: 1.75rem;
box-shadow: 0 24px 64px rgba(8, 52, 183, 0.12);
padding: 2.75rem 2.5rem;
width: min(100%, 400px);
}
.logo {
font-family: 'Sora', sans-serif;
font-size: 2rem;
font-weight: 800;
color: #062b96;
letter-spacing: -0.05em;
text-decoration: none;
display: block;
margin-bottom: 0.3rem;
}
.logo span { color: #7f95ad; }
.subtitle {
color: #53637b;
font-size: 0.9rem;
margin-bottom: 2rem;
}
label {
display: block;
font-weight: 700;
font-size: 0.88rem;
color: #1d2738;
margin-bottom: 0.45rem;
}
input {
width: 100%;
padding: 0.85rem 1rem;
border: 1.5px solid rgba(29, 39, 56, 0.16);
border-radius: 0.9rem;
font-family: inherit;
font-size: 0.96rem;
color: #1d2738;
background: #f9fbff;
outline: none;
transition: border-color 150ms ease;
margin-bottom: 1.1rem;
}
input:focus {
border-color: #0834b7;
background: white;
}
.error-msg {
background: #fff0f0;
border: 1px solid #fca5a5;
color: #b91c1c;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
font-size: 0.88rem;
margin-bottom: 1.25rem;
}
button {
width: 100%;
padding: 1rem;
background: linear-gradient(180deg, #1b49d6 0%, #0834b7 100%);
color: white;
font-family: inherit;
font-weight: 800;
font-size: 1rem;
border: none;
border-radius: 1rem;
cursor: pointer;
box-shadow: 0 14px 28px rgba(8, 52, 183, 0.22);
transition: transform 150ms ease, box-shadow 150ms ease;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 18px 32px rgba(8, 52, 183, 0.28);
}
.back {
display: block;
text-align: center;
margin-top: 1.25rem;
color: #53637b;
font-size: 0.88rem;
text-decoration: none;
}
.back:hover { color: #0834b7; }
</style>
</head>
<body>
<div class="card">
<a href="/" class="logo">Renacer<span>.</span></a>
<p class="subtitle">Panel de administración</p>
{error && <p class="error-msg">{error}</p>}
<form method="POST">
<label for="user">Usuario</label>
<input
type="text"
id="user"
name="user"
autocomplete="username"
required
placeholder="admin"
/>
<label for="pass">Contraseña</label>
<input
type="password"
id="pass"
name="pass"
autocomplete="current-password"
required
placeholder="••••••••"
/>
<button type="submit">Ingresar</button>
</form>
<a href="/" class="back">← Volver al sitio</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,4 @@
---
Astro.cookies.delete('renacer_admin', { path: '/' });
return Astro.redirect('/admin-login');
---

131
src/pages/admin/index.astro Normal file
View File

@@ -0,0 +1,131 @@
---
// Protegido por middleware
---
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Panel Admin | Renacer</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;700;800&family=Sora:wght@600;700;800&display=swap"
rel="stylesheet"
/>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Manrope', sans-serif;
background: linear-gradient(135deg, #eef4ff 0%, #f5f8ff 100%);
min-height: 100svh;
display: grid;
place-items: center;
padding: 1.5rem;
}
h1, h2, h3 { font-family: 'Sora', sans-serif; letter-spacing: -.04em; }
.shell { width: min(100%, 680px); }
.header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 2rem;
}
.logo {
font-family: 'Sora', sans-serif; font-size: 2rem; font-weight: 800;
color: #062b96; letter-spacing: -.05em; text-decoration: none;
}
.logo span { color: #7f95ad; }
.logout {
font-size: .84rem; font-weight: 700; color: #53627b;
text-decoration: none; padding: .5rem .9rem;
border: 1.5px solid rgba(29,39,56,.14); border-radius: .7rem;
transition: color 140ms, border-color 140ms;
}
.logout:hover { color: #0834b7; border-color: rgba(8,52,183,.3); }
h2 { font-size: 1.5rem; color: #1d2738; margin-bottom: .4rem; }
.sub { color: #53627b; font-size: .92rem; margin-bottom: 1.75rem; }
.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 520px) { .cards { grid-template-columns: 1fr; } }
.card {
display: flex; flex-direction: column;
padding: 1.5rem;
background: white;
border-radius: 1.25rem;
box-shadow: 0 12px 36px rgba(8,52,183,.08);
text-decoration: none;
transition: transform 160ms, box-shadow 160ms;
border: 1.5px solid transparent;
}
.card:hover {
transform: translateY(-3px);
box-shadow: 0 18px 44px rgba(8,52,183,.13);
border-color: rgba(8,52,183,.12);
}
.card-icon {
font-size: 2rem; margin-bottom: .85rem;
width: 3rem; height: 3rem;
display: grid; place-items: center;
background: #eef2ff; border-radius: .75rem;
}
.card h3 { font-size: 1rem; color: #1b2740; margin-bottom: .35rem; }
.card p { font-size: .83rem; color: #53627b; line-height: 1.55; flex: 1; }
.card-arrow {
margin-top: 1rem; font-size: .82rem; font-weight: 800; color: #0834b7;
}
.card-primary { background: linear-gradient(135deg, #0834b7, #062b96); }
.card-primary h3 { color: white; }
.card-primary p { color: #c7d4f8; }
.card-primary .card-arrow { color: #a5b4fc; }
.card-primary .card-icon { background: rgba(255,255,255,.15); }
.site-link {
display: block; text-align: center; margin-top: 1.5rem;
color: #53627b; font-size: .84rem; text-decoration: none;
transition: color 140ms;
}
.site-link:hover { color: #0834b7; }
</style>
</head>
<body>
<div class="shell">
<div class="header">
<a href="/" class="logo">Renacer<span>.</span></a>
<a href="/admin-logout" class="logout">Cerrar sesión</a>
</div>
<h2>Panel de administración</h2>
<p class="sub">¿Qué quieres hacer hoy?</p>
<div class="cards">
<a href="/admin/programa-builder" class="card card-primary">
<div class="card-icon">✏️</div>
<h3>Constructor de programa</h3>
<p>Crea una nueva página de programa con preview en vivo y genera el YAML automáticamente.</p>
<span class="card-arrow">Abrir →</span>
</a>
<a href="/keystatic/" class="card">
<div class="card-icon">🗂️</div>
<h3>Editar programas (CMS)</h3>
<p>Gestiona todos los programas existentes, edita contenido y sube imágenes.</p>
<span class="card-arrow">Abrir →</span>
</a>
</div>
<a href="/" class="site-link">← Ver el sitio público</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,931 @@
---
// Protegido por middleware — requiere login
---
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Constructor de Programa | Admin Renacer</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;700;800&family=Sora:wght@600;700;800&display=swap"
rel="stylesheet"
/>
<style>
/* ── Reset & base ─────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--brand: #0834b7;
--brand-dark: #062b96;
--ink: #1d2738;
--muted: #53627b;
--line: rgba(29,39,56,.12);
--bg: #f5f8ff;
--surface: #ffffff;
--radius: 1rem;
}
body {
font-family: 'Manrope', sans-serif;
background: var(--bg);
color: var(--ink);
min-height: 100svh;
overflow: hidden;
}
h1,h2,h3,h4 { font-family: 'Sora', sans-serif; letter-spacing: -.04em; }
/* ── Topbar ───────────────────────────────────── */
.topbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 50;
display: flex; align-items: center; justify-content: space-between;
gap: 1rem;
padding: .9rem 1.5rem;
background: rgba(255,255,255,.95);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(12px);
height: 56px;
}
.topbar-left { display: flex; align-items: center; gap: 1.2rem; }
.brand {
font-family: 'Sora', sans-serif; font-size: 1.4rem; font-weight: 800;
color: var(--brand-dark); letter-spacing: -.05em; text-decoration: none;
}
.brand span { color: #7f95ad; }
.page-title { font-size: .88rem; font-weight: 700; color: var(--muted); }
.topbar-actions { display: flex; gap: .7rem; }
.btn {
display: inline-flex; align-items: center; gap: .4rem;
padding: .6rem 1.1rem; border-radius: .75rem;
font-family: inherit; font-size: .88rem; font-weight: 800;
cursor: pointer; border: none; transition: transform 140ms, box-shadow 140ms;
text-decoration: none;
}
.btn:hover { transform: translateY(-1px); }
.btn-ghost {
background: transparent; border: 1.5px solid var(--line);
color: var(--muted);
}
.btn-primary {
background: linear-gradient(180deg,#1b49d6,var(--brand));
color: #fff;
box-shadow: 0 8px 20px rgba(8,52,183,.22);
}
.btn-success {
background: linear-gradient(180deg,#16a34a,#15803d);
color: #fff;
box-shadow: 0 8px 20px rgba(22,163,74,.22);
}
/* ── Main layout ──────────────────────────────── */
.builder {
display: grid;
grid-template-columns: 1fr 420px;
height: calc(100svh - 56px);
margin-top: 56px;
overflow: hidden;
}
/* ── Preview pane ─────────────────────────────── */
.preview-pane {
overflow-y: auto;
background: #eef3ff;
border-right: 1px solid var(--line);
padding: 2rem 1.5rem;
}
.preview-label {
font-size: .75rem; font-weight: 800; letter-spacing: .12em;
text-transform: uppercase; color: var(--brand);
margin-bottom: 1rem;
display: flex; align-items: center; gap: .5rem;
}
.preview-label::before {
content: ''; display: block; width: 8px; height: 8px;
border-radius: 50%; background: #22c55e;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,100% { opacity: 1; } 50% { opacity: .4; }
}
/* ── Preview: replica visual de ProgramInfoPage ── */
.prev-card {
background: var(--surface);
border-radius: 1.5rem;
overflow: hidden;
box-shadow: 0 20px 50px rgba(8,52,183,.1);
font-size: .88rem;
}
.prev-hero {
padding: 2rem 1.75rem 1.75rem;
background: linear-gradient(135deg,#f0f4ff 0%,#e8eeff 100%);
border-bottom: 1px solid rgba(8,52,183,.1);
}
.prev-back {
font-size: .8rem; font-weight: 700; color: var(--brand);
margin-bottom: .75rem; display: flex; align-items: center; gap: .3rem;
}
.prev-kicker {
display: inline-block;
padding: .35rem .8rem;
background: rgba(8,52,183,.1);
color: var(--brand);
border-radius: 999px;
font-size: .75rem; font-weight: 800;
letter-spacing: .08em; text-transform: uppercase;
margin-bottom: .75rem;
min-height: 1.5rem;
}
.prev-titulo {
font-family: 'Sora', sans-serif;
font-size: 1.35rem; font-weight: 800;
line-height: 1.15; letter-spacing: -.04em;
color: #1b2740;
margin-bottom: .75rem;
min-height: 2rem;
}
.prev-lead {
color: var(--muted); line-height: 1.65;
font-size: .85rem;
min-height: 2.5rem;
}
.prev-info-pill {
display: inline-flex; align-items: center;
padding: .4rem .85rem;
background: rgba(8,52,183,.08);
border-radius: 999px;
font-size: .75rem; font-weight: 700;
color: var(--brand);
margin-top: 1rem;
}
.prev-body { padding: 1.5rem 1.75rem; }
.prev-parrafos { display: flex; flex-direction: column; gap: .9rem; }
.prev-parrafo {
color: #4a5568; line-height: 1.7; font-size: .84rem;
min-height: 1.2rem;
}
.prev-parrafo.empty {
height: 2.5rem;
background: #f3f4f6; border-radius: .5rem;
display: flex; align-items: center; justify-content: center;
color: #9ca3af; font-style: italic; font-size: .78rem;
}
.prev-stats {
display: grid; grid-template-columns: 1fr 1fr;
gap: .75rem; margin-top: 1.25rem;
}
.prev-stat {
padding: .9rem;
background: linear-gradient(135deg,#f8faff,#f0f4ff);
border-radius: .85rem;
border: 1px solid rgba(8,52,183,.08);
}
.prev-stat-valor {
font-family: 'Sora', sans-serif;
font-size: 1.15rem; font-weight: 800;
color: var(--brand); letter-spacing: -.04em;
min-height: 1.4rem;
}
.prev-stat-etiqueta {
font-size: .75rem; color: var(--muted);
line-height: 1.4; margin-top: .25rem;
min-height: 1rem;
}
.prev-support {
margin-top: 1.25rem; padding: 1.1rem 1.25rem;
background: linear-gradient(135deg,#101b33,#15223c);
border-radius: 1rem;
}
.prev-support-text {
color: #c7d1df; font-size: .82rem; line-height: 1.6;
min-height: 2rem;
}
.prev-support-btns {
display: flex; gap: .6rem; margin-top: .85rem; flex-wrap: wrap;
}
.prev-btn-fake {
padding: .5rem .9rem; border-radius: .6rem;
font-size: .78rem; font-weight: 800;
}
.prev-btn-primary { background: var(--brand); color: white; }
.prev-btn-secondary {
border: 1px solid rgba(255,255,255,.2); color: #d0d8e8;
}
/* Placeholder text */
.placeholder { color: #cbd5e1; font-style: italic; }
/* ── Form pane ────────────────────────────────── */
.form-pane {
overflow-y: auto;
background: var(--surface);
padding: 1.5rem;
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--line);
}
.form-section:last-child { border-bottom: none; margin-bottom: 0; }
.section-label {
font-size: .72rem; font-weight: 800;
letter-spacing: .12em; text-transform: uppercase;
color: var(--brand); margin-bottom: .9rem;
}
.field { margin-bottom: .85rem; }
.field:last-child { margin-bottom: 0; }
label {
display: block; font-size: .8rem; font-weight: 700;
color: var(--ink); margin-bottom: .35rem;
}
label .hint {
font-weight: 400; color: var(--muted); font-size: .75rem;
}
input[type="text"], input[type="number"], select, textarea {
width: 100%;
padding: .65rem .85rem;
border: 1.5px solid var(--line);
border-radius: .7rem;
font-family: inherit; font-size: .88rem;
color: var(--ink); background: #fafbff;
outline: none;
transition: border-color 140ms, background 140ms;
}
input:focus, select:focus, textarea:focus {
border-color: var(--brand); background: white;
}
textarea { resize: vertical; min-height: 70px; line-height: 1.55; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
.row-3 { display: grid; grid-template-columns: 1fr 1fr 80px; gap: .6rem; }
/* Stats grid */
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; }
.stat-item {
padding: .75rem;
background: #f8faff;
border: 1px solid rgba(8,52,183,.08);
border-radius: .75rem;
}
.stat-item label { color: var(--brand); font-size: .72rem; }
.stat-item input { font-size: .82rem; padding: .5rem .7rem; }
.stat-item input:first-of-type { margin-bottom: .4rem; }
/* Párrafos dinámicos */
.parrafos-list { display: flex; flex-direction: column; gap: .6rem; }
.parrafo-item { position: relative; }
.parrafo-item textarea { padding-right: 2.2rem; }
.parrafo-remove {
position: absolute; top: .5rem; right: .5rem;
width: 1.5rem; height: 1.5rem;
background: #fee2e2; color: #dc2626;
border: none; border-radius: .4rem;
font-size: .9rem; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 140ms;
}
.parrafo-remove:hover { background: #fca5a5; }
.btn-add-parrafo {
width: 100%; padding: .6rem;
border: 1.5px dashed rgba(8,52,183,.25);
border-radius: .7rem;
background: transparent;
color: var(--brand); font-family: inherit;
font-size: .82rem; font-weight: 700;
cursor: pointer; transition: background 140ms;
margin-top: .5rem;
}
.btn-add-parrafo:hover { background: rgba(8,52,183,.05); }
/* ── Toast ────────────────────────────────────── */
#toast {
position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%);
padding: .75rem 1.4rem;
border-radius: .85rem;
font-weight: 700; font-size: .88rem;
opacity: 0; transition: opacity 300ms;
pointer-events: none; z-index: 100;
white-space: nowrap;
}
#toast.show { opacity: 1; }
#toast.success { background: #16a34a; color: white; }
#toast.error { background: #dc2626; color: white; }
#toast.info { background: var(--brand); color: white; }
/* ── YAML Modal ───────────────────────────────── */
#yaml-modal {
border: none; border-radius: 1.25rem;
box-shadow: 0 30px 80px rgba(0,0,0,.25);
padding: 0; width: min(96vw, 680px);
max-height: 80svh;
overflow: hidden;
}
#yaml-modal::backdrop { background: rgba(0,0,0,.45); }
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1.1rem 1.4rem;
border-bottom: 1px solid var(--line);
background: #fafbff;
}
.modal-header h3 { font-size: 1rem; color: var(--ink); }
.modal-actions { display: flex; gap: .6rem; }
.modal-body { padding: 1.25rem; overflow-y: auto; max-height: 55svh; }
#yaml-output {
background: #0f172a; color: #e2e8f0;
padding: 1.25rem; border-radius: .85rem;
font-family: 'Menlo', 'Consolas', monospace;
font-size: .8rem; line-height: 1.65;
white-space: pre; overflow-x: auto;
tab-size: 2;
}
.btn-close {
background: none; border: none; cursor: pointer;
font-size: 1.3rem; color: var(--muted);
line-height: 1; padding: .2rem;
transition: color 140ms;
}
.btn-close:hover { color: var(--ink); }
</style>
</head>
<body>
<!-- Topbar -->
<nav class="topbar">
<div class="topbar-left">
<a href="/" class="brand">Renacer<span>.</span></a>
<span class="page-title">Constructor de Programa</span>
</div>
<div class="topbar-actions">
<a href="/keystatic/" class="btn btn-ghost">Panel CMS</a>
<button class="btn btn-primary" id="btn-yaml" type="button">Ver YAML</button>
<button class="btn btn-success" id="btn-save" type="button">Guardar</button>
</div>
</nav>
<!-- Builder: Preview + Form -->
<div class="builder">
<!-- ── PREVIEW ──────────────────────────────── -->
<div class="preview-pane">
<div class="preview-label">Preview en vivo</div>
<div class="prev-card">
<!-- Hero -->
<div class="prev-hero">
<div class="prev-back">← Inicio</div>
<div class="prev-kicker" id="prev-kicker">
<span class="placeholder">Kicker</span>
</div>
<h1 class="prev-titulo" id="prev-titulo">
<span class="placeholder">Título del programa aparece aquí…</span>
</h1>
<p class="prev-lead" id="prev-lead">
<span class="placeholder">El párrafo introductorio aparecerá aquí mientras escribes.</span>
</p>
<div class="prev-info-pill" id="prev-estado">Activo</div>
</div>
<!-- Cuerpo -->
<div class="prev-body">
<!-- Párrafos -->
<div class="prev-parrafos" id="prev-parrafos">
<p class="prev-parrafo empty">Los párrafos aparecerán aquí…</p>
</div>
<!-- Stats -->
<div class="prev-stats" id="prev-stats">
<div class="prev-stat">
<div class="prev-stat-valor"><span class="placeholder">—</span></div>
<div class="prev-stat-etiqueta"><span class="placeholder">Descripción</span></div>
</div>
<div class="prev-stat">
<div class="prev-stat-valor"><span class="placeholder">—</span></div>
<div class="prev-stat-etiqueta"><span class="placeholder">Descripción</span></div>
</div>
<div class="prev-stat">
<div class="prev-stat-valor"><span class="placeholder">—</span></div>
<div class="prev-stat-etiqueta"><span class="placeholder">Descripción</span></div>
</div>
<div class="prev-stat">
<div class="prev-stat-valor"><span class="placeholder">—</span></div>
<div class="prev-stat-etiqueta"><span class="placeholder">Descripción</span></div>
</div>
</div>
<!-- Apoyo -->
<div class="prev-support">
<p class="prev-support-text" id="prev-apoyo">
<span class="placeholder">El texto de apoyo aparecerá aquí.</span>
</p>
<div class="prev-support-btns">
<span class="prev-btn-fake prev-btn-primary">Colaborar mensualmente</span>
<span class="prev-btn-fake prev-btn-secondary">contacto@familiarenacer.cl</span>
</div>
</div>
</div>
</div>
</div>
<!-- ── FORMULARIO ─────────────────────────────── -->
<div class="form-pane">
<!-- Sección 1: Tarjeta landing -->
<div class="form-section">
<div class="section-label">Tarjeta en el landing</div>
<div class="field">
<label for="f-nombre">Nombre del programa</label>
<input type="text" id="f-nombre" placeholder="Ej: Ropero Solidario" />
</div>
<div class="field row-2">
<div>
<label for="f-estado">Estado</label>
<select id="f-estado">
<option value="Activo">Activo</option>
<option value="Vacaciones">Vacaciones</option>
<option value="Anual">Anual</option>
<option value="Temporada">Temporada</option>
<option value="En planificación">En planificación</option>
</select>
</div>
<div>
<label for="f-orden">Orden <span class="hint">(posición)</span></label>
<input type="number" id="f-orden" value="99" min="1" max="999" />
</div>
</div>
<div class="field">
<label for="f-detalle">Descripción breve <span class="hint">para la tarjeta</span></label>
<textarea id="f-detalle" rows="2" placeholder="Texto corto que aparece en la tarjeta del landing."></textarea>
</div>
<div class="field">
<label for="f-icono">Icono de la tarjeta</label>
<select id="f-icono">
<option value="HeartHandshake">Manos y apoyo</option>
<option value="UtensilsCrossed">Alimentación</option>
<option value="Package">Canasta / apoyo</option>
<option value="Shirt">Vestimenta</option>
<option value="Sprout">Naturaleza</option>
<option value="Gift">Regalo / campaña</option>
<option value="Puzzle">Juego e infancia</option>
<option value="Users">Comunidad</option>
<option value="HandHelping">Inclusión</option>
</select>
</div>
<div class="field">
<label for="f-bajada">Resumen corto <span class="hint">opcional, ya no visible en la tarjeta</span></label>
<input type="text" id="f-bajada" placeholder="Campo editorial secundario" />
</div>
</div>
<!-- Sección 2: Página -->
<div class="form-section">
<div class="section-label">Configuración de página</div>
<div class="field">
<label for="f-kicker">Kicker <span class="hint">nombre corto sobre el título</span></label>
<input type="text" id="f-kicker" placeholder="Ej: Ropero Solidario" />
</div>
<div class="field">
<label for="f-titulo">Título</label>
<input type="text" id="f-titulo" placeholder="Título largo y descriptivo de la página" />
</div>
<div class="field">
<label for="f-lead">Párrafo introductorio</label>
<textarea id="f-lead" rows="3" placeholder="Párrafo destacado que aparece bajo el título…"></textarea>
</div>
</div>
<!-- Sección 3: Párrafos -->
<div class="form-section">
<div class="section-label">Párrafos del cuerpo</div>
<div class="parrafos-list" id="parrafos-list">
<div class="parrafo-item">
<textarea class="parrafo-input" rows="3" placeholder="Primer párrafo…"></textarea>
<button class="parrafo-remove" type="button" title="Eliminar">×</button>
</div>
</div>
<button class="btn-add-parrafo" id="btn-add-parrafo" type="button">+ Agregar párrafo</button>
</div>
<!-- Sección 4: Estadísticas -->
<div class="form-section">
<div class="section-label">Estadísticas (4 datos clave)</div>
<div class="stats-grid">
<div class="stat-item">
<label>Stat 1 — Valor</label>
<input type="text" class="stat-valor" data-idx="0" placeholder="167" />
<label>Descripción</label>
<input type="text" class="stat-etiqueta" data-idx="0" placeholder="niños acompañados" />
</div>
<div class="stat-item">
<label>Stat 2 — Valor</label>
<input type="text" class="stat-valor" data-idx="1" placeholder="Todo el año" />
<label>Descripción</label>
<input type="text" class="stat-etiqueta" data-idx="1" placeholder="funcionamiento continuo" />
</div>
<div class="stat-item">
<label>Stat 3 — Valor</label>
<input type="text" class="stat-valor" data-idx="2" placeholder="+20" />
<label>Descripción</label>
<input type="text" class="stat-etiqueta" data-idx="2" placeholder="actividades disponibles" />
</div>
<div class="stat-item">
<label>Stat 4 — Valor</label>
<input type="text" class="stat-valor" data-idx="3" placeholder="Comunidad" />
<label>Descripción</label>
<input type="text" class="stat-etiqueta" data-idx="3" placeholder="impacto comunitario" />
</div>
</div>
</div>
<!-- Sección 5: Apoyo -->
<div class="form-section">
<div class="section-label">Texto de apoyo</div>
<div class="field">
<label for="f-apoyo">¿Cómo colaborar?</label>
<textarea id="f-apoyo" rows="3" placeholder="Si deseas conocer más o ser parte de esta iniciativa…"></textarea>
</div>
</div>
<!-- Sección 6: Avanzado -->
<div class="form-section">
<div class="section-label">Opciones avanzadas</div>
<div class="field">
<label for="f-external">URL externa <span class="hint">dejar vacío para generar desde el slug</span></label>
<input type="text" id="f-external" placeholder="Ej: /ropero-solidario/" />
</div>
<div class="field">
<label>
<input type="checkbox" id="f-tiene-pagina" checked style="margin-right:.4rem" />
¿Tiene página propia?
</label>
</div>
</div>
</div><!-- /form-pane -->
</div><!-- /builder -->
<!-- Toast -->
<div id="toast"></div>
<!-- YAML Modal -->
<dialog id="yaml-modal">
<div class="modal-header">
<h3>YAML generado</h3>
<div class="modal-actions">
<button class="btn btn-primary" id="btn-copy">Copiar</button>
<button class="btn-close" id="btn-close-modal" title="Cerrar">×</button>
</div>
</div>
<div class="modal-body">
<pre id="yaml-output"></pre>
</div>
</dialog>
<script>
// ── Utilidades ────────────────────────────────────────────────
function $(id) { return document.getElementById(id); }
function setEl(id, text) {
const el = $(id);
if (!el) return;
if (text && text.trim()) {
el.textContent = text;
el.querySelectorAll('.placeholder').forEach(p => p.remove());
} else {
el.innerHTML = '<span class="placeholder">' + (el.dataset.ph || '') + '</span>';
}
}
function toast(msg, type = 'info') {
const t = $('toast');
t.textContent = msg;
t.className = 'show ' + type;
clearTimeout(t._t);
t._t = setTimeout(() => t.className = '', 2800);
}
// ── Preview live ──────────────────────────────────────────────
function updatePreview() {
const nombre = $('f-nombre').value;
const kicker = $('f-kicker').value || nombre;
const titulo = $('f-titulo').value;
const lead = $('f-lead').value;
const estado = $('f-estado').value;
const apoyo = $('f-apoyo').value;
// Kicker
const kickEl = $('prev-kicker');
kickEl.textContent = kicker || '';
if (!kicker) kickEl.innerHTML = '<span class="placeholder">Kicker</span>';
// Título
const titEl = $('prev-titulo');
titEl.textContent = titulo || '';
if (!titulo) titEl.innerHTML = '<span class="placeholder">Título del programa aparece aquí…</span>';
// Lead
const leadEl = $('prev-lead');
leadEl.textContent = lead || '';
if (!lead) leadEl.innerHTML = '<span class="placeholder">El párrafo introductorio aparecerá aquí mientras escribes.</span>';
// Estado pill
$('prev-estado').textContent = estado;
// Párrafos
const parrafoInputs = document.querySelectorAll('.parrafo-input');
const parrafoContainer = $('prev-parrafos');
parrafoContainer.innerHTML = '';
let anyParagraph = false;
parrafoInputs.forEach(inp => {
const val = inp.value.trim();
if (val) {
anyParagraph = true;
const p = document.createElement('p');
p.className = 'prev-parrafo';
p.textContent = val;
parrafoContainer.appendChild(p);
}
});
if (!anyParagraph) {
parrafoContainer.innerHTML = '<p class="prev-parrafo empty">Los párrafos aparecerán aquí…</p>';
}
// Stats
const statValues = document.querySelectorAll('.stat-valor');
const statEtiqs = document.querySelectorAll('.stat-etiqueta');
const statsContainer = $('prev-stats');
statsContainer.innerHTML = '';
for (let i = 0; i < 4; i++) {
const v = statValues[i]?.value || '';
const e = statEtiqs[i]?.value || '';
const div = document.createElement('div');
div.className = 'prev-stat';
div.innerHTML = `
<div class="prev-stat-valor">${v || '<span class="placeholder">—</span>'}</div>
<div class="prev-stat-etiqueta">${e || '<span class="placeholder">Descripción</span>'}</div>`;
statsContainer.appendChild(div);
}
// Apoyo
const apoyoEl = $('prev-apoyo');
apoyoEl.textContent = apoyo || '';
if (!apoyo) apoyoEl.innerHTML = '<span class="placeholder">El texto de apoyo aparecerá aquí.</span>';
}
// ── Eventos de formulario ─────────────────────────────────────
document.addEventListener('input', updatePreview);
document.addEventListener('change', updatePreview);
// ── Párrafos dinámicos ────────────────────────────────────────
function removeParagraph(btn) {
const list = $('parrafos-list');
if (list.querySelectorAll('.parrafo-item').length > 1) {
btn.closest('.parrafo-item').remove();
updatePreview();
} else {
btn.closest('.parrafo-item').querySelector('textarea').value = '';
updatePreview();
}
}
function addParagraph() {
const list = $('parrafos-list');
const item = document.createElement('div');
item.className = 'parrafo-item';
const n = list.querySelectorAll('.parrafo-item').length + 1;
item.innerHTML = `
<textarea class="parrafo-input" rows="3" placeholder="Párrafo ${n}…"></textarea>
<button class="parrafo-remove" type="button" title="Eliminar" onclick="removeParagraph(this)">×</button>`;
list.appendChild(item);
item.querySelector('textarea').focus();
}
$('btn-add-parrafo').addEventListener('click', addParagraph);
// Wire up initial remove button
document.querySelector('.parrafo-remove')
.addEventListener('click', function() { removeParagraph(this); });
// ── Recolectar datos del formulario ───────────────────────────
function collectData() {
const parrafos = [...document.querySelectorAll('.parrafo-input')]
.map(t => t.value.trim()).filter(Boolean);
const stats = [];
for (let i = 0; i < 4; i++) {
stats.push({
valor: document.querySelector(`.stat-valor[data-idx="${i}"]`)?.value.trim() || '',
etiqueta: document.querySelector(`.stat-etiqueta[data-idx="${i}"]`)?.value.trim() || '',
});
}
return {
nombre: $('f-nombre').value.trim(),
estado: $('f-estado').value,
bajada: $('f-bajada').value.trim(),
detalle: $('f-detalle').value.trim(),
icono: $('f-icono').value,
orden: parseInt($('f-orden').value) || 99,
tienePagina: $('f-tiene-pagina').checked,
externalHref: $('f-external').value.trim(),
kicker: $('f-kicker').value.trim(),
titulo: $('f-titulo').value.trim(),
lead: $('f-lead').value.trim(),
parrafos,
stats,
textoApoyo: $('f-apoyo').value.trim(),
imagen: '',
};
}
// ── Generador YAML ────────────────────────────────────────────
function toSlug(nombre) {
return nombre.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
function yamlStr(v) {
if (v == null || v === '') return "''";
v = String(v);
// Necesita comillas si tiene caracteres especiales
if (/[:#\[\]{}&*!|>'"?%@`]/.test(v) || /^[-+~\s]/.test(v) || /\s$/.test(v)) {
return '"' + v.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
return v;
}
function yamlMultiline(v, indent = '') {
if (!v) return "''";
const lines = v.split('\n');
if (lines.length === 1 && v.length <= 72 && !/[:#\[\]{}&*!|>'"?%@`]/.test(v)) {
return v;
}
return '>-\n' + lines.map(l => indent + ' ' + l).join('\n');
}
function generateYAMLString(d) {
const slug = toSlug(d.nombre || 'nuevo-programa');
const lines = [];
lines.push(`nombre: ${yamlStr(d.nombre)}`);
lines.push(`estado: ${yamlStr(d.estado)}`);
lines.push(`bajada: ${yamlStr(d.bajada)}`);
lines.push(`detalle: ${yamlStr(d.detalle)}`);
lines.push(`icono: ${yamlStr(d.icono || 'HeartHandshake')}`);
lines.push(`orden: ${d.orden}`);
lines.push(`tienePagina: ${d.tienePagina}`);
lines.push(`externalHref: ${yamlStr(d.externalHref || '')}`);
lines.push(`kicker: ${yamlStr(d.kicker || d.nombre)}`);
lines.push(`titulo: ${yamlStr(d.titulo)}`);
const leadYaml = yamlMultiline(d.lead);
if (leadYaml.includes('\n')) {
lines.push(`lead: ${leadYaml}`);
} else {
lines.push(`lead: ${yamlStr(d.lead)}`);
}
lines.push(`parrafos:`);
for (const p of d.parrafos) {
const ml = yamlMultiline(p, ' ');
if (ml.includes('\n')) {
lines.push(` - ${ml}`);
} else {
lines.push(` - ${yamlStr(p)}`);
}
}
if (!d.parrafos.length) lines.push(` []`);
lines.push(`stats:`);
for (const s of d.stats) {
lines.push(` - valor: ${yamlStr(s.valor)}`);
lines.push(` etiqueta: ${yamlStr(s.etiqueta)}`);
}
const apoyoYaml = yamlMultiline(d.textoApoyo);
if (apoyoYaml.includes('\n')) {
lines.push(`textoApoyo: ${apoyoYaml}`);
} else {
lines.push(`textoApoyo: ${yamlStr(d.textoApoyo)}`);
}
lines.push(`imagen: ''`);
return { yaml: lines.join('\n'), slug };
}
// ── Modal YAML ────────────────────────────────────────────────
$('btn-yaml').addEventListener('click', () => {
const d = collectData();
const { yaml } = generateYAMLString(d);
$('yaml-output').textContent = yaml;
$('yaml-modal').showModal();
});
$('btn-close-modal').addEventListener('click', () => {
$('yaml-modal').close();
});
$('yaml-modal').addEventListener('click', (e) => {
if (e.target === $('yaml-modal')) $('yaml-modal').close();
});
$('btn-copy').addEventListener('click', () => {
const text = $('yaml-output').textContent;
navigator.clipboard.writeText(text).then(() => {
toast('YAML copiado al portapapeles', 'success');
$('yaml-modal').close();
});
});
// ── Guardar programa ──────────────────────────────────────────
$('btn-save').addEventListener('click', async () => {
const d = collectData();
if (!d.nombre) {
toast('El nombre del programa es requerido', 'error');
$('f-nombre').focus();
return;
}
const btn = $('btn-save');
btn.textContent = 'Guardando…';
btn.disabled = true;
try {
const res = await fetch('/api/save-programa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(d),
});
const json = await res.json();
if (res.ok) {
toast(`✓ Guardado: src/content/programas/${json.slug}.yaml`, 'success');
} else {
toast('Error: ' + (json.error || 'desconocido'), 'error');
}
} catch (err) {
toast('Error de conexión', 'error');
} finally {
btn.textContent = 'Guardar';
btn.disabled = false;
}
});
// Init preview
updatePreview();
</script>
</body>
</html>

View File

@@ -0,0 +1,86 @@
import type { APIRoute } from 'astro';
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import yaml from 'js-yaml';
const SESSION_COOKIE = 'renacer_admin';
function getExpectedToken() {
const user = import.meta.env.ADMIN_USER ?? 'admin';
const pass = import.meta.env.ADMIN_PASS ?? '';
return `${user}::${pass}`;
}
function toSlug(nombre: string): string {
return nombre
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
export const POST: APIRoute = async ({ request, cookies }) => {
// Verificar sesión
const session = cookies.get(SESSION_COOKIE);
if (session?.value !== getExpectedToken()) {
return new Response(JSON.stringify({ error: 'No autorizado' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
let data: Record<string, unknown>;
try {
data = await request.json();
} catch {
return new Response(JSON.stringify({ error: 'JSON inválido' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const nombre = String(data.nombre ?? '').trim();
if (!nombre) {
return new Response(JSON.stringify({ error: 'El nombre es requerido' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const slug = String(data.slug || '').trim() || toSlug(nombre);
const filePath = join(process.cwd(), 'src', 'content', 'programas', `${slug}.yaml`);
const content = {
nombre,
estado: data.estado ?? 'Activo',
bajada: data.bajada ?? '',
detalle: data.detalle ?? '',
icono: data.icono ?? 'HeartHandshake',
orden: Number(data.orden) || 99,
tienePagina: data.tienePagina !== false,
externalHref: data.externalHref ?? '',
kicker: data.kicker ?? nombre,
titulo: data.titulo ?? '',
lead: data.lead ?? '',
parrafos: Array.isArray(data.parrafos) ? data.parrafos.filter(Boolean) : [],
stats: Array.isArray(data.stats) ? data.stats : [],
textoApoyo: data.textoApoyo ?? '',
imagen: '',
galeria: [],
};
try {
mkdirSync(join(process.cwd(), 'src', 'content', 'programas'), { recursive: true });
writeFileSync(filePath, yaml.dump(content, { lineWidth: 80, forceQuotes: false }), 'utf8');
return new Response(JSON.stringify({ ok: true, slug, path: filePath }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (err) {
return new Response(JSON.stringify({ error: String(err) }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

View File

@@ -1,11 +1,3 @@
---
import Layout from '../layouts/Layout.astro';
import CanastaFamiliarPage from '../components/CanastaFamiliarPage.astro';
return Astro.redirect('/programas/canasta-familiar/', 301);
---
<Layout
title="Canasta Familiar | Renacer"
description="Conoce el programa Canasta Familiar de Renacer y cómo entrega apoyo directo con alimentos y productos esenciales para el hogar."
>
<CanastaFamiliarPage />
</Layout>

View File

@@ -1,28 +1,3 @@
---
import Layout from '../layouts/Layout.astro';
import ProgramInfoPage from '../components/ProgramInfoPage.astro';
return Astro.redirect('/programas/cena-navidena/', 301);
---
<Layout
title="Cena Navideña | Renacer"
description="Conoce la Cena Navideña de Renacer, una instancia de encuentro, compañía y entrega para personas y familias que viven la Navidad en dificultad o soledad."
>
<ProgramInfoPage
kicker="Cena Navideña"
title="Una noche de encuentro, compañía y celebración compartida."
lead="La Cena Navideña de Renacer se realiza cada 24 de diciembre y está pensada especialmente para personas y familias que atraviesan dificultades económicas o que viven la Navidad en soledad."
paragraphs={[
'Sabemos que para muchas personas esta fecha puede ser compleja: a veces no alcanza el dinero para una cena especial, y en otros casos, la falta de compañía hace que la celebración pierda su sentido. Por eso abrimos este espacio como una oportunidad para compartir, acompañar y vivir una noche distinta.',
'Desde el año 2006, este programa ha sido parte de nuestra comunidad, creciendo gracias al apoyo de voluntarios y colaboradores. En su versión más reciente, durante la Navidad 2025, se lograron entregar 100 cenas navideñas.',
'La cena consiste en un menú de tres tiempos preparado gracias a las colaboraciones de personas que aportan con alimentos, recursos o tiempo. Además de la atención presencial, el programa contempla la entrega a domicilio para personas con enfermedades, movilidad reducida o adultos mayores.',
'Más que una cena, este programa busca generar un espacio donde las personas puedan sentirse acompañadas, valoradas y parte de una comunidad en una fecha significativa.',
]}
stats={[
{ valor: '24 dic', etiqueta: 'fecha en que se realiza cada año' },
{ valor: '100', etiqueta: 'cenas entregadas en Navidad 2025' },
{ valor: '2006', etiqueta: 'año desde el que forma parte de Renacer' },
{ valor: '3 tiempos', etiqueta: 'estructura del menú compartido' },
]}
supportText="Si deseas colaborar con este programa, puedes hacerlo a través de aportes, voluntariado o difusión para que más personas puedan vivir una Noche Buena acompañada."
/>
</Layout>

View File

@@ -1,11 +1,3 @@
---
import Layout from '../layouts/Layout.astro';
import ComedorSolidarioPage from '../components/ComedorSolidarioPage.astro';
return Astro.redirect('/programas/comedor-solidario/', 301);
---
<Layout
title="Comedor Solidario | Renacer"
description="Conoce el Programa Comedor Solidario de Renacer en Quillota y cómo sostiene almuerzos calientes y acompañamiento comunitario."
>
<ComedorSolidarioPage />
</Layout>

View File

@@ -0,0 +1,3 @@
---
return Astro.redirect('/programas/fiestas-patrias/', 301);
---

View File

@@ -0,0 +1,3 @@
---
return Astro.redirect('/programas/germinando-suenos/', 301);
---

View File

@@ -0,0 +1,3 @@
---
return Astro.redirect('/programas/juegotetra/', 301);
---

View File

@@ -1,28 +1,3 @@
---
import Layout from '../layouts/Layout.astro';
import ProgramInfoPage from '../components/ProgramInfoPage.astro';
return Astro.redirect('/programas/navidad-solidaria/', 301);
---
<Layout
title="Navidad Solidaria | Renacer"
description="Conoce Navidad Solidaria de Renacer, una campaña comunitaria de apadrinamiento y celebración para niños y niñas."
>
<ProgramInfoPage
kicker="Navidad Solidaria"
title="Una celebración comunitaria que acompaña a niños y niñas con esperanza y afecto."
lead="Navidad Solidaria de Renacer se desarrolla durante diciembre para acompañar a niños y niñas en una celebración significativa, entregando alegría y contención a través de una experiencia comunitaria."
paragraphs={[
'Está dirigida a niños y niñas desde 1 mes hasta los 15 años, pertenecientes a familias vinculadas a los programas de Renacer.',
'A través de una campaña de apadrinamiento, personas de la comunidad pueden colaborar entregando un regalo a un niño o niña, de forma presencial o a distancia. Cada padrino o madrina recibe información básica de su ahijado o ahijada para que el regalo tenga un sentido más personal.',
'Además del regalo, cada niño o niña recibe una bolsa de dulces y un presente preparado por Renacer, buscando transmitir el sentido de la Navidad desde una mirada de esperanza, amor y comunidad.',
'La entrega se realiza en una celebración organizada por Renacer, con actividades recreativas, juegos y momentos compartidos. Más allá de lo material, este programa busca generar una experiencia significativa y aliviar una carga económica adicional para muchas familias.',
]}
stats={[
{ valor: '167', etiqueta: 'niños y niñas acompañados en la celebración' },
{ valor: '83', etiqueta: 'madrinas participantes' },
{ valor: '39', etiqueta: 'padrinos participantes' },
{ valor: 'Diciembre', etiqueta: 'mes en que se activa esta campaña comunitaria' },
]}
supportText="Si deseas conocer más o ser parte de esta iniciativa, puedes hacerlo a través de nuestros canales y sumarte como padrino, madrina, colaborador o voluntario."
/>
</Layout>

View File

@@ -0,0 +1,36 @@
---
import Layout from '../../layouts/Layout.astro';
import ProgramInfoPage from '../../components/ProgramInfoPage.astro';
import { getProgramBySlug } from '../../lib/programs';
const { slug } = Astro.params;
if (!slug) return Astro.redirect('/');
const program = await getProgramBySlug(slug);
if (!program) return Astro.redirect('/');
if (!program.tienePagina) return Astro.redirect('/');
if (program.externalHrefNormalizado && program.externalHrefNormalizado !== program.dynamicHref) {
return Astro.redirect(program.externalHrefNormalizado, 301);
}
---
<Layout
title={`${program.nombreNormalizado} | Renacer`}
description={program.detalle}
>
<ProgramInfoPage
kicker={program.kicker ?? program.nombreNormalizado}
title={program.titulo ?? program.nombreNormalizado}
lead={program.lead ?? ''}
paragraphs={(program.parrafos ?? []) as string[]}
stats={
((program.stats ?? []) as { valor: string; etiqueta: string }[]).map(
(s) => ({ valor: s.valor, etiqueta: s.etiqueta })
)
}
supportText={program.textoApoyo ?? ''}
images={program.imagenes}
/>
</Layout>

View File

@@ -1,28 +1,3 @@
---
import Layout from '../layouts/Layout.astro';
import ProgramInfoPage from '../components/ProgramInfoPage.astro';
return Astro.redirect('/programas/ropero-solidario/', 301);
---
<Layout
title="Ropero Solidario | Renacer"
description="Conoce el programa Ropero Solidario de Renacer y cómo acompaña a familias con vestimenta y apoyo digno durante todo el año."
>
<ProgramInfoPage
kicker="Ropero Solidario"
title="Vestimenta digna, rescate textil y apoyo concreto para la comunidad."
lead="El programa Ropero Solidario de Renacer apoya a familias que enfrentan dificultades para acceder a vestimenta, entregando ropa en buen estado y artículos esenciales según sus necesidades."
paragraphs={[
'Este espacio funciona gracias a la donación de ropa por parte de la comunidad, la cual es recibida, clasificada, seleccionada, lavada y organizada para luego ser entregada de manera digna y respetuosa a quienes lo necesitan.',
'El programa se desarrolla durante todo el año, adaptándose a las distintas temporadas. En invierno, se refuerza la entrega de abrigo para personas en situación de calle, familias e inmigrantes, mientras que en verano se continúa apoyando con vestimenta adecuada a la estación.',
'A través de este programa también promovemos una lógica de economía circular, donde las familias no solo pueden recibir apoyo, sino también donar ropa que ya no utilizan, generando una red de colaboración dentro de la misma comunidad y fomentando la reutilización y el cuidado del entorno.',
'Más allá de la entrega de ropa, este espacio busca ser un apoyo concreto, resguardando la dignidad de cada persona y ofreciendo un entorno de respeto y cercanía.',
]}
stats={[
{ valor: 'Todo el año', etiqueta: 'funcionamiento continuo según temporada' },
{ valor: 'Abrigo', etiqueta: 'refuerzo especial en invierno para calle, familias e inmigrantes' },
{ valor: 'Circular', etiqueta: 'reutilización y rescate textil desde la comunidad' },
{ valor: 'Digno', etiqueta: 'clasificación, lavado y entrega respetuosa' },
]}
supportText="Si deseas conocer más o ser parte de esta iniciativa, puedes hacerlo a través de nuestros canales. Toda colaboración permite que este apoyo continúe llegando a quienes lo necesitan."
/>
</Layout>

View File

@@ -1,28 +1,3 @@
---
import Layout from '../layouts/Layout.astro';
import ProgramInfoPage from '../components/ProgramInfoPage.astro';
return Astro.redirect('/programas/sala-del-juguete/', 301);
---
<Layout
title="Sala del Juguete | Renacer"
description="Conoce la Sala del Juguete de Renacer, un espacio para rescatar el valor del juego, la infancia y la reutilización comunitaria."
>
<ProgramInfoPage
kicker="Sala del Juguete"
title="Un espacio para rescatar el juego, la imaginación y la infancia."
lead="La Sala del Juguete de Renacer está pensada para devolver al juego el lugar que merece en la infancia, ofreciendo a niños y niñas la oportunidad de explorar, imaginar y compartir más allá de las pantallas."
paragraphs={[
'Hoy sabemos que el juego no es solo una forma de entretención, sino una parte fundamental del desarrollo. A través del juego, los niños fortalecen su creatividad, habilidades sociales, expresión emocional y aprendizaje.',
'Este espacio busca recuperar ese tiempo de juego que muchas veces se ve limitado, especialmente en contextos donde predominan las pantallas o donde no siempre existen las condiciones para acceder a juguetes.',
'La Sala del Juguete funciona también como un espacio de economía circular, donde se reciben donaciones de juguetes nuevos y usados en buen estado, los cuales son seleccionados y puestos a disposición de los niños para su uso dentro del espacio o para ser entregados en fechas especiales.',
'Más allá de los objetos, este programa promueve el juego, la reutilización, el cuidado del entorno y la colaboración entre familias como parte del bienestar comunitario.',
]}
stats={[
{ valor: 'Juego', etiqueta: 'como herramienta de desarrollo, encuentro y bienestar' },
{ valor: 'Circular', etiqueta: 'reutilización de juguetes nuevos y usados en buen estado' },
{ valor: 'Infancia', etiqueta: 'espacio pensado para explorar, imaginar y compartir' },
{ valor: 'Comunidad', etiqueta: 'apoyo a celebraciones y actividades durante el año' },
]}
supportText="Si deseas colaborar con la donación de juguetes o conocer más sobre este espacio, puedes hacerlo a través de nuestros canales y campañas comunitarias."
/>
</Layout>

View File

@@ -1,28 +1,3 @@
---
import Layout from '../layouts/Layout.astro';
import ProgramInfoPage from '../components/ProgramInfoPage.astro';
return Astro.redirect('/programas/tardes-recreativas/', 301);
---
<Layout
title="Tardes Recreativas | Renacer"
description="Conoce las Tardes Recreativas de Renacer, un espacio seguro y acompañado para niños y niñas durante vacaciones de verano e invierno."
>
<ProgramInfoPage
kicker="Tardes Recreativas"
title="Vacaciones con cuidado, juego y actividades seguras para niños y niñas."
lead="El programa Tardes Recreativas de Renacer ofrece un espacio seguro, acompañado y entretenido para niños y niñas durante las vacaciones de verano e invierno."
paragraphs={[
'Está pensado especialmente para familias donde los adultos deben trabajar en estos períodos y no siempre cuentan con redes de apoyo para el cuidado de sus hijos.',
'Durante estas jornadas, los niños participan en actividades recreativas, artísticas y de convivencia en un entorno supervisado por monitores, donde pueden compartir, aprender y disfrutar su tiempo de manera segura.',
'Además, el programa incluye almuerzo y colación, asegurando que los niños cuenten con una alimentación adecuada durante su permanencia. En verano se realizan juegos con agua, actividades al aire libre y talleres de cocina; en invierno, cine, juegos grupales, manualidades y momentos de relajación.',
'Más allá de las actividades, este espacio busca entregar cuidado, contención y un entorno donde los niños se sientan acompañados, especialmente en períodos donde pueden pasar más tiempo solos.',
]}
stats={[
{ valor: '60', etiqueta: 'niños recibidos en el programa' },
{ valor: 'Verano', etiqueta: 'juegos con agua, aire libre y talleres de cocina' },
{ valor: 'Invierno', etiqueta: 'cine, manualidades y juegos en espacios cerrados' },
{ valor: 'Cuidado', etiqueta: 'incluye almuerzo y colación durante la jornada' },
]}
supportText="Si deseas conocer más o ser parte de esta iniciativa, puedes colaborar con recursos, voluntariado o difusión para fortalecer estos espacios de cuidado."
/>
</Layout>