feat: vários ginásios (/3070/ e /<código postal>/), CRUD de ginásios, modalidades após a galeria
Estrutura multi-ginásio: as mesmas secções do site, conteúdo próprio por ginásio, e cada um numa pasta da URL com o código postal da zona. - migration_20260926_gyms: tabela gyms (pasta, nome, localidade, publicado, principal, secções visíveis, título/imagem do header, SEO, galeria, Place ID, página CrossFit) com o FitMe 3070 como ginásio 1 em /3070/; gym_id em team, modalidades (o horário segue a modalidade), work_hours, memberships, membership_leads e website_offer (tudo o existente → ginásio 1); events com gym_id NULL = todos; fitme3070_info passa a uma linha por ginásio (id = gym). - Site: /<pasta>/, /<pasta>/crossfit, /<pasta>/agendar|inscricao. Ginásio por publicar → 404 para o público, pré-visualização (com faixa) para a equipa. / e /home → 301 para o ginásio principal (a manter a query); com mais de um ginásio publicado, a raiz passa a uma página para escolher. Secções desligadas não aparecem nem no menu; menu, canonical, JSON-LD, sitemap, formulários, e-mails e reviews (Place ID e cache) são do ginásio. - Backoffice: Gestão → Ginásios (criar/editar/apagar, secções, e «começar com o conteúdo de» outro ginásio — cópias independentes) e o seletor «A editar» no topo; equipa, modalidades, horários, horário de funcionamento, mensalidades, pedidos, contactos e reviews trabalham sobre o ginásio escolhido, e um registo de outro ginásio não abre. - Modalidades passam para logo a seguir à galeria das instalações. - Galeria sem cantos arredondados (raio da marca). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2f8f68c61a
commit
5e300fc341
33 files changed
+1146
-197
No files matched your search
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
// ─── Ginásios (multi-gym) ─────────────────────────────────────────────────────
|
||||
// One site, several gyms: the same homepage sections, each gym with its own
|
||||
// content. A gym lives in a URL folder named after its postal code
|
||||
// (/3070/, /XXXX/) and can be unpublished — then only logged-in staff can open
|
||||
// it (preview), everyone else gets a 404.
|
||||
//
|
||||
// Content tables carry gym_id (team, modalidades → horario, work_hours,
|
||||
// memberships, membership_leads, website_offer; events: NULL = every gym) and
|
||||
// fitme3070_info has one row per gym with id = gyms.id.
|
||||
//
|
||||
// Public site — site_gym($pdo): the gym of the current request (?gym=<slug>,
|
||||
// set by .htaccess from the URL folder), default gym otherwise.
|
||||
// Backoffice — admin_gym_id(): the gym picked in the header switcher
|
||||
// (session), which every content CRUD page reads and writes.
|
||||
|
||||
// Homepage sections a gym can switch off. Order = page order.
|
||||
function gym_sections(): array {
|
||||
return [
|
||||
'oferta' => 'A Nossa Oferta',
|
||||
'galeria' => 'Galeria (instalações)',
|
||||
'modalidades' => 'Modalidades',
|
||||
'equipa' => 'Equipa',
|
||||
'horario' => 'Horário de aulas',
|
||||
'mensalidades' => 'Mensalidades',
|
||||
'reviews' => 'Reviews Google',
|
||||
'contactos' => 'Contactos + formulário',
|
||||
'workhours' => 'Horário de funcionamento',
|
||||
'personal_trainer' => 'Personal Trainer (chamada)',
|
||||
];
|
||||
}
|
||||
|
||||
// URL folders that would shadow a real path or route.
|
||||
function gym_reserved_slugs(): array {
|
||||
return ['home', 'site', 'views', 'api', 'assets', 'uploads', 'shared', 'config', 'database',
|
||||
'includes', 'templates', 'vendor', 'logs', 'backups', 'evento', 'robots.txt', 'sitemap.xml'];
|
||||
}
|
||||
|
||||
function gym_defaults(): array {
|
||||
return [
|
||||
'slug' => '', 'name' => '', 'locality' => '', 'postal_code' => '', 'is_published' => 0,
|
||||
'is_default' => 0, 'sections' => null, 'hero_title' => '', 'hero_subtitle' => '',
|
||||
'hero_image' => '', 'seo_title' => '', 'seo_description' => '', 'gallery_id' => null,
|
||||
'google_place_id' => '', 'has_crossfit' => 0, 'item_order' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function db_gyms_list(PDO $pdo, bool $include_deleted = false): array {
|
||||
try {
|
||||
$sql = "SELECT * FROM gyms" . ($include_deleted ? "" : " WHERE deleted_at IS NULL")
|
||||
. " ORDER BY deleted_at IS NOT NULL, item_order, id";
|
||||
return $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (Throwable $e) { return []; }
|
||||
}
|
||||
|
||||
function db_gyms_get(PDO $pdo, int $id): ?array {
|
||||
try {
|
||||
$st = $pdo->prepare("SELECT * FROM gyms WHERE id = ? LIMIT 1");
|
||||
$st->execute([$id]);
|
||||
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
} catch (Throwable $e) { return null; }
|
||||
}
|
||||
|
||||
function db_gyms_by_slug(PDO $pdo, string $slug): ?array {
|
||||
try {
|
||||
$st = $pdo->prepare("SELECT * FROM gyms WHERE slug = ? AND deleted_at IS NULL LIMIT 1");
|
||||
$st->execute([$slug]);
|
||||
return $st->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
} catch (Throwable $e) { return null; }
|
||||
}
|
||||
|
||||
function db_gyms_default(PDO $pdo): ?array {
|
||||
try {
|
||||
$row = $pdo->query("SELECT * FROM gyms WHERE deleted_at IS NULL
|
||||
ORDER BY is_default DESC, is_published DESC, item_order, id LIMIT 1")
|
||||
->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
} catch (Throwable $e) { return null; }
|
||||
}
|
||||
|
||||
function db_gyms_published(PDO $pdo): array {
|
||||
return array_values(array_filter(db_gyms_list($pdo), fn($g) => (int) $g['is_published'] === 1));
|
||||
}
|
||||
|
||||
// A gym row, or a stand-in for installs that haven't run the migration yet so
|
||||
// the public site keeps working exactly as before.
|
||||
function gym_fallback(): array {
|
||||
return array_merge(gym_defaults(), [
|
||||
'id' => 1, 'slug' => '3070', 'name' => 'FitMe 3070', 'locality' => 'Mira', 'postal_code' => '3070',
|
||||
'is_published' => 1, 'is_default' => 1, 'hero_title' => 'Ginásio, Box, Aulas',
|
||||
'hero_subtitle' => '& Acompanhamento', 'hero_image' => 'site/assets/img/home.jpg',
|
||||
'gallery_id' => 1, 'has_crossfit' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
// Is a homepage section on for this gym? NULL/empty config = everything on.
|
||||
function gym_section_on(array $gym, string $key): bool {
|
||||
$s = $gym['sections'] ?? null;
|
||||
if ($s === null || $s === '') return true;
|
||||
$list = json_decode((string) $s, true);
|
||||
return !is_array($list) || in_array($key, $list, true);
|
||||
}
|
||||
|
||||
function gym_url(array $gym, string $path = ''): string {
|
||||
return base_url(rawurlencode((string) $gym['slug']) . '/' . ltrim($path, '/'));
|
||||
}
|
||||
|
||||
// ─── Public request → gym ─────────────────────────────────────────────────────
|
||||
// Set once per request by site/_open.php (and the POST handlers). Unknown or
|
||||
// unpublished-for-this-visitor gyms return null; the caller 404s.
|
||||
function site_gym(PDO $pdo, ?string $slug = null): ?array {
|
||||
static $cache = [];
|
||||
$slug = $slug ?? (string) ($_GET['gym'] ?? '');
|
||||
if (isset($cache[$slug])) return $cache[$slug];
|
||||
$gym = $slug !== '' ? db_gyms_by_slug($pdo, $slug) : db_gyms_default($pdo);
|
||||
if ($gym === null && $slug === '' ) $gym = gym_fallback(); // pre-migration install
|
||||
if ($gym && !(int) $gym['is_published'] && !(function_exists('is_staff') && is_staff())) $gym = null;
|
||||
return $cache[$slug] = $gym;
|
||||
}
|
||||
|
||||
// The gym the current page is rendering (set by _open.php); default gym for
|
||||
// code that runs outside a gym page (privacy page, events, mail handlers).
|
||||
function current_gym(): array {
|
||||
return $GLOBALS['_gym'] ?? gym_fallback();
|
||||
}
|
||||
|
||||
// ─── Backoffice: which gym am I editing? ─────────────────────────────────────
|
||||
|
||||
function admin_gym_id(): int {
|
||||
global $pdo;
|
||||
$id = (int) ($_SESSION['admin_gym_id'] ?? 0);
|
||||
if ($id && $pdo instanceof PDO && db_gyms_get($pdo, $id) && !db_gyms_get($pdo, $id)['deleted_at']) return $id;
|
||||
$def = ($pdo instanceof PDO) ? db_gyms_default($pdo) : null;
|
||||
return $def ? (int) $def['id'] : 1;
|
||||
}
|
||||
|
||||
function admin_gym(PDO $pdo): array {
|
||||
return db_gyms_get($pdo, admin_gym_id()) ?? gym_fallback();
|
||||
}
|
||||
|
||||
// ─── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null): array {
|
||||
$slug = strtolower(trim((string) ($post['slug'] ?? '')));
|
||||
$data = [
|
||||
'slug' => $slug,
|
||||
'name' => mb_substr(trim((string) ($post['name'] ?? '')), 0, 100),
|
||||
'locality' => mb_substr(trim((string) ($post['locality'] ?? '')), 0, 100),
|
||||
'postal_code' => mb_substr(trim((string) ($post['postal_code'] ?? '')), 0, 20),
|
||||
'is_published' => !empty($post['is_published']) ? 1 : 0,
|
||||
'is_default' => !empty($post['is_default']) ? 1 : 0,
|
||||
'hero_title' => mb_substr(trim((string) ($post['hero_title'] ?? '')), 0, 150),
|
||||
'hero_subtitle' => mb_substr(trim((string) ($post['hero_subtitle'] ?? '')), 0, 150),
|
||||
'hero_image' => mb_substr(trim((string) ($post['hero_image'] ?? '')), 0, 255),
|
||||
'seo_title' => mb_substr(trim((string) ($post['seo_title'] ?? '')), 0, 160),
|
||||
'seo_description' => mb_substr(trim((string) ($post['seo_description'] ?? '')), 0, 300),
|
||||
'gallery_id' => (int) ($post['gallery_id'] ?? 0) ?: null,
|
||||
'google_place_id' => mb_substr(trim((string) ($post['google_place_id'] ?? '')), 0, 120),
|
||||
'has_crossfit' => !empty($post['has_crossfit']) ? 1 : 0,
|
||||
'item_order' => (int) ($post['item_order'] ?? 0),
|
||||
];
|
||||
$on = array_values(array_intersect(array_keys(gym_sections()), (array) ($post['sections'] ?? [])));
|
||||
$data['sections'] = count($on) === count(gym_sections()) ? null : json_encode($on);
|
||||
|
||||
if ($data['name'] === '') $errors[] = 'O nome é obrigatório.';
|
||||
if (!preg_match('/^[a-z0-9][a-z0-9-]{1,19}$/', $slug)) {
|
||||
$errors[] = 'A pasta da URL deve ter 2–20 letras minúsculas, números ou hífens (ex.: 3070).';
|
||||
} elseif (in_array($slug, gym_reserved_slugs(), true)) {
|
||||
$errors[] = 'Essa pasta da URL está reservada pelo site.';
|
||||
} else {
|
||||
$other = db_gyms_by_slug($pdo, $slug);
|
||||
if ($other && (int) $other['id'] !== (int) $id) $errors[] = 'Já existe um ginásio com essa pasta da URL.';
|
||||
}
|
||||
if ($data['is_default'] && !$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado.';
|
||||
if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp)$~i', $data['hero_image'])) {
|
||||
$errors[] = 'Imagem do header: caminho de uma imagem do site (ex.: site/assets/img/home.jpg).';
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
function _gyms_params(array $d): array {
|
||||
return [$d['slug'], $d['name'], $d['locality'], $d['postal_code'], $d['is_published'], $d['is_default'],
|
||||
$d['sections'], $d['hero_title'], $d['hero_subtitle'], $d['hero_image'], $d['seo_title'],
|
||||
$d['seo_description'], $d['gallery_id'], $d['google_place_id'], $d['has_crossfit'], $d['item_order']];
|
||||
}
|
||||
|
||||
function db_gyms_create(PDO $pdo, array $d): int {
|
||||
$pdo->beginTransaction();
|
||||
$pdo->prepare("INSERT INTO gyms (slug, name, locality, postal_code, is_published, is_default, sections,
|
||||
hero_title, hero_subtitle, hero_image, seo_title, seo_description, gallery_id,
|
||||
google_place_id, has_crossfit, item_order, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, NOW(), NOW())")->execute(_gyms_params($d));
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
||||
// Its own contacts row (id = gym id), starting from the gym's name.
|
||||
$pdo->prepare("INSERT IGNORE INTO fitme3070_info (id, name, adress, phone, telephone, whatsapp, instagram,
|
||||
facebook, messenger, email, website, google_maps, google_maps_link)
|
||||
VALUES (?, ?, '', '', '', '', '', '', '', '', '', '', '')")->execute([$id, $d['name']]);
|
||||
$pdo->commit();
|
||||
return $id;
|
||||
}
|
||||
|
||||
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
|
||||
$pdo->beginTransaction();
|
||||
$params = _gyms_params($d); $params[] = $id;
|
||||
$st = $pdo->prepare("UPDATE gyms SET slug = ?, name = ?, locality = ?, postal_code = ?, is_published = ?,
|
||||
is_default = ?, sections = ?, hero_title = ?, hero_subtitle = ?, hero_image = ?,
|
||||
seo_title = ?, seo_description = ?, gallery_id = ?, google_place_id = ?,
|
||||
has_crossfit = ?, item_order = ?, updated_at = NOW()
|
||||
WHERE id = ?");
|
||||
$st->execute($params);
|
||||
$changed = $st->rowCount() > 0;
|
||||
if ($d['is_default']) $pdo->prepare("UPDATE gyms SET is_default = 0 WHERE id <> ?")->execute([$id]);
|
||||
$pdo->commit();
|
||||
return $changed;
|
||||
}
|
||||
|
||||
// Soft delete. The default gym can't go (the root URL needs somewhere to land).
|
||||
function db_gyms_delete(PDO $pdo, int $id): bool {
|
||||
$st = $pdo->prepare("UPDATE gyms SET deleted_at = NOW(), is_published = 0
|
||||
WHERE id = ? AND is_default = 0 AND deleted_at IS NULL");
|
||||
$st->execute([$id]);
|
||||
return $st->rowCount() > 0;
|
||||
}
|
||||
|
||||
function db_gyms_restore(PDO $pdo, int $id): bool {
|
||||
$st = $pdo->prepare("UPDATE gyms SET deleted_at = NULL WHERE id = ? AND deleted_at IS NOT NULL");
|
||||
$st->execute([$id]);
|
||||
return $st->rowCount() > 0;
|
||||
}
|
||||
|
||||
// ─── Start a gym from another one's content ──────────────────────────────────
|
||||
// What can be copied, and a copy routine for each. Copies are independent rows:
|
||||
// editing one gym never changes the other.
|
||||
function gyms_copy_options(): array {
|
||||
return [
|
||||
'modalidades' => 'Modalidades e horário de aulas',
|
||||
'work_hours' => 'Horário de funcionamento',
|
||||
'memberships' => 'Mensalidades',
|
||||
'offer' => 'A Nossa Oferta',
|
||||
'team' => 'Equipa',
|
||||
'info' => 'Contactos (redes sociais, e-mail — ajustar depois)',
|
||||
];
|
||||
}
|
||||
|
||||
function gyms_copy_content(PDO $pdo, int $from, int $to, array $what): array {
|
||||
$done = [];
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
if (in_array('modalidades', $what, true)) {
|
||||
$map = [];
|
||||
$st = $pdo->prepare("SELECT * FROM modalidades WHERE gym_id = ? AND deleted_at IS NULL");
|
||||
$st->execute([$from]);
|
||||
$ins = $pdo->prepare("INSERT INTO modalidades (gym_id, nome, cor, icon, descricao, item_order) VALUES (?,?,?,?,?,?)");
|
||||
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $m) {
|
||||
$ins->execute([$to, $m['nome'], $m['cor'], $m['icon'], $m['descricao'], $m['item_order']]);
|
||||
$map[(int) $m['m_id']] = (int) $pdo->lastInsertId();
|
||||
}
|
||||
if ($map) {
|
||||
$in = implode(',', array_keys($map));
|
||||
$hs = $pdo->query("SELECT * FROM horario WHERE id_modalidade IN ($in)"
|
||||
. (_gyms_has_col($pdo, 'horario', 'deleted_at') ? " AND deleted_at IS NULL" : ""))
|
||||
->fetchAll(PDO::FETCH_ASSOC);
|
||||
$hi = $pdo->prepare("INSERT INTO horario (id_modalidade, dia_semana, hora_inicio, hora_fim, local) VALUES (?,?,?,?,?)");
|
||||
foreach ($hs as $h) $hi->execute([$map[(int) $h['id_modalidade']], $h['dia_semana'], $h['hora_inicio'], $h['hora_fim'], $h['local']]);
|
||||
}
|
||||
$done[] = count($map) . ' modalidades';
|
||||
}
|
||||
if (in_array('work_hours', $what, true)) {
|
||||
$n = $pdo->prepare("INSERT INTO work_hours (gym_id, week_day, open, close)
|
||||
SELECT ?, week_day, open, close FROM work_hours WHERE gym_id = ?"
|
||||
. (_gyms_has_col($pdo, 'work_hours', 'deleted_at') ? " AND deleted_at IS NULL" : ""));
|
||||
$n->execute([$to, $from]);
|
||||
$done[] = $n->rowCount() . ' horários de funcionamento';
|
||||
}
|
||||
if (in_array('memberships', $what, true)) {
|
||||
$n = $pdo->prepare("INSERT INTO memberships (gym_id, name, subtitle, price, is_addon, price_suffix, features,
|
||||
is_featured, featured_label, cta_label, item_order, is_active)
|
||||
SELECT ?, name, subtitle, price, is_addon, price_suffix, features, is_featured,
|
||||
featured_label, cta_label, item_order, is_active
|
||||
FROM memberships WHERE gym_id = ? AND deleted_at IS NULL");
|
||||
$n->execute([$to, $from]);
|
||||
$done[] = $n->rowCount() . ' mensalidades';
|
||||
}
|
||||
if (in_array('offer', $what, true)) {
|
||||
$n = $pdo->prepare("INSERT INTO website_offer (gym_id, o_img, o_title, o_description)
|
||||
SELECT ?, o_img, o_title, o_description FROM website_offer
|
||||
WHERE gym_id = ? AND (o_deleted_at IS NULL OR o_deleted_at = '0000-00-00 00:00:00' OR o_deleted_at = '')");
|
||||
$n->execute([$to, $from]);
|
||||
$done[] = $n->rowCount() . ' itens da oferta';
|
||||
}
|
||||
if (in_array('team', $what, true)) {
|
||||
$n = $pdo->prepare("INSERT INTO team (gym_id, photo, name, title, birthday, `at`, status, description, skills, certifications, created_at, updated_at)
|
||||
SELECT ?, photo, name, title, birthday, `at`, status, description, skills, certifications, NOW(), NOW()
|
||||
FROM team WHERE gym_id = ? AND deleted_at IS NULL");
|
||||
$n->execute([$to, $from]);
|
||||
$done[] = $n->rowCount() . ' membros da equipa';
|
||||
}
|
||||
if (in_array('info', $what, true)) {
|
||||
$src = $pdo->prepare("SELECT * FROM fitme3070_info WHERE id = ?");
|
||||
$src->execute([$from]);
|
||||
if ($i = $src->fetch(PDO::FETCH_ASSOC)) {
|
||||
// Brand-wide channels only; address, phones and map are the new gym's own.
|
||||
$pdo->prepare("UPDATE fitme3070_info SET instagram = ?, facebook = ?, messenger = ?, email = ?, website = ?
|
||||
WHERE id = ?")->execute([$i['instagram'], $i['facebook'], $i['messenger'], $i['email'], $i['website'], $to]);
|
||||
$done[] = 'contactos (redes sociais e e-mail)';
|
||||
}
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
return $done;
|
||||
}
|
||||
|
||||
function _gyms_has_col(PDO $pdo, string $table, string $col): bool {
|
||||
static $cache = [];
|
||||
$k = "$table.$col";
|
||||
if (!isset($cache[$k])) {
|
||||
// (SHOW COLUMNS … LIKE ? can't take a bound parameter with native prepares.)
|
||||
$st = $pdo->prepare("SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?");
|
||||
$st->execute([$table, $col]);
|
||||
$cache[$k] = (bool) $st->fetch();
|
||||
}
|
||||
return $cache[$k];
|
||||
}
|
||||
Reference in new issue
Block a user