Compare commits

...

7 Commits

Author SHA1 Message Date
a39f9f170c Servidor MCP para el asistente del equipo y API para idioma, traducciones y necesidades
- mcp/: servidor MCP (stdio) con list/get/create/update animal, set_status, upload_photo,
  get/set_needs, sobre la API REST con contraseña de aplicación.
- REST: campos language y translation_of (crear la versión en euskera enlazada y heredar
  los datos comunes); endpoint bilagun/v1/needs.
- README: herramientas del equipo y tareas pendientes fuera del código.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:44:44 +02:00
d29a66d18b Aviso para activar la verificación en dos pasos (posponible 30 días)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:41:52 +02:00
d7a17a2aca Cuestionario de adopción online y hueco para el aviso de nuevos animales
- [ga_cuestionario]: preguntas editables en ES/EU (Personalizar), borrador guardado en el
  navegador, envío a Mensajes y por correo. Páginas «Cuestionario de adopción /
  Adopzio-galdetegia» fuera del menú y con noindex; admite ?adoptar=ID.
- Pie: formulario de suscripción de MailPoet cuando se configure su shortcode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:32:29 +02:00
1ad21c1b08 Finales felices (con revisión) y apadrinamiento, preparados y ocultos
- Formulario [ga_final_feliz]: las familias envían texto y foto (solo imágenes reales ≤5 MB,
  límite de envíos, consentimiento de publicación). Queda pendiente hasta que el equipo
  la publica; aviso por correo y contador en el menú.
- Sección «Finales felices»: historias aprobadas o, si no hay, los adoptados.
- Casilla «Busca padrino o madrina», sección «Apadrina» y motivo de contacto (sin pagos).
- Páginas «Finales felices / Amaiera zoriontsuak» creadas como borrador.
- API: profile.sponsorable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:30:53 +02:00
fac5e6035c SEO local, perdidos/encontrados y solicitud de cita
- Datos estructurados schema.org AnimalShelter en la portada (dirección, horario para
  Google configurable, teléfono, redes, idiomas).
- Preguntas frecuentes: animales perdidos o encontrados, con el buscador oficial de Euskadi.
- Formulario: motivo «Pedir cita para una visita» con día y hora preferidos.
- Migración 6: actualiza las preguntas frecuentes si no se han editado.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:23:04 +02:00
130c29bd61 Compartir: imagen automática para redes y carteles con QR
- Imagen 1200×630 por animal (foto, nombre, estado, logo), en su idioma y con los colores
  del tema; se regenera sola si cambia la foto o el nombre. Se usa en og:image/twitter
  (con Yoast o sin él) y se puede descargar desde la ficha del panel.
- Cartel A4 imprimible con QR a la ficha (acción de fila y acción en bloque).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:21:32 +02:00
c1bbf78b5c Panel: fichas por revisar, acciones en bloque, destacar con un clic y borrado de mensajes antiguos
- Escritorio: animales sin foto, sin euskera, sin «Cómo es», sin especie o en proceso
  desde hace más de N días (configurable).
- Listado: acciones en bloque de estado y destacado (en los dos idiomas) y estrella clicable.
- Se guarda la fecha del último cambio de estado.
- Borrado diario de mensajes del formulario más antiguos que N meses (12 por defecto).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:17:14 +02:00
33 changed files with 2732 additions and 26 deletions

View File

@@ -2,3 +2,4 @@
.git
*.md
docker-compose*.yml
mcp/

View File

@@ -46,9 +46,31 @@ Cada ficha tiene su propio enlace y botones para compartirla por WhatsApp o Face
| 3 | Plugins recomendados |
| 4 | APIs (WPGraphQL, Swagger) |
| 5 | Indicaciones del cliente: páginas Acoge y Preguntas frecuentes, menú con submenús, retirada del hotel, estados nuevos y sin animaciones |
| 6 | Preguntas frecuentes con perdidos/encontrados; páginas del cuestionario y de finales felices (borrador) |
`docker/setup-content.php` es idempotente. **Las páginas que el cliente haya editado nunca se sobrescriben**; se detecta con una huella del contenido original.
## Herramientas para el equipo
- **Escritorio → «Fichas por revisar»**: animales sin foto, sin euskera, sin «Cómo es», sin especie o en proceso desde hace demasiado.
- **Animales (listado)**: estrella ☆/★ para destacar con un clic; acciones en bloque (estado, destacar, **imprimir carteles con QR**).
- **Ficha del animal → caja «Compartir»**: imagen para redes generada automáticamente (la que sale al compartir el enlace) y cartel A4 con QR.
- **Finales felices** (Animales → Finales felices): historias enviadas por las familias desde la página «Finales felices» (borrador hasta que queráis publicarla); se publican solo tras revisarlas.
- **Cuestionario de adopción** online: página fuera del menú y sin indexar; enviad el enlace tras el primer contacto (`?adoptar=ID` lo rellena con el animal). Preguntas en *Personalizar → Adopción y colabora → Cuestionario*.
- **Mensajes**: se borran solos a los 12 meses (configurable).
- **Asistente (MCP)**: ver [`mcp/README.md`](mcp/README.md).
### Pendiente fuera del código (requiere cuentas del cliente)
| Tarea | Cómo |
|---|---|
| Copias fuera del servidor | UpdraftPlus → Ajustes → Google Drive o Dropbox |
| Aviso si la web se cae | UptimeRobot o Better Stack (gratis) apuntando a la portada |
| Caché y cortafuegos | Cloudflare delante del dominio (proxy naranja) |
| Google Maps / búsqueda local | Ficha de Google Business con la misma dirección, horario y web |
| Aviso de «Nuevos animales» | Activar MailPoet, crear un formulario y un email de tipo «Aviso de nuevas publicaciones» para *Animales*; pegar el shortcode en *Personalizar → Pie* |
| Apadrinamiento con pago | Confirmar medios de pago, activar GiveWP y enlazarlo desde la sección «Apadrina» |
## Logo y colores
- El logo original se ha vectorizado en `wp-content/themes/gure-ametsa/assets/img/`:

View File

@@ -0,0 +1,7 @@
<!-- wp:paragraph -->
<p>Gracias por querer conocernos. Estas preguntas nos ayudan a saber cómo es tu día a día y a encontrar juntos al compañero que mejor encaje contigo. No hay respuestas buenas o malas: cuéntanoslo con naturalidad.</p>
<!-- /wp:paragraph -->
<!-- wp:shortcode -->
[ga_cuestionario]
<!-- /wp:shortcode -->

View File

@@ -0,0 +1,7 @@
<!-- wp:paragraph -->
<p>¿Adoptaste a uno de nuestros animales? Nos encantará saber cómo está. Envíanos unas líneas y una foto: lo revisaremos antes de publicarlo.</p>
<!-- /wp:paragraph -->
<!-- wp:shortcode -->
[ga_final_feliz]
<!-- /wp:shortcode -->

View File

@@ -34,6 +34,12 @@
<!-- /wp:paragraph --></details>
<!-- /wp:details -->
<!-- wp:details -->
<details class="wp-block-details"><summary>He perdido o he encontrado un animal, ¿qué hago?</summary><!-- wp:paragraph -->
<p>Avisa a la Policía Municipal de tu localidad y consulta el <a href="https://www.euskadi.eus/gobierno-vasco/-/encuentra-a-tu-mascota-perdida/">buscador de mascotas perdidas del Gobierno Vasco</a>. Si es en Irun u Hondarribia, escríbenos o llámanos también.</p>
<!-- /wp:paragraph --></details>
<!-- /wp:details -->
<!-- wp:paragraph -->
<p>¿No encuentras tu respuesta? <a href="/contacto/#formulario">Escríbenos</a>.</p>
<!-- /wp:paragraph -->

View File

@@ -0,0 +1,7 @@
<!-- wp:paragraph -->
<p>Eskerrik asko gu ezagutu nahi izateagatik. Galdera hauek zure eguneroko bizitza nolakoa den jakiten eta zurekin hobekien bat datorren laguna elkarrekin aurkitzen laguntzen digute. Ez dago erantzun onik edo txarrik: kontaiguzu naturaltasunez.</p>
<!-- /wp:paragraph -->
<!-- wp:shortcode -->
[ga_cuestionario]
<!-- /wp:shortcode -->

View File

@@ -0,0 +1,7 @@
<!-- wp:paragraph -->
<p>Gure animalietako bat adoptatu zenuen? Asko gustatuko litzaiguke nola dagoen jakitea. Bidali iezaguzu lerro batzuk eta argazki bat: argitaratu aurretik berrikusiko dugu.</p>
<!-- /wp:paragraph -->
<!-- wp:shortcode -->
[ga_final_feliz]
<!-- /wp:shortcode -->

View File

@@ -34,6 +34,12 @@
<!-- /wp:paragraph --></details>
<!-- /wp:details -->
<!-- wp:details -->
<details class="wp-block-details"><summary>Animalia bat galdu edo aurkitu dut, zer egin?</summary><!-- wp:paragraph -->
<p>Jakinarazi zure herriko Udaltzaingoari eta kontsultatu <a href="https://www.euskadi.eus/gobierno-vasco/-/encuentra-a-tu-mascota-perdida/">Eusko Jaurlaritzaren maskota galduen bilatzailea</a>. Irunen edo Hondarribian bada, idatzi edo deitu iezaguzu ere.</p>
<!-- /wp:paragraph --></details>
<!-- /wp:details -->
<!-- wp:paragraph -->
<p>Ez duzu zure erantzuna aurkitzen? <a href="/eu/harremanetarako/#formulario">Idatzi iezaguzu</a>.</p>
<!-- /wp:paragraph -->

View File

@@ -79,7 +79,7 @@ function ga_setup_untouched( WP_Post $post ) {
return $post->post_modified_gmt === $post->post_date_gmt;
}
function ga_setup_page( $slug, $title, $file, $lang ) {
function ga_setup_page( $slug, $title, $file, $lang, $status = 'publish' ) {
$content = file_exists( $file ) ? file_get_contents( $file ) : '';
$page = get_page_by_path( $slug, OBJECT, 'page' );
@@ -95,7 +95,7 @@ function ga_setup_page( $slug, $title, $file, $lang ) {
} else {
$id = wp_insert_post( array(
'post_type' => 'page',
'post_status' => 'publish',
'post_status' => $status,
'post_title' => $title,
'post_name' => $slug,
'post_content' => $content,
@@ -120,6 +120,7 @@ $pages = array(
'quienes-somos' => array( 'Quiénes somos', 'nor-gara', 'Nor gara' ),
'contacto' => array( 'Contacto', 'harremanetarako', 'Harremanetarako' ),
'preguntas-frecuentes' => array( 'Preguntas frecuentes', 'ohiko-galderak', 'Ohiko galderak' ),
'cuestionario-adopcion' => array( 'Cuestionario de adopción', 'adopzio-galdetegia', 'Adopzio-galdetegia' ),
'politica-de-privacidad' => array( 'Política de privacidad', 'pribatutasun-politika', 'Pribatutasun-politika' ),
'aviso-legal' => array( 'Aviso legal', 'lege-oharra', 'Lege-oharra' ),
);
@@ -147,6 +148,20 @@ foreach ( array( 'hotel', 'hotela' ) as $slug ) {
}
}
// Páginas preparadas pero sin publicar: el equipo las publica cuando quiera.
$drafts = array( 'finales-felices' => array( 'Finales felices', 'amaiera-zoriontsuak', 'Amaiera zoriontsuak' ) );
foreach ( $drafts as $slug => [ $title_es, $slug_eu, $title_eu ] ) {
$exists = get_posts( array( 'post_type' => 'page', 'name' => $slug, 'post_status' => 'any', 'numberposts' => 1, 'lang' => '' ) );
if ( $exists ) {
continue;
}
$draft_ids = array( 'es' => ga_setup_page( $slug, $title_es, "$src/es/$slug.html", 'es', 'draft' ) );
if ( $pll ) {
$draft_ids['eu'] = ga_setup_page( $slug_eu, $title_eu, "$src/eu/$slug_eu.html", 'eu', 'draft' );
pll_save_post_translations( $draft_ids );
}
}
update_option( 'show_on_front', 'page' );
update_option( 'page_on_front', $ids['inicio']['es'] );
update_option( 'wp_page_for_privacy_policy', $ids['politica-de-privacidad']['es'] );

View File

@@ -183,4 +183,11 @@ if (( VERSION < 5 )); then
wp_ option update ga_setup_version 5
fi
# Etapa 6: preguntas frecuentes con perdidos/encontrados (solo si la página no se ha editado)
if (( VERSION < 6 )); then
log "Migración 6: contenido"
GA_SEED_MENUS=false wp_ eval-file "$SRC/setup-content.php" || log "Fallo en la migración 6"
wp_ option update ga_setup_version 6
fi
log "Configuración completada"

1
mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

60
mcp/README.md Normal file
View File

@@ -0,0 +1,60 @@
# Bilagun MCP server
Lets an AI assistant (Claude Desktop, Claude Code…) manage the Bilagun website in plain language,
through the website's REST API. Examples:
- "Crea la ficha de Kira, perra de unos 2 años, tamaño mediano, muy cariñosa. Déjala en borrador."
- "Marca a Txuri como adoptada."
- "Sube esta foto como principal de Hartz."
- "Añade 'arena para gatos' a lo que necesitamos, en castellano y en euskera."
## Tools
| Tool | What it does |
|---|---|
| `list_animals` | List animals (filter by language, status, species, text) |
| `get_animal` | Full record: story, profile, translation |
| `create_animal` | New animal (draft by default). With `language: "eu"` + `translationOf` it creates the Basque version and links it |
| `update_animal` | Change name, story, texts or profile fields |
| `set_status` | available / pending / adopted (both languages) |
| `upload_photo` | Upload a JPG/PNG/WebP (local file or URL) as main photo or to the gallery |
| `get_needs` / `set_needs` | Material needs list shown on the website (es / eu) |
It can't read the contact messages, delete content or change settings: that stays in the dashboard.
## Setup
1. In WordPress create a dedicated user (role **Editor**) and, in *Users → Profile → Application passwords*,
create one called "Asistente". Copy it (it is shown only once). It can be revoked at any time.
2. Install: `cd mcp && npm install` (Node 20+).
3. Add the server to your assistant.
**Claude Code**
```bash
claude mcp add bilagun \
--env BILAGUN_URL=https://gureametsabilagun.pireon.dev \
--env BILAGUN_USER=asistente \
--env "BILAGUN_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx" \
-- node /absolute/path/to/mcp/index.js
```
**Claude Desktop** (`claude_desktop_config.json`)
```json
{
"mcpServers": {
"bilagun": {
"command": "node",
"args": ["/absolute/path/to/mcp/index.js"],
"env": {
"BILAGUN_URL": "https://gureametsabilagun.pireon.dev",
"BILAGUN_USER": "asistente",
"BILAGUN_APP_PASSWORD": "xxxx xxxx xxxx xxxx xxxx xxxx"
}
}
}
}
```
Application passwords only work over HTTPS (the production site already is).

220
mcp/index.js Normal file
View File

@@ -0,0 +1,220 @@
#!/usr/bin/env node
/**
* Bilagun MCP server.
*
* Lets an assistant (Claude Desktop, Claude Code…) manage the website through its REST API:
* list / read / create / update animals, change status, feature, upload photos and edit the
* material needs list. Authentication: WordPress application password (Users → Profile).
*
* Env: BILAGUN_URL (https://…), BILAGUN_USER, BILAGUN_APP_PASSWORD
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { readFile } from 'node:fs/promises';
import { basename, extname } from 'node:path';
const BASE = (process.env.BILAGUN_URL || '').replace(/\/+$/, '');
const USER = process.env.BILAGUN_USER || '';
const PASS = process.env.BILAGUN_APP_PASSWORD || '';
if (!BASE || !USER || !PASS) {
console.error('Set BILAGUN_URL, BILAGUN_USER and BILAGUN_APP_PASSWORD.');
process.exit(1);
}
const AUTH = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
async function api(path, { method = 'GET', body, headers = {}, raw = false } = {}) {
const res = await fetch(`${BASE}/wp-json${path}`, {
method,
headers: { Authorization: AUTH, ...(body && !raw ? { 'Content-Type': 'application/json' } : {}), ...headers },
body: body ? (raw ? body : JSON.stringify(body)) : undefined,
});
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch { data = text; }
if (!res.ok) {
const msg = (data && data.message) || text.slice(0, 300);
throw new Error(`${method} ${path} → ${res.status}: ${msg}`);
}
return data;
}
const ok = (data) => ({ content: [{ type: 'text', text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }] });
const fail = (e) => ({ isError: true, content: [{ type: 'text', text: String(e.message || e) }] });
const tool = (fn) => async (args) => { try { return ok(await fn(args)); } catch (e) { return fail(e); } };
const SPECIES = { dog: { es: 'perro', eu: 'txakurra' }, cat: { es: 'gato', eu: 'katua' } };
async function speciesId(kind, lang) {
const slug = SPECIES[kind][lang];
const terms = await api(`/wp/v2/species?slug=${slug}&lang=${lang}&_fields=id`);
if (!terms.length) throw new Error(`Species "${slug}" not found.`);
return terms[0].id;
}
/** Compact view of an animal for the assistant. */
const summary = (a) => ({
id: a.id,
name: a.title?.rendered ?? a.title?.raw,
wpStatus: a.status,
language: a.language,
translationOf: a.translation_of,
link: a.link,
profile: a.profile,
});
const Profile = {
status: z.enum(['available', 'pending', 'adopted']).optional().describe('Adoption status. Adopted animals disappear from public listings.'),
featured: z.boolean().optional().describe('Show on the home page.'),
sponsorable: z.boolean().optional().describe('Looking for a sponsor.'),
sex: z.enum(['male', 'female']).optional(),
birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('YYYY-MM-DD'),
age: z.number().nonnegative().optional().describe('Approximate age when the birth date is unknown.'),
ageUnit: z.enum(['years', 'months']).optional(),
size: z.enum(['small', 'medium', 'large']).optional().describe('Dogs only.'),
arrivalDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Date it arrived at the shelter (only if confirmed).'),
video: z.string().url().optional(),
personality: z.string().optional().describe('What it is like (in the language of this record).'),
needs: z.string().optional().describe('What it needs.'),
compatibility: z.string().optional().describe('Observed behaviour with children and other animals, with context. Never claim what is not known.'),
idealHome: z.string().optional().describe('The home we are looking for.'),
};
const pickProfile = (args) => Object.fromEntries(Object.keys(Profile).filter((k) => args[k] !== undefined).map((k) => [k, args[k]]));
const server = new McpServer({ name: 'bilagun', version: '1.0.0' });
server.registerTool('list_animals', {
title: 'List animals',
description: 'List animals of the website. Filters are optional.',
inputSchema: {
language: z.enum(['es', 'eu']).default('es'),
status: z.enum(['available', 'pending', 'adopted']).optional(),
species: z.enum(['dog', 'cat']).optional(),
search: z.string().optional().describe('Text to search in the name or story.'),
includeDrafts: z.boolean().default(false),
},
}, tool(async ({ language, status, species, search, includeDrafts }) => {
const params = new URLSearchParams({ lang: language, per_page: '100', context: 'edit', _fields: 'id,title,status,link,language,translation_of,profile' });
params.set('status', includeDrafts ? 'publish,draft,pending' : 'publish');
if (search) params.set('search', search);
if (species) params.set('species', String(await speciesId(species, language)));
let animals = (await api(`/wp/v2/animals?${params}`)).map(summary);
if (status) animals = animals.filter((a) => a.profile?.status === status);
return animals.map(({ id, name, wpStatus, profile, translationOf }) => ({ id, name, wpStatus, status: profile?.status, featured: profile?.featured, age: profile?.labels?.age, translationOf }));
}));
server.registerTool('get_animal', {
title: 'Get animal',
description: 'Full record of one animal, including its story and profile.',
inputSchema: { id: z.number().int() },
}, tool(async ({ id }) => {
const a = await api(`/wp/v2/animals/${id}?context=edit&_fields=id,title,content,excerpt,status,link,language,translation_of,featured_media,profile`);
return { ...summary(a), story: a.content?.raw, shortPhrase: a.excerpt?.raw, featuredMedia: a.featured_media };
}));
server.registerTool('create_animal', {
title: 'Create animal',
description: 'Create an animal record. It is saved as a draft unless publish=true. To create the Basque version of an existing animal, pass language="eu" and translationOf=<id of the Spanish record>: shared data (status, dates, photos…) is copied automatically; write the texts in Basque.',
inputSchema: {
name: z.string().min(1),
species: z.enum(['dog', 'cat']),
language: z.enum(['es', 'eu']).default('es'),
translationOf: z.number().int().optional(),
story: z.string().optional().describe('Main story (plain text or HTML).'),
shortPhrase: z.string().optional().describe('Optional one-line phrase.'),
publish: z.boolean().default(false),
...Profile,
},
}, tool(async (args) => {
const body = {
title: args.name,
content: args.story ?? '',
excerpt: args.shortPhrase ?? '',
status: args.publish ? 'publish' : 'draft',
species: [await speciesId(args.species, args.language)],
language: args.language,
profile: { status: 'available', ...pickProfile(args) },
};
if (args.translationOf) body.translation_of = args.translationOf;
const created = await api('/wp/v2/animals', { method: 'POST', body });
return { created: summary(created), editInDashboard: `${BASE}/wp-admin/post.php?post=${created.id}&action=edit` };
}));
server.registerTool('update_animal', {
title: 'Update animal',
description: 'Update an animal. Only the fields provided change. Shared fields (status, featured, dates, size, photos) are applied to both languages automatically; texts only to this record.',
inputSchema: {
id: z.number().int(),
name: z.string().optional(),
story: z.string().optional(),
shortPhrase: z.string().optional(),
publish: z.boolean().optional().describe('true = publish, false = back to draft.'),
...Profile,
},
}, tool(async (args) => {
const body = {};
if (args.name !== undefined) body.title = args.name;
if (args.story !== undefined) body.content = args.story;
if (args.shortPhrase !== undefined) body.excerpt = args.shortPhrase;
if (args.publish !== undefined) body.status = args.publish ? 'publish' : 'draft';
const profile = pickProfile(args);
if (Object.keys(profile).length) body.profile = profile;
return summary(await api(`/wp/v2/animals/${args.id}`, { method: 'POST', body }));
}));
server.registerTool('set_status', {
title: 'Set adoption status',
description: 'Shortcut to mark an animal as available, pending (in process) or adopted, in both languages.',
inputSchema: { id: z.number().int(), status: z.enum(['available', 'pending', 'adopted']) },
}, tool(async ({ id, status }) => summary(await api(`/wp/v2/animals/${id}`, { method: 'POST', body: { profile: { status } } }))));
server.registerTool('upload_photo', {
title: 'Upload photo',
description: 'Upload a photo (local file path or https URL) and attach it to an animal, as the main photo or added to its gallery.',
inputSchema: {
animalId: z.number().int(),
source: z.string().describe('Local file path or https URL of a JPG, PNG or WebP image.'),
asMain: z.boolean().default(false).describe('true = main photo; false = add to the gallery.'),
altText: z.string().optional(),
},
}, tool(async ({ animalId, source, asMain, altText }) => {
let bytes, name;
if (/^https?:\/\//.test(source)) {
const res = await fetch(source);
if (!res.ok) throw new Error(`Could not download ${source}: ${res.status}`);
bytes = Buffer.from(await res.arrayBuffer());
name = basename(new URL(source).pathname) || 'photo.jpg';
} else {
bytes = await readFile(source);
name = basename(source);
}
const ext = extname(name).toLowerCase();
const type = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' }[ext];
if (!type) throw new Error('Only JPG, PNG or WebP images are allowed.');
const media = await api('/wp/v2/media', {
method: 'POST', raw: true, body: bytes,
headers: { 'Content-Type': type, 'Content-Disposition': `attachment; filename="${name.replace(/"/g, '')}"` },
});
if (altText) await api(`/wp/v2/media/${media.id}`, { method: 'POST', body: { alt_text: altText } });
if (asMain) {
await api(`/wp/v2/animals/${animalId}`, { method: 'POST', body: { featured_media: media.id } });
} else {
const a = await api(`/wp/v2/animals/${animalId}?context=edit&_fields=profile`);
const ids = [...new Set([...(a.profile?.galleryIds || []), media.id])];
await api(`/wp/v2/animals/${animalId}`, { method: 'POST', body: { profile: { galleryIds: ids } } });
}
return { mediaId: media.id, url: media.source_url, attachedTo: animalId, as: asMain ? 'main photo' : 'gallery' };
}));
server.registerTool('get_needs', {
title: 'Get material needs',
description: 'Current list of material needs shown on the website, in Spanish and Basque.',
inputSchema: {},
}, tool(async () => api('/bilagun/v1/needs')));
server.registerTool('set_needs', {
title: 'Set material needs',
description: 'Replace the list of material needs. Provide both languages when possible (es = Spanish, eu = Basque).',
inputSchema: { es: z.array(z.string()).optional(), eu: z.array(z.string()).optional() },
}, tool(async ({ es, eu }) => api('/bilagun/v1/needs', { method: 'PUT', body: { ...(es ? { es } : {}), ...(eu ? { eu } : {}) } })));
await server.connect(new StdioServerTransport());

1210
mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
mcp/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "bilagun-mcp",
"version": "1.0.0",
"description": "MCP server to manage the Bilagun website (animals, photos, material needs) through its REST API.",
"type": "module",
"bin": { "bilagun-mcp": "index.js" },
"main": "index.js",
"engines": { "node": ">=20" },
"scripts": { "start": "node index.js" },
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.1",
"zod": "^4.6.5"
},
"license": "GPL-2.0-or-later"
}

View File

@@ -12,3 +12,8 @@ require_once __DIR__ . '/gure-ametsa/i18n.php';
require_once __DIR__ . '/gure-ametsa/animals.php';
require_once __DIR__ . '/gure-ametsa/forms.php';
require_once __DIR__ . '/gure-ametsa/api.php';
require_once __DIR__ . '/gure-ametsa/admin.php';
require_once __DIR__ . '/gure-ametsa/share.php';
require_once __DIR__ . '/gure-ametsa/seo.php';
require_once __DIR__ . '/gure-ametsa/stories.php';
require_once __DIR__ . '/gure-ametsa/questionnaire.php';

View File

@@ -0,0 +1,227 @@
<?php
/**
* Herramientas del panel: fichas por revisar, acciones en bloque, destacar con un clic
* y borrado automático de mensajes antiguos.
*/
defined( 'ABSPATH' ) || exit;
/* -------------------------------------------------------------------------
* Fecha del último cambio de estado (para saber cuánto lleva «en proceso»)
* ---------------------------------------------------------------------- */
$ga_status_changed = function ( $meta_id, $post_id, $meta_key ) {
if ( '_ga_estado' === $meta_key ) {
update_post_meta( $post_id, '_ga_estado_desde', current_time( 'Y-m-d' ) );
}
};
add_action( 'added_post_meta', $ga_status_changed, 10, 3 );
add_action( 'updated_post_meta', $ga_status_changed, 10, 3 );
/* -------------------------------------------------------------------------
* Escritorio: fichas por revisar
* ---------------------------------------------------------------------- */
/** Animales (versión en castellano) con algo pendiente, agrupados por problema. */
function ga_review_issues() {
$animals = get_posts( array(
'post_type' => 'animal',
'post_status' => array( 'publish', 'draft', 'pending' ),
'numberposts' => -1,
'lang' => function_exists( 'pll_default_language' ) ? pll_default_language() : '',
) );
$days = max( 1, (int) ga_setting( 'adopcion', 'review_days', 30 ) );
$issues = array(
'photo' => array( 'Sin foto principal', array() ),
'eu' => array( 'Sin versión en euskera', array() ),
'text' => array( 'Sin texto «Cómo es» (castellano o euskera)', array() ),
'species' => array( 'Sin especie (perro o gato)', array() ),
'process' => array( sprintf( 'En proceso desde hace más de %d días', $days ), array() ),
);
foreach ( $animals as $animal ) {
if ( 'adoptado' === ga_status( $animal->ID ) ) {
continue;
}
$eu = function_exists( 'pll_get_post' ) ? pll_get_post( $animal->ID, 'eu' ) : 0;
if ( ! has_post_thumbnail( $animal ) ) {
$issues['photo'][1][] = $animal;
}
if ( function_exists( 'pll_get_post' ) && ! $eu ) {
$issues['eu'][1][] = $animal;
}
if ( '' === ga_get( $animal->ID, 'caracter' ) || ( $eu && '' === ga_get( $eu, 'caracter' ) ) ) {
$issues['text'][1][] = $animal;
}
if ( ! has_term( '', 'especie', $animal ) ) {
$issues['species'][1][] = $animal;
}
$since = ga_get( $animal->ID, 'estado_desde' );
if ( 'proceso' === ga_status( $animal->ID ) && $since && strtotime( $since ) < strtotime( "-$days days" ) ) {
$issues['process'][1][] = $animal;
}
}
return array_filter( $issues, fn( $i ) => ! empty( $i[1] ) );
}
add_action( 'wp_dashboard_setup', function () {
if ( ! current_user_can( 'edit_posts' ) ) {
return;
}
wp_add_dashboard_widget( 'ga_review', 'Bilagun · Fichas por revisar', function () {
$issues = ga_review_issues();
if ( ! $issues ) {
echo '<p>✓ Todas las fichas están completas.</p>';
}
foreach ( $issues as [ $title, $animals ] ) {
printf( '<h4 style="margin:14px 0 6px">%s <span class="count">(%d)</span></h4><ul style="margin:0 0 0 1em;list-style:disc">', esc_html( $title ), count( $animals ) );
foreach ( array_slice( $animals, 0, 8 ) as $animal ) {
printf( '<li><a href="%s">%s</a></li>', esc_url( get_edit_post_link( $animal ) ), esc_html( get_the_title( $animal ) ) );
}
if ( count( $animals ) > 8 ) {
echo '<li>…</li>';
}
echo '</ul>';
}
$messages = (int) wp_count_posts( 'ga_mensaje' )->private;
printf(
'<p style="margin-top:16px;border-top:1px solid #eee;padding-top:10px"><a class="button" href="%s">Añadir animal</a> <a class="button" href="%s">Mensajes (%d)</a></p>',
esc_url( admin_url( 'post-new.php?post_type=animal' ) ),
esc_url( admin_url( 'edit.php?post_type=ga_mensaje' ) ),
$messages
);
} );
// Arriba del todo.
global $wp_meta_boxes;
$widget = $wp_meta_boxes['dashboard']['normal']['core']['ga_review'] ?? null;
if ( $widget ) {
unset( $wp_meta_boxes['dashboard']['normal']['core']['ga_review'] );
$wp_meta_boxes['dashboard']['normal']['high']['ga_review'] = $widget;
}
} );
/* -------------------------------------------------------------------------
* Listado de animales: acciones en bloque y destacar con un clic
* ---------------------------------------------------------------------- */
function ga_bulk_actions() {
return array(
'ga_disponible' => array( 'Estado: Disponible', 'estado', 'disponible' ),
'ga_proceso' => array( 'Estado: En proceso', 'estado', 'proceso' ),
'ga_adoptado' => array( 'Estado: Adoptado', 'estado', 'adoptado' ),
'ga_destacar' => array( 'Destacar en portada', 'destacado', '1' ),
'ga_no_destacar' => array( 'Quitar de destacados', 'destacado', '' ),
);
}
add_filter( 'bulk_actions-edit-animal', function ( $actions ) {
foreach ( ga_bulk_actions() as $key => [ $label ] ) {
$actions[ $key ] = $label;
}
return $actions;
} );
add_filter( 'handle_bulk_actions-edit-animal', function ( $redirect, $action, $ids ) {
$actions = ga_bulk_actions();
if ( ! isset( $actions[ $action ] ) ) {
return $redirect;
}
[ , $field, $value ] = $actions[ $action ];
$done = 0;
foreach ( $ids as $id ) {
if ( current_user_can( 'edit_post', $id ) ) {
ga_update_meta_all_languages( $id, "_ga_$field", $value );
++$done;
}
}
return add_query_arg( 'ga_bulk', $done, $redirect );
}, 10, 3 );
add_action( 'admin_notices', function () {
if ( isset( $_GET['ga_bulk'] ) && 'edit-animal' === ( get_current_screen()->id ?? '' ) ) {
printf( '<div class="notice notice-success is-dismissible"><p>%d animales actualizados (también su versión en el otro idioma).</p></div>', absint( $_GET['ga_bulk'] ) );
}
} );
/* Estrella clicable en la columna «★» */
add_action( 'manage_animal_posts_custom_column', function ( $col, $post_id ) {
if ( 'ga_destacado' !== $col || ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
$on = (bool) ga_get( $post_id, 'destacado' );
$url = wp_nonce_url( admin_url( 'admin-post.php?action=ga_toggle_featured&post=' . $post_id ), 'ga_toggle_featured_' . $post_id );
printf(
'<a href="%s" title="%s" style="font-size:18px;text-decoration:none;color:%s">%s</a>',
esc_url( $url ),
esc_attr( $on ? 'Quitar de destacados' : 'Destacar en portada' ),
$on ? '#bd8537' : '#c3c4c7',
$on ? '★' : '☆'
);
}, 20, 2 );
add_action( 'admin_post_ga_toggle_featured', function () {
$id = absint( $_GET['post'] ?? 0 );
check_admin_referer( 'ga_toggle_featured_' . $id );
if ( ! current_user_can( 'edit_post', $id ) ) {
wp_die( 'Sin permiso.' );
}
ga_update_meta_all_languages( $id, '_ga_destacado', ga_get( $id, 'destacado' ) ? '' : '1' );
wp_safe_redirect( wp_get_referer() ?: admin_url( 'edit.php?post_type=animal' ) );
exit;
} );
/* -------------------------------------------------------------------------
* Protección de datos: borrado automático de mensajes antiguos
* ---------------------------------------------------------------------- */
add_action( 'init', function () {
if ( ! wp_next_scheduled( 'ga_purge_messages' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'ga_purge_messages' );
}
} );
add_action( 'ga_purge_messages', 'ga_purge_messages' );
/** Borra los mensajes del formulario más antiguos que el plazo configurado (0 = nunca). */
function ga_purge_messages() {
$months = (int) ga_setting( 'adopcion', 'retention_months', 12 );
if ( $months <= 0 ) {
return 0;
}
$old = get_posts( array(
'post_type' => 'ga_mensaje',
'post_status' => 'any',
'numberposts' => 200,
'fields' => 'ids',
'date_query' => array( array( 'before' => "$months months ago" ) ),
) );
foreach ( $old as $id ) {
wp_delete_post( $id, true );
}
return count( $old );
}
/* -------------------------------------------------------------------------
* Seguridad: recordar la verificación en dos pasos (plugin Two Factor)
* ---------------------------------------------------------------------- */
add_action( 'admin_notices', function () {
if ( ! class_exists( 'Two_Factor_Core' ) || ! current_user_can( 'edit_posts' ) ) {
return;
}
$user_id = get_current_user_id();
if ( Two_Factor_Core::is_user_using_two_factor( $user_id ) || (int) get_user_meta( $user_id, 'ga_2fa_snooze', true ) > time() ) {
return;
}
printf(
'<div class="notice notice-warning"><p><strong>Protege tu cuenta:</strong> activa la verificación en dos pasos (un código de tu móvil además de la contraseña). Tarda dos minutos. <a class="button button-primary" href="%s">Activarla</a> <a href="%s">Recordármelo en 30 días</a></p></div>',
esc_url( admin_url( 'profile.php#two-factor-options' ) ),
esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=ga_2fa_snooze' ), 'ga_2fa_snooze' ) )
);
} );
add_action( 'admin_post_ga_2fa_snooze', function () {
check_admin_referer( 'ga_2fa_snooze' );
update_user_meta( get_current_user_id(), 'ga_2fa_snooze', time() + 30 * DAY_IN_SECONDS );
wp_safe_redirect( wp_get_referer() ?: admin_url() );
exit;
} );

View File

@@ -103,6 +103,7 @@ function ga_fields() {
'adoptado' => ga__( 'Adoptado', 'Adoptatuta' ),
), 'help' => 'Los adoptados salen solos del listado.' ),
'destacado' => array( 'shared' => true, 'label' => ga__( 'Destacado', 'Nabarmendua' ), 'type' => 'checkbox', 'help' => 'Aparece en la portada. Útil para quien lleva más tiempo esperando o necesita un hogar especial.' ),
'apadrinable' => array( 'shared' => true, 'label' => ga__( 'Busca padrino o madrina', 'Aitabitxi edo amabitxi bila' ), 'type' => 'checkbox', 'help' => 'Aparece en la sección «Apadrina» (si está activada en Personalizar).' ),
'sexo' => array( 'shared' => true, 'label' => ga__( 'Sexo', 'Sexua' ), 'type' => 'select', 'options' => array(
'macho' => ga__( 'Macho', 'Arra' ),
'hembra' => ga__( 'Hembra', 'Emea' ),
@@ -382,8 +383,6 @@ add_action( 'manage_animal_posts_custom_column', function ( $col, $post_id ) {
echo get_the_post_thumbnail( $post_id, array( 60, 60 ) );
} elseif ( 'ga_estado' === $col ) {
echo esc_html( ga_label( $post_id, 'estado' ) );
} elseif ( 'ga_destacado' === $col ) {
echo ga_get( $post_id, 'destacado' ) ? '★' : '';
}
}, 10, 2 );
@@ -436,7 +435,7 @@ add_action( 'pre_get_posts', function ( $q ) {
* @param array $args numero, especie (slugs), estado (lista o 'adoptado'), destacados (bool).
*/
function ga_query_animals( $args = array() ) {
$args = wp_parse_args( $args, array( 'numero' => 6, 'especie' => '', 'estado' => '', 'destacados' => false ) );
$args = wp_parse_args( $args, array( 'numero' => 6, 'especie' => '', 'estado' => '', 'destacados' => false, 'apadrinables' => false ) );
$query = array(
'post_type' => 'animal',
'posts_per_page' => (int) $args['numero'],
@@ -459,6 +458,9 @@ function ga_query_animals( $args = array() ) {
if ( $args['destacados'] ) {
$meta[] = array( 'key' => '_ga_destacado', 'value' => '1' );
}
if ( $args['apadrinables'] ) {
$meta[] = array( 'key' => '_ga_apadrinable', 'value' => '1' );
}
$query['meta_query'] = $meta;
$q = new WP_Query( $query );
@@ -474,8 +476,10 @@ function ga_query_animals( $args = array() ) {
/* [ga_animales numero="6" especie="perro" destacados="1" vista="carrusel"] */
add_shortcode( 'ga_animales', function ( $atts ) {
$atts = shortcode_atts( array( 'numero' => 6, 'especie' => '', 'estado' => '', 'destacados' => '', 'vista' => 'rejilla' ), $atts, 'ga_animales' );
$atts['destacados'] = in_array( $atts['destacados'], array( '1', 'si', 'true' ), true );
$atts = shortcode_atts( array( 'numero' => 6, 'especie' => '', 'estado' => '', 'destacados' => '', 'apadrinables' => '', 'vista' => 'rejilla' ), $atts, 'ga_animales' );
foreach ( array( 'destacados', 'apadrinables' ) as $flag ) {
$atts[ $flag ] = in_array( $atts[ $flag ], array( '1', 'si', 'true' ), true );
}
$q = ga_query_animals( $atts );
ob_start();
if ( $q->have_posts() ) {

View File

@@ -40,6 +40,7 @@ function ga_api_map() {
return array(
'status' => 'estado',
'featured' => 'destacado',
'sponsorable' => 'apadrinable',
'sex' => 'sexo',
'birthDate' => 'nacimiento',
'age' => 'edad',
@@ -73,6 +74,7 @@ function ga_animal_api_data( $post_id ) {
$data = array(
'status' => ga_api_to( 'estado', ga_status( $post_id ) ),
'featured' => (bool) ga_get( $post_id, 'destacado' ),
'sponsorable' => (bool) ga_get( $post_id, 'apadrinable' ),
'sex' => ga_api_to( 'sexo', ga_get( $post_id, 'sexo' ) ),
'birthDate' => $text( 'nacimiento' ),
'age' => '' === ga_get( $post_id, 'edad' ) ? null : (float) ga_get( $post_id, 'edad' ),
@@ -116,6 +118,7 @@ function ga_animal_api_update( array $profile, $post_id ) {
}
break;
case 'featured':
case 'sponsorable':
$value = $raw ? '1' : '';
break;
case 'age':
@@ -146,6 +149,18 @@ function ga_animal_api_update( array $profile, $post_id ) {
return true;
}
function ga_lines_of( $text ) {
return array_values( array_filter( array_map( 'trim', explode( "\n", (string) $text ) ) ) );
}
/** Default needs list (same as the theme registry), used when nothing has been saved yet. */
function ga_default_needs( $lang ) {
if ( function_exists( 'ga_customizer_registry' ) ) {
return ga_customizer_registry()['material']['fields']['list'][ $lang ] ?? '';
}
return '';
}
/** JSON schema of the profile (used by REST validation and the OpenAPI docs). */
function ga_animal_api_schema() {
$string_or_null = array( 'type' => array( 'string', 'null' ) );
@@ -157,6 +172,7 @@ function ga_animal_api_schema() {
'properties' => array(
'status' => array( 'type' => 'string', 'enum' => array_values( ga_api_enums()['estado'] ), 'description' => 'Adopted animals are hidden from public listings.' ),
'featured' => array( 'type' => 'boolean', 'description' => 'Shown on the home page.' ),
'sponsorable' => array( 'type' => 'boolean', 'description' => 'Looking for a sponsor.' ),
'sex' => $enum( 'sexo' ),
'birthDate' => $string_or_null + array( 'format' => 'date' ),
'age' => array( 'type' => array( 'number', 'null' ), 'description' => 'Approximate age, used when birthDate is unknown.' ),
@@ -177,6 +193,79 @@ function ga_animal_api_schema() {
}
add_action( 'rest_api_init', function () {
// Language of the animal and link to its translation (write), e.g. {"language":"eu","translation_of":123}.
register_rest_field( 'animal', 'language', array(
'get_callback' => fn( $post ) => function_exists( 'pll_get_post_language' ) ? pll_get_post_language( $post['id'] ) : 'es',
'update_callback' => function ( $value, $post ) {
if ( function_exists( 'pll_set_post_language' ) ) {
pll_set_post_language( $post->ID, $value );
}
return true;
},
'schema' => array( 'type' => 'string', 'enum' => array( 'es', 'eu' ), 'description' => 'Language of this translation.', 'context' => array( 'view', 'edit' ) ),
) );
register_rest_field( 'animal', 'translation_of', array(
'get_callback' => function ( $post ) {
$translations = function_exists( 'pll_get_post_translations' ) ? pll_get_post_translations( $post['id'] ) : array();
unset( $translations[ pll_get_post_language( $post['id'] ) ?: '' ] );
return $translations ? (int) reset( $translations ) : null;
},
'update_callback' => function ( $value, $post ) {
if ( ! $value || ! function_exists( 'pll_save_post_translations' ) || 'animal' !== get_post_type( $value ) ) {
return true;
}
if ( ! current_user_can( 'edit_post', $value ) ) {
return new WP_Error( 'rest_forbidden', 'Cannot edit the linked animal.', array( 'status' => 403 ) );
}
$translations = pll_get_post_translations( $value );
$translations[ pll_get_post_language( $post->ID ) ] = $post->ID;
pll_save_post_translations( $translations );
// The new translation inherits the shared data (status, dates, photos…).
foreach ( ga_fields() as $key => $field ) {
if ( ! empty( $field['shared'] ) && '' === ga_get( $post->ID, $key ) && '' !== ga_get( $value, $key ) ) {
update_post_meta( $post->ID, "_ga_$key", ga_get( $value, $key ) );
}
}
if ( ! has_post_thumbnail( $post->ID ) && has_post_thumbnail( $value ) ) {
set_post_thumbnail( $post->ID, get_post_thumbnail_id( $value ) );
}
return true;
},
'schema' => array( 'type' => array( 'integer', 'null' ), 'description' => 'ID of the same animal in the other language.', 'context' => array( 'view', 'edit' ) ),
) );
// Material needs list (Personalizar → Ayuda material), per language.
register_rest_route( 'bilagun/v1', '/needs', array(
array(
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => fn() => array(
'es' => ga_lines_of( get_theme_mod( 'ga_material_list', ga_default_needs( 'es' ) ) ),
'eu' => ga_lines_of( get_theme_mod( 'ga_material_list_eu', ga_default_needs( 'eu' ) ) ),
),
),
array(
'methods' => 'PUT',
'permission_callback' => fn() => current_user_can( 'edit_others_posts' ),
'args' => array(
'es' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
'eu' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
),
'callback' => function ( WP_REST_Request $req ) {
foreach ( array( 'es' => 'ga_material_list', 'eu' => 'ga_material_list_eu' ) as $lang => $mod ) {
if ( null !== $req->get_param( $lang ) ) {
$items = array_filter( array_map( 'sanitize_text_field', (array) $req->get_param( $lang ) ) );
set_theme_mod( $mod, implode( "\n", $items ) );
}
}
return array(
'es' => ga_lines_of( get_theme_mod( 'ga_material_list', '' ) ),
'eu' => ga_lines_of( get_theme_mod( 'ga_material_list_eu', '' ) ),
);
},
),
) );
register_rest_field( 'animal', 'profile', array(
'get_callback' => fn( $post ) => ga_animal_api_data( $post['id'] ),
'update_callback' => fn( $value, $post ) => ga_animal_api_update( (array) $value, $post->ID ),
@@ -211,6 +300,7 @@ add_action( 'graphql_register_types', function () {
'fields' => array(
'status' => array( 'type' => 'AnimalStatusEnum' ),
'featured' => array( 'type' => 'Boolean' ),
'sponsorable' => array( 'type' => 'Boolean' ),
'sex' => array( 'type' => 'AnimalSexEnum' ),
'birthDate' => array( 'type' => 'String' ),
'age' => array( 'type' => 'Float' ),

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -9,9 +9,11 @@ defined( 'ABSPATH' ) || exit;
function ga_contact_reasons() {
return array(
'adopcion' => ga__( 'Adopción', 'Adopzioa' ),
'visita' => ga__( 'Pedir cita para una visita', 'Bisita baterako hitzordua eskatu' ),
'acogida' => ga__( 'Casa de acogida', 'Harrera-etxea' ),
'voluntario' => ga__( 'Voluntariado', 'Boluntariotza' ),
'material' => ga__( 'Ayuda material', 'Laguntza materiala' ),
'apadrinar' => ga__( 'Apadrinamiento', 'Apadrinatzea' ),
'otro' => ga__( 'Otro', 'Beste bat' ),
);
}
@@ -74,6 +76,11 @@ add_shortcode( 'ga_contacto', function ( $atts ) {
</label>
<?php endif; ?>
</div>
<?php if ( ! $is_animal ) : ?>
<label class="ga-form__visit"<?php echo 'visita' === $preselect ? '' : ' hidden'; ?>><?php ga_e( '¿Qué día y hora te vendría bien?', 'Zein egun eta ordu komeni zaizu?' ); ?>
<input type="text" name="preferencia" placeholder="<?php echo esc_attr( ga__( 'Por ejemplo: martes o jueves por la tarde', 'Adibidez: asteartea edo osteguna arratsaldez' ) ); ?>">
</label>
<?php endif; ?>
<?php if ( $animal_id && ! $is_animal ) : ?>
<p class="ga-form__animal"><?php echo esc_html( ga__( 'Animal: ', 'Animalia: ' ) . get_the_title( $animal_id ) ); ?></p>
<?php endif; ?>
@@ -124,6 +131,7 @@ function ga_handle_contact() {
'telefono' => sanitize_text_field( wp_unslash( $_POST['telefono'] ?? '' ) ),
'motivo' => $reasons[ $reason ] ?? $reasons['otro'],
'mensaje' => sanitize_textarea_field( wp_unslash( $_POST['mensaje'] ?? '' ) ),
'visita' => 'visita' === $reason ? sanitize_text_field( wp_unslash( $_POST['preferencia'] ?? '' ) ) : '',
'idioma' => 'eu' === ( $_POST['idioma'] ?? '' ) ? 'Euskera' : 'Castellano',
);
$message_required = ! $animal;
@@ -135,8 +143,9 @@ function ga_handle_contact() {
? sprintf( '[Web] Quiero conocer a %s – %s', $animal, $data['nombre'] )
: sprintf( '[Web] %s – %s', $data['motivo'], $data['nombre'] );
$body = sprintf(
"Nombre: %s\nEmail: %s\nTeléfono: %s\nMotivo: %s\nAnimal: %s\nIdioma: %s\n\n%s",
"Nombre: %s\nEmail: %s\nTeléfono: %s\nMotivo: %s%s\nAnimal: %s\nIdioma: %s\n\n%s",
$data['nombre'], $data['email'], $data['telefono'] ?: '-', $data['motivo'],
$data['visita'] ? "\nPreferencia de cita: " . $data['visita'] : '',
$animal ? $animal . ' — ' . get_permalink( $animal_id ) : '-',
$data['idioma'], $data['mensaje'] ?: '(sin mensaje)'
);

View File

@@ -0,0 +1,124 @@
<?php
/**
* Cuestionario de adopción online [ga_cuestionario]. Las preguntas se editan en
* Personalizar → Adopción y colabora → Cuestionario. La página no se indexa.
*/
defined( 'ABSPATH' ) || exit;
function ga_questionnaire_questions() {
return array_values( array_filter( array_map( 'trim', explode( "\n", (string) ga_setting( 'cuestionario', 'questions', '' ) ) ) ) );
}
add_shortcode( 'ga_cuestionario', function () {
$status = sanitize_key( $_GET['cuestionario'] ?? '' );
$animal_id = absint( $_GET['adoptar'] ?? 0 );
$animal = $animal_id && 'animal' === get_post_type( $animal_id ) ? get_the_title( $animal_id ) : '';
$questions = ga_questionnaire_questions();
$privacy = ga_page_url( 'politica-de-privacidad' );
ob_start();
if ( 'ok' === $status ) {
echo '<div class="ga-notice ga-notice--ok" role="status" data-ga-clear-draft>' . esc_html( ga_setting( 'cuestionario', 'confirm', '' ) ) . '</div>';
return ob_get_clean();
}
if ( 'error' === $status ) {
echo '<div class="ga-notice ga-notice--error" role="alert">' . esc_html( ga__( 'Falta algún dato obligatorio. Revisa el formulario: lo que habías escrito se ha guardado.', 'Derrigorrezko daturen bat falta da. Berrikusi formularioa: idatzitakoa gorde da.' ) ) . '</div>';
}
?>
<form class="ga-form ga-questionnaire" method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" data-ga-draft="ga-cuestionario-<?php echo esc_attr( ga_lang() ); ?>">
<input type="hidden" name="action" value="ga_cuestionario">
<input type="hidden" name="idioma" value="<?php echo esc_attr( ga_lang() ); ?>">
<input type="hidden" name="redirect" value="<?php echo esc_url( get_permalink() ); ?>">
<?php wp_nonce_field( 'ga_cuestionario', 'ga_nonce' ); ?>
<div class="ga-hp" aria-hidden="true"><label>No rellenar <input type="text" name="web" tabindex="-1" autocomplete="off"></label></div>
<fieldset>
<legend><?php ga_e( 'Tus datos', 'Zure datuak' ); ?></legend>
<div class="ga-form__row">
<label><?php ga_e( 'Nombre y apellidos', 'Izen-abizenak' ); ?> *<input type="text" name="nombre" required autocomplete="name"></label>
<label><?php ga_e( 'Correo electrónico', 'Posta elektronikoa' ); ?> *<input type="email" name="email" required autocomplete="email"></label>
</div>
<div class="ga-form__row">
<label><?php ga_e( 'Teléfono', 'Telefonoa' ); ?> *<input type="tel" name="telefono" required autocomplete="tel"></label>
<label><?php ga_e( 'Localidad', 'Herria' ); ?><input type="text" name="localidad" autocomplete="address-level2"></label>
</div>
<label><?php ga_e( 'Animal por el que te interesas', 'Interesatzen zaizun animalia' ); ?><input type="text" name="animal" value="<?php echo esc_attr( $animal ); ?>"></label>
</fieldset>
<fieldset>
<legend><?php ga_e( 'Sobre ti y tu hogar', 'Zuri eta zure etxeari buruz' ); ?></legend>
<?php foreach ( $questions as $i => $question ) : ?>
<label><?php echo esc_html( $question ); ?> *
<textarea name="respuestas[<?php echo (int) $i; ?>]" rows="3" required></textarea>
</label>
<?php endforeach; ?>
</fieldset>
<label class="ga-form__check">
<input type="checkbox" name="privacidad" value="1" required>
<span><?php printf( wp_kses( ga__( 'He leído y acepto la <a href="%s" target="_blank">política de privacidad</a>. *', '<a href="%s" target="_blank">Pribatutasun-politika</a> irakurri eta onartzen dut. *' ), array( 'a' => array( 'href' => array(), 'target' => array() ) ) ), esc_url( $privacy ) ); ?></span>
</label>
<p class="ga-form__hint"><?php ga_e( 'Lo que escribes se guarda en este navegador hasta que lo envíes.', 'Idazten duzuna nabigatzaile honetan gordetzen da bidali arte.' ); ?></p>
<button type="submit" class="ga-btn"><?php ga_e( 'Enviar cuestionario', 'Galdetegia bidali' ); ?></button>
</form>
<?php
return ob_get_clean();
} );
add_action( 'admin_post_nopriv_ga_cuestionario', 'ga_handle_questionnaire' );
add_action( 'admin_post_ga_cuestionario', 'ga_handle_questionnaire' );
function ga_handle_questionnaire() {
$redirect = wp_validate_redirect( esc_url_raw( wp_unslash( $_POST['redirect'] ?? '' ) ), home_url( '/' ) );
$go = function ( $status ) use ( $redirect ) {
wp_safe_redirect( add_query_arg( 'cuestionario', $status, $redirect ) );
exit;
};
if ( ! isset( $_POST['ga_nonce'] ) || ! wp_verify_nonce( $_POST['ga_nonce'], 'ga_cuestionario' ) ) {
$go( 'error' );
}
if ( ! empty( $_POST['web'] ) ) {
$go( 'ok' );
}
$lang = 'eu' === ( $_POST['idioma'] ?? '' ) ? 'eu' : 'es';
$questions = ga_with_lang( $lang, 'ga_questionnaire_questions' );
$answers = array_map( fn( $a ) => sanitize_textarea_field( wp_unslash( $a ) ), (array) ( $_POST['respuestas'] ?? array() ) );
$data = array(
'nombre' => sanitize_text_field( wp_unslash( $_POST['nombre'] ?? '' ) ),
'email' => sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ),
'telefono' => sanitize_text_field( wp_unslash( $_POST['telefono'] ?? '' ) ),
'localidad' => sanitize_text_field( wp_unslash( $_POST['localidad'] ?? '' ) ),
'animal' => sanitize_text_field( wp_unslash( $_POST['animal'] ?? '' ) ),
);
if ( ! $data['nombre'] || ! is_email( $data['email'] ) || ! $data['telefono'] || empty( $_POST['privacidad'] ) ) {
$go( 'error' );
}
foreach ( array_keys( $questions ) as $i ) {
if ( '' === trim( $answers[ $i ] ?? '' ) ) {
$go( 'error' );
}
}
$subject = sprintf( '[Web] Cuestionario de adopción – %s%s', $data['nombre'], $data['animal'] ? ' (' . $data['animal'] . ')' : '' );
$body = sprintf(
"Nombre: %s\nEmail: %s\nTeléfono: %s\nLocalidad: %s\nAnimal: %s\nIdioma: %s\n",
$data['nombre'], $data['email'], $data['telefono'], $data['localidad'] ?: '-', $data['animal'] ?: '-', 'eu' === $lang ? 'Euskera' : 'Castellano'
);
foreach ( $questions as $i => $question ) {
$body .= "\n" . $question . "\n" . ( $answers[ $i ] ?? '' ) . "\n";
}
wp_insert_post( array( 'post_type' => 'ga_mensaje', 'post_status' => 'private', 'post_title' => $subject, 'post_content' => $body ) );
wp_mail( ga_contact_email(), $subject, $body, array( 'Reply-To: ' . $data['nombre'] . ' <' . $data['email'] . '>' ) );
$go( 'ok' );
}
/* La página del cuestionario no aparece en buscadores (el enlace lo envía el equipo). */
add_filter( 'wp_robots', function ( $robots ) {
if ( is_page( array( 'cuestionario-adopcion', 'adopzio-galdetegia' ) ) ) {
$robots['noindex'] = true;
$robots['nofollow'] = true;
}
return $robots;
} );

View File

@@ -0,0 +1,50 @@
<?php
/**
* SEO local: datos estructurados schema.org de la protectora (AnimalShelter) en la portada.
*/
defined( 'ABSPATH' ) || exit;
/** Dirección estructurada a partir de «Calle, nº · CP Localidad (Provincia)». */
function ga_postal_address() {
$raw = (string) ga_info( 'direccion' );
$address = array( '@type' => 'PostalAddress', 'addressCountry' => 'ES' );
if ( preg_match( '/^(.+?)\s*·\s*(\d{5})\s+(.+?)(?:\s*\((.+)\))?$/u', $raw, $m ) ) {
$address += array_filter( array(
'streetAddress' => trim( $m[1] ),
'postalCode' => $m[2],
'addressLocality' => trim( $m[3] ),
'addressRegion' => $m[4] ?? '',
) );
} else {
$address['streetAddress'] = $raw;
}
return $address;
}
add_action( 'wp_head', function () {
if ( ! is_front_page() ) {
return;
}
$hours = array_filter( array_map( 'trim', explode( ',', (string) ga_setting( 'contact', 'hours_schema', 'Mo-Fr 10:00-13:00, Mo-Fr 14:00-19:00' ) ) ) );
$data = array_filter( array(
'@context' => 'https://schema.org',
'@type' => 'AnimalShelter',
'@id' => home_url( '/#protectora' ),
'name' => 'Bilagun · Gure Ametsa Bi Lagun',
'alternateName'=> array( 'Bilagun', 'Gure Ametsa Bi Lagun' ),
'url' => home_url( '/' ),
'logo' => content_url( 'mu-plugins/gure-ametsa/assets/logo-mark.png' ),
'image' => content_url( 'mu-plugins/gure-ametsa/assets/logo-mark.png' ),
'description' => get_bloginfo( 'description' ),
'telephone' => ga_info( 'telefono' ) ? '+34 ' . ga_info( 'telefono' ) : '',
'email' => ga_info( 'email' ),
'address' => ga_postal_address(),
'openingHours' => $hours,
'hasMap' => ga_info( 'maps' ),
'areaServed' => array( 'Hondarribia', 'Irun' ),
'sameAs' => array_values( array_filter( array( ga_info( 'instagram' ), ga_info( 'facebook' ), ga_info( 'tiktok' ), ga_info( 'youtube' ) ) ) ),
'knowsLanguage'=> array( 'es', 'eu' ),
) );
echo '<script type="application/ld+json">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . "</script>\n";
}, 20 );

View File

@@ -0,0 +1,317 @@
<?php
/**
* Compartir: imagen automática para redes (1200×630) y carteles imprimibles con QR.
*/
defined( 'ABSPATH' ) || exit;
define( 'GA_SHARE_VERSION', 2 );
define( 'GA_QR_JS', 'https://cdn.jsdelivr.net/npm/qrcode-generator@2.0.4/dist/qrcode.js' );
define( 'GA_QR_SRI', 'sha384-e9EFD6BGC90bkW9aDV5xbbBfzwN7G8YImHao2lfLVKV/hPB0E0go+H3I64h7oHtA' );
/* -------------------------------------------------------------------------
* Imagen para redes
* ---------------------------------------------------------------------- */
/** Ruta y URL de la imagen de un animal (el nombre cambia si cambia la foto, el nombre o el diseño). */
function ga_share_image_paths( $post_id ) {
$uploads = wp_upload_dir();
$lang = function_exists( 'pll_get_post_language' ) ? pll_get_post_language( $post_id ) : 'es';
$hash = substr( md5( implode( '|', array(
GA_SHARE_VERSION, get_the_title( $post_id ), get_post_thumbnail_id( $post_id ), $lang, ga_status( $post_id ),
function_exists( 'ga_color' ) ? ga_color( 'primary' ) . ga_color( 'accent' ) : '',
) ) ), 0, 10 );
$name = "animal-$post_id-$hash.jpg";
return array(
'dir' => trailingslashit( $uploads['basedir'] ) . 'ga-share',
'file' => trailingslashit( $uploads['basedir'] ) . "ga-share/$name",
'url' => trailingslashit( $uploads['baseurl'] ) . "ga-share/$name",
'glob' => trailingslashit( $uploads['basedir'] ) . "ga-share/animal-$post_id-*.jpg",
);
}
/** Devuelve la URL de la imagen para redes, generándola si hace falta. */
function ga_share_image_url( $post_id ) {
$p = ga_share_image_paths( $post_id );
if ( ! file_exists( $p['file'] ) && ! ga_generate_share_image( $post_id ) ) {
return '';
}
return $p['url'];
}
function ga_hex_color( $img, $hex ) {
$hex = ltrim( $hex, '#' );
return imagecolorallocate( $img, hexdec( substr( $hex, 0, 2 ) ), hexdec( substr( $hex, 2, 2 ) ), hexdec( substr( $hex, 4, 2 ) ) );
}
/** Carga una imagen de la biblioteca (JPEG, PNG o WebP) como recurso GD. */
function ga_gd_load( $path ) {
$type = wp_check_filetype( $path )['type'] ?? '';
return match ( $type ) {
'image/jpeg' => @imagecreatefromjpeg( $path ),
'image/png' => @imagecreatefrompng( $path ),
'image/webp' => function_exists( 'imagecreatefromwebp' ) ? @imagecreatefromwebp( $path ) : false,
default => false,
};
}
/** Parte un texto en líneas que quepan en $width con la fuente y tamaño dados. */
function ga_gd_wrap( $text, $font, $size, $width ) {
$lines = array( '' );
foreach ( preg_split( '/\s+/u', $text ) as $word ) {
$try = trim( end( $lines ) . ' ' . $word );
$box = imagettfbbox( $size, 0, $font, $try );
if ( $box[2] - $box[0] > $width && '' !== end( $lines ) ) {
$lines[] = $word;
} else {
$lines[ count( $lines ) - 1 ] = $try;
}
}
return $lines;
}
function ga_generate_share_image( $post_id ) {
if ( ! function_exists( 'imagettftext' ) ) {
return false;
}
$p = ga_share_image_paths( $post_id );
wp_mkdir_p( $p['dir'] );
foreach ( glob( $p['glob'] ) ?: array() as $old ) {
@unlink( $old ); // Versiones anteriores de este animal.
}
$w = 1200;
$h = 630;
$split = 630; // Ancho de la foto (cuadrada, a la izquierda).
$img = imagecreatetruecolor( $w, $h );
$primary = function_exists( 'ga_color' ) ? ga_color( 'primary' ) : '#401f0c';
$accent = function_exists( 'ga_color' ) ? ga_color( 'accent' ) : '#bd8537';
$surface = function_exists( 'ga_color' ) ? ga_color( 'surface' ) : '#f3ecdc';
$bg = ga_hex_color( $img, $primary );
imagefilledrectangle( $img, 0, 0, $w, $h, $bg );
// Foto (recorte cuadrado centrado) o, si no hay, el símbolo del logo sobre fondo claro.
$photo = get_post_thumbnail_id( $post_id ) ? get_attached_file( get_post_thumbnail_id( $post_id ) ) : '';
$src = $photo ? ga_gd_load( $photo ) : false;
if ( $src ) {
$sw = imagesx( $src );
$sh = imagesy( $src );
$side = min( $sw, $sh );
imagecopyresampled( $img, $src, 0, 0, (int) ( ( $sw - $side ) / 2 ), (int) ( ( $sh - $side ) / 3 ), $split, $h, $side, $side );
imagedestroy( $src );
} else {
imagefilledrectangle( $img, 0, 0, $split, $h, ga_hex_color( $img, $surface ) );
$mark = @imagecreatefrompng( __DIR__ . '/assets/logo-mark.png' );
if ( $mark ) {
$mw = 380;
$mh = (int) ( imagesy( $mark ) * $mw / imagesx( $mark ) );
imagecopyresampled( $img, $mark, (int) ( ( $split - $mw ) / 2 ), (int) ( ( $h - $mh ) / 2 ), 0, 0, $mw, $mh, imagesx( $mark ), imagesy( $mark ) );
imagedestroy( $mark );
}
}
// Texto a la derecha.
$serif = __DIR__ . '/assets/fraunces-800.ttf';
$sans = __DIR__ . '/assets/karla-600.ttf';
$white = imagecolorallocate( $img, 255, 255, 255 );
$gold = ga_hex_color( $img, $accent );
$soft = ga_hex_color( $img, $surface );
$x = $split + 56;
$max = $w - $x - 50;
$lang = function_exists( 'pll_get_post_language' ) ? pll_get_post_language( $post_id ) : 'es';
$eu = 'eu' === $lang;
imagettftext( $img, 17, 0, $x, 92, $gold, $sans, $eu ? 'BILAGUN · ADOPZIOA' : 'BILAGUN · ADOPCIÓN' );
$name = get_the_title( $post_id );
$size = 64;
do {
$lines = ga_gd_wrap( $name, $serif, $size, $max );
$size -= 4;
} while ( ( count( $lines ) > 2 ) && $size > 30 );
$size += 4;
$y = 190;
foreach ( array_slice( $lines, 0, 3 ) as $line ) {
imagettftext( $img, $size, 0, $x, $y, $white, $serif, $line );
$y += (int) ( $size * 1.25 );
}
$status = ga_status( $post_id );
$tag = 'proceso' === $status ? ( $eu ? 'Prozesuan' : 'En proceso de adopción' ) : ( $eu ? 'Familia bila' : 'Busca familia' );
imagettftext( $img, 28, 0, $x, $y + 20, $soft, $sans, $tag );
$host = wp_parse_url( home_url(), PHP_URL_HOST );
imagettftext( $img, 18, 0, $x, $h - 60, $gold, $sans, $host );
$mark = @imagecreatefrompng( __DIR__ . '/assets/logo-mark.png' );
if ( $mark ) {
$mw = 96;
$mh = (int) ( imagesy( $mark ) * $mw / imagesx( $mark ) );
imagecopyresampled( $img, $mark, $w - $mw - 44, $h - $mh - 44, 0, 0, $mw, $mh, imagesx( $mark ), imagesy( $mark ) );
imagedestroy( $mark );
}
$ok = imagejpeg( $img, $p['file'], 86 );
imagedestroy( $img );
return $ok;
}
/* Se regenera al guardar (si cambió la foto o el nombre, cambia el nombre de archivo). */
add_action( 'save_post_animal', function ( $post_id ) {
if ( ! wp_is_post_revision( $post_id ) && 'publish' === get_post_status( $post_id ) ) {
ga_share_image_url( $post_id );
}
}, 30 );
/* Etiquetas Open Graph / Twitter con la imagen generada */
add_action( 'wpseo_add_opengraph_images', function ( $container ) {
if ( is_singular( 'animal' ) && ( $url = ga_share_image_url( get_queried_object_id() ) ) ) {
$container->add_image( array( 'url' => $url, 'width' => 1200, 'height' => 630, 'type' => 'image/jpeg' ) );
}
} );
add_filter( 'wpseo_opengraph_image', fn( $url ) => is_singular( 'animal' ) ? ( ga_share_image_url( get_queried_object_id() ) ?: $url ) : $url );
add_filter( 'wpseo_twitter_image', fn( $url ) => is_singular( 'animal' ) ? ( ga_share_image_url( get_queried_object_id() ) ?: $url ) : $url );
add_filter( 'wpseo_opengraph_image_width', fn( $v ) => is_singular( 'animal' ) ? 1200 : $v );
add_filter( 'wpseo_opengraph_image_height', fn( $v ) => is_singular( 'animal' ) ? 630 : $v );
add_filter( 'wpseo_twitter_card_type', fn( $v ) => is_singular( 'animal' ) ? 'summary_large_image' : $v );
/* Sin Yoast: etiquetas mínimas propias */
add_action( 'wp_head', function () {
if ( defined( 'WPSEO_VERSION' ) || ! is_singular( 'animal' ) ) {
return;
}
$id = get_queried_object_id();
printf( '<meta property="og:title" content="%s">' . "\n", esc_attr( get_the_title( $id ) ) );
printf( '<meta property="og:url" content="%s">' . "\n", esc_url( get_permalink( $id ) ) );
printf( '<meta property="og:image" content="%s">' . "\n", esc_url( ga_share_image_url( $id ) ) );
echo '<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630"><meta name="twitter:card" content="summary_large_image">' . "\n";
}, 5 );
/* En la ficha del panel: vista previa y descarga (para publicarla en Instagram, etc.) */
add_action( 'add_meta_boxes_animal', function ( $post ) {
add_meta_box( 'ga_share', 'Compartir', function ( $post ) {
if ( 'publish' !== $post->post_status ) {
echo '<p class="description">Disponible cuando el animal esté publicado.</p>';
return;
}
$url = ga_share_image_url( $post->ID );
if ( $url ) {
printf( '<p><img src="%1$s" alt="" style="width:100%%;height:auto;border-radius:4px"></p><p><a class="button" href="%1$s" download>Descargar imagen para redes</a></p>', esc_url( $url ) );
}
printf(
'<p><a class="button" href="%s" target="_blank">Imprimir cartel con QR</a></p><p class="description">La imagen se crea sola con la foto principal y el nombre, y es la que aparece al compartir el enlace en WhatsApp o redes.</p>',
esc_url( ga_poster_url( array( $post->ID ) ) )
);
}, 'animal', 'side', 'default' );
} );
/* -------------------------------------------------------------------------
* Carteles con QR (para las jaulas, jornadas de adopción, etc.)
* ---------------------------------------------------------------------- */
function ga_poster_url( array $ids ) {
return wp_nonce_url( admin_url( 'admin-post.php?action=ga_poster&ids=' . implode( ',', array_map( 'absint', $ids ) ) ), 'ga_poster' );
}
add_filter( 'post_row_actions', function ( $actions, $post ) {
if ( 'animal' === $post->post_type && 'publish' === $post->post_status ) {
$actions['ga_poster'] = sprintf( '<a href="%s" target="_blank">Cartel QR</a>', esc_url( ga_poster_url( array( $post->ID ) ) ) );
}
return $actions;
}, 10, 2 );
add_filter( 'bulk_actions-edit-animal', function ( $actions ) {
$actions['ga_posters'] = 'Imprimir carteles con QR';
return $actions;
} );
add_filter( 'handle_bulk_actions-edit-animal', function ( $redirect, $action, $ids ) {
return 'ga_posters' === $action ? ga_poster_url( $ids ) : $redirect;
}, 10, 3 );
add_action( 'admin_post_ga_poster', function () {
check_admin_referer( 'ga_poster' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( 'Sin permiso.' );
}
$ids = array_filter( array_map( 'absint', explode( ',', (string) ( $_GET['ids'] ?? '' ) ) ) );
$ids = array_filter( $ids, fn( $id ) => 'animal' === get_post_type( $id ) && 'publish' === get_post_status( $id ) );
if ( ! $ids ) {
wp_die( 'No hay animales publicados seleccionados.' );
}
$primary = function_exists( 'ga_color' ) ? ga_color( 'primary' ) : '#401f0c';
$accent = function_exists( 'ga_color' ) ? ga_color( 'accent' ) : '#bd8537';
$logo = function_exists( 'ga_logo_svg' ) ? ga_logo_svg( 'full' ) : '';
$fonts = content_url( 'themes/gure-ametsa/assets/fonts/' );
?>
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Carteles · Bilagun</title>
<style>
@font-face { font-family: Fraunces; src: url("<?php echo esc_url( $fonts . 'fraunces.woff2' ); ?>") format("woff2"); font-weight: 100 900; }
@font-face { font-family: Karla; src: url("<?php echo esc_url( $fonts . 'karla.woff2' ); ?>") format("woff2"); font-weight: 100 900; }
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body { margin: 0; font-family: Karla, sans-serif; color: <?php echo esc_html( $primary ); ?>; background: #eee; }
.bar { padding: 12px 20px; background: <?php echo esc_html( $primary ); ?>; color: #fff; display: flex; gap: 16px; align-items: center; }
.bar button { font: 700 15px Karla, sans-serif; padding: 8px 18px; border-radius: 999px; border: 0; background: <?php echo esc_html( $accent ); ?>; cursor: pointer; }
.poster { width: 210mm; height: 297mm; margin: 10mm auto; background: #fff; padding: 16mm; display: flex; flex-direction: column; page-break-after: always; }
.photo { flex: 1; min-height: 0; border-radius: 6mm; overflow: hidden; background: #f3ecdc; display: grid; place-items: center; }
.photo img { width: 100%; height: 100%; object-fit: cover; }
.photo svg { width: 60%; height: auto; }
h1 { font: 800 30mm/1 Fraunces, serif; margin: 9mm 0 3mm; letter-spacing: -0.02em; }
.lead { font-size: 6mm; margin: 0 0 6mm; }
.foot { display: flex; align-items: center; gap: 8mm; border-top: 0.6mm solid #e6d9b7; padding-top: 7mm; }
.qr svg { width: 48mm; height: 48mm; display: block; }
.cta { flex: 1; }
.cta strong { display: block; font: 800 9mm/1.1 Fraunces, serif; }
.cta span { display: block; font-size: 5mm; margin-top: 2mm; color: <?php echo esc_html( $accent ); ?>; word-break: break-all; }
.logo svg { width: 34mm; height: auto; }
@media print { body { background: #fff; } .bar { display: none; } .poster { margin: 0; } }
</style>
</head>
<body>
<div class="bar"><button onclick="window.print()">Imprimir</button> <?php echo count( $ids ); ?> cartel(es) · A4</div>
<?php foreach ( $ids as $id ) : ?>
<?php
$photo = get_the_post_thumbnail_url( $id, 'large' );
$lead = has_excerpt( $id ) ? get_the_excerpt( $id ) : '';
?>
<section class="poster">
<div class="photo">
<?php if ( $photo ) : ?>
<img src="<?php echo esc_url( $photo ); ?>" alt="">
<?php else : ?>
<?php echo function_exists( 'ga_logo_svg' ) ? ga_logo_svg( 'mark' ) : ''; ?>
<?php endif; ?>
</div>
<h1><?php echo esc_html( get_the_title( $id ) ); ?></h1>
<?php if ( $lead ) : ?>
<p class="lead"><?php echo esc_html( $lead ); ?></p>
<?php endif; ?>
<div class="foot">
<div class="qr" data-qr="<?php echo esc_url( get_permalink( $id ) ); ?>"></div>
<div class="cta">
<strong>¿Quieres conocerme?<br>Ezagutu nahi nauzu?</strong>
<span><?php echo esc_html( preg_replace( '#^https?://#', '', get_permalink( $id ) ) ); ?></span>
</div>
<div class="logo"><?php echo $logo; // SVG del tema. ?></div>
</div>
</section>
<?php endforeach; ?>
<script src="<?php echo esc_url( GA_QR_JS ); ?>" integrity="<?php echo esc_attr( GA_QR_SRI ); ?>" crossorigin="anonymous"></script>
<script>
document.querySelectorAll( '[data-qr]' ).forEach( function ( el ) {
var qr = qrcode( 0, 'M' );
qr.addData( el.dataset.qr );
qr.make();
el.innerHTML = qr.createSvgTag( { cellSize: 4, margin: 0, scalable: true } );
} );
</script>
</body>
</html>
<?php
exit;
} );

View File

@@ -0,0 +1,175 @@
<?php
/**
* Finales felices (historias enviadas por las familias, con revisión) y apadrinamiento.
*/
defined( 'ABSPATH' ) || exit;
/* -------------------------------------------------------------------------
* Historias: tipo de contenido
* ---------------------------------------------------------------------- */
add_action( 'init', function () {
register_post_type( 'ga_historia', array(
'labels' => array(
'name' => 'Finales felices',
'singular_name' => 'Final feliz',
'add_new_item' => 'Añadir final feliz',
'edit_item' => 'Revisar final feliz',
'all_items' => 'Finales felices',
'not_found' => 'Aún no hay historias',
'featured_image' => 'Foto',
),
'public' => false,
'show_ui' => true,
'show_in_menu' => 'edit.php?post_type=animal',
'supports' => array( 'title', 'editor', 'thumbnail' ),
'map_meta_cap' => true,
) );
} );
/* Contador de historias pendientes en el menú */
add_action( 'admin_menu', function () {
global $submenu;
$pending = (int) wp_count_posts( 'ga_historia' )->pending;
foreach ( $submenu['edit.php?post_type=animal'] ?? array() as $i => $item ) {
if ( 'edit.php?post_type=ga_historia' === $item[2] && $pending ) {
$submenu['edit.php?post_type=animal'][ $i ][0] .= ' <span class="awaiting-mod">' . $pending . '</span>';
}
}
}, 99 );
/** Historias publicadas en el idioma actual (o en cualquiera si no hay). */
function ga_query_stories( $number = 6 ) {
return new WP_Query( array(
'post_type' => 'ga_historia',
'post_status' => 'publish',
'posts_per_page' => $number,
'no_found_rows' => true,
) );
}
/* -------------------------------------------------------------------------
* [ga_final_feliz] formulario para que las familias envíen su historia
* ---------------------------------------------------------------------- */
add_shortcode( 'ga_final_feliz', function () {
$status = sanitize_key( $_GET['historia'] ?? '' );
$privacy = ga_page_url( 'politica-de-privacidad' );
ob_start();
if ( 'ok' === $status ) {
echo '<div class="ga-notice ga-notice--ok" role="status">' . esc_html( ga__( '¡Gracias por compartirlo! Lo revisaremos antes de publicarlo.', 'Eskerrik asko partekatzeagatik! Argitaratu aurretik berrikusiko dugu.' ) ) . '</div>';
return ob_get_clean();
}
if ( 'error' === $status ) {
echo '<div class="ga-notice ga-notice--error" role="alert">' . esc_html( ga__( 'No hemos podido enviarlo. Revisa los campos y que la foto sea JPG, PNG o WebP de menos de 5 MB.', 'Ezin izan dugu bidali. Egiaztatu eremuak eta argazkia JPG, PNG edo WebP dela eta 5 MB baino gutxiago dituela.' ) ) . '</div>';
}
?>
<form class="ga-form" method="post" enctype="multipart/form-data" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="ga_final_feliz">
<input type="hidden" name="redirect" value="<?php echo esc_url( get_permalink() ); ?>">
<input type="hidden" name="idioma" value="<?php echo esc_attr( ga_lang() ); ?>">
<?php wp_nonce_field( 'ga_final_feliz', 'ga_nonce' ); ?>
<div class="ga-hp" aria-hidden="true"><label>No rellenar <input type="text" name="web" tabindex="-1" autocomplete="off"></label></div>
<div class="ga-form__row">
<label><?php ga_e( 'Nombre del animal', 'Animaliaren izena' ); ?> *<input type="text" name="animal" required maxlength="80"></label>
<label><?php ga_e( 'Vuestro nombre', 'Zuen izena' ); ?> *<input type="text" name="familia" required maxlength="80"></label>
</div>
<label><?php ga_e( 'Correo electrónico (no se publica)', 'Posta elektronikoa (ez da argitaratzen)' ); ?> *<input type="email" name="email" required></label>
<label><?php ga_e( '¿Cómo está ahora?', 'Nola dago orain?' ); ?> *<textarea name="texto" rows="5" required maxlength="800"></textarea></label>
<label><?php ga_e( 'Una foto (JPG, PNG o WebP, máx. 5 MB)', 'Argazki bat (JPG, PNG edo WebP, gehienez 5 MB)' ); ?><input type="file" name="foto" accept="image/jpeg,image/png,image/webp"></label>
<label class="ga-form__check"><input type="checkbox" name="publicar" value="1" required>
<span><?php ga_e( 'Autorizo a Bilagun a publicar este texto y la foto en su web y redes. *', 'Bilaguni baimena ematen diot testu hau eta argazkia bere webgunean eta sareetan argitaratzeko. *' ); ?></span></label>
<label class="ga-form__check"><input type="checkbox" name="privacidad" value="1" required>
<span><?php
printf(
wp_kses( ga__( 'He leído y acepto la <a href="%s" target="_blank">política de privacidad</a>. *', '<a href="%s" target="_blank">Pribatutasun-politika</a> irakurri eta onartzen dut. *' ), array( 'a' => array( 'href' => array(), 'target' => array() ) ) ),
esc_url( $privacy )
);
?></span></label>
<button type="submit" class="ga-btn"><?php ga_e( 'Enviar nuestra historia', 'Gure istorioa bidali' ); ?></button>
</form>
<?php
return ob_get_clean();
} );
add_action( 'admin_post_nopriv_ga_final_feliz', 'ga_handle_story' );
add_action( 'admin_post_ga_final_feliz', 'ga_handle_story' );
function ga_handle_story() {
$redirect = wp_validate_redirect( esc_url_raw( wp_unslash( $_POST['redirect'] ?? '' ) ), home_url( '/' ) );
$go = function ( $status ) use ( $redirect ) {
wp_safe_redirect( add_query_arg( 'historia', $status, $redirect ) . '#historia' );
exit;
};
if ( ! isset( $_POST['ga_nonce'] ) || ! wp_verify_nonce( $_POST['ga_nonce'], 'ga_final_feliz' ) ) {
$go( 'error' );
}
if ( ! empty( $_POST['web'] ) ) {
$go( 'ok' );
}
// Límite sencillo: 3 envíos por hora y dirección IP.
$key = 'ga_story_' . md5( $_SERVER['REMOTE_ADDR'] ?? '' );
$count = (int) get_transient( $key );
if ( $count >= 3 ) {
$go( 'error' );
}
set_transient( $key, $count + 1, HOUR_IN_SECONDS );
$animal = sanitize_text_field( wp_unslash( $_POST['animal'] ?? '' ) );
$familia = sanitize_text_field( wp_unslash( $_POST['familia'] ?? '' ) );
$email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) );
$texto = mb_substr( sanitize_textarea_field( wp_unslash( $_POST['texto'] ?? '' ) ), 0, 800 );
if ( ! $animal || ! $familia || ! is_email( $email ) || ! $texto || empty( $_POST['publicar'] ) || empty( $_POST['privacidad'] ) ) {
$go( 'error' );
}
$story_id = wp_insert_post( array(
'post_type' => 'ga_historia',
'post_status' => 'pending',
'post_title' => $animal,
'post_content' => $texto,
) );
if ( ! $story_id || is_wp_error( $story_id ) ) {
$go( 'error' );
}
update_post_meta( $story_id, '_ga_familia', $familia );
update_post_meta( $story_id, '_ga_email', $email );
update_post_meta( $story_id, '_ga_idioma', 'eu' === ( $_POST['idioma'] ?? '' ) ? 'eu' : 'es' );
// Foto: solo imágenes reales de hasta 5 MB.
if ( ! empty( $_FILES['foto']['tmp_name'] ) && UPLOAD_ERR_OK === $_FILES['foto']['error'] ) {
$file = $_FILES['foto'];
$check = wp_check_filetype_and_ext( $file['tmp_name'], $file['name'], array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' ) );
if ( $file['size'] > 5 * MB_IN_BYTES || ! $check['type'] || ! @getimagesize( $file['tmp_name'] ) ) {
wp_delete_post( $story_id, true );
$go( 'error' );
}
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
$attachment = media_handle_upload( 'foto', $story_id, array(), array( 'test_form' => false, 'mimes' => array( 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'webp' => 'image/webp' ) ) );
if ( ! is_wp_error( $attachment ) ) {
set_post_thumbnail( $story_id, $attachment );
}
}
wp_mail(
ga_contact_email(),
'[Web] Nuevo final feliz por revisar: ' . $animal,
sprintf( "%s ha enviado la historia de %s.\n\nRevísala y publícala aquí: %s", $familia, $animal, admin_url( 'post.php?action=edit&post=' . $story_id ) )
);
$go( 'ok' );
}
/* En el panel: quién la envió (sin publicar su correo) */
add_action( 'add_meta_boxes_ga_historia', function () {
add_meta_box( 'ga_historia_info', 'Enviada por', function ( $post ) {
printf(
'<p><strong>%s</strong><br>%s<br>Idioma: %s</p><p class="description">Para publicarla pulsa «Publicar». El nombre de la familia se muestra; el correo no.</p>',
esc_html( get_post_meta( $post->ID, '_ga_familia', true ) ?: '—' ),
esc_html( get_post_meta( $post->ID, '_ga_email', true ) ?: '' ),
esc_html( get_post_meta( $post->ID, '_ga_idioma', true ) ?: 'es' )
);
}, 'ga_historia', 'side' );
} );

View File

@@ -83,3 +83,34 @@
} );
} );
} )();
// Formulario: campo «día y hora» solo cuando el motivo es pedir cita.
( function () {
document.querySelectorAll( '.ga-form select[name="motivo"]' ).forEach( function ( select ) {
var field = select.form.querySelector( '.ga-form__visit' );
if ( ! field ) { return; }
select.addEventListener( 'change', function () { field.hidden = select.value !== 'visita'; } );
} );
} )();
// Cuestionario: guarda el borrador en este navegador hasta que se envía.
( function () {
var done = document.querySelector( '[data-ga-clear-draft]' );
try {
if ( done ) { Object.keys( localStorage ).filter( function ( k ) { return k.indexOf( 'ga-cuestionario-' ) === 0; } ).forEach( function ( k ) { localStorage.removeItem( k ); } ); }
} catch ( e ) {}
document.querySelectorAll( 'form[data-ga-draft]' ).forEach( function ( form ) {
var key = form.dataset.gaDraft, saved = {};
try { saved = JSON.parse( localStorage.getItem( key ) || '{}' ); } catch ( e ) {}
Array.prototype.forEach.call( form.elements, function ( el ) {
if ( el.name && el.type !== 'hidden' && el.type !== 'checkbox' && saved[ el.name ] && ! el.value ) { el.value = saved[ el.name ]; }
} );
form.addEventListener( 'input', function () {
var data = {};
Array.prototype.forEach.call( form.elements, function ( el ) {
if ( el.name && el.type !== 'hidden' && el.type !== 'checkbox' && el.name !== 'web' ) { data[ el.name ] = el.value; }
} );
try { localStorage.setItem( key, JSON.stringify( data ) ); } catch ( e ) {}
} );
} );
} )();

View File

@@ -61,6 +61,13 @@ $columns = array(
</div>
</div>
<?php $newsletter = trim( (string) ga_opt( 'footer', 'newsletter' ) ); ?>
<?php if ( $newsletter && preg_match( '/^\[mailpoet_form[^\]]*\]$/', $newsletter ) && shortcode_exists( 'mailpoet_form' ) ) : ?>
<div class="ga-footer__newsletter">
<h2><?php ga_opt_e( 'footer', 'newsletter_title' ); ?></h2>
<?php echo do_shortcode( $newsletter ); ?>
</div>
<?php endif; ?>
<div class="ga-footer__bottom">
<span>© <?php echo esc_html( wp_date( 'Y' ) ); ?> <?php ga_opt_e( 'footer', 'copyright' ); ?></span>
<?php

View File

@@ -170,6 +170,8 @@ function ga_customizer_registry() {
'Bilagun es la protectora de Jaizubia, en Hondarribia. Cuidamos de los perros y gatos que llegan a nosotras y les buscamos una segunda oportunidad.',
'Bilagun Jaizubiko babeslekua da, Hondarribian. Guregana iristen diren txakur eta katuak zaintzen ditugu eta bigarren aukera bat bilatzen diegu.', 'textarea' ),
'copyright' => array( 'type' => 'text', 'label' => 'Texto de copyright', 'default' => 'Gure Ametsa Bi Lagun' ),
'newsletter' => array( 'type' => 'text', 'label' => 'Suscripción «Nuevos animales» (shortcode de MailPoet, p. ej. [mailpoet_form id="1"])', 'default' => '', 'description' => 'Vacío = no se muestra. Ver README: cómo crear el aviso automático en MailPoet.' ),
'newsletter_title' => $t( 'Suscripción · título', 'Te avisamos cuando lleguen nuevos animales', 'Animalia berriak iristen direnean abisatuko dizugu' ),
),
),
@@ -183,6 +185,7 @@ function ga_customizer_registry() {
'telefono' => array( 'type' => 'text', 'label' => 'Teléfono de atención', 'default' => '943 64 52 79', 'description' => 'El de atención de Bilagun, no el del servicio municipal.' ),
'email' => array( 'type' => 'email', 'label' => 'Email', 'default' => 'hola@gureametsabilagun.org' ),
'horario' => $t( 'Horario de atención al público', 'Lunes a viernes: 10:00–13:00 y 14:00–19:00', 'Astelehenetik ostiralera: 10:00–13:00 eta 14:00–19:00' ),
'hours_schema' => array( 'type' => 'text', 'label' => 'Horario para Google (formato: Mo-Fr 10:00-13:00, Sa 10:00-14:00)', 'default' => 'Mo-Fr 10:00-13:00, Mo-Fr 14:00-19:00', 'description' => 'Días en inglés abreviado (Mo, Tu, We, Th, Fr, Sa, Su). Aparece en Google y Maps.' ),
'cita' => $t( 'Aviso de cita previa (vacío = ocultar)', 'Atendemos con cita previa. Escríbenos o llámanos antes de venir.', 'Aurretiko hitzorduarekin artatzen dugu. Idatzi edo deitu iezaguzu etorri aurretik.', 'textarea' ),
),
),
@@ -236,6 +239,19 @@ function ga_customizer_registry() {
'process_note' => $t( 'Proceso · nota', 'Enviar la solicitud no confirma una adopción: es el primer paso para conocernos.', 'Eskaera bidaltzeak ez du adopzioa baieztatzen: elkar ezagutzeko lehen urratsa da.' ),
'share' => array( 'type' => 'checkbox', 'label' => 'Botones para compartir la ficha (WhatsApp, Facebook, copiar enlace)', 'default' => true ),
'form_email' => array( 'type' => 'email', 'label' => 'Correo que recibe las solicitudes y mensajes', 'default' => '', 'description' => 'Vacío = el de la variable GA_CONTACT_EMAIL del servidor.' ),
'review_days' => array( 'type' => 'number', 'label' => 'Avisar en el Escritorio si un animal lleva «En proceso» más de (días)', 'default' => 30, 'min' => 1, 'max' => 365 ),
'retention_months' => array( 'type' => 'number', 'label' => 'Borrar los mensajes del formulario pasados (meses, 0 = nunca)', 'default' => 12, 'min' => 0, 'max' => 120, 'description' => 'Protección de datos: no guardar datos personales más tiempo del necesario.' ),
),
),
'cuestionario' => array(
'panel' => 'ga_adopt',
'title' => 'Cuestionario de adopción',
'description' => 'Página «Cuestionario de adopción» (no aparece en el menú ni en Google). Enviad su enlace tras el primer contacto; añadiendo ?adoptar=ID del animal aparece ya rellenado.',
'fields' => array(
'questions' => $t( 'Preguntas (una por línea)',
"¿Por qué quieres adoptar?\n¿Cómo es tu vivienda? (piso o casa, si tiene jardín o terraza, si es de alquiler)\n¿Quién vive contigo? ¿Hay niños? ¿Estáis todos de acuerdo con la adopción?\n¿Tienes o has tenido otros animales? Cuéntanos cómo son.\n¿Cuántas horas al día pasaría solo en casa?\n¿Qué harías con él en vacaciones o si te mudaras?\n¿Qué esperas de tu nuevo compañero?",
"Zergatik adoptatu nahi duzu?\nNolakoa da zure etxebizitza? (pisua edo etxea, lorategia edo terraza duen, alokairuan dagoen)\nNor bizi da zurekin? Haurrik ba al dago? Denak ados zaudete adopzioarekin?\nBeste animaliarik baduzu edo izan duzu? Kontaiguzu nolakoak diren.\nZenbat ordu egongo litzateke bakarrik etxean egunean?\nZer egingo zenuke harekin oporretan edo etxez aldatuko bazina?\nZer espero duzu zure lagun berriarengandik?", 'textarea' ),
'confirm' => $t( 'Confirmación tras enviarlo', '¡Gracias! Hemos recibido tu cuestionario. El equipo lo valorará y te llamaremos para quedar, siempre con cita previa.', 'Eskerrik asko! Zure galdetegia jaso dugu. Taldeak baloratuko du eta deituko dizugu geratzeko, beti aurretiko hitzorduarekin.', 'textarea' ),
),
),
'material' => array(
@@ -406,11 +422,26 @@ function ga_customizer_registry() {
'eyebrow' => $t( 'Antetítulo', 'Ya están en casa', 'Etxean daude jada' ),
'title' => $t( 'Título', 'Finales felices', 'Amaiera zoriontsuak' ),
'text' => $t( 'Texto', 'Gracias por formar parte de sus segundas oportunidades.', 'Eskerrik asko haien bigarren aukeren parte izateagatik.', 'textarea' ),
'count' => array( 'type' => 'number', 'label' => 'Número de animales', 'default' => 8, 'min' => 1, 'max' => 24 ),
'count' => array( 'type' => 'number', 'label' => 'Número de historias o animales', 'default' => 8, 'min' => 1, 'max' => 24 ),
'view' => array( 'type' => 'select', 'label' => 'Presentación', 'default' => 'carrusel', 'choices' => array(
'carrusel' => 'Carrusel (se desliza)',
'rejilla' => 'Rejilla',
) ),
'button' => $t( 'Botón para enviar historias (vacío = ocultar)', 'Cuéntanos vuestra historia', 'Kontaiguzu zuen istorioa' ),
),
),
'sponsor' => array(
'panel' => 'ga_home',
'title' => 'Apadrina',
'section' => true,
'hidden' => true,
'description' => 'Muestra los animales marcados como «Busca padrino o madrina».',
'fields' => array(
'eyebrow' => $t( 'Antetítulo', 'Apadrina', 'Apadrinatu' ),
'title' => $t( 'Título', 'Acompáñales *mientras esperan*', 'Lagun egiozu *itxaroten duten bitartean*' ),
'text' => $t( 'Texto', 'Algunos llevan mucho tiempo con nosotras. Si no puedes adoptar, puedes apadrinar a uno de ellos. Escríbenos y te contamos cómo.', 'Batzuk denbora luzea daramate gurekin. Ezin baduzu adoptatu, haietako bat apadrinatu dezakezu. Idatzi iezaguzu eta nola kontatuko dizugu.', 'textarea' ),
'button' => $t( 'Botón', 'Quiero apadrinar', 'Apadrinatu nahi dut' ),
'count' => array( 'type' => 'number', 'label' => 'Número de animales', 'default' => 4, 'min' => 1, 'max' => 12 ),
),
),
'intake' => array(

View File

@@ -550,6 +550,15 @@ body { overflow-x: clip; }
.ga-animal__photo { position: static; }
}
/* Finales felices */
.ga-story { display: flex; flex-direction: column; background: #fff; border: 1px solid var(--ga-line); border-radius: var(--ga-radius); overflow: hidden; }
.ga-story__img { aspect-ratio: 4/3; background: var(--ga-green-soft); }
.ga-story__img img { width: 100%; height: 100%; object-fit: cover; }
.ga-story__body { margin: 0; padding: 18px 20px 20px; font-size: .98rem; flex: 1; display: flex; flex-direction: column; }
.ga-story__body p::before { content: "“"; font: 800 2rem/0 var(--ga-serif); color: var(--ga-red); vertical-align: -.35em; margin-right: 2px; }
.ga-story__body footer { margin-top: auto; padding-top: 10px; border-top: 1px solid var(--ga-line); color: var(--ga-muted); font-size: .9rem; }
.ga-story__body footer strong { font-family: var(--ga-serif); color: var(--ga-green-dark); }
/* Pestañas Perros / Gatos */
.ga-tabs { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 28px; }
.ga-tab { padding: 11px 26px; border-radius: 999px; border: 2px solid var(--ga-green); color: var(--ga-green); font-weight: 700; text-decoration: none; font-size: 1.05rem; }
@@ -610,6 +619,12 @@ body { overflow-x: clip; }
.ga-needs-note { font-weight: 600; color: var(--ga-green-dark); }
.ga-form__animal { margin: 0; font-weight: 700; }
.ga-footer__cita { color: #fff; font-weight: 600; }
.ga-footer__newsletter { margin-top: 44px; padding: 24px 28px; border-radius: var(--ga-radius); background: rgba(255, 255, 255, .06); }
.ga-footer__newsletter h2 { margin-bottom: .8em; }
.ga-questionnaire fieldset { border: 1px solid var(--ga-line); border-radius: var(--ga-radius); padding: 18px 20px 22px; margin: 0; display: grid; gap: 16px; }
.ga-questionnaire legend { padding: 0 8px; font: 800 1.1rem var(--ga-serif); color: var(--ga-green-dark); }
.ga-questionnaire label { text-transform: none; letter-spacing: 0; font-size: .98rem; }
.ga-form__hint { margin: 0; font-size: .88rem; color: var(--ga-muted); }
/* =========================================================================
Formularios

View File

@@ -0,0 +1,15 @@
<?php
$html = do_shortcode( sprintf( '[ga_animales numero="%d" apadrinables="1"]', max( 1, (int) ga_opt( 'sponsor', 'count' ) ) ) );
if ( false !== strpos( $html, 'ga-empty' ) ) {
return; // Nadie marcado como «Busca padrino o madrina».
}
$button = ga_opt( 'sponsor', 'button' )
? sprintf( '<a class="ga-btn ga-btn--primary" href="%s">%s</a>', esc_url( add_query_arg( 'motivo', 'apadrinar', ga_page_url( 'contacto' ) ) . '#formulario' ), esc_html( ga_opt( 'sponsor', 'button' ) ) )
: '';
?>
<section class="ga-section ga-section--sand" id="apadrina">
<div class="ga-wrap">
<?php ga_section_head( 'sponsor', array( 'extra' => $button ) ); ?>
<?php echo $html; // Generado por el shortcode (escapado). ?>
</div>
</section>

View File

@@ -1,25 +1,40 @@
<?php
/* Últimos animales adoptados: se oculta si todavía no hay ninguno. */
$adopted = ga_query_animals( array(
'numero' => max( 1, (int) ga_opt( 'stories', 'count' ) ),
'estado' => 'adoptado',
) );
if ( ! $adopted->have_posts() ) {
/*
* Finales felices: las historias aprobadas (enviadas por las familias); si aún no hay,
* los últimos animales adoptados. Si no hay nada, la sección no aparece.
*/
$number = max( 1, (int) ga_opt( 'stories', 'count' ) );
$stories = function_exists( 'ga_query_stories' ) ? ga_query_stories( $number ) : null;
$adopted = ( $stories && $stories->have_posts() ) ? null : ga_query_animals( array( 'numero' => $number, 'estado' => 'adoptado' ) );
if ( ! ( $stories && $stories->have_posts() ) && ! $adopted->have_posts() ) {
return;
}
$form_page = get_page_by_path( 'finales-felices' );
$form_url = $form_page && 'publish' === $form_page->post_status ? ga_page_url( 'finales-felices' ) : '';
$button = $form_url && ga_opt( 'stories', 'button' )
? sprintf( '<a class="ga-btn ga-btn--outline" href="%s">%s</a>', esc_url( $form_url ), esc_html( ga_opt( 'stories', 'button' ) ) )
: '';
$carousel = 'carrusel' === ga_opt( 'stories', 'view' );
?>
<section class="ga-section">
<div class="ga-wrap">
<?php ga_section_head( 'stories', array( 'center' => true ) ); ?>
<?php
$carousel = 'carrusel' === ga_opt( 'stories', 'view' );
$carousel ? ga_carousel_open( ga_opt( 'stories', 'title' ) ) : print( '<div class="ga-grid">' );
while ( $adopted->have_posts() ) :
$adopted->the_post();
get_template_part( 'template-parts/animal-card' );
endwhile;
wp_reset_postdata();
$carousel ? ga_carousel_close() : print( '</div>' );
?>
<?php ga_section_head( 'stories', array( 'extra' => $button ) ); ?>
<?php $carousel ? ga_carousel_open( ga_opt( 'stories', 'title' ) ) : print( '<div class="ga-grid">' ); ?>
<?php if ( $stories && $stories->have_posts() ) : ?>
<?php while ( $stories->have_posts() ) : $stories->the_post(); ?>
<article class="ga-story">
<div class="ga-story__img">
<?php echo has_post_thumbnail() ? get_the_post_thumbnail( null, 'ga-card', array( 'loading' => 'lazy' ) ) : '<div class="ga-card__placeholder">' . ga_placeholder_svg() . '</div>'; ?>
</div>
<blockquote class="ga-story__body">
<p><?php echo esc_html( wp_strip_all_tags( get_the_content() ) ); ?></p>
<footer><strong><?php the_title(); ?></strong><?php $f = get_post_meta( get_the_ID(), '_ga_familia', true ); echo $f ? ' · ' . esc_html( $f ) : ''; ?></footer>
</blockquote>
</article>
<?php endwhile; ?>
<?php else : ?>
<?php while ( $adopted->have_posts() ) : $adopted->the_post(); get_template_part( 'template-parts/animal-card' ); endwhile; ?>
<?php endif; ?>
<?php wp_reset_postdata(); $carousel ? ga_carousel_close() : print( '</div>' ); ?>
</div>
</section>