feat(site): Comunidade section with Instagram posts the gym was tagged in

Per-gym Graph API /tags fetch with file cache and local thumbnails, section after the gallery, navbar link and admin page (Gestão > Site).
This commit is contained in:
andre committed 2026-10-26 14:19:43 +00:00
1 parent 6ac5213624
commit e804c52b4e
10 files changed
+491 -3

No files matched your search

+4
View File
@@ -20,6 +20,10 @@ uploads/**/.trash/
# (config/functions_privacy.php) para o browser não as pedir à Google. # (config/functions_privacy.php) para o browser não as pedir à Google.
uploads/reviews/ uploads/reviews/
# Miniaturas das publicações do Instagram da secção «Comunidade»
# (config/functions_instagram.php), descarregadas a cada atualização.
uploads/instagram/
# Nota: as fotos da equipa, galerias e ícones em uploads/ ainda estão # Nota: as fotos da equipa, galerias e ícones em uploads/ ainda estão
# versionados (vieram assim do site antigo). Em produção uploads/ é um volume # versionados (vieram assim do site antigo). Em produção uploads/ é um volume
# (/srv/data/fitme.andmore.pt/uploads) e é esse a fonte; o que está no git é # (/srv/data/fitme.andmore.pt/uploads) e é esse a fonte; o que está no git é
+7 -1
View File
@@ -96,7 +96,13 @@ Fleet transition to JPEG XL (root `Y:\GitTea\CLAUDE.md`, "Images"); fitme3070 is
- **Moves/deletes keep the pair together:** `img_fallback_follow($from, $to='')` in trash (`_trash_image_file`), `gyms_remove_image()`, `events_image_remove()`. - **Moves/deletes keep the pair together:** `img_fallback_follow($from, $to='')` in trash (`_trash_image_file`), `gyms_remove_image()`, `events_image_remove()`.
- Still open: events/modalidades/team uploads don't accept `.jxl` yet. Every image input in the backoffice shows the current image next to it with a remove control (gyms form does; check events, modalidades, team, galerias, system settings). - Still open: events/modalidades/team uploads don't accept `.jxl` yet. Every image input in the backoffice shows the current image next to it with a remove control (gyms form does; check events, modalidades, team, galerias, system settings).
The navbar only links sections the gym shows **and** has content for (`site_section_has_content()` in `config/functions_site.php`: Galeria = photos in its folder, Modalidades, Equipa = active members (`site_team_active()`, also used by the page), Horário, Mensalidades). The gallery section background is a 7% brand-colour tint, not white. The navbar only links sections the gym shows **and** has content for (`site_section_has_content()` in `config/functions_site.php`: Galeria = photos in its folder, Modalidades, Equipa = active members (`site_team_active()`, also used by the page), Horário, Mensalidades, Comunidade = cached posts). The gallery section background is a 7% brand-colour tint, not white.
## Comunidade: Instagram posts the gym was tagged in (2026-10-26)
Section `comunidade` (after the gallery in `site/index.php`, navbar link «Comunidade», key in `gym_sections()`) shows the latest public Instagram posts where the gym's account was tagged, as a square grid linking to each post; hidden when there are none. `config/functions_instagram.php` mirrors the reviews module: Graph API `GET /{ig-user-id}/tags` (`INSTAGRAM_GRAPH`, v21.0), cache `config/cache/instagram_<gid>.cache.php`, lazy refresh under a non-blocking lock, old copy kept on error (retry after 1 h). Thumbnails (`thumbnail_url` for videos, `media_url` otherwise; only JPEGs from `cdninstagram.com`/`fbcdn.net`) are downloaded at refresh time to `uploads/instagram/<gid>/<media id>.jpg` (git-ignored) because Instagram CDN URLs expire; stale ones are deleted.
- **Per gym, no migration:** `system_settings` keys `instagram_user_id_<gid>` and `instagram_token_<gid>` (encrypted with `secret_encrypt`; `.env` `INSTAGRAM_TOKEN_<gid>` wins); `instagram_max` (3–24, default 8) and `instagram_cache_hours` (default 6) are shared. Admin: Gestão → Site → «Comunidade (Instagram)» (`views/admin/instagram/index.php`, edits the gym in the switcher): token, account id, «Encontrar» (lists `me/accounts` Pages with their linked IG account), «Atualizar agora», status + preview.
- **Needs from Meta:** an Instagram professional (business/creator) account linked to a Facebook Page, a Meta app, and a token with `instagram_basic`, `instagram_manage_comments`, `pages_read_engagement` (a Business Manager system-user token does not expire; a long-lived user token lasts 60 days). Private posts and some reels without `media_url` are skipped.
- Gyms whose `sections` column is an explicit list (not NULL) don't get the new section until it is ticked in Ginásios → Secções. Not configured anywhere yet (2026-10-26): tested on dev with fake cached posts only.
## platform-tools ## platform-tools
+1
View File
@@ -41,6 +41,7 @@ require_once __DIR__ . '/functions_events.php'; // time-limited events +
require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_info) editor require_once __DIR__ . '/functions_info.php'; // contactos (fitme3070_info) editor
require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição require_once __DIR__ . '/functions_memberships.php'; // mensalidades + pedidos de inscrição
require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache require_once __DIR__ . '/functions_reviews.php'; // reviews Google (Places API) + cache
require_once __DIR__ . '/functions_instagram.php'; // «Comunidade»: posts do Instagram onde o ginásio é identificado
require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental require_once __DIR__ . '/functions_trials.php'; // pedidos de aula experimental
require_once __DIR__ . '/functions_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente require_once __DIR__ . '/functions_privacy.php'; // mapa e fotos da Google só a pedido / servidos localmente
require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos require_once __DIR__ . '/functions_forms.php'; // anti-spam + bloqueio de IP dos formulários públicos
+1
View File
@@ -19,6 +19,7 @@ function gym_sections(): array {
return [ return [
'oferta' => 'A Nossa Oferta', 'oferta' => 'A Nossa Oferta',
'galeria' => 'Galeria (instalações)', 'galeria' => 'Galeria (instalações)',
'comunidade' => 'Comunidade (Instagram)',
'modalidades' => 'Modalidades', 'modalidades' => 'Modalidades',
'equipa' => 'Equipa', 'equipa' => 'Equipa',
'horario' => 'Horário de aulas', 'horario' => 'Horário de aulas',
+188
View File
@@ -0,0 +1,188 @@
<?php
// «Comunidade»: the latest Instagram posts where the gym was tagged, via the
// Instagram Graph API (GET /{ig-user-id}/tags). Per gym, no migration: the
// account id and token live in system_settings as instagram_user_id_<gid> /
// instagram_token_<gid> (token encrypted, or INSTAGRAM_TOKEN_<gid> in .env).
// Same cache pattern as functions_reviews.php: config/cache/instagram_<gid>.cache.php,
// lazy refresh under a non-blocking lock, old copy kept on error. Thumbnails
// are downloaded at refresh time to uploads/instagram/<gid>/ (Instagram CDN
// URLs expire), so visitors never wait on Instagram.
const INSTAGRAM_GRAPH = 'https://graph.facebook.com/v21.0/';
function instagram_use_gym(array $gym): void {
$GLOBALS['_instagram_gym'] = $gym;
}
function instagram_gym(): array {
return $GLOBALS['_instagram_gym'] ?? current_gym();
}
function _instagram_gid(): int {
return (int) (instagram_gym()['id'] ?? 1);
}
function instagram_config(): array {
$gid = _instagram_gid();
return [
'user_id' => preg_replace('/\D+/', '', (string) setting('instagram_user_id_' . $gid, '')),
'token' => setting_secret('instagram_token_' . $gid, 'INSTAGRAM_TOKEN_' . $gid),
'max' => max(3, min(24, (int) setting('instagram_max', 8))),
'cache_hours' => max(1, (int) setting('instagram_cache_hours', 6)),
];
}
function instagram_configured(): bool {
$c = instagram_config();
return $c['user_id'] !== '' && $c['token'] !== '';
}
function _instagram_cache_path(): string {
return __DIR__ . '/cache/instagram_' . _instagram_gid() . '.cache.php';
}
function _instagram_media_dir(): string {
return dirname(__DIR__) . '/uploads/instagram/' . _instagram_gid() . '/';
}
function instagram_cache_read(): ?array {
$path = _instagram_cache_path();
if (!is_file($path)) return null;
$data = @include $path;
return is_array($data) ? $data : null;
}
function _instagram_cache_write(array $data): void {
$path = _instagram_cache_path();
if (!is_dir(dirname($path))) @mkdir(dirname($path), 0775, true);
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (@file_put_contents($tmp, "<?php\nreturn " . var_export($data, true) . ";\n", LOCK_EX) === false) return;
@rename($tmp, $path);
if (function_exists('opcache_invalidate')) @opcache_invalidate($path, true);
}
function instagram_cache_clear(): void {
$path = _instagram_cache_path();
if (is_file($path)) @unlink($path);
}
// GET against the Graph API. Returns [decoded|null, error].
function _instagram_request(string $url): array {
if (!function_exists('curl_init')) return [null, 'cURL indisponível nesta instalação.'];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 6,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$cerr = curl_error($ch);
curl_close($ch);
if ($raw === false) return [null, 'Sem ligação ao Instagram: ' . $cerr];
$json = json_decode((string) $raw, true);
if ($status !== 200) {
$msg = is_array($json) ? (string) ($json['error']['message'] ?? '') : '';
return [$json, 'Instagram respondeu ' . $status . ($msg !== '' ? ': ' . $msg : '')];
}
return [is_array($json) ? $json : null, is_array($json) ? '' : 'Resposta inválida do Instagram.'];
}
// Saves one thumbnail locally; returns the uploads-relative path or ''.
function _instagram_save_thumb(string $src, string $id): string {
if (!preg_match('#^https://[a-z0-9.-]+\.(cdninstagram\.com|fbcdn\.net)/#i', $src)) return '';
$dir = _instagram_media_dir();
$rel = 'uploads/instagram/' . _instagram_gid() . '/' . $id . '.jpg';
if (is_file($dir . $id . '.jpg')) return $rel;
$ch = curl_init($src);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 2,
CURLOPT_TIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
]);
$bin = curl_exec($ch);
curl_close($ch);
$info = (is_string($bin) && $bin !== '' && strlen($bin) < 4 * 1024 * 1024) ? @getimagesizefromstring($bin) : false;
if (!$info || $info['mime'] !== 'image/jpeg') return '';
if (!is_dir($dir)) @mkdir($dir, 0775, true);
return @file_put_contents($dir . $id . '.jpg', $bin) ? $rel : '';
}
// Fetch the tagged media now and rewrite the cache. Returns the cache array.
function instagram_refresh(): array {
$c = instagram_config();
$old = instagram_cache_read() ?? [];
if ($c['user_id'] === '' || $c['token'] === '') {
return $old + ['error' => 'Falta o ID da conta ou o token.'];
}
// Ask for a few extra: private posts are never returned, and some come without a picture.
$url = INSTAGRAM_GRAPH . $c['user_id'] . '/tags?' . http_build_query([
'fields' => 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,username',
'limit' => min(50, $c['max'] + 6),
'access_token' => $c['token'],
]);
[$json, $err] = _instagram_request($url);
if ($err !== '') {
// Keep serving the previous copy; retry after an hour, not a full window.
$data = $old;
$data['error'] = $err;
$data['error_at'] = time();
$data['fetched_at'] = time() - max(0, $c['cache_hours'] * 3600 - 3600);
_instagram_cache_write($data);
error_log('[instagram] ' . $err);
return $data;
}
$posts = [];
foreach ((array) ($json['data'] ?? []) as $m) {
if (count($posts) >= $c['max']) break;
$id = preg_replace('/\D+/', '', (string) ($m['id'] ?? ''));
if ($id === '' || empty($m['permalink'])) continue;
// Videos/reels: the cover (thumbnail_url); photos and albums: media_url.
$src = (string) (($m['media_type'] ?? '') === 'VIDEO' ? ($m['thumbnail_url'] ?? '') : ($m['media_url'] ?? ''));
$img = $src !== '' ? _instagram_save_thumb($src, $id) : '';
if ($img === '') continue;
$posts[] = [
'id' => $id,
'image' => $img,
'type' => (string) ($m['media_type'] ?? ''),
'permalink' => (string) $m['permalink'],
'username' => (string) ($m['username'] ?? ''),
'caption' => (string) ($m['caption'] ?? ''),
'timestamp' => (string) ($m['timestamp'] ?? ''),
];
}
// Drop thumbnails of posts that are no longer shown.
$keep = array_flip(array_column($posts, 'id'));
foreach (glob(_instagram_media_dir() . '*.jpg') ?: [] as $f) {
if (!isset($keep[basename($f, '.jpg')])) @unlink($f);
}
$data = ['fetched_at' => time(), 'posts' => $posts, 'error' => ''];
_instagram_cache_write($data);
return $data;
}
// Cached data, refreshed when older than the cache window (one refresh at a time).
function instagram_get(): array {
$c = instagram_config();
$cache = instagram_cache_read();
$fresh = $cache && (time() - (int) ($cache['fetched_at'] ?? 0)) < $c['cache_hours'] * 3600;
if ($fresh || !instagram_configured()) return $cache ?? [];
$lock = @fopen(_instagram_cache_path() . '.lock', 'c');
if ($lock && flock($lock, LOCK_EX | LOCK_NB)) {
$cache = instagram_refresh();
flock($lock, LOCK_UN);
}
if ($lock) fclose($lock);
return $cache ?? [];
}
// Posts the homepage shows (only those whose thumbnail is on disk), or [] to hide the section.
function site_instagram(?array $gym = null): array {
if ($gym !== null) instagram_use_gym($gym);
try { $data = instagram_get(); } catch (Throwable $e) { error_log('[instagram] ' . $e->getMessage()); return []; }
$root = dirname(__DIR__) . '/';
return array_values(array_filter((array) ($data['posts'] ?? []), fn($p) => is_file($root . $p['image'])));
}
+3 -2
View File
@@ -33,13 +33,14 @@ function site_info(PDO $pdo, ?int $gym_id = null): array {
function site_section_has_content(array $gym, string $sec): bool { function site_section_has_content(array $gym, string $sec): bool {
$pdo = $GLOBALS['pdo'] ?? null; $pdo = $GLOBALS['pdo'] ?? null;
$gid = (int) $gym['id']; $gid = (int) $gym['id'];
if (!$pdo && $sec !== 'galeria') return true; if (!$pdo && $sec !== 'galeria' && $sec !== 'comunidade') return true;
return match ($sec) { return match ($sec) {
'modalidades' => (bool) site_modalidades($pdo, $gid), 'modalidades' => (bool) site_modalidades($pdo, $gid),
'equipa' => (bool) site_team_active($pdo, $gid), 'equipa' => (bool) site_team_active($pdo, $gid),
'horario' => (bool) site_horario($pdo, '', $gid), 'horario' => (bool) site_horario($pdo, '', $gid),
'mensalidades' => (bool) site_memberships($pdo, $gid), 'mensalidades' => (bool) site_memberships($pdo, $gid),
'galeria' => !empty($gym['gallery_id']) && img_list_dir('uploads/gallery/' . (int) $gym['gallery_id']), 'galeria' => !empty($gym['gallery_id']) && img_list_dir('uploads/gallery/' . (int) $gym['gallery_id']),
'comunidade' => (bool) site_instagram($gym),
default => true, default => true,
}; };
} }
@@ -54,7 +55,7 @@ function site_team_active(PDO $pdo, ?int $gym_id = null): array {
function site_navbar(?array $gym = null): array { function site_navbar(?array $gym = null): array {
$gym = $gym ?? current_gym(); $gym = $gym ?? current_gym();
$items = ['Home' => ['#home', null]]; $items = ['Home' => ['#home', null]];
foreach (['Galeria' => 'galeria', 'Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario', foreach (['Galeria' => 'galeria', 'Comunidade' => 'comunidade', 'Modalidades' => 'modalidades', 'Equipa' => 'equipa', 'Horário' => 'horario',
'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) { 'Mensalidades' => 'mensalidades', 'Contactos' => 'contactos'] as $label => $sec) {
if (!gym_section_on($gym, $sec)) continue; if (!gym_section_on($gym, $sec)) continue;
// A section with nothing to show isn't rendered, so no link to it either. // A section with nothing to show isn't rendered, so no link to it either.
+1
View File
@@ -81,6 +81,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070';
<?= bo_nav_link('views/admin/events/index.php', 'bi-megaphone', 'Eventos', ['mod' => 'sub2', 'match' => 'views/admin/events/']) ?> <?= bo_nav_link('views/admin/events/index.php', 'bi-megaphone', 'Eventos', ['mod' => 'sub2', 'match' => 'views/admin/events/']) ?>
<?= bo_nav_link('views/admin/info/edit.php', 'bi-telephone', 'Contactos', ['mod' => 'sub2', 'match' => 'views/admin/info/']) ?> <?= bo_nav_link('views/admin/info/edit.php', 'bi-telephone', 'Contactos', ['mod' => 'sub2', 'match' => 'views/admin/info/']) ?>
<?= bo_nav_link('views/admin/reviews/index.php', 'bi-star', 'Reviews Google', ['mod' => 'sub2', 'match' => 'views/admin/reviews/']) ?> <?= bo_nav_link('views/admin/reviews/index.php', 'bi-star', 'Reviews Google', ['mod' => 'sub2', 'match' => 'views/admin/reviews/']) ?>
<?= bo_nav_link('views/admin/instagram/index.php', 'bi-instagram', 'Comunidade (Instagram)', ['mod' => 'sub2', 'match' => 'views/admin/instagram/']) ?>
<?php bo_nav_group_end(); ?> <?php bo_nav_group_end(); ?>
<?php bo_nav_group_start('horarios', 'Horários', 'bi-clock-history', true); ?> <?php bo_nav_group_start('horarios', 'Horários', 'bi-clock-history', true); ?>
<?= bo_nav_link('views/admin/work_hours/index.php', 'bi-clock', 'Horário de Funcionamento', ['mod' => 'sub2', 'match' => 'views/admin/work_hours/']) ?> <?= bo_nav_link('views/admin/work_hours/index.php', 'bi-clock', 'Horário de Funcionamento', ['mod' => 'sub2', 'match' => 'views/admin/work_hours/']) ?>
+68
View File
@@ -2687,6 +2687,7 @@ h3.contact-title {
4.5:1), so those use the ink green; dark sections keep the bright accent. */ 4.5:1), so those use the ink green; dark sections keep the bright accent. */
.feature-section .section-eyebrow, .feature-section .section-eyebrow,
.gallery-section .section-eyebrow, .gallery-section .section-eyebrow,
.community-section .section-eyebrow,
.service-section .section-eyebrow, .service-section .section-eyebrow,
.schedule-section .section-eyebrow, .schedule-section .section-eyebrow,
.workhours-section .section-eyebrow { .workhours-section .section-eyebrow {
@@ -4953,3 +4954,70 @@ a:where(:not(.site-btn, .gym-switch-btn, .gal-link, .skip-link, .splide__arrow,
h1, h2, h1, h2,
.navbar-brand img, .navbar-brand img,
.gym-switch-logo { filter: url(#pt-noise); } .gym-switch-logo { filter: url(#pt-noise); }
/* ── Comunidade (Instagram posts the gym was tagged in, site/index.php) ── */
.community-section {
padding: 80px 0 70px;
background: var(--fitme-white);
}
.community-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(220px, 45%), 1fr));
gap: 12px;
margin: 0;
}
.community-post {
position: relative;
display: block;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--fitme-radius);
background: var(--fitme-surface-2);
}
.community-post img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .4s ease;
}
.community-type {
position: absolute;
top: 10px;
right: 10px;
color: #fff;
text-shadow: 0 1px 4px rgba(var(--fitme-dark-rgb), .6);
}
.community-user {
position: absolute;
inset: auto 0 0;
padding: 28px 12px 10px;
color: #fff;
font-size: .85rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: linear-gradient(transparent, rgba(var(--fitme-dark-rgb), .75));
opacity: 0;
transition: opacity var(--fitme-transition);
}
.community-post:hover img,
.community-post:focus-visible img { transform: scale(1.05); }
.community-post:hover .community-user,
.community-post:focus-visible .community-user { opacity: 1; }
/* Touch screens have no hover: keep the @user visible. */
@media (hover: none) {
.community-user { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.community-post img { transition: none; }
}
+42
View File
@@ -37,6 +37,7 @@ usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open']));
$events = site_active_events($pdo, $gid); // time-limited events, banner below the hero $events = site_active_events($pdo, $gid); // time-limited events, banner below the hero
$memberships = $on('mensalidades') ? site_memberships($pdo, $gid) : []; $memberships = $on('mensalidades') ? site_memberships($pdo, $gid) : [];
$reviews = $on('reviews') ? site_reviews($gym) : null; // null when not configured / nothing qualifies $reviews = $on('reviews') ? site_reviews($gym) : null; // null when not configured / nothing qualifies
$community = $on('comunidade') ? site_instagram($gym) : []; // tagged Instagram posts, [] when not configured
$hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img/home.jpg'; $hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img/home.jpg';
// ── Hero section ──────────────────────────────────────────────────────────────── // ── Hero section ────────────────────────────────────────────────────────────────
@@ -128,6 +129,47 @@ $gallery_h2 = 'Conhece as nossas instalações';
</section> </section>
<?php endif; ?> <?php endif; ?>
<?php
// ── Comunidade (Instagram) ──────────────────────────────────────────────────────
// Latest public posts where the gym was tagged (functions_instagram.php), cached
// with local thumbnails; each tile opens the post on Instagram.
if ($community):
?>
<section id="comunidade" class="community-section" aria-labelledby="comunidade-title">
<div class="container">
<div class="row justify-content-md-center mb-4">
<div class="col-lg-8">
<div class="section-title text-center deco">
<span class="section-eyebrow">Comunidade</span>
<h2 id="comunidade-title">A nossa comunidade</h2>
<p class="section-subtitle">Os treinos de quem nos identifica no Instagram.</p>
</div>
</div>
</div>
<ul class="community-grid list-unstyled">
<?php foreach ($community as $p):
$cm_alt = trim(mb_strimwidth(preg_replace('/[[:space:]]+/u', ' ', $p['caption']), 0, 120, '…'));
?>
<li>
<a class="community-post" href="<?= htmlspecialchars($p['permalink']) ?>" target="_blank" rel="noopener"
aria-label="<?= htmlspecialchars('Publicação de @' . $p['username'] . ' no Instagram') ?>">
<img src="<?= htmlspecialchars(base_url($p['image'])) ?>" alt="<?= htmlspecialchars($cm_alt) ?>" loading="lazy" decoding="async">
<?php if ($p['type'] === 'VIDEO'): ?><i class="fas fa-play community-type" aria-hidden="true"></i>
<?php elseif ($p['type'] === 'CAROUSEL_ALBUM'): ?><i class="far fa-clone community-type" aria-hidden="true"></i><?php endif; ?>
<span class="community-user"><i class="fab fa-instagram" aria-hidden="true"></i> @<?= htmlspecialchars($p['username']) ?></span>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php if (!empty($info['instagram'])): ?>
<p class="text-center mt-4 mb-0">
<a class="btn btn-outline-dark" href="<?= htmlspecialchars($info['instagram']) ?>" target="_blank" rel="noopener"><i class="fab fa-instagram me-1"></i> Identifica-nos no Instagram</a>
</p>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
<?php <?php
// ── Modalidades ───────────────────────────────────────────────────────────────── // ── Modalidades ─────────────────────────────────────────────────────────────────
if ($modalidades): if ($modalidades):
+176
View File
@@ -0,0 +1,176 @@
<?php
require __DIR__ . '/../../../config/db.php';
require __DIR__ . '/../../../config/functions.php';
$user = require_admin();
// Account, token and cache are per gym: this page works on the gym being edited.
$igym = admin_gym($pdo);
instagram_use_gym($igym);
$gid = (int) $igym['id'];
$self = base_url('views/admin/instagram/index.php');
$accounts = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$uid = (int) ($user['id'] ?? 0) ?: null;
$action = (string) ($_POST['action'] ?? '');
if ($action === 'save') {
$before = instagram_config();
// Token: blank keeps the stored one; the "remover" box clears it.
$token = trim((string) ($_POST['instagram_token'] ?? ''));
if (!empty($_POST['clear_token'])) {
db_update_setting($pdo, 'instagram_token_' . $gid, '', $uid);
} elseif ($token !== '') {
db_update_setting($pdo, 'instagram_token_' . $gid, secret_encrypt($token), $uid);
}
$ig_id = preg_replace('/\D+/', '', (string) ($_POST['instagram_user_id'] ?? ''));
db_update_setting($pdo, 'instagram_user_id_' . $gid, $ig_id, $uid);
db_update_setting($pdo, 'instagram_max', (string) max(3, min(24, (int) ($_POST['max'] ?? 8))), $uid);
db_update_setting($pdo, 'instagram_cache_hours', (string) max(1, (int) ($_POST['cache_hours'] ?? 6)), $uid);
// A new account or token starts from scratch (setting() is cached per request, so compare with what was posted).
if (!empty($_POST['clear_token']) || ($token !== '' && $token !== $before['token']) || $ig_id !== $before['user_id']) {
instagram_cache_clear();
}
session()->setFlashdata('success', 'Definições guardadas.');
header('Location: ' . $self);
exit;
}
if ($action === 'refresh') {
$r = instagram_refresh();
session()->setFlashdata(!empty($r['error']) ? 'error' : 'success',
!empty($r['error']) ? 'Falhou: ' . $r['error'] : 'Publicações atualizadas (' . count($r['posts'] ?? []) . ' com imagem).');
header('Location: ' . $self);
exit;
}
if ($action === 'accounts') {
// Lists the Facebook Pages the token manages and the Instagram account linked to each.
$token = instagram_config()['token'];
if ($token === '') {
$accounts = ['error' => 'Guarda primeiro o token.', 'list' => []];
} else {
[$json, $err] = _instagram_request(INSTAGRAM_GRAPH . 'me/accounts?' . http_build_query([
'fields' => 'name,instagram_business_account{id,username}', 'limit' => 50, 'access_token' => $token,
]));
$list = [];
foreach ((array) ($json['data'] ?? []) as $pg) {
if (!empty($pg['instagram_business_account']['id'])) {
$list[] = ['page' => (string) ($pg['name'] ?? ''), 'id' => (string) $pg['instagram_business_account']['id'],
'username' => (string) ($pg['instagram_business_account']['username'] ?? '')];
}
}
$accounts = ['error' => $err, 'list' => $list];
}
}
}
$cfg = instagram_config();
$env_tok = (string) env('INSTAGRAM_TOKEN_' . $gid, '') !== '';
$cache = instagram_cache_read();
$preview = site_instagram();
$page_title = 'Comunidade (Instagram)';
include __DIR__ . '/../../../includes/header.php';
?>
<div class="container-fluid" style="max-width:900px;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">Comunidade (Instagram)</h1>
<a class="btn btn-outline-secondary btn-sm" href="<?= htmlspecialchars(gym_url($igym, '#comunidade')) ?>" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Ver no site</a>
</div>
<p class="text-muted small">A secção «Comunidade» (a seguir à galeria) mostra as últimas publicações em que <strong><?= htmlspecialchars($igym['name']) ?></strong> foi identificado no Instagram. Só aparecem publicações de contas públicas. As imagens ficam guardadas no site; se não houver nenhuma, a secção fica escondida. Liga ou desliga a secção em Ginásios → Secções.</p>
<div class="card shadow-sm mb-4"><div class="card-body">
<h2 class="h5 mb-3">Configuração</h2>
<form method="POST">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<div class="row g-3">
<div class="col-md-7">
<label class="form-label" for="instagram_token">Token de acesso (Graph API)</label>
<?php if ($env_tok): ?>
<input class="form-control" id="instagram_token" disabled value="Definido no .env (INSTAGRAM_TOKEN_<?= $gid ?>)">
<?php else: ?>
<input class="form-control font-monospace" type="password" id="instagram_token" name="instagram_token" autocomplete="off"
placeholder="<?= $cfg['token'] !== '' ? 'Guardado — deixa vazio para manter' : 'EAA…' ?>">
<?php if ($cfg['token'] !== ''): ?>
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="clear_token" id="clear_token" value="1">
<label class="form-check-label small" for="clear_token">Remover token guardado</label></div>
<?php endif; ?>
<?php if (!secrets_available()): ?><div class="form-text text-warning">Sem chave de encriptação no .env — o token fica guardado sem encriptação.</div><?php endif; ?>
<?php endif; ?>
<div class="form-text">Token de um utilizador do sistema (Meta Business) ou de longa duração, com <code>instagram_basic</code>, <code>instagram_manage_comments</code> e <code>pages_read_engagement</code>.</div>
</div>
<div class="col-md-5">
<label class="form-label" for="instagram_user_id">ID da conta Instagram</label>
<input class="form-control font-monospace" id="instagram_user_id" name="instagram_user_id" inputmode="numeric" value="<?= htmlspecialchars($cfg['user_id']) ?>" placeholder="1784…">
<div class="form-text">Conta profissional ligada a uma Página do Facebook. Não sabes? Usa «Encontrar» abaixo.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="ig_max">Número de publicações</label>
<input type="number" class="form-control" id="ig_max" name="max" min="3" max="24" value="<?= $cfg['max'] ?>">
<div class="form-text">Igual para todos os ginásios.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="ig_hours">Atualizar a cada (horas)</label>
<input type="number" class="form-control" id="ig_hours" name="cache_hours" min="1" value="<?= $cfg['cache_hours'] ?>">
<div class="form-text">Igual para todos os ginásios.</div>
</div>
</div>
<button class="btn btn-primary mt-3">Guardar</button>
</form>
</div></div>
<div class="card shadow-sm mb-4"><div class="card-body">
<h2 class="h5 mb-3">Encontrar o ID da conta</h2>
<p class="small text-muted">Lista as Páginas do Facebook a que o token guardado tem acesso e a conta Instagram ligada a cada uma.</p>
<form method="POST" class="mb-0">
<?= csrf_field() ?>
<input type="hidden" name="action" value="accounts">
<button class="btn btn-outline-primary btn-sm" <?= $cfg['token'] !== '' ? '' : 'disabled' ?>><i class="bi bi-search me-1"></i>Encontrar</button>
</form>
<?php if ($accounts !== null): ?>
<?php if ($accounts['error'] !== ''): ?><div class="alert alert-danger mt-3 mb-0"><?= htmlspecialchars($accounts['error']) ?></div>
<?php elseif (!$accounts['list']): ?><div class="alert alert-warning mt-3 mb-0">Nenhuma Página com conta Instagram profissional ligada.</div>
<?php else: ?>
<ul class="list-group mt-3">
<?php foreach ($accounts['list'] as $a): ?>
<li class="list-group-item d-flex justify-content-between align-items-center gap-2">
<div><strong>@<?= htmlspecialchars($a['username']) ?></strong> <span class="small text-muted">· Página <?= htmlspecialchars($a['page']) ?></span>
<div><code class="small"><?= htmlspecialchars($a['id']) ?></code></div></div>
<button type="button" class="btn btn-sm btn-outline-success text-nowrap" onclick="document.getElementById('instagram_user_id').value='<?= htmlspecialchars($a['id'], ENT_QUOTES) ?>';window.scrollTo(0,0);">Usar</button>
</li>
<?php endforeach; ?>
</ul>
<div class="form-text">Depois de «Usar», carrega em Guardar.</div>
<?php endif; ?>
<?php endif; ?>
</div></div>
<div class="card shadow-sm"><div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">Estado</h2>
<form method="POST" class="mb-0"><?= csrf_field() ?><input type="hidden" name="action" value="refresh">
<button class="btn btn-sm btn-outline-primary" <?= instagram_configured() ? '' : 'disabled' ?>><i class="bi bi-arrow-clockwise me-1"></i>Atualizar agora</button></form>
</div>
<?php if (!instagram_configured()): ?>
<p class="text-muted mb-0">Por configurar — a secção não aparece no site.</p>
<?php else: ?>
<p class="small mb-2">Última atualização: <?= !empty($cache['fetched_at']) ? date('d/m/Y H:i', (int) $cache['fetched_at']) : '—' ?></p>
<?php if (!empty($cache['error'])): ?><div class="alert alert-danger small">Último erro: <?= htmlspecialchars($cache['error']) ?></div><?php endif; ?>
<?php if ($preview): ?>
<p class="small text-muted mb-2">No site aparecem <?= count($preview) ?>:</p>
<div class="row g-2">
<?php foreach ($preview as $p): ?>
<div class="col-4 col-md-3">
<a href="<?= htmlspecialchars($p['permalink']) ?>" target="_blank" rel="noopener" class="d-block text-decoration-none small">
<img src="<?= htmlspecialchars(base_url($p['image'])) ?>" alt="" class="img-thumbnail w-100" style="aspect-ratio:1;object-fit:cover;" loading="lazy">
<span class="d-block text-truncate">@<?= htmlspecialchars($p['username']) ?></span>
</a>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="text-muted small mb-0">Ainda sem publicações identificadas — a secção está escondida.</p>
<?php endif; ?>
<?php endif; ?>
</div></div>
</div>
<?php include __DIR__ . '/../../../includes/footer.php';