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:
1 parent
6ac5213624
commit
e804c52b4e
10 files changed
+491
-3
No files matched your search
@@ -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 é
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'])));
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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/']) ?>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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';
|
||||||
Reference in new issue
Block a user