diff --git a/.gitignore b/.gitignore index f9d3e4f..b158a05 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ uploads/**/.trash/ # (config/functions_privacy.php) para o browser não as pedir à Google. 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 # 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 é diff --git a/CLAUDE.md b/CLAUDE.md index 12fe414..d3a0587 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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()`. - 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_.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//.jpg` (git-ignored) because Instagram CDN URLs expire; stale ones are deleted. +- **Per gym, no migration:** `system_settings` keys `instagram_user_id_` and `instagram_token_` (encrypted with `secret_encrypt`; `.env` `INSTAGRAM_TOKEN_` 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 diff --git a/config/functions.php b/config/functions.php index a2a803f..8c6e7bb 100644 --- a/config/functions.php +++ b/config/functions.php @@ -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 diff --git a/config/functions_gyms.php b/config/functions_gyms.php index bbef01d..e71c5ec 100644 --- a/config/functions_gyms.php +++ b/config/functions_gyms.php @@ -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', diff --git a/config/functions_instagram.php b/config/functions_instagram.php new file mode 100644 index 0000000..e216813 --- /dev/null +++ b/config/functions_instagram.php @@ -0,0 +1,188 @@ + / +// instagram_token_ (token encrypted, or INSTAGRAM_TOKEN_ in .env). +// Same cache pattern as functions_reviews.php: config/cache/instagram_.cache.php, +// lazy refresh under a non-blocking lock, old copy kept on error. Thumbnails +// are downloaded at refresh time to uploads/instagram// (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, " 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']))); +} diff --git a/config/functions_site.php b/config/functions_site.php index 323bc1f..3ccb1f4 100644 --- a/config/functions_site.php +++ b/config/functions_site.php @@ -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. diff --git a/includes/header.php b/includes/header.php index 07427ea..710a90b 100644 --- a/includes/header.php +++ b/includes/header.php @@ -81,6 +81,7 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; 'sub2', 'match' => 'views/admin/events/']) ?> 'sub2', 'match' => 'views/admin/info/']) ?> 'sub2', 'match' => 'views/admin/reviews/']) ?> + 'sub2', 'match' => 'views/admin/instagram/']) ?> 'sub2', 'match' => 'views/admin/work_hours/']) ?> diff --git a/site/assets/css/style.css b/site/assets/css/style.css index 4b9320e..91a24b6 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -2687,6 +2687,7 @@ h3.contact-title { 4.5:1), so those use the ink green; dark sections keep the bright accent. */ .feature-section .section-eyebrow, .gallery-section .section-eyebrow, +.community-section .section-eyebrow, .service-section .section-eyebrow, .schedule-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, .navbar-brand img, .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; } +} diff --git a/site/index.php b/site/index.php index 014503c..f29dd8d 100644 --- a/site/index.php +++ b/site/index.php @@ -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 $memberships = $on('mensalidades') ? site_memberships($pdo, $gid) : []; $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 section ──────────────────────────────────────────────────────────────── @@ -128,6 +129,47 @@ $gallery_h2 = 'Conhece as nossas instalações'; + +
+
+
+
+
+ Comunidade +

A nossa comunidade

+

Os treinos de quem nos identifica no Instagram.

+
+
+
+ + +

+ Identifica-nos no Instagram +

+ +
+
+ + 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'; +?> +
+
+

Comunidade (Instagram)

+ Ver no site +
+

A secção «Comunidade» (a seguir à galeria) mostra as últimas publicações em que 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.

+ +
+

Configuração

+
+ + +
+
+ + + + + + +
+
+ +
Sem chave de encriptação no .env — o token fica guardado sem encriptação.
+ +
Token de um utilizador do sistema (Meta Business) ou de longa duração, com instagram_basic, instagram_manage_comments e pages_read_engagement.
+
+
+ + +
Conta profissional ligada a uma Página do Facebook. Não sabes? Usa «Encontrar» abaixo.
+
+
+ + +
Igual para todos os ginásios.
+
+
+ + +
Igual para todos os ginásios.
+
+
+ +
+
+ +
+

Encontrar o ID da conta

+

Lista as Páginas do Facebook a que o token guardado tem acesso e a conta Instagram ligada a cada uma.

+
+ + + +
+ +
+
Nenhuma Página com conta Instagram profissional ligada.
+ +
    + +
  • +
    @ · Página +
    + +
  • + +
+
Depois de «Usar», carrega em Guardar.
+ + +
+ +
+
+

Estado

+
+
+
+ +

Por configurar — a secção não aparece no site.

+ +

Última atualização:

+
Último erro:
+ +

No site aparecem :

+
+ + + +
+ +

Ainda sem publicações identificadas — a secção está escondida.

+ + +
+
+