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

+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_memberships.php'; // mensalidades + pedidos de inscrição
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_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
+1
View File
@@ -19,6 +19,7 @@ function gym_sections(): array {
return [
'oferta' => 'A Nossa Oferta',
'galeria' => 'Galeria (instalações)',
'comunidade' => 'Comunidade (Instagram)',
'modalidades' => 'Modalidades',
'equipa' => 'Equipa',
'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 {
$pdo = $GLOBALS['pdo'] ?? null;
$gid = (int) $gym['id'];
if (!$pdo && $sec !== 'galeria') return true;
if (!$pdo && $sec !== 'galeria' && $sec !== 'comunidade') return true;
return match ($sec) {
'modalidades' => (bool) site_modalidades($pdo, $gid),
'equipa' => (bool) site_team_active($pdo, $gid),
'horario' => (bool) site_horario($pdo, '', $gid),
'mensalidades' => (bool) site_memberships($pdo, $gid),
'galeria' => !empty($gym['gallery_id']) && img_list_dir('uploads/gallery/' . (int) $gym['gallery_id']),
'comunidade' => (bool) site_instagram($gym),
default => true,
};
}
@@ -54,7 +55,7 @@ function site_team_active(PDO $pdo, ?int $gym_id = null): array {
function site_navbar(?array $gym = null): array {
$gym = $gym ?? current_gym();
$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) {
if (!gym_section_on($gym, $sec)) continue;
// A section with nothing to show isn't rendered, so no link to it either.