From f95c9ebc52b86f03ebb9caba055f7612f31604ed Mon Sep 17 00:00:00 2001 From: andre Date: Thu, 24 Sep 2026 14:58:57 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20contactos=20edit=C3=A1veis,=20equipa=20?= =?UTF-8?q?com=20t=C3=ADtulo/aptid=C3=B5es,=20mensalidades=20e=20reviews?= =?UTF-8?q?=20Google?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Contactos: nova página no backoffice (fitme3070_info) e 964 201 137 como telefone principal; o telefone secundário só aparece quando existe. - Equipa: título, aptidões/funções e formações; o cartão mostra foto, nome e título e abre um cartão horizontal de detalhe (hover no desktop, toque no telemóvel) sem mexer na grelha. - Mensalidades: cartões editáveis com um destaque, modal de inscrição que guarda o pedido (RGPD + estado + campos reservados para o CRM) e avisa o ginásio por e-mail; lista de pedidos com estados, notas e CSV. - Reviews Google via Places API (New), filtradas por classificação mínima e em cache configurável; página de configuração com pesquisa do Place ID. - Assets com ?v=: site/assets é servido com cache imutável de 1 ano. Co-Authored-By: Claude Opus 5.5 --- .htaccess | 1 + config/ci_compat.php | 12 +- config/functions.php | 3 + config/functions_info.php | 58 ++ config/functions_memberships.php | 191 ++++++ config/functions_reviews.php | 200 +++++++ config/functions_site.php | 1 + config/functions_team.php | 13 +- .../migration_20260924_info_main_phone.sql | 8 + database/migration_20260924_memberships.sql | 60 ++ .../migration_20260924_team_title_skills.sql | 9 + includes/header.php | 12 + site/assets/css/style.css | 544 +++++++++++++++++- site/assets/js/main.js | 78 +++ site/index.php | 206 ++++++- site/inscricao.php | 93 +++ views/admin/dashboard.php | 28 + views/admin/info/edit.php | 52 ++ views/admin/memberships/_form.php | 63 ++ views/admin/memberships/create.php | 38 ++ views/admin/memberships/edit.php | 42 ++ views/admin/memberships/index.php | 70 +++ views/admin/memberships/leads.php | 105 ++++ views/admin/reviews/index.php | 161 ++++++ views/admin/team/create.php | 16 +- views/admin/team/edit.php | 13 +- views/admin/team/index.php | 2 +- 27 files changed, 2063 insertions(+), 16 deletions(-) create mode 100644 config/functions_info.php create mode 100644 config/functions_memberships.php create mode 100644 config/functions_reviews.php create mode 100644 database/migration_20260924_info_main_phone.sql create mode 100644 database/migration_20260924_memberships.sql create mode 100644 database/migration_20260924_team_title_skills.sql create mode 100644 site/inscricao.php create mode 100644 views/admin/info/edit.php create mode 100644 views/admin/memberships/_form.php create mode 100644 views/admin/memberships/create.php create mode 100644 views/admin/memberships/edit.php create mode 100644 views/admin/memberships/index.php create mode 100644 views/admin/memberships/leads.php create mode 100644 views/admin/reviews/index.php diff --git a/.htaccess b/.htaccess index 2c2b8ae..d2f4cfb 100644 --- a/.htaccess +++ b/.htaccess @@ -36,6 +36,7 @@ RewriteRule ^home/modalidades/?$ site/modalidades.php [L,QSA] RewriteRule ^home/horario/?$ site/horario.php [L,QSA] RewriteRule ^home/contactos/?$ site/contactos.php [L,QSA] RewriteRule ^home/agendar/?$ site/agendar.php [L,QSA] +RewriteRule ^home/inscricao/?$ site/inscricao.php [L,QSA] RewriteRule ^home/crossfit/?$ site/crossfit.php [L,QSA] RewriteRule ^home/privacidade/?$ site/privacidade.php [L,QSA] RewriteRule ^evento/([a-zA-Z0-9-]+)/?$ site/evento.php?slug=$1 [L,QSA] diff --git a/config/ci_compat.php b/config/ci_compat.php index 0a4d0f2..485f026 100644 --- a/config/ci_compat.php +++ b/config/ci_compat.php @@ -9,11 +9,19 @@ if (!defined('SITE_INCLUDES')) { } // ' . "\n"; + return '' . "\n"; } function link_tag(string $href, string $rel = 'stylesheet'): string { - return '' . "\n"; + return '' . "\n"; } function site_url(string $path = ''): string { return base_url($path); } diff --git a/config/functions.php b/config/functions.php index 4551269..ee54c12 100644 --- a/config/functions.php +++ b/config/functions.php @@ -31,6 +31,9 @@ require_once __DIR__ . '/functions_galerias.php'; // galleries + images CRU require_once __DIR__ . '/functions_work_hours.php'; // work hours CRUD require_once __DIR__ . '/functions_horario.php'; // class schedule CRUD require_once __DIR__ . '/functions_events.php'; // time-limited events + landing pages + leads +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 // ─── DB-backed settings → legacy constants ─────────────────────────────────── // Resolved once from the settings cache so call sites read plain constants. diff --git a/config/functions_info.php b/config/functions_info.php new file mode 100644 index 0000000..e5eaade --- /dev/null +++ b/config/functions_info.php @@ -0,0 +1,58 @@ + [label, max length, input type]. Order is the form order. +function info_fields(): array { + return [ + 'name' => ['Nome do ginásio', 200, 'text'], + 'adress' => ['Morada', 200, 'text'], + 'phone' => ['Telefone principal', 25, 'tel'], + 'telephone' => ['Telefone secundário', 25, 'tel'], + 'whatsapp' => ['WhatsApp (só dígitos, ex.: 351964201137)', 13, 'text'], + 'email' => ['E-mail', 100, 'email'], + 'website' => ['Website', 100, 'text'], + 'instagram' => ['Instagram (URL)', 200, 'url'], + 'facebook' => ['Facebook (URL)', 200, 'url'], + 'messenger' => ['Messenger (URL)', 200, 'url'], + 'google_maps_link' => ['Google Maps — link', 255, 'url'], + 'google_maps' => ['Google Maps — código embed (iframe)', 0, 'textarea'], + ]; +} + +function info_from_post(array $post, array &$errors): array { + $data = []; + foreach (info_fields() as $key => [$label, $max, $type]) { + $v = trim((string) ($post[$key] ?? '')); + if ($key === 'whatsapp') $v = preg_replace('/\D+/', '', $v); + if ($max > 0 && mb_strlen($v) > $max) $errors[] = "«{$label}» tem mais de {$max} caracteres."; + if ($v !== '' && $type === 'email' && !filter_var($v, FILTER_VALIDATE_EMAIL)) $errors[] = 'O e-mail não é válido.'; + if ($v !== '' && $type === 'url' && !preg_match('~^https?://~i', $v)) $errors[] = "«{$label}» tem de começar por https://"; + $data[$key] = $v; + } + // Only a Google Maps iframe survives; anything else is dropped here rather + // than stored and filtered on every page view. + $data['google_maps'] = clean_map_embed($data['google_maps']); + if ($data['name'] === '') $errors[] = 'O nome é obrigatório.'; + if ($data['phone'] === '') $errors[] = 'O telefone principal é obrigatório.'; + return $data; +} + +function db_info_save(PDO $pdo, array $data): bool { + $cols = array_keys(info_fields()); + $exists = (bool) $pdo->query("SELECT COUNT(*) FROM fitme3070_info WHERE id = 1")->fetchColumn(); + $vals = array_map(fn($c) => $data[$c] ?? '', $cols); + if ($exists) { + $sets = implode(', ', array_map(fn($c) => "`$c` = ?", $cols)); + $stmt = $pdo->prepare("UPDATE fitme3070_info SET $sets, updated_at = NOW() WHERE id = 1"); + } else { + $names = implode(', ', array_map(fn($c) => "`$c`", $cols)); + $marks = implode(', ', array_fill(0, count($cols), '?')); + $stmt = $pdo->prepare("INSERT INTO fitme3070_info (id, $names, updated_at) VALUES (1, $marks, NOW())"); + } + $stmt->execute($vals); + return $stmt->rowCount() > 0; +} diff --git a/config/functions_memberships.php b/config/functions_memberships.php new file mode 100644 index 0000000..e18db5f --- /dev/null +++ b/config/functions_memberships.php @@ -0,0 +1,191 @@ + '', 'subtitle' => '', 'price' => '', 'is_addon' => 0, + 'price_suffix' => '/mês', 'features' => '', 'is_featured' => 0, + 'featured_label' => 'Mais popular', 'cta_label' => 'Inscrever', + 'item_order' => 0, 'is_active' => 1, + ]; +} + +function db_memberships_list(PDO $pdo, bool $include_deleted = false): array { + $sql = "SELECT * FROM memberships"; + if (!$include_deleted) $sql .= " WHERE deleted_at IS NULL"; + $sql .= " ORDER BY deleted_at IS NOT NULL, item_order, name"; + return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); +} + +// Public list: active, not deleted. Defensive — the table may not exist yet on +// an install that hasn't run the migration. +function site_memberships(PDO $pdo): array { + try { + return $pdo->query( + "SELECT * FROM memberships WHERE deleted_at IS NULL AND is_active = 1 ORDER BY item_order, name" + )->fetchAll(PDO::FETCH_ASSOC); + } catch (Throwable $e) { return []; } +} + +function db_memberships_get(PDO $pdo, int $id): ?array { + $stmt = $pdo->prepare("SELECT * FROM memberships WHERE id = ? LIMIT 1"); + $stmt->execute([$id]); + return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; +} + +// Normalise raw POST into the row shape; appends messages to $errors. +function memberships_from_post(array $post, array &$errors): array { + $price = str_replace([' ', '€'], '', trim((string) ($post['price'] ?? ''))); + $price = str_replace(',', '.', $price); + $data = [ + 'name' => mb_substr(trim((string) ($post['name'] ?? '')), 0, 100), + 'subtitle' => mb_substr(trim((string) ($post['subtitle'] ?? '')), 0, 255), + 'price' => $price, + 'is_addon' => !empty($post['is_addon']) ? 1 : 0, + 'price_suffix' => mb_substr(trim((string) ($post['price_suffix'] ?? '')), 0, 60), + 'features' => trim((string) ($post['features'] ?? '')), + 'is_featured' => !empty($post['is_featured']) ? 1 : 0, + 'featured_label' => mb_substr(trim((string) ($post['featured_label'] ?? '')), 0, 40), + 'cta_label' => mb_substr(trim((string) ($post['cta_label'] ?? '')), 0, 40), + 'item_order' => (int) ($post['item_order'] ?? 0), + 'is_active' => !empty($post['is_active']) ? 1 : 0, + ]; + if ($data['name'] === '') $errors[] = 'O nome é obrigatório.'; + if ($price !== '' && (!is_numeric($price) || (float) $price < 0)) $errors[] = 'Preço inválido — usa um número, ex.: 34,90.'; + if ($data['featured_label'] === '') $data['featured_label'] = 'Mais popular'; + if ($data['cta_label'] === '') $data['cta_label'] = 'Inscrever'; + return $data; +} + +function _memberships_params(array $d): array { + return [ + $d['name'], $d['subtitle'], + $d['price'] === '' || $d['price'] === null ? null : number_format((float) $d['price'], 2, '.', ''), + $d['is_addon'], $d['price_suffix'], $d['features'] !== '' ? $d['features'] : null, + $d['is_featured'], $d['featured_label'], $d['cta_label'], $d['item_order'], $d['is_active'], + ]; +} + +function _memberships_clear_featured(PDO $pdo, int $except): void { + $pdo->prepare("UPDATE memberships SET is_featured = 0 WHERE id <> ? AND is_featured = 1")->execute([$except]); +} + +function db_memberships_create(PDO $pdo, array $d): int { + $pdo->beginTransaction(); + $stmt = $pdo->prepare(" + INSERT INTO memberships (name, subtitle, price, is_addon, price_suffix, features, + is_featured, featured_label, cta_label, item_order, is_active, + created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW()) + "); + $stmt->execute(_memberships_params($d)); + $id = (int) $pdo->lastInsertId(); + if ($d['is_featured']) _memberships_clear_featured($pdo, $id); + $pdo->commit(); + return $id; +} + +function db_memberships_update(PDO $pdo, int $id, array $d): bool { + $pdo->beginTransaction(); + $stmt = $pdo->prepare(" + UPDATE memberships SET name = ?, subtitle = ?, price = ?, is_addon = ?, price_suffix = ?, + features = ?, is_featured = ?, featured_label = ?, cta_label = ?, item_order = ?, + is_active = ?, updated_at = NOW() + WHERE id = ? + "); + $params = _memberships_params($d); + $params[] = $id; + $stmt->execute($params); + $changed = $stmt->rowCount() > 0; + if ($d['is_featured']) _memberships_clear_featured($pdo, $id); + $pdo->commit(); + return $changed; +} + +function db_memberships_delete(PDO $pdo, int $id): bool { + $stmt = $pdo->prepare("UPDATE memberships SET deleted_at = NOW(), is_featured = 0 WHERE id = ? AND deleted_at IS NULL"); + $stmt->execute([$id]); + return $stmt->rowCount() > 0; +} + +function db_memberships_restore(PDO $pdo, int $id): bool { + $stmt = $pdo->prepare("UPDATE memberships SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL"); + $stmt->execute([$id]); + return $stmt->rowCount() > 0; +} + +// "34,90 €" / "+10 €" / "Sob consulta" — PT formatting, drops ",00". +function membership_price_label(array $m): string { + if ($m['price'] === null || $m['price'] === '') return 'Sob consulta'; + $p = (float) $m['price']; + $s = number_format($p, floor($p) == $p ? 0 : 2, ',', ' '); + return (!empty($m['is_addon']) ? '+' : '') . $s . ' €'; +} + +// Non-empty trimmed lines of a textarea — used for features, skills, formações. +function text_lines(?string $text): array { + $out = []; + foreach (preg_split('/\R/', (string) $text) as $line) { + $line = trim($line, " \t-•"); + if ($line !== '') $out[] = $line; + } + return $out; +} + +// ─── Pedidos de inscrição (leads) ───────────────────────────────────────────── + +function membership_lead_statuses(): array { + return ['novo' => 'Novo', 'contactado' => 'Contactado', 'inscrito' => 'Inscrito', 'descartado' => 'Descartado']; +} + +function db_membership_leads_list(PDO $pdo, string $status = ''): array { + $sql = "SELECT * FROM membership_leads"; + $args = []; + if ($status !== '' && isset(membership_lead_statuses()[$status])) { $sql .= " WHERE status = ?"; $args[] = $status; } + $sql .= " ORDER BY created_at DESC, id DESC"; + $stmt = $pdo->prepare($sql); + $stmt->execute($args); + return $stmt->fetchAll(PDO::FETCH_ASSOC); +} + +function db_membership_leads_count_new(PDO $pdo): int { + try { return (int) $pdo->query("SELECT COUNT(*) FROM membership_leads WHERE status = 'novo'")->fetchColumn(); } + catch (Throwable $e) { return 0; } +} + +function db_membership_lead_create(PDO $pdo, array $d): int { + $stmt = $pdo->prepare(" + INSERT INTO membership_leads (membership_id, membership_name, name, email, phone, message, + consent, consent_text, ip, user_agent, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?, NOW(), NOW()) + "); + $stmt->execute([ + $d['membership_id'] ?: null, + mb_substr((string) ($d['membership_name'] ?? ''), 0, 100), + mb_substr(trim((string) ($d['name'] ?? '')), 0, 150), + mb_substr(trim((string) ($d['email'] ?? '')), 0, 190), + mb_substr(trim((string) ($d['phone'] ?? '')), 0, 40), + mb_substr(trim((string) ($d['message'] ?? '')), 0, 1000), + !empty($d['consent']) ? 1 : 0, + mb_substr((string) ($d['consent_text'] ?? ''), 0, 255), + mb_substr((string) ($d['ip'] ?? ''), 0, 45), + mb_substr((string) ($d['user_agent'] ?? ''), 0, 255), + ]); + return (int) $pdo->lastInsertId(); +} + +function db_membership_lead_update(PDO $pdo, int $id, string $status, string $notes): bool { + if (!isset(membership_lead_statuses()[$status])) return false; + $stmt = $pdo->prepare("UPDATE membership_leads SET status = ?, admin_notes = ?, updated_at = NOW() WHERE id = ?"); + $stmt->execute([$status, $notes !== '' ? $notes : null, $id]); + return $stmt->rowCount() > 0; +} + +function db_membership_lead_delete(PDO $pdo, int $id): bool { + $stmt = $pdo->prepare("DELETE FROM membership_leads WHERE id = ?"); + $stmt->execute([$id]); + return $stmt->rowCount() > 0; +} diff --git a/config/functions_reviews.php b/config/functions_reviews.php new file mode 100644 index 0000000..edc7413 --- /dev/null +++ b/config/functions_reviews.php @@ -0,0 +1,200 @@ + setting_secret('google_places_api_key', 'GOOGLE_PLACES_API_KEY'), + 'place_id' => trim((string) setting('google_place_id', '')), + 'min_rating' => max(1, min(5, (int) setting('google_reviews_min_rating', 4))), + 'max' => max(1, min(5, (int) setting('google_reviews_max', 3))), + 'cache_hours' => max(1, (int) setting('google_reviews_cache_hours', 24)), + ]; +} + +function reviews_configured(): bool { + $c = reviews_config(); + return $c['api_key'] !== '' && $c['place_id'] !== ''; +} + +function _reviews_cache_path(): string { + return __DIR__ . '/cache/google_reviews.cache.php'; +} + +function reviews_cache_read(): ?array { + $path = _reviews_cache_path(); + if (!is_file($path)) return null; + $data = @include $path; + return is_array($data) ? $data : null; +} + +function _reviews_cache_write(array $data): void { + $path = _reviews_cache_path(); + if (!is_dir(dirname($path))) @mkdir(dirname($path), 0775, true); + $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; + if (@file_put_contents($tmp, " $method, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 5, + ]); + $raw = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $cerr = curl_error($ch); + curl_close($ch); + if ($raw === false) return [0, null, 'Sem ligação à Google: ' . $cerr]; + $json = json_decode((string) $raw, true); + if ($status !== 200) { + $msg = is_array($json) ? (string) ($json['error']['message'] ?? '') : ''; + return [$status, $json, 'Google respondeu ' . $status . ($msg !== '' ? ': ' . $msg : '')]; + } + return [$status, is_array($json) ? $json : null, is_array($json) ? '' : 'Resposta inválida da Google.']; +} + +// Fetch Place Details now and rewrite the cache. Returns the cache array. +function reviews_refresh(): array { + $c = reviews_config(); + $old = reviews_cache_read() ?? []; + if ($c['api_key'] === '' || $c['place_id'] === '') { + return $old + ['error' => 'Falta a chave da API ou o Place ID.']; + } + $url = 'https://places.googleapis.com/v1/places/' . rawurlencode($c['place_id']) . '?languageCode=pt-PT'; + [$status, $json, $err] = _places_request('GET', $url, $c['api_key'], REVIEWS_FIELD_MASK); + + 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); + _reviews_cache_write($data); + error_log('[reviews] ' . $err); + return $data; + } + + $reviews = []; + foreach ((array) ($json['reviews'] ?? []) as $r) { + $reviews[] = [ + 'rating' => (int) ($r['rating'] ?? 0), + 'text' => (string) ($r['text']['text'] ?? $r['originalText']['text'] ?? ''), + 'relative' => (string) ($r['relativePublishTimeDescription'] ?? ''), + 'published' => (string) ($r['publishTime'] ?? ''), + 'author' => (string) ($r['authorAttribution']['displayName'] ?? ''), + 'author_uri' => (string) ($r['authorAttribution']['uri'] ?? ''), + 'author_photo' => (string) ($r['authorAttribution']['photoUri'] ?? ''), + 'uri' => (string) ($r['googleMapsUri'] ?? ''), + ]; + } + $data = [ + 'fetched_at' => time(), + 'name' => (string) ($json['displayName']['text'] ?? ''), + 'rating' => isset($json['rating']) ? (float) $json['rating'] : null, + 'total' => (int) ($json['userRatingCount'] ?? 0), + 'maps_uri' => (string) ($json['googleMapsUri'] ?? ''), + 'reviews' => $reviews, + 'error' => '', + ]; + _reviews_cache_write($data); + return $data; +} + +// Cached data, refreshed when older than the cache window. The refresh is +// serialised with a non-blocking lock so a burst of visitors at expiry makes +// one Google call, not one each — the others just serve the stale copy. +function reviews_get(): array { + $c = reviews_config(); + $cache = reviews_cache_read(); + $fresh = $cache && (time() - (int) ($cache['fetched_at'] ?? 0)) < $c['cache_hours'] * 3600; + if ($fresh || !reviews_configured()) return $cache ?? []; + + $lock = @fopen(_reviews_cache_path() . '.lock', 'c'); + if ($lock && flock($lock, LOCK_EX | LOCK_NB)) { + $cache = reviews_refresh(); + flock($lock, LOCK_UN); + } + if ($lock) fclose($lock); + return $cache ?? []; +} + +// What the homepage renders: summary + the best qualifying reviews, or null +// when there is nothing worth showing (not configured, no qualifying review). +function site_reviews(): ?array { + try { $data = reviews_get(); } catch (Throwable $e) { error_log('[reviews] ' . $e->getMessage()); return null; } + if (!$data || empty($data['reviews'])) return null; + $c = reviews_config(); + $picked = array_values(array_filter($data['reviews'], fn($r) => $r['rating'] >= $c['min_rating'] && trim($r['text']) !== '')); + // Highest rating first; Google's relevance order breaks ties (usort is stable). + usort($picked, fn($a, $b) => $b['rating'] <=> $a['rating']); + $picked = array_slice($picked, 0, $c['max']); + if (!$picked) return null; + return [ + 'rating' => $data['rating'], + 'total' => $data['total'], + 'maps_uri' => $data['maps_uri'], + 'write_uri' => 'https://search.google.com/local/writereview?placeid=' . rawurlencode($c['place_id']), + 'reviews' => $picked, + ]; +} + +// Backoffice helper: find the Place ID from a free-text query (Text Search, +// IDs + name + address only — the cheapest field set). +function reviews_find_place(string $query, string $apiKey): array { + [$status, $json, $err] = _places_request( + 'POST', 'https://places.googleapis.com/v1/places:searchText', $apiKey, + 'places.id,places.displayName,places.formattedAddress,places.rating,places.userRatingCount', + ['textQuery' => $query, 'languageCode' => 'pt-PT', 'regionCode' => 'PT'] + ); + if ($err !== '') return ['error' => $err, 'places' => []]; + $out = []; + foreach ((array) ($json['places'] ?? []) as $p) { + $out[] = [ + 'id' => (string) ($p['id'] ?? ''), + 'name' => (string) ($p['displayName']['text'] ?? ''), + 'address' => (string) ($p['formattedAddress'] ?? ''), + 'rating' => $p['rating'] ?? null, + 'total' => (int) ($p['userRatingCount'] ?? 0), + ]; + } + return ['error' => '', 'places' => $out]; +} diff --git a/config/functions_site.php b/config/functions_site.php index 5725525..b32b669 100644 --- a/config/functions_site.php +++ b/config/functions_site.php @@ -30,6 +30,7 @@ function site_navbar(): array { 'Equipa' => '#equipa', 'Modalidades' => '#modalidades', 'Horário' => '#horario', + 'Mensalidades' => '#mensalidades', 'Contactos' => '#contactos', 'CrossFit' => 'home/crossfit', // separate page, navbar styles it specially ]; diff --git a/config/functions_team.php b/config/functions_team.php index f7737ba..3adf515 100644 --- a/config/functions_team.php +++ b/config/functions_team.php @@ -18,28 +18,35 @@ function db_team_get(PDO $pdo, int $id): ?array { function db_team_create(PDO $pdo, array $data): int { $stmt = $pdo->prepare(" - INSERT INTO team (name, birthday, `at`, status, description, photo, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW()) + INSERT INTO team (name, title, birthday, `at`, status, description, skills, certifications, photo, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW()) "); $stmt->execute([ $data['name'], + $data['title'] ?? '', $data['birthday'] ?: null, $data['at'] ?: null, $data['status'] ?? 'ativo', $data['description'] ?? null, + ($data['skills'] ?? '') !== '' ? $data['skills'] : null, + ($data['certifications'] ?? '') !== '' ? $data['certifications'] : null, $data['photo'] ?? null, ]); return (int) $pdo->lastInsertId(); } function db_team_update(PDO $pdo, int $id, array $data, ?string $photo = null): bool { - $sets = ["name = ?", "birthday = ?", "`at` = ?", "status = ?", "description = ?", "updated_at = NOW()"]; + $sets = ["name = ?", "title = ?", "birthday = ?", "`at` = ?", "status = ?", "description = ?", + "skills = ?", "certifications = ?", "updated_at = NOW()"]; $params = [ $data['name'], + $data['title'] ?? '', $data['birthday'] ?: null, $data['at'] ?: null, $data['status'] ?? 'ativo', $data['description'] ?? null, + ($data['skills'] ?? '') !== '' ? $data['skills'] : null, + ($data['certifications'] ?? '') !== '' ? $data['certifications'] : null, ]; if ($photo !== null) { $sets[] = "photo = ?"; diff --git a/database/migration_20260924_info_main_phone.sql b/database/migration_20260924_info_main_phone.sql new file mode 100644 index 0000000..bd7bd07 --- /dev/null +++ b/database/migration_20260924_info_main_phone.sql @@ -0,0 +1,8 @@ +-- ─── Contactos: novo número principal ──────────────────────────────────────── +-- 964 201 137 passa a ser o telefone principal (phone), substituindo o número +-- anterior, que sai do site. O telefone secundário (telephone) não muda. +-- A partir daqui os contactos editam-se no backoffice (Gestão → Contactos). + +UPDATE `fitme3070_info` + SET `phone` = '964 201 137', `updated_at` = NOW() + WHERE `id` = 1; diff --git a/database/migration_20260924_memberships.sql b/database/migration_20260924_memberships.sql new file mode 100644 index 0000000..a015b79 --- /dev/null +++ b/database/migration_20260924_memberships.sql @@ -0,0 +1,60 @@ +-- ─── Mensalidades + pedidos de inscrição ──────────────────────────────────── +-- memberships: cartões de mensalidade editáveis no backoffice. Um só pode estar +-- em destaque (garantido na aplicação). is_addon = acresce à mensalidade base +-- (mostra "+X €"). +-- membership_leads: pedidos do modal "Inscrever". Guarda o consentimento RGPD +-- (texto + IP + UA), o estado de acompanhamento e, reservado para a futura +-- integração com o CRM do ginásio, crm_ref / crm_synced_at. +-- Safe to re-run. + +CREATE TABLE IF NOT EXISTS `memberships` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `subtitle` VARCHAR(255) NOT NULL DEFAULT '', + `price` DECIMAL(8,2) DEFAULT NULL, + `is_addon` TINYINT(1) NOT NULL DEFAULT 0, + `price_suffix` VARCHAR(60) NOT NULL DEFAULT '/mês', + `features` TEXT DEFAULT NULL, + `is_featured` TINYINT(1) NOT NULL DEFAULT 0, + `featured_label` VARCHAR(40) NOT NULL DEFAULT 'Mais popular', + `cta_label` VARCHAR(40) NOT NULL DEFAULT 'Inscrever', + `item_order` INT(11) NOT NULL DEFAULT 0, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_memberships_list` (`deleted_at`, `is_active`, `item_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `membership_leads` ( + `id` INT(11) NOT NULL AUTO_INCREMENT, + `membership_id` INT(11) DEFAULT NULL, + `membership_name` VARCHAR(100) NOT NULL DEFAULT '', + `name` VARCHAR(150) NOT NULL, + `email` VARCHAR(190) NOT NULL DEFAULT '', + `phone` VARCHAR(40) NOT NULL DEFAULT '', + `message` VARCHAR(1000) NOT NULL DEFAULT '', + `status` ENUM('novo','contactado','inscrito','descartado') NOT NULL DEFAULT 'novo', + `admin_notes` TEXT DEFAULT NULL, + `consent` TINYINT(1) NOT NULL DEFAULT 0, + `consent_text` VARCHAR(255) NOT NULL DEFAULT '', + `ip` VARCHAR(45) NOT NULL DEFAULT '', + `user_agent` VARCHAR(255) NOT NULL DEFAULT '', + `crm_ref` VARCHAR(100) DEFAULT NULL, + `crm_synced_at` DATETIME DEFAULT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_membership_leads_status` (`status`, `created_at`), + KEY `idx_membership_leads_crm` (`crm_synced_at`), + CONSTRAINT `fk_membership_leads_membership` + FOREIGN KEY (`membership_id`) REFERENCES `memberships` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Cartão de exemplo (o preço fica por definir no backoffice). +INSERT INTO `memberships` (`name`, `subtitle`, `price`, `is_addon`, `price_suffix`, `features`, `item_order`) +SELECT 'Off-hours', 'Mensalidade para as horas baixas do ginásio.', NULL, 1, 'acresce à mensalidade', + 'Acesso nas horas de menor afluência\nSala de musculação e cardio', 10 + FROM DUAL + WHERE NOT EXISTS (SELECT 1 FROM `memberships`); diff --git a/database/migration_20260924_team_title_skills.sql b/database/migration_20260924_team_title_skills.sql new file mode 100644 index 0000000..7f20f5c --- /dev/null +++ b/database/migration_20260924_team_title_skills.sql @@ -0,0 +1,9 @@ +-- ─── Equipa: título, aptidões/funções e formações ─────────────────────────── +-- O cartão público mostra foto + nome + título; aptidões e formações (uma por +-- linha) aparecem no cartão de detalhe ao passar o rato / tocar. +-- Safe to re-run. + +ALTER TABLE `team` + ADD COLUMN IF NOT EXISTS `title` VARCHAR(100) NOT NULL DEFAULT '' AFTER `name`, + ADD COLUMN IF NOT EXISTS `skills` TEXT DEFAULT NULL AFTER `description`, + ADD COLUMN IF NOT EXISTS `certifications` TEXT DEFAULT NULL AFTER `skills`; diff --git a/includes/header.php b/includes/header.php index 897c448..677de01 100644 --- a/includes/header.php +++ b/includes/header.php @@ -130,6 +130,18 @@ $app_name = defined('APP_NAME') ? APP_NAME : 'FitMe 3070'; Horário de Aulas + + + diff --git a/site/assets/css/style.css b/site/assets/css/style.css index 8c4e0e8..289ea87 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -2204,6 +2204,11 @@ section[id] { margin-bottom: .5rem; } +/* Short titles (e.g. "Modalidades") would otherwise sit on the eyebrow's line. */ +.section-title.deco .section-eyebrow { + display: block; +} + .section-title.deco h2 { position: relative; display: inline-block; @@ -2913,4 +2918,541 @@ section[id] { .event-consent a { color: var(--fitme-accent-dark); text-decoration: underline; } @media (max-width: 575px) { .event-form-grid { grid-template-columns: 1fr; } -} \ No newline at end of file +} + +/* ══════════════════════════════════════════════════════════════════════════ + Equipa — título + cartão de detalhe horizontal + Default card = photo + name + title. The detail card is laid OVER the grid + (absolute), so opening it never reflows the team wall. Desktop (fine pointer) + opens it on hover / keyboard focus; touch opens it on tap (.is-open, main.js), + where it becomes a centred fixed card with its own close button. + ══════════════════════════════════════════════════════════════════════════ */ +/* content-visibility:auto implies paint containment, which would clip the + overlay at the section edge and trap position:fixed inside the section. */ +#equipa.trainers-section { + content-visibility: visible; + contain: none; + position: relative; + z-index: 3; /* hover card may overhang the next section */ +} + +/* While a card is open on touch, lift the whole section over the navbar and + the mobile CTA bar so the dimmed backdrop covers everything. */ +#equipa.trainers-section.has-open-detail { + z-index: 1100; +} + +/* The global scroll-reveal keeps transform:translateY(0) applied (fill + forwards), and any transform makes an element the containing block for + position:fixed descendants — which would pin the mobile detail card inside + its own tile. Reveal the team with opacity only. */ +#equipa.revealed, +#equipa .trainer-item.revealed { + animation-name: fitmeFadeOnly; +} + +@keyframes fitmeFadeOnly { + from { opacity: 0; } + to { opacity: 1; } +} + +.trainer-item { + position: relative; +} + +/* The name used to be the tile's last child and got its size from the + `.trainer-item>*:last-child` rule; pin it so it stays the same whether or + not a title / "Saber mais" follows it. */ +.trainers-section .trainer-item h4 { + font-size: .9rem; +} + +/* …and keep that same rule (white, .9rem) off the detail card when it is the + last child. */ +.trainers-section .trainer-item>.trainer-detail { + color: var(--fitme-text); + font-size: 1rem; +} + +.trainers-section .trainer-item p.trainer-title { + margin: 0; + font-size: .85rem; + font-weight: 600; + letter-spacing: .04em; + color: var(--fitme-accent); +} + +.trainer-item.has-detail { + cursor: pointer; + outline: none; +} + +.trainer-more { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: .55rem; + font-size: .72rem; + font-weight: 700; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--fitme-ash); + transition: color var(--fitme-transition); +} + +.trainer-more i { font-size: .65rem; } + +.trainer-item.has-detail:hover .trainer-more, +.trainer-item.has-detail:focus-visible .trainer-more, +.trainer-item.has-detail.is-open .trainer-more { + color: var(--fitme-accent); +} + +.trainer-detail { + position: absolute; + z-index: 30; + top: -10px; + left: 50%; + width: min(560px, calc(100vw - 32px)); + transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(8px); + display: flex; + text-align: left; + background: #fff; + color: var(--fitme-text); + border-radius: 6px; + border-left: 4px solid var(--fitme-accent); + box-shadow: 0 24px 60px rgba(0, 0, 0, .45); + overflow: hidden; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity .2s ease, transform .2s ease, visibility 0s linear .2s; +} + +.trainer-item.is-open .trainer-detail { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(0); + transition: opacity .2s ease, transform .2s ease, visibility 0s; +} + +@media (hover: hover) and (pointer: fine) { + .trainer-item.has-detail:hover .trainer-detail, + .trainer-item.has-detail:focus-within .trainer-detail { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translateX(calc(-50% + var(--td-shift, 0px))) translateY(0); + transition: opacity .2s ease .12s, transform .2s ease .12s, visibility 0s .12s; + } + .trainer-item.has-detail:hover { z-index: 31; } +} + +.trainer-item.is-open { z-index: 31; } + +.td-photo { + flex: 0 0 38%; + max-width: 210px; + background: var(--fitme-dark-2); +} + +.td-photo img { + width: 100%; + height: 100%; + min-height: 220px; + object-fit: cover; + object-position: top; + display: block; +} + +.td-body { + flex: 1 1 auto; + padding: 18px 22px 18px 20px; + max-height: 340px; + overflow-y: auto; +} + +.td-name { + display: block; + font-size: 1.15rem; + font-weight: 800; + color: var(--fitme-dark); + padding-right: 20px; +} + +.td-title { + display: block; + font-size: .82rem; + font-weight: 700; + color: var(--fitme-accent-dark); + margin-bottom: .6rem; +} + +.td-bio { + font-size: .88rem; + color: var(--fitme-text-soft); + margin-bottom: .6rem; +} + +.td-bio p:last-child { margin-bottom: 0; } + +.td-block + .td-block { margin-top: .55rem; } + +.td-label { + display: block; + font-size: .68rem; + font-weight: 800; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--fitme-text-muted); + margin-bottom: .25rem; +} + +.td-block ul { + list-style: none; + padding: 0; + margin: 0; +} + +.td-block li { + position: relative; + padding-left: 14px; + font-size: .87rem; + line-height: 1.45; + color: var(--fitme-text); +} + +.td-block li:before { + content: ''; + position: absolute; + left: 0; + top: .55em; + width: 6px; + height: 6px; + border-radius: 1px; + background: var(--fitme-accent); +} + +.td-close { + position: absolute; + top: 6px; + right: 10px; + z-index: 2; + background: transparent; + border: 0; + font-size: 1.6rem; + line-height: 1; + color: var(--fitme-text-muted); + cursor: pointer; + display: none; +} + +/* Touch / small screens: a centred fixed card over a dimmed page. */ +@media (max-width: 767px), (hover: none) { + .trainer-detail { + position: fixed; + top: 50%; + left: 16px; + right: 16px; + width: auto; + max-width: 560px; + margin: 0 auto; + transform: translateY(-46%); + max-height: calc(100vh - 48px); + } + .trainer-item.is-open .trainer-detail { transform: translateY(-50%); } + .trainer-item.is-open:after { + content: ''; + position: fixed; + inset: 0; + z-index: 29; + background: rgba(0, 0, 0, .55); + } + .td-close { display: block; } + .td-photo { flex-basis: 34%; } + .td-photo img { min-height: 180px; } + .td-body { max-height: calc(100vh - 48px); padding: 16px 16px 16px 14px; } +} + +@media (max-width: 339px) { + .trainer-detail { flex-direction: column; } + .td-photo { max-width: none; flex-basis: auto; } + .td-photo img { min-height: 0; height: 160px; } +} + +/* ══════════════════════════════════════════════════════════════════════════ + Mensalidades — cartões de preço + ══════════════════════════════════════════════════════════════════════════ */ +.pricing-section { + padding: 90px 0 70px; + background: var(--fitme-surface); +} + +.pricing-grid { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: stretch; + gap: 24px; +} + +.price-card { + position: relative; + flex: 0 1 270px; + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid var(--fitme-surface-2); + border-radius: 6px; + padding: 30px 26px 26px; + box-shadow: var(--fitme-shadow); + transition: transform var(--fitme-transition), box-shadow var(--fitme-transition); +} + +.price-card:hover { + transform: translateY(-4px); + box-shadow: var(--fitme-shadow-lg); +} + +.price-card.is-featured { + background: var(--fitme-dark-2); + border: 2px solid var(--fitme-accent); + color: var(--fitme-ash); + box-shadow: 0 18px 50px rgba(var(--fitme-dark-rgb), .28); +} + +@media (min-width: 992px) { + .price-card.is-featured { transform: scale(1.04); } + .price-card.is-featured:hover { transform: scale(1.04) translateY(-4px); } +} + +.pc-badge { + position: absolute; + top: 0; + left: 50%; + transform: translate(-50%, -50%); + white-space: nowrap; + background: var(--fitme-accent); + color: var(--fitme-dark); + font-size: .72rem; + font-weight: 800; + letter-spacing: .12em; + text-transform: uppercase; + padding: 6px 14px; + border-radius: 999px; +} + +.pc-badge i { font-size: .65rem; } + +.price-card .pc-name { + font-size: 1.35rem; + font-weight: 900; + margin: 0 0 .35rem; + color: var(--fitme-dark); +} + +.price-card.is-featured .pc-name { color: #fff; } + +.pc-sub { + font-size: .9rem; + line-height: 1.45; + color: var(--fitme-text-muted); + margin: 0 0 1rem; +} + +.price-card.is-featured .pc-sub { color: var(--fitme-ash); } + +.pc-price { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + padding: .9rem 0 1rem; + border-top: 1px solid var(--fitme-surface-2); + border-bottom: 1px solid var(--fitme-surface-2); + margin-bottom: 1rem; +} + +.price-card.is-featured .pc-price { border-color: rgba(255, 255, 255, .12); } + +.pc-amount { + font-size: 2.3rem; + font-weight: 900; + line-height: 1; + color: var(--fitme-dark); +} + +.pc-amount.is-text { font-size: 1.35rem; } + +.price-card.is-featured .pc-amount { color: var(--fitme-accent); } + +.pc-suffix { + font-size: .88rem; + color: var(--fitme-text-muted); +} + +.price-card.is-featured .pc-suffix { color: var(--fitme-ash); } + +.pc-features { + list-style: none; + padding: 0; + margin: 0 0 1.4rem; + flex: 1 1 auto; +} + +.pc-features li { + display: flex; + gap: 10px; + font-size: .92rem; + line-height: 1.4; + padding: 4px 0; + color: var(--fitme-text-soft); +} + +.price-card.is-featured .pc-features li { color: #fff; } + +.pc-features i { + color: var(--fitme-accent-dark); + margin-top: 3px; + font-size: .8rem; +} + +.price-card.is-featured .pc-features i { color: var(--fitme-accent); } + +.price-card .pc-cta { + width: 100%; + border: none; + margin-top: auto; +} + +.price-card:not(.is-featured) .pc-cta { + background: var(--fitme-dark-2); + color: var(--fitme-accent); +} + +.price-card:not(.is-featured) .pc-cta:hover { + background: var(--fitme-dark-3); + box-shadow: none; +} + +@media (max-width: 575px) { + .pricing-section { padding: 60px 0 50px; } + .price-card { flex-basis: 100%; } +} + +/* ══════════════════════════════════════════════════════════════════════════ + Reviews Google + ══════════════════════════════════════════════════════════════════════════ */ +.reviews-section { + padding: 90px 0 60px; + background: var(--fitme-dark); + color: var(--fitme-ash); +} + +.reviews-section .section-title h2 { color: #fff; } + +.reviews-summary { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 6px 12px; + margin: 1rem 0 0; + color: var(--fitme-ash); +} + +.reviews-summary strong { + font-size: 1.8rem; + font-weight: 900; + color: #fff; + line-height: 1; +} + +.rv-stars { + color: #FBBC04; + letter-spacing: 2px; + font-size: .95rem; +} + +.reviews-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 22px; +} + +.review-card { + display: flex; + flex-direction: column; + margin: 0; + background: var(--fitme-dark-2); + border: 1px solid var(--fitme-dark-3); + border-radius: 6px; + padding: 24px; +} + +.rv-text { + flex: 1 1 auto; + margin: .8rem 0 1.2rem; + font-size: .95rem; + line-height: 1.6; + color: #fff; + display: -webkit-box; + -webkit-line-clamp: 7; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.rv-author { + display: flex; + align-items: center; + gap: 12px; +} + +.rv-author img { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; +} + +.rv-author span { + display: flex; + flex-direction: column; + font-weight: 700; + color: #fff; +} + +.rv-author a { color: #fff; } +.rv-author a:hover { color: var(--fitme-accent); } + +.rv-author small { + font-weight: 400; + color: var(--fitme-ash); +} + +.reviews-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + margin-top: 2rem; +} + +.reviews-section .site-btn.sb-line { + border-color: #fff; + color: #fff; +} + +.reviews-section .site-btn.sb-line:hover { + background: #fff; + color: var(--fitme-dark); +} + +.reviews-attribution { + margin: 1.2rem 0 0; + text-align: center; + font-size: .75rem; + color: var(--fitme-ash); + opacity: .8; +} diff --git a/site/assets/js/main.js b/site/assets/js/main.js index 7eebf67..e92b8f6 100644 --- a/site/assets/js/main.js +++ b/site/assets/js/main.js @@ -222,3 +222,81 @@ document.addEventListener('DOMContentLoaded', function () { var t; window.addEventListener('resize', function () { clearTimeout(t); t = setTimeout(sync, 150); }, { passive: true }); }); + + +/* ── Equipa: horizontal detail card ───────────────────────────────────────── + Desktop (fine pointer) opens it on hover purely in CSS; this handles tap / + click / keyboard (.is-open, one at a time; Esc, the × and outside clicks + close) and nudges the absolutely-positioned card sideways (--td-shift) so an + edge column's card stays inside the viewport. On small / touch screens the + CSS turns it into a centred fixed card, so no shift is applied there. */ +document.addEventListener('DOMContentLoaded', function () { + var items = document.querySelectorAll('.trainer-item.has-detail'); + if (!items.length) return; + var floating = window.matchMedia('(max-width: 767px), (hover: none)'); + + function place(item) { + var card = item.querySelector('.trainer-detail'); + if (!card) return; + card.style.setProperty('--td-shift', '0px'); + if (floating.matches) return; + var r = card.getBoundingClientRect(); + var itemR = item.getBoundingClientRect(); + // Where the card would sit centred on the item (ignoring its current transform). + var left = itemR.left + itemR.width / 2 - r.width / 2; + var right = left + r.width; + var gutter = 16, vw = document.documentElement.clientWidth, shift = 0; + if (left < gutter) shift = gutter - left; + else if (right > vw - gutter) shift = (vw - gutter) - right; + card.style.setProperty('--td-shift', Math.round(shift) + 'px'); + } + var section = document.getElementById('equipa'); + function setOpen(item, open) { + item.classList.toggle('is-open', open); + item.setAttribute('aria-expanded', open ? 'true' : 'false'); + if (section) section.classList.toggle('has-open-detail', !!section.querySelector('.trainer-item.is-open')); + } + function closeAll(except) { + items.forEach(function (it) { if (it !== except) setOpen(it, false); }); + } + + items.forEach(function (item) { + item.addEventListener('mouseenter', function () { place(item); }); + item.addEventListener('focus', function () { place(item); }); + item.addEventListener('click', function (e) { + e.stopPropagation(); + if (e.target.closest('.td-close')) { setOpen(item, false); return; } + // Clicks inside an open card (e.g. scrolling / selecting text) keep it open. + if (e.target.closest('.trainer-detail') && item.classList.contains('is-open')) return; + // Tapping the dimmed backdrop (the item's ::after) closes it. + if (floating.matches && item.classList.contains('is-open') && !e.target.closest('.trainer-detail')) { + setOpen(item, false); return; + } + closeAll(item); + place(item); + setOpen(item, !item.classList.contains('is-open')); + }); + item.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { + if (e.target !== item) return; + e.preventDefault(); + closeAll(item); place(item); + setOpen(item, !item.classList.contains('is-open')); + } else if (e.key === 'Escape') { + setOpen(item, false); + item.blur(); + } + }); + }); + document.addEventListener('click', function () { closeAll(null); }); + document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAll(null); }); +}); + +/* ── Mensalidades: pre-select the clicked card in the sign-up modal ───────── */ +document.addEventListener('DOMContentLoaded', function () { + var select = document.querySelector('#joinModal select[name="membership_id"]'); + if (!select) return; + document.querySelectorAll('[data-target="#joinModal"][data-membership]').forEach(function (btn) { + btn.addEventListener('click', function () { select.value = btn.getAttribute('data-membership'); }); + }); +}); diff --git a/site/index.php b/site/index.php index fe38ff5..cce2034 100644 --- a/site/index.php +++ b/site/index.php @@ -11,6 +11,8 @@ $modalidades = site_modalidades($pdo); $horario = site_horario($pdo); $whours = site_work_hours($pdo); $events = site_active_events($pdo); // time-limited events, banner below the hero +$memberships = site_memberships($pdo); +$reviews = site_reviews(); // null when not configured / nothing qualifies // ── Hero section ──────────────────────────────────────────────────────────────── ?> @@ -118,15 +120,46 @@ if ($team):
- +
-
+ -
@@ -387,6 +420,108 @@ if (!in_array($default_day, $week_values, true)) { $default_day = $week_values[0 + +
+
+
+
+
+ Mensalidades +

Escolhe o teu plano

+

Encontra a mensalidade que encaixa na tua rotina.

+
+
+
+ + + + + +
+ +
+ +

+

+
+ + +
+ +
    +
  • +
+ + +
+ +
+
+
+ + += $i ? 'fas fa-star' : ($r >= $i - 0.5 ? 'fas fa-star-half-alt' : 'far fa-star'); + $out .= ''; + } + return $out; + }; +?> +
+
+
+
+
+ Reviews +

O que dizem de nós

+ +

+ + + avaliações no Google +

+ +
+
+
+
+ +
+ +
+
+ + + + + +
+
+ +
+ +

Reviews via Google Maps

+
+
+ + ">

- -

+ +
+

+
@@ -618,6 +755,63 @@ $work_hours_days = ['Segunda-Sexta'=>'segunda','Sábado'=>'sabado','Domingo/Feri + + + + + + + $membership ? (int) $membership['id'] : null, + 'membership_name' => $membership ? $membership['name'] : '', + 'name' => trim((string) ($_POST['nome'] ?? '')), + 'phone' => trim((string) ($_POST['telefone'] ?? '')), + 'email' => trim((string) ($_POST['email'] ?? '')), + 'message' => trim((string) ($_POST['mensagem'] ?? '')), + 'consent' => !empty($_POST['consent']) ? 1 : 0, + 'consent_text' => $consent_text, + // First hop of X-Forwarded-For (behind the reverse proxy), else peer. + 'ip' => trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))[0]) ?: ($_SERVER['REMOTE_ADDR'] ?? ''), + 'user_agent' => (string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), +]; + +if ($lead['name'] === '' || $lead['phone'] === '' || !$lead['consent'] + || ($lead['email'] !== '' && !filter_var($lead['email'], FILTER_VALIDATE_EMAIL))) { + inscricao_redirect('erro'); +} + +try { + db_membership_lead_create($pdo, $lead); +} catch (Throwable $e) { + error_log('[inscricao] ' . $e->getMessage()); + inscricao_redirect('erro'); +} + +// Notify the gym. Best effort: the request is already stored. +try { + $info = site_info($pdo); + $to = $info['email'] !== '' ? $info['email'] : EMAIL_FROM; + $e = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); + $rows = [ + 'Mensalidade' => $lead['membership_name'], + 'Nome' => $lead['name'], + 'Telemóvel' => $lead['phone'], + 'E-mail' => $lead['email'], + 'Mensagem' => $lead['message'], + ]; + $body = '

Novo pedido de inscrição

'; + $alt = "Novo pedido de inscrição\n\n"; + foreach ($rows as $label => $value) { + if (trim((string) $value) === '') continue; + $body .= ''; + $alt .= "$label: $value\n"; + } + $body .= '
' . $e($label) . ':' . nl2br($e($value)) . '

' + . 'Pedido guardado no backoffice (Mensalidades → Pedidos de inscrição).

'; + + $id = enqueue_email([ + 'to_address' => $to, + 'to_name' => $info['name'] !== '' ? $info['name'] : APP_NAME, + 'subject' => 'Pedido de inscrição' . ($lead['membership_name'] !== '' ? ' (' . $lead['membership_name'] . ')' : '') . ' — ' . $lead['name'], + 'body' => $body, + 'alt_body' => $alt, + 'context_type' => 'fitme_inscricao', + ]); + flush_message($id); +} catch (Throwable $e) { + error_log('[inscricao] email: ' . $e->getMessage()); +} + +inscricao_redirect('ok'); diff --git a/views/admin/dashboard.php b/views/admin/dashboard.php index 0da3c66..49a92e5 100644 --- a/views/admin/dashboard.php +++ b/views/admin/dashboard.php @@ -7,6 +7,7 @@ $user = require_admin(); $counts = [ 'clientes' => (int) $pdo->query("SELECT COUNT(*) FROM users WHERE role = 'client'")->fetchColumn(), 'equipa' => (int) $pdo->query("SELECT COUNT(*) FROM users WHERE role IN ('admin','staff')")->fetchColumn(), + 'leads_novos' => db_membership_leads_count_new($pdo), ]; $page_title = 'Painel'; @@ -81,6 +82,33 @@ include __DIR__ . '/../../includes/header.php'; + + + diff --git a/views/admin/info/edit.php b/views/admin/info/edit.php new file mode 100644 index 0000000..6459e19 --- /dev/null +++ b/views/admin/info/edit.php @@ -0,0 +1,52 @@ +setFlashdata($changed ? 'success' : 'warning', $changed ? 'Contactos atualizados.' : 'Nenhuma alteração.'); + header('Location: ' . base_url('views/admin/info/edit.php')); + exit; + } +} + +$page_title = 'Contactos'; +include __DIR__ . '/../../../includes/header.php'; +?> +
+
+

Contactos

+ Ver no site +
+

Estes dados alimentam a secção de contactos, o rodapé, o botão «Ligar» no telemóvel, o botão de WhatsApp e os dados para o Google (SEO). Campos vazios não aparecem no site.

+
+ +
+ +
+ [$label, $max, $type]): $req = in_array($key, ['name', 'phone'], true); ?> + +
+ +
No Google Maps: Partilhar → Incorporar um mapa → Copiar HTML. Só é aceite um iframe do Google Maps.
+ +
+ + value="">
+ + +
+
+ + Cancelar +
+
+
+ diff --git a/views/admin/memberships/_form.php b/views/admin/memberships/_form.php new file mode 100644 index 0000000..422cfc4 --- /dev/null +++ b/views/admin/memberships/_form.php @@ -0,0 +1,63 @@ + of create.php and +// edit.php. Expects $data (see memberships_defaults()). +$price_val = ($data['price'] ?? '') === null ? '' : (string) ($data['price'] ?? ''); +if ($price_val !== '' && is_numeric($price_val)) { + $p = (float) $price_val; + $price_val = number_format($p, floor($p) == $p ? 0 : 2, ',', ''); +} +?> +
+
+ + +
+
+ + +
Menor aparece primeiro.
+
+
+ + +
+
+ + +
Vazio = «Sob consulta».
+
+
+ + +
ex.: «/mês» ou «acresce à mensalidade».
+
+
+
+ > + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ > + +
+
+ > + +
+
+
Só um cartão pode estar em destaque — ao marcar este, o anterior deixa de estar.
+
diff --git a/views/admin/memberships/create.php b/views/admin/memberships/create.php new file mode 100644 index 0000000..2700076 --- /dev/null +++ b/views/admin/memberships/create.php @@ -0,0 +1,38 @@ +setFlashdata('success', 'Mensalidade criada.'); + header('Location: ' . base_url('views/admin/memberships/index.php')); + exit; + } +} + +$page_title = 'Nova Mensalidade'; +include __DIR__ . '/../../../includes/header.php'; +?> +
+
+

Nova Mensalidade

+ Voltar +
+
+
+ + +
+ + Cancelar +
+
+
+ diff --git a/views/admin/memberships/edit.php b/views/admin/memberships/edit.php new file mode 100644 index 0000000..8724721 --- /dev/null +++ b/views/admin/memberships/edit.php @@ -0,0 +1,42 @@ +setFlashdata($changed ? 'success' : 'warning', $changed ? 'Mensalidade atualizada.' : 'Nenhuma alteração.'); + header('Location: ' . base_url('views/admin/memberships/edit.php?id=' . $id)); + exit; + } +} + +$page_title = 'Editar Mensalidade'; +include __DIR__ . '/../../../includes/header.php'; +?> +
+
+

Editar Mensalidade

+ Voltar +
+
+
+ + +
+ + Cancelar +
+
+
+ diff --git a/views/admin/memberships/index.php b/views/admin/memberships/index.php new file mode 100644 index 0000000..f0a0c78 --- /dev/null +++ b/views/admin/memberships/index.php @@ -0,0 +1,70 @@ +setFlashdata('success', 'Mensalidade apagada.'); + } elseif (isset($_POST['restore'])) { + db_memberships_restore($pdo, (int) $_POST['restore']); + session()->setFlashdata('success', 'Mensalidade restaurada.'); + } + header('Location: ' . base_url('views/admin/memberships/index.php')); + exit; +} + +$items = db_memberships_list($pdo, true); +$new_leads = db_membership_leads_count_new($pdo); +$page_title = 'Mensalidades'; +include __DIR__ . '/../../../includes/header.php'; +?> +
+
+

Mensalidades

+ +
+ +
+
+ + + + + + + + + + + + + + + + + +
NomePreçoEstadoOrdemAções
Nenhuma mensalidade.
+ Destaque +
Apagada + Visível + Oculta + + + + + + +
+
+
+
+setFlashdata('success', 'Pedido apagado.'); + } elseif (isset($_POST['save'])) { + $id = (int) $_POST['save']; + db_membership_lead_update($pdo, $id, (string) ($_POST['status'][$id] ?? ''), trim((string) ($_POST['notes'][$id] ?? ''))); + session()->setFlashdata('success', 'Pedido atualizado.'); + } + header('Location: ' . $self); + exit; +} + +$leads = db_membership_leads_list($pdo, $filter); + +// CSV export of the current filter (Excel-friendly: UTF-8 BOM + ';'). +if (isset($_GET['csv'])) { + header('Content-Type: text/csv; charset=utf-8'); + header('Content-Disposition: attachment; filename="pedidos-inscricao-' . date('Y-m-d') . '.csv"'); + $out = fopen('php://output', 'w'); + fwrite($out, "\xEF\xBB\xBF"); + fputcsv($out, ['Data', 'Mensalidade', 'Nome', 'Telemóvel', 'E-mail', 'Mensagem', 'Estado', 'Notas', 'Consentimento'], ';', '"', ''); + foreach ($leads as $l) { + fputcsv($out, [$l['created_at'], $l['membership_name'], $l['name'], $l['phone'], $l['email'], $l['message'], + $statuses[$l['status']] ?? $l['status'], (string) $l['admin_notes'], $l['consent'] ? 'sim' : 'não'], ';', '"', ''); + } + exit; +} + +$badge = ['novo' => 'bg-danger', 'contactado' => 'bg-info text-dark', 'inscrito' => 'bg-success', 'descartado' => 'bg-secondary']; +$page_title = 'Pedidos de inscrição'; +include __DIR__ . '/../../../includes/header.php'; +?> +
+
+

Pedidos de inscrição

+ +
+

Pedidos enviados pelo botão de inscrição dos cartões de mensalidade. Cada pedido também é enviado por e-mail para o endereço dos Contactos.

+ + + +
+ +
+ + + + + + + + + + + + + + + + + + +
DataMensalidadeContactoMensagemEstado / notas
Sem pedidos.
+ +
+
+ RGPD +
—' ?> +
+ + +
+ +
+ + +
+
+
+
+setFlashdata('success', 'Definições guardadas.'); + header('Location: ' . base_url('views/admin/reviews/index.php')); + exit; + } + if ($action === 'refresh') { + $r = reviews_refresh(); + session()->setFlashdata(!empty($r['error']) ? 'error' : 'success', + !empty($r['error']) ? 'Falhou: ' . $r['error'] : 'Reviews atualizadas (' . count($r['reviews'] ?? []) . ' recebidas da Google).'); + header('Location: ' . base_url('views/admin/reviews/index.php')); + exit; + } + if ($action === 'search') { + $apiKey = reviews_config()['api_key']; + $q = trim((string) ($_POST['query'] ?? '')); + $search = ($apiKey === '') ? ['error' => 'Guarda primeiro a chave da API.', 'places' => []] + : ($q === '' ? ['error' => 'Escreve o nome do ginásio.', 'places' => []] : reviews_find_place($q, $apiKey)); + } +} + +$cfg = reviews_config(); +$env_key = (string) env('GOOGLE_PLACES_API_KEY', '') !== ''; +$cache = reviews_cache_read(); +$preview = site_reviews(); +$info = site_info($pdo); +$page_title = 'Reviews Google'; +include __DIR__ . '/../../../includes/header.php'; +?> +
+
+

Reviews Google

+ Ver no site +
+

A secção mostra as melhores reviews do ginásio no Google, obtidas pela Places API. A Google devolve no máximo 5 reviews (escolhidas por relevância); o site mostra as que têm a classificação mínima definida. Se nenhuma se qualificar, a secção fica escondida.

+ +
+

Configuração

+
+ + +
+
+ + + + + + +
+
+ +
Sem chave de encriptação no .env — a chave fica guardada sem encriptação.
+ +
Google Cloud Console → ativar «Places API (New)» → Credenciais. Restringe a chave a essa API.
+
+
+ + +
Não sabes? Usa a pesquisa abaixo.
+
+
+ + +
+
+ + +
+
+ + +
Cada atualização é um pedido pago à Google.
+
+
+ +
+
+ +
+

Encontrar o Place ID

+
+ + + + +
+ +
+
Sem resultados.
+ +
    + +
  • +
    + · ★ () +
    +
    + +
  • + +
+
Depois de «Usar», carrega em Guardar.
+ + +
+ +
+
+

Estado

+
+
+
+ +

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

+ +

+ Última atualização: + · ★ em avaliações +

+
Último erro:
+ +

No site aparecem :

+ +
+ · · +
+
+ + +

Nenhuma review qualifica neste momento — a secção está escondida.

+ + +
+
+ '', 'birthday' => '', 'at' => '', 'status' => 'ativo', 'description' => '']; +$data = ['name' => '', 'title' => '', 'birthday' => '', 'at' => '', 'status' => 'ativo', 'description' => '', + 'skills' => '', 'certifications' => '']; if ($_SERVER['REQUEST_METHOD'] === 'POST') { csrf_check(); @@ -14,6 +15,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'at' => trim($_POST['at'] ?? ''), 'status' => $_POST['status'] ?? 'ativo', 'description' => trim($_POST['description'] ?? ''), + 'title' => mb_substr(trim($_POST['title'] ?? ''), 0, 100), + 'skills' => trim($_POST['skills'] ?? ''), + 'certifications' => trim($_POST['certifications'] ?? ''), ]; if ($data['name'] === '') $errors[] = 'O nome é obrigatório.'; @@ -70,7 +74,15 @@ include __DIR__ . '/../../../includes/header.php';
-
+
+ +
Aparece no site por baixo do nome.
+
+
+
+
+
Aptidões e formações aparecem no cartão de detalhe do site (hover no computador, toque no telemóvel).
+
diff --git a/views/admin/team/edit.php b/views/admin/team/edit.php index 696764e..320f08f 100644 --- a/views/admin/team/edit.php +++ b/views/admin/team/edit.php @@ -29,6 +29,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { 'at' => trim($_POST['at'] ?? ''), 'status' => $_POST['status'] ?? 'ativo', 'description' => trim($_POST['description'] ?? ''), + 'title' => mb_substr(trim($_POST['title'] ?? ''), 0, 100), + 'skills' => trim($_POST['skills'] ?? ''), + 'certifications' => trim($_POST['certifications'] ?? ''), ]; if ($data['name'] === '') $errors[] = 'O nome é obrigatório.'; @@ -92,7 +95,15 @@ include __DIR__ . '/../../../includes/header.php';
-
+
+ +
Aparece no site por baixo do nome.
+
+
+
+
+
Aptidões e formações aparecem no cartão de detalhe do site (hover no computador, toque no telemóvel).
+
diff --git a/views/admin/team/index.php b/views/admin/team/index.php index 6d756ff..4c0b800 100644 --- a/views/admin/team/index.php +++ b/views/admin/team/index.php @@ -53,7 +53,7 @@ include __DIR__ . '/../../../includes/header.php'; - +
apagado