- Sidebar do backoffice: Backups/Auditoria/IP bans/Geo/Origem das visitas/ Atualizações deixam de ser um link cada (enumerados à mão) e passam a um só link "Sistema" para o índice partilhado do platform-tools (platform.php?tool=index), como a fotografia/design/dance já fazem — uma ferramenta nova no platform-tools passa a aparecer sozinha, sem link novo a escrever aqui. Definições, Mensagens e Comunicações continuam com link próprio (são páginas do fitme, não do router). - Horário de Aulas (home e CrossFit, mesmo componente partilhado): a grelha fica fechada por omissão atrás de um botão "Ver Horário das aulas" (Bootstrap collapse); clicar em "Horário" na navbar (ou abrir a página já com #horario no URL) abre-a de imediato em vez de só rolar até uma secção fechada. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
733 lines
43 KiB
PHP
733 lines
43 KiB
PHP
<?php
|
|
// One-page site: Home + Equipa + Modalidades + Horário + Contactos.
|
|
// CrossFit is a separate page (site/crossfit.php).
|
|
$current_uri_2 = '';
|
|
|
|
// No gym in the URL (/, /home, old links): send people to a gym page. With a
|
|
// single published gym that is a 301 to it (query string kept, e.g.
|
|
// ?agendar=ok); with more, the gym chooser.
|
|
if (($_GET['gym'] ?? '') === '') {
|
|
require_once __DIR__ . '/../config/db.php';
|
|
require_once __DIR__ . '/../config/functions.php';
|
|
$published = db_gyms_published($pdo);
|
|
if (count($published) > 1) { require __DIR__ . '/gym_chooser.php'; exit; }
|
|
$to = $published[0] ?? db_gyms_default($pdo) ?? gym_fallback();
|
|
$qs = $_SERVER['QUERY_STRING'] ?? '';
|
|
header('Location: ' . gym_url($to) . ($qs !== '' ? '?' . $qs : ''), true, 301);
|
|
exit;
|
|
}
|
|
|
|
require __DIR__ . '/_open.php'; // resolves $gym (404 if unknown / not visible)
|
|
|
|
// ── Load all data ───────────────────────────────────────────────────────────────
|
|
// Everything is this gym's content; a section the gym switched off
|
|
// (Gestão → Ginásios) loads as empty, so its block below simply isn't printed.
|
|
$gid = (int) $gym['id'];
|
|
$on = fn(string $key) => gym_section_on($gym, $key);
|
|
$offer = $on('oferta') ? site_offer($pdo, $gid) : [];
|
|
$team = $on('equipa') ? site_team($pdo, $gid) : [];
|
|
$modalidades = $on('modalidades') ? site_modalidades($pdo, $gid) : [];
|
|
$horario = $on('horario') ? site_horario($pdo, '', $gid) : [];
|
|
$whours = $on('workhours') ? site_work_hours($pdo, $gid) : [];
|
|
$work_hours_days = ['Segunda-Sexta'=>'segunda','Sábado'=>'sabado','Domingo/Feriados'=>'domingo'];
|
|
usort($whours, fn($x, $y) => strcmp((string) $x['open'], (string) $y['open'])); // morning before afternoon
|
|
$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
|
|
$hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img/home.jpg';
|
|
|
|
// ── Hero section ────────────────────────────────────────────────────────────────
|
|
?>
|
|
<!-- Mobile sticky CTA bar -->
|
|
<div class="mobile-cta-bar">
|
|
<a href="tel:<?= htmlspecialchars(site_tel($info['phone'] ?? '')) ?>" class="cta-btn cta-btn-call"><i class="fas fa-phone-alt"></i> Ligar</a>
|
|
<a href="#contactos" class="cta-btn cta-btn-cta smooth-scroll">Inscreve-te Já!</a>
|
|
</div>
|
|
|
|
<!-- Back to top -->
|
|
<button class="back-to-top" id="backToTop" aria-label="Voltar ao topo" title="Voltar ao topo">↑</button>
|
|
|
|
<!-- Hero background is inline (no data-setbg) so it paints without waiting for JS;
|
|
the set-bg class is kept for its cover/center styling. -->
|
|
<section id="home" class="hero-section set-bg fade-in" style="background-image:url('<?= htmlspecialchars(base_url($hero_img), ENT_QUOTES) ?>')">
|
|
<div class="container">
|
|
<div class="hero-text">
|
|
<?php /* Eventos a decorrer — destacados no topo do header. Cada um liga
|
|
à sua landing page em /evento/<slug>. */ ?>
|
|
<?php if ($events): ?>
|
|
<div class="hero-events">
|
|
<?php foreach ($events as $ev):
|
|
$ac = $ev['cor'] !== '' ? $ev['cor'] : '#C2D500';
|
|
$ev_img = $ev['hero_image'] !== '' ? base_url('uploads/events/' . rawurlencode($ev['hero_image'])) : '';
|
|
?>
|
|
<a class="hero-event<?= $ev_img ? ' has-img' : '' ?>" href="<?= base_url('evento/' . rawurlencode($ev['slug'])) ?>"
|
|
style="--ev-accent:<?= htmlspecialchars($ac, ENT_QUOTES) ?><?= $ev_img ? ';--ev-img:url(\'' . htmlspecialchars($ev_img, ENT_QUOTES) . '\')' : '' ?>">
|
|
<span class="hero-event-badge"><span class="event-live-dot"></span><i class="fas fa-bolt"></i> Evento</span>
|
|
<span class="hero-event-text">
|
|
<strong class="hero-event-title"><?= htmlspecialchars($ev['title']) ?></strong>
|
|
<?php if ($ev['summary'] !== ''): ?><span class="hero-event-sub"><?= htmlspecialchars($ev['summary']) ?></span><?php endif; ?>
|
|
</span>
|
|
<span class="hero-event-go">Saber mais <i class="fas fa-arrow-right"></i></span>
|
|
</a>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
<h1><?= htmlspecialchars($gym['hero_title'] !== '' ? $gym['hero_title'] : $gym['name']) ?><?php if ($gym['hero_subtitle'] !== ''): ?><br><span><?= htmlspecialchars($gym['hero_subtitle']) ?></span><?php endif; ?></h1>
|
|
<div class="hero-cta-row">
|
|
<a href="?assunto=insc#contactos" class="site-btn">Inscreve-te Já!</a>
|
|
<button type="button" class="site-btn sb-ghost" data-bs-toggle="modal" data-bs-target="#trialModal">Marca uma Aula Experimental</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<?php
|
|
// ── Oferta (Feature section) ───────────────────────────────────────────────────
|
|
if ($offer):
|
|
?>
|
|
<section id="oferta" class="feature-section" aria-labelledby="oferta-title">
|
|
<div class="container">
|
|
<div class="row justify-content-md-center">
|
|
<div class="col-lg-8">
|
|
<div class="section-title text-center deco">
|
|
<span class="section-eyebrow">FitMe 3070</span>
|
|
<h2 id="oferta-title">A Nossa Oferta</h2>
|
|
<p class="section-subtitle">Tudo o que precisas para evoluir, num só espaço.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row auto-grid">
|
|
<?php foreach($offer as $o): ?>
|
|
<div class="col-6 col-sm-4 col-lg-3">
|
|
<div class="icon-box-item">
|
|
<div class="ib-icon">
|
|
<img loading="lazy" decoding="async" <?= site_img_dims_attr($o['o_img'], 'uploads/website/offer') ?> src="<?= htmlspecialchars(site_img_url($o['o_img'], 'uploads/website/offer')) ?>" alt="<?= htmlspecialchars((string) $o['o_title']) ?>">
|
|
</div>
|
|
<h3><?= htmlspecialchars((string) $o['o_title']) ?></h3>
|
|
<p><?= htmlspecialchars((string) $o['o_description']) ?></p>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// ── Galeria (Instalações) ───────────────────────────────────────────────────────
|
|
$gallery_path = !empty($gym['gallery_id']) ? 'uploads/gallery/' . (int) $gym['gallery_id'] . '/' : '';
|
|
$gallery_files = ($on('galeria') && $gallery_path !== '') ? glob($gallery_path . '*.jpg') : [];
|
|
if ($gallery_files && count($gallery_files) > 0):
|
|
$gallery_h2 = 'Conhece as nossas instalações';
|
|
?>
|
|
<section id="galeria" class="gallery-section" style="padding:70px 0 0;background:var(--fitme-white)">
|
|
<?php include __DIR__ . '/includes/gallery.php'; ?>
|
|
</section>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// ── Modalidades ─────────────────────────────────────────────────────────────────
|
|
if ($modalidades):
|
|
?>
|
|
<section id="modalidades" class="service-section" aria-labelledby="modalidades-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">Treinos</span>
|
|
<h2 id="modalidades-title">Modalidades</h2>
|
|
<p class="section-subtitle">Encontra a modalidade certa para ti.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php /* Full-width strip of modalidade cards. It is a real horizontal
|
|
scroller (touch, trackpad/wheel, mouse drag, arrows) that also
|
|
glides on its own until someone interacts (main.js). A card with a
|
|
description opens .mod-detail — one shared panel outside the
|
|
scroller, so it isn't clipped — which grows out of the card. */ ?>
|
|
<div class="mod-strip">
|
|
<button type="button" class="mod-nav mod-nav-prev" aria-label="Modalidades anteriores"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>
|
|
<div class="mod-scroll" tabindex="0" aria-label="Modalidades — desliza para ver mais">
|
|
<?php foreach($modalidades as $d): $desc = trim(strip_tags((string) ($d['descricao'] ?? ''))) !== '' ? trim((string) $d['descricao']) : ''; ?>
|
|
<div class="icon-box-item mod-card<?= $desc !== '' ? ' has-info' : '' ?>" data-color="<?= htmlspecialchars((string) $d['cor']) ?>"<?= $desc !== '' ? ' role="button" tabindex="0" aria-haspopup="dialog" aria-label="Ver descrição de ' . htmlspecialchars((string) $d['nome']) . '"' : '' ?>>
|
|
<div class="ib-icon">
|
|
<?= modalidade_icon_html($d['icon']) /* inline animated SVG for the FitMe set, else <img> */ ?>
|
|
</div>
|
|
<h3><?= htmlspecialchars((string) $d['nome']) ?></h3>
|
|
<?php if ($desc !== ''): ?>
|
|
<?php /* descricao is admin-authored rich text — sanitised allowlist HTML */ ?>
|
|
<template class="mod-desc"><?= clean_richtext($desc) ?></template>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<button type="button" class="mod-nav mod-nav-next" aria-label="Mais modalidades"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
|
|
</div>
|
|
<div class="mod-detail" role="dialog" aria-modal="false" aria-hidden="true">
|
|
<button type="button" class="md-close" aria-label="Fechar">×</button>
|
|
<div class="md-card">
|
|
<div class="ib-icon"></div>
|
|
<h3></h3>
|
|
</div>
|
|
<div class="md-body">
|
|
<strong class="md-name"></strong>
|
|
<div class="md-text"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// ── Equipa ──────────────────────────────────────────────────────────────────────
|
|
if ($team):
|
|
?>
|
|
<section id="equipa" class="trainers-section" aria-labelledby="equipa-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">Quem somos</span>
|
|
<h2 id="equipa-title">Conhece os membros da equipa 3070</h2>
|
|
<p class="section-subtitle">Profissionais dedicados a ajudar-te a alcançar os teus objetivos.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="section-title text-center">
|
|
<div class="row auto-grid" id="powerRotation">
|
|
<?php foreach ($team as $d): if ($d['status'] == 'ativo'):
|
|
// Default card: photo + name + title. The horizontal detail card
|
|
// (hover on desktop, tap on touch) only exists when there is
|
|
// something extra to show, so a bare member stays a plain card.
|
|
$t_title = trim((string) ($d['title'] ?? ''));
|
|
$t_skills = text_lines($d['skills'] ?? '');
|
|
$t_certs = text_lines($d['certifications'] ?? '');
|
|
$t_bio = trim(strip_tags((string) ($d['description'] ?? ''))) !== '' ? clean_richtext((string) $d['description']) : '';
|
|
$t_detail = $t_skills || $t_certs || $t_bio !== '';
|
|
$t_img = htmlspecialchars(site_img_url($d['photo'], 'uploads/team/foto'));
|
|
$t_dims = site_img_dims_attr($d['photo'], 'uploads/team/foto');
|
|
$t_id = 'td-' . (int) $d['id'];
|
|
?>
|
|
<div class="col-6 col-md-4 col-lg-3" style="margin-top: 20px;">
|
|
<div class="trainer-item<?= $t_detail ? ' has-detail' : '' ?>"<?= $t_detail ? ' tabindex="0" role="button" aria-expanded="false" aria-controls="' . $t_id . '" aria-label="Ver detalhes de ' . htmlspecialchars((string) $d['name']) . '"' : '' ?>>
|
|
<div class="trainer-pic">
|
|
<img loading="lazy" decoding="async" <?= $t_dims ?> src="<?= $t_img ?>" alt="<?= htmlspecialchars((string) $d['name']) ?>">
|
|
</div>
|
|
<h3><?= htmlspecialchars((string) $d['name']) ?></h3>
|
|
<?php if ($t_title !== ''): ?><p class="trainer-title"><?= htmlspecialchars($t_title) ?></p><?php endif; ?>
|
|
<?php if ($t_detail): ?>
|
|
<div class="trainer-detail" id="<?= $t_id ?>">
|
|
<button type="button" class="td-close" aria-label="Fechar">×</button>
|
|
<div class="td-photo"><img loading="lazy" decoding="async" <?= $t_dims ?> src="<?= $t_img ?>" alt=""></div>
|
|
<div class="td-body">
|
|
<strong class="td-name"><?= htmlspecialchars((string) $d['name']) ?></strong>
|
|
<?php if ($t_title !== ''): ?><span class="td-title"><?= htmlspecialchars($t_title) ?></span><?php endif; ?>
|
|
<?php if ($t_bio !== ''): ?><div class="td-bio"><?= $t_bio ?></div><?php endif; ?>
|
|
<?php if ($t_skills): ?>
|
|
<div class="td-block"><span class="td-label">Aptidões / Funções</span>
|
|
<ul><?php foreach ($t_skills as $x): ?><li><?= htmlspecialchars($x) ?></li><?php endforeach; ?></ul></div>
|
|
<?php endif; ?>
|
|
<?php if ($t_certs): ?>
|
|
<div class="td-block"><span class="td-label">Formações</span>
|
|
<ul><?php foreach ($t_certs as $x): ?><li><?= htmlspecialchars($x) ?></li><?php endforeach; ?></ul></div>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
<?php endif; endforeach; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// ── Horário ─────────────────────────────────────────────────────────────────────
|
|
if ($horario):
|
|
$week_day = ['Seg'=>'segunda','Ter'=>'terça','Qua'=>'quarta','Qui'=>'quinta','Sex'=>'sexta','Sab'=>'sábado'];
|
|
// The schedule shows one day at a time on screen; default to today (or the first
|
|
// listed day if today isn't in the table, e.g. Sunday). Rendered server-side too
|
|
// so there's no flash of all days before the JS runs, and so it still works with
|
|
// JavaScript off. The JPG export clears these to show the whole week.
|
|
$week_values = array_values($week_day);
|
|
$today_map = [1=>'segunda', 2=>'terça', 3=>'quarta', 4=>'quinta', 5=>'sexta', 6=>'sábado'];
|
|
$default_day = $today_map[(int) date('N')] ?? null;
|
|
if (!in_array($default_day, $week_values, true)) { $default_day = $week_values[0]; }
|
|
?>
|
|
<section id="horario" class="schedule-section" style="padding:60px 0;" aria-labelledby="horario-title">
|
|
<div class="schedule fade-in">
|
|
<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">Calendário</span>
|
|
<h2 id="horario-title">Horário de Aulas</h2>
|
|
<p class="section-subtitle">Planeia a tua semana de treinos connosco.</p>
|
|
</div>
|
|
<div class="text-center">
|
|
<button type="button" class="site-btn" data-bs-toggle="collapse" data-bs-target="#horarioCollapse" aria-expanded="false" aria-controls="horarioCollapse">
|
|
Ver Horário das aulas
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="collapse" id="horarioCollapse">
|
|
<div class="text-center mb-4">
|
|
<button type="button" class="schedule-dl-btn" id="scheduleDownload">
|
|
<i class="fas fa-download" aria-hidden="true"></i> Guardar horário
|
|
</button>
|
|
</div>
|
|
<?php
|
|
// ── Build a time-aligned grid ──────────────────────────────────────────
|
|
// Distinct start times become CSS-grid rows and days become columns, so
|
|
// classes that start at the same time line up on the same line across the
|
|
// week (e.g. every 07:00 CrossFit sits on one row, and Saturday's 09:15
|
|
// Cycling drops to the 09:15 row instead of riding up with the 07:00s).
|
|
// A single (day, time) cell can hold several classes — different rooms run
|
|
// concurrently (e.g. 19:15 CrossFit + FBB + YOURFIT PUMP) — and they sit
|
|
// side by side inside the cell so the overlap reads at a glance.
|
|
$slot_times = [];
|
|
foreach ($horario as $d) { $slot_times[$d['hora_inicio']] = true; }
|
|
ksort($slot_times); // 'HH:MM' strings sort chronologically
|
|
$slot_times = array_keys($slot_times);
|
|
|
|
// Split the day into Manhã (before 12:00) and Tarde (12:00 onward), then
|
|
// assign each piece a CSS-grid row: row 1 is the day headers, then a
|
|
// full-width section label precedes each non-empty block of time slots.
|
|
$morning = $afternoon = [];
|
|
foreach ($slot_times as $t) {
|
|
if ((int) substr($t, 0, 2) < 12) { $morning[] = $t; } else { $afternoon[] = $t; }
|
|
}
|
|
$row_of = []; // 'HH:MM' => grid-row
|
|
$sections = []; // [label, grid-row] for the divider rows
|
|
$gr = 2; // row 1 = day headers
|
|
if ($morning) { $sections[] = ['Manhã', $gr++]; foreach ($morning as $t) { $row_of[$t] = $gr++; } }
|
|
if ($afternoon) { $sections[] = ['Tarde', $gr++]; foreach ($afternoon as $t) { $row_of[$t] = $gr++; } }
|
|
$total_rows = $gr - 1;
|
|
|
|
$grid = []; // $grid[dia_semana][hora_inicio] = [classes…]
|
|
foreach ($horario as $d) {
|
|
$grid[$d['dia_semana']][$d['hora_inicio']][] = $d;
|
|
}
|
|
?>
|
|
<div class="row">
|
|
<div class="col">
|
|
<div class="schedule_container">
|
|
<div class="schedule_panel" style="--sched-days:<?= count($week_day) ?>;--sched-rows:<?= $total_rows ?>;">
|
|
<?php // Full-height background band per day, painted behind the cards so each
|
|
// weekday column reads as its own vertical stripe. Emitted first so it
|
|
// sits underneath; alternating columns get a slightly deeper tint. ?>
|
|
<?php $col = 0; foreach ($week_day as $key => $value): $col++; ?>
|
|
<div class="schedule_stripe<?= $col % 2 === 0 ? ' is-alt' : '' ?>" aria-hidden="true" style="grid-column:<?= $col ?>;grid-row:1 / -1;"></div>
|
|
<?php endforeach; ?>
|
|
<?php $col = 0; foreach ($week_day as $key => $value): $col++; ?>
|
|
<div class="schedule_dayhead<?= $value === $default_day ? '' : ' sched-hide' ?>" data-day="<?= htmlspecialchars($value) ?>" style="grid-column:<?= $col ?>;grid-row:1;">
|
|
<button type="button" class="sched-nav sched-prev" aria-label="Dia anterior"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>
|
|
<div class="rounded_frame"><?= $key ?></div>
|
|
<button type="button" class="sched-nav sched-next" aria-label="Dia seguinte"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
<?php // Manhã / Tarde dividers spanning the full width above each block. ?>
|
|
<?php foreach ($sections as $sec): ?>
|
|
<div class="schedule_section_label" data-section="<?= $sec[0] === 'Manhã' ? 'manha' : 'tarde' ?>" style="grid-row:<?= $sec[1] ?>;"><span><?= htmlspecialchars($sec[0]) ?></span></div>
|
|
<?php endforeach; ?>
|
|
<?php $col = 0; foreach ($week_day as $key => $value): $col++; ?>
|
|
<?php foreach ($slot_times as $time): ?>
|
|
<?php if (!empty($grid[$value][$time])): ?>
|
|
<div class="schedule_cell<?= $value === $default_day ? '' : ' sched-hide' ?>" data-day="<?= htmlspecialchars($value) ?>" data-section="<?= ((int) substr($time, 0, 2) < 12) ? 'manha' : 'tarde' ?>" style="grid-column:<?= $col ?>;grid-row:<?= $row_of[$time] ?>;">
|
|
<?php foreach ($grid[$value][$time] as $d): ?>
|
|
<div class="schedule_box" style="background-color:<?= htmlspecialchars((string) $d['cor']) ?>">
|
|
<img src="<?= htmlspecialchars(site_img_url($d['icon'], 'uploads/modalidades/icons')) ?>" alt="<?= htmlspecialchars((string) $d['nome']) ?>" <?= site_img_dims_attr($d['icon'], 'uploads/modalidades/icons') ?> decoding="async">
|
|
<a><b><?= htmlspecialchars((string) $d['nome']) ?></b></a>
|
|
<div class="schedule_info">
|
|
<?= htmlspecialchars((string) $d['hora_inicio']) ?>
|
|
<b><?= htmlspecialchars((string) $d['local']) ?></b>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
<?php endforeach; ?>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p class="schedule-scroll-hint">← Deslize para ver todos os dias →</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<script>
|
|
/* Quick "save the weekly schedule as JPG". html2canvas (1.4.1, loaded lazily
|
|
on first use) re-renders the page inside a hidden iframe sized by
|
|
`windowWidth`, so we capture the live schedule but restyle its CLONE
|
|
(onclone) with .schedule-export and render in a 1920px virtual window:
|
|
desktop media queries apply there, so the image is always the full desktop
|
|
week at exactly 1920px wide — even when saved from a phone. The date stamp
|
|
makes a saved image obviously dated if the schedule changes later. */
|
|
(function () {
|
|
var btn = document.getElementById('scheduleDownload');
|
|
var schedule = document.querySelector('#horario .schedule');
|
|
if (!btn || !schedule) return;
|
|
var H2C_SRC = '<?= base_url('site/assets/js/print/html2canvas.min.js') ?>?v=1.4.1';
|
|
var BOARD_W = 1920;
|
|
|
|
function loadH2C() {
|
|
if (window.html2canvas) return Promise.resolve();
|
|
return new Promise(function (resolve, reject) {
|
|
var s = document.createElement('script');
|
|
s.src = H2C_SRC;
|
|
s.onload = resolve;
|
|
s.onerror = function () { reject(new Error('Falha a carregar html2canvas')); };
|
|
document.head.appendChild(s);
|
|
});
|
|
}
|
|
|
|
btn.addEventListener('click', function () {
|
|
var label = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = 'A gerar imagem…';
|
|
|
|
var fontsReady = (document.fonts && document.fonts.ready) ? document.fonts.ready : Promise.resolve();
|
|
|
|
loadH2C().then(function () { return fontsReady; }).then(function () {
|
|
var bg = (getComputedStyle(document.body).getPropertyValue('--fitme-surface') || '#F7F8FA').trim();
|
|
return html2canvas(schedule, {
|
|
backgroundColor: bg,
|
|
scale: 1, // 1 CSS px = 1 canvas px → image is exactly 1920px wide
|
|
useCORS: true,
|
|
logging: false,
|
|
windowWidth: BOARD_W, // desktop-sized virtual window → desktop media queries
|
|
windowHeight: 1080,
|
|
scrollX: 0,
|
|
scrollY: 0,
|
|
onclone: function (doc) {
|
|
var board = doc.querySelector('#horario .schedule');
|
|
if (!board) return;
|
|
board.classList.add('schedule-export');
|
|
// The #horario section uses `content-visibility:auto`, which makes
|
|
// the browser skip rendering its contents when it considers them
|
|
// off-screen — inside the capture iframe that yields a blank image.
|
|
// Force it visible (and drop the intrinsic-size placeholder) so the
|
|
// grid actually paints.
|
|
var sec = doc.getElementById('horario');
|
|
if (sec) {
|
|
sec.style.contentVisibility = 'visible';
|
|
sec.style.containIntrinsicSize = 'auto';
|
|
sec.style.contain = 'none';
|
|
}
|
|
// The .fade-in / .revealed entry animations replay from
|
|
// their opacity:0 keyframe inside the capture iframe and
|
|
// would snapshot as a blank white image — strip them (the
|
|
// .schedule-export CSS also freezes opacity as a backstop).
|
|
board.classList.remove('fade-in');
|
|
Array.prototype.forEach.call(board.querySelectorAll('.fade-in, .revealed'), function (el) {
|
|
el.classList.remove('fade-in');
|
|
el.classList.remove('revealed');
|
|
});
|
|
var b = doc.getElementById('scheduleDownload'); if (b) { var w = b.closest('div'); (w || b).parentNode.removeChild(w || b); }
|
|
// Un-hide every day (single-day mode may have hidden 5 of 6).
|
|
Array.prototype.forEach.call(board.querySelectorAll('.sched-hide'), function (c) { c.classList.remove('sched-hide'); });
|
|
|
|
var stamp = doc.createElement('p');
|
|
stamp.className = 'schedule-stamp';
|
|
stamp.textContent = 'Gerado a ' + new Date().toLocaleDateString('pt-PT') + ' · sujeito a alterações';
|
|
(board.querySelector('.container') || board).appendChild(stamp);
|
|
}
|
|
});
|
|
}).then(function (canvas) {
|
|
return new Promise(function (resolve, reject) {
|
|
canvas.toBlob(function (blob) {
|
|
if (!blob) { reject(new Error('Falha ao gerar a imagem')); return; }
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'horario-fitme3070-' + new Date().toISOString().slice(0, 10) + '.jpg';
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
setTimeout(function () { URL.revokeObjectURL(url); }, 1500);
|
|
resolve();
|
|
}, 'image/jpeg', 0.95);
|
|
});
|
|
}).catch(function (err) {
|
|
console.error(err);
|
|
alert('Não foi possível gerar a imagem. Tenta novamente.');
|
|
}).then(function () {
|
|
btn.disabled = false;
|
|
btn.innerHTML = label;
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// ── Mensalidades ────────────────────────────────────────────────────────────────
|
|
// Cards come from the backoffice (Gestão → Mensalidades); one may be featured.
|
|
// "Inscrever" opens #joinModal pre-set to that card (site/inscricao.php stores
|
|
// the request as a lead and emails the gym).
|
|
if ($memberships):
|
|
?>
|
|
<section id="mensalidades" class="pricing-section" aria-labelledby="mensalidades-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">Mensalidades</span>
|
|
<h2 id="mensalidades-title">Escolhe o teu plano</h2>
|
|
<p class="section-subtitle">Encontra a mensalidade que encaixa na tua rotina.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php if (($_GET['inscricao'] ?? '') === 'ok'): ?>
|
|
<div class="alert alert-success text-center" role="alert">Pedido de inscrição enviado! Vamos entrar em contacto contigo muito em breve.</div>
|
|
<?php elseif (($_GET['inscricao'] ?? '') === 'erro'): ?>
|
|
<div class="alert alert-danger text-center" role="alert">Não foi possível enviar o pedido. Verifica os dados e tenta novamente.</div>
|
|
<?php endif; ?>
|
|
<div class="pricing-grid">
|
|
<?php foreach ($memberships as $m): $features = text_lines($m['features']); ?>
|
|
<article class="price-card<?= $m['is_featured'] ? ' is-featured' : '' ?>">
|
|
<?php if ($m['is_featured']): ?><span class="pc-badge"><i class="fas fa-star"></i> <?= htmlspecialchars($m['featured_label']) ?></span><?php endif; ?>
|
|
<h3 class="pc-name"><?= htmlspecialchars($m['name']) ?></h3>
|
|
<?php if ($m['subtitle'] !== ''): ?><p class="pc-sub"><?= htmlspecialchars($m['subtitle']) ?></p><?php endif; ?>
|
|
<div class="pc-price">
|
|
<span class="pc-amount<?= $m['price'] === null ? ' is-text' : '' ?>"><?= htmlspecialchars(membership_price_label($m)) ?></span>
|
|
<?php if ($m['price'] !== null && $m['price_suffix'] !== ''): ?><span class="pc-suffix"><?= htmlspecialchars($m['price_suffix']) ?></span><?php endif; ?>
|
|
</div>
|
|
<?php if ($features): ?>
|
|
<ul class="pc-features">
|
|
<?php foreach ($features as $f): ?><li><i class="fas fa-check" aria-hidden="true"></i><?= htmlspecialchars($f) ?></li><?php endforeach; ?>
|
|
</ul>
|
|
<?php endif; ?>
|
|
<button type="button" class="site-btn pc-cta" data-bs-toggle="modal" data-bs-target="#joinModal"
|
|
data-membership="<?= (int) $m['id'] ?>"><?= htmlspecialchars($m['cta_label']) ?></button>
|
|
</article>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// ── Reviews Google ──────────────────────────────────────────────────────────────
|
|
// Live from the Places API (functions_reviews.php), cached; only reviews at or
|
|
// above the backoffice minimum rating. Hidden when nothing qualifies.
|
|
if ($reviews):
|
|
$stars = static function (float $r): string {
|
|
$out = '';
|
|
for ($i = 1; $i <= 5; $i++) {
|
|
$cls = $r >= $i ? 'fas fa-star' : ($r >= $i - 0.5 ? 'fas fa-star-half-alt' : 'far fa-star');
|
|
$out .= '<i class="' . $cls . '" aria-hidden="true"></i>';
|
|
}
|
|
return $out;
|
|
};
|
|
?>
|
|
<section id="reviews" class="reviews-section" aria-labelledby="reviews-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">Reviews</span>
|
|
<h2 id="reviews-title">O que dizem de nós</h2>
|
|
<?php if ($reviews['rating'] !== null): ?>
|
|
<p class="reviews-summary">
|
|
<strong><?= number_format((float) $reviews['rating'], 1, ',', '') ?></strong>
|
|
<span class="rv-stars" role="img" aria-label="<?= number_format((float) $reviews['rating'], 1, ',', '') ?> de 5 estrelas"><?= $stars((float) $reviews['rating']) ?></span>
|
|
<span><?= (int) $reviews['total'] ?> avaliações no Google</span>
|
|
</p>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="reviews-grid">
|
|
<?php foreach ($reviews['reviews'] as $r): ?>
|
|
<figure class="review-card">
|
|
<?php // Reviewer first: big photo (or initial), name, date, stars — then the text, smaller. ?>
|
|
<div class="rv-avatar">
|
|
<?php $rv_img = review_avatar_url($r); /* guardada no próprio site (config/functions_privacy.php) */ ?>
|
|
<?php if ($rv_img !== ''): ?><img src="<?= htmlspecialchars($rv_img) ?>" alt="" loading="lazy" decoding="async" width="84" height="84">
|
|
<?php else: ?><span aria-hidden="true"><?= htmlspecialchars(mb_strtoupper(mb_substr($r['author'], 0, 1))) ?></span><?php endif; ?>
|
|
</div>
|
|
<figcaption class="rv-author">
|
|
<?php if ($r['author_uri'] !== ''): ?><a href="<?= htmlspecialchars($r['author_uri']) ?>" target="_blank" rel="noopener nofollow"><?= htmlspecialchars($r['author']) ?></a><?php else: ?><?= htmlspecialchars($r['author']) ?><?php endif; ?>
|
|
<small><?= htmlspecialchars($r['relative']) ?></small>
|
|
</figcaption>
|
|
<div class="rv-stars rv-stars-lg" role="img" aria-label="<?= (int) $r['rating'] ?> de 5 estrelas"><?= $stars((float) $r['rating']) ?></div>
|
|
<blockquote class="rv-text"><?= nl2br(htmlspecialchars($r['text'])) ?></blockquote>
|
|
</figure>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<div class="reviews-actions">
|
|
<?php if ($reviews['maps_uri'] !== ''): ?><a class="reviews-link" href="<?= htmlspecialchars($reviews['maps_uri']) ?>" target="_blank" rel="noopener">Ver todas no Google</a><?php endif; ?>
|
|
<a class="site-btn" href="<?= htmlspecialchars($reviews['write_uri']) ?>" target="_blank" rel="noopener">Deixa a tua review</a>
|
|
</div>
|
|
<p class="reviews-attribution">Reviews via Google Maps</p>
|
|
</div>
|
|
</section>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// ── Contactos + Horário de Funcionamento + Mapa ────────────────────────────────
|
|
$assunto = '';
|
|
if (isset($_GET['assunto'])) {
|
|
$assunto = $_GET['assunto'];
|
|
if ($assunto == 'pt'): $assunto = 'Informações - Personal Trainer';
|
|
elseif ($assunto == 'pt_cf'): $assunto = 'Informações - Personal Trainer CrossFit';
|
|
elseif ($assunto == 'insc'): $assunto = 'Informações - Inscrição no Ginásio';
|
|
elseif ($assunto == 'aula'): $assunto = 'Informações - Aula Experimental';
|
|
elseif ($assunto == 'nut'): $assunto = 'Informações - Nutricionista';
|
|
elseif ($assunto == 'fis'): $assunto = 'Informações - Fisioterapia';
|
|
endif;
|
|
}
|
|
echo view('contactos', [
|
|
'whours' => $whours,
|
|
'work_hours_days' => $work_hours_days,
|
|
'assunto' => $assunto,
|
|
'show' => $on('contactos'),
|
|
]);
|
|
|
|
// ── Personal Trainer CTA ────────────────────────────────────────────────────────
|
|
if ($on('personal_trainer')):
|
|
?>
|
|
<section id="personal-trainer" class="classes-section" aria-labelledby="pt-title">
|
|
<div class="container">
|
|
<div class="row">
|
|
<div class="col-lg-6">
|
|
<div class="section-title">
|
|
<h2 id="pt-title">Precisas de um <span>Personal Trainer</span>?</h2>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-6">
|
|
<div class="classes-text">
|
|
<p>Melhor do que treinar sozinho é treinar acompanhado pelos nossos profissionais. Além de te mostrarmos o caminho, ajudamos-te a cada passo, sempre lado a lado, focados nos teus objetivos e bem-estar.</p>
|
|
<a href="?assunto=pt#contactos" class="site-btn">Entra em contacto connosco</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<?php endif; /* personal trainer */ ?>
|
|
|
|
<?php
|
|
// ── Aula experimental modal ─────────────────────────────────────────────────
|
|
// Queues a booking request to id.andmore.pt (centralized mail) via site/agendar.php.
|
|
?>
|
|
<div class="modal fade trial-modal-wrap" id="trialModal" tabindex="-1" role="dialog" aria-labelledby="trialModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
|
<div class="modal-content trial-modal">
|
|
<div class="trial-modal-head">
|
|
<span class="section-eyebrow">FitMe 3070</span>
|
|
<h3 id="trialModalLabel">Marca a tua aula experimental</h3>
|
|
<p>Treina connosco sem compromisso. Deixa os teus dados e entramos em contacto para confirmar o dia e a hora.</p>
|
|
<button type="button" class="trial-close" data-bs-dismiss="modal" aria-label="Fechar">×</button>
|
|
</div>
|
|
<form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'agendar')) ?>">
|
|
<?= csrf_field() ?>
|
|
<?= public_form_trap('trial') /* honeypot + carimbo + prova de JS */ ?>
|
|
<div class="row">
|
|
<div class="col-12 col-md-6 mb-3">
|
|
<input type="text" name="nome" required placeholder="Nome *" aria-label="Nome">
|
|
</div>
|
|
<div class="col-12 col-md-6 mb-3">
|
|
<input type="tel" name="telefone" required placeholder="Telemóvel *" aria-label="Telemóvel">
|
|
</div>
|
|
<div class="col-12 col-md-6 mb-3">
|
|
<input type="email" name="email" required placeholder="E-mail *" aria-label="E-mail">
|
|
</div>
|
|
<div class="col-12 col-md-6 mb-3">
|
|
<input type="date" name="data" aria-label="Dia preferido" min="<?= date('Y-m-d') ?>">
|
|
</div>
|
|
<div class="col-12 mb-3">
|
|
<select name="periodo" aria-label="Período preferido">
|
|
<option value="">Período preferido (opcional)</option>
|
|
<option value="Manhã">Manhã</option>
|
|
<option value="Tarde">Tarde</option>
|
|
<option value="Fim do dia">Fim do dia</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-12 mb-3">
|
|
<textarea name="mensagem" rows="3" placeholder="Alguma modalidade ou objetivo em mente? (opcional)" aria-label="Mensagem"></textarea>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="consent-check">
|
|
<input type="checkbox" name="consent" value="1" required>
|
|
<span>Li e aceito a <a href="<?= base_url('home/privacidade') ?>" target="_blank" rel="noopener">Política de Privacidade</a>.</span>
|
|
</label>
|
|
</div>
|
|
<div class="col-12">
|
|
<button type="submit" class="site-btn w-100">Enviar pedido</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php if (($_GET['agendar'] ?? '') === 'erro'): ?>
|
|
<script>document.addEventListener('DOMContentLoaded',function(){if(window.bootstrap){new bootstrap.Modal(document.getElementById('trialModal')).show();}});</script>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// ── Inscrição (mensalidades) modal ──────────────────────────────────────────
|
|
// The select is pre-set by main.js from the clicked card's data-membership;
|
|
// without JS it still works as a plain chooser.
|
|
if ($memberships):
|
|
?>
|
|
<div class="modal fade trial-modal-wrap" id="joinModal" tabindex="-1" role="dialog" aria-labelledby="joinModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
|
<div class="modal-content trial-modal">
|
|
<div class="trial-modal-head">
|
|
<span class="section-eyebrow"><?= htmlspecialchars($info['name'] !== '' ? $info['name'] : APP_NAME) ?></span>
|
|
<h3 id="joinModalLabel">Inscreve-te</h3>
|
|
<p>Deixa os teus contactos e entramos em contacto para concluir a inscrição.</p>
|
|
<button type="button" class="trial-close" data-bs-dismiss="modal" aria-label="Fechar">×</button>
|
|
</div>
|
|
<form class="trial-form" method="post" action="<?= htmlspecialchars(gym_url($gym, 'inscricao')) ?>">
|
|
<?= csrf_field() ?>
|
|
<?= public_form_trap('inscricao') /* honeypot + carimbo + prova de JS */ ?>
|
|
<div class="row">
|
|
<div class="col-12 mb-3">
|
|
<select name="membership_id" aria-label="Mensalidade" required>
|
|
<?php foreach ($memberships as $m): ?>
|
|
<option value="<?= (int) $m['id'] ?>"<?= $m['is_featured'] ? ' selected' : '' ?>><?= htmlspecialchars($m['name'] . ' — ' . membership_price_label($m)) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</div>
|
|
<div class="col-12 col-md-6 mb-3">
|
|
<input type="text" name="nome" required maxlength="150" placeholder="Nome *" aria-label="Nome">
|
|
</div>
|
|
<div class="col-12 col-md-6 mb-3">
|
|
<input type="tel" name="telefone" required maxlength="40" placeholder="Telemóvel *" aria-label="Telemóvel">
|
|
</div>
|
|
<div class="col-12 mb-3">
|
|
<input type="email" name="email" maxlength="190" placeholder="E-mail" aria-label="E-mail">
|
|
</div>
|
|
<div class="col-12 mb-3">
|
|
<textarea name="mensagem" rows="3" maxlength="1000" placeholder="Alguma dúvida ou objetivo? (opcional)" aria-label="Mensagem"></textarea>
|
|
</div>
|
|
<div class="col-12">
|
|
<label class="consent-check">
|
|
<input type="checkbox" name="consent" value="1" required>
|
|
<span>Autorizo o contacto e o tratamento dos meus dados para efeitos desta inscrição, nos termos da <a href="<?= base_url('home/privacidade') ?>" target="_blank" rel="noopener">Política de Privacidade</a>.</span>
|
|
</label>
|
|
</div>
|
|
<div class="col-12">
|
|
<button type="submit" class="site-btn w-100">Enviar pedido</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<?php if (($_GET['inscricao'] ?? '') === 'erro'): ?>
|
|
<script>document.addEventListener('DOMContentLoaded',function(){if(window.bootstrap){new bootstrap.Modal(document.getElementById('joinModal')).show();}});</script>
|
|
<?php endif; ?>
|
|
<?php endif; ?>
|
|
|
|
<?php
|
|
// ── Footer ────────────────────────────────────────────────────────────────────
|
|
// Newsletter hidden on request (2026-09); the partial stays in
|
|
// site/includes/newsletter.php — re-enable with: echo view('newsletter');
|
|
echo view('footer');
|