Os últimos 3 pontos de SQL direto em vistas, que tinham entrado depois da auditoria original (trabalho de multi-ginásio): gyms/create.php e gyms/edit.php partilhavam a mesma query da lista de galerias — agora db_galleries_options() em functions_galerias.php (e simplificada: o _gyms_has_col() defensivo já não é preciso, galleries.deleted_at existe desde a migração de 25/09). reviews/index.php gravava google_place_id à mão — agora db_gym_set_place_id(), separado do db_gyms_update() geral para não auditar/validar o resto do registo por uma só coluna. Zero vistas com SQL direto agora (grep confirma). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
344 lines
18 KiB
PHP
344 lines
18 KiB
PHP
<?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();
|
||
$__id = $id;
|
||
audit_change($pdo, 'gym', 'gyms', 'id', (int) $__id, 'create', null, 'name');
|
||
return $__id;
|
||
}
|
||
|
||
function db_gyms_update(PDO $pdo, int $id, array $d): bool {
|
||
$__before = audit_row($pdo, 'gyms', 'id', (int) $id);
|
||
$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();
|
||
$__r = $changed;
|
||
audit_change($pdo, 'gym', 'gyms', 'id', (int) $id, 'update', $__before, 'name');
|
||
return $__r;
|
||
}
|
||
|
||
// Single-field setter for views/admin/reviews/index.php — that page only
|
||
// touches google_place_id, not the rest of the gym record db_gyms_update()
|
||
// would validate/audit.
|
||
function db_gym_set_place_id(PDO $pdo, int $id, string $placeId): void {
|
||
$pdo->prepare("UPDATE gyms SET google_place_id = ?, updated_at = NOW() WHERE id = ?")
|
||
->execute([$placeId, $id]);
|
||
}
|
||
|
||
// 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];
|
||
}
|