Files
fitme3070.pt/config/functions_gyms.php
T

370 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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, 'brand_color' => '', 'logo' => '', '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);
}
// Brand colour of a gym as :root overrides for the public site. Derives the
// hover shade, the text-on-light ink and the text colour that stays readable on
// the colour (dark on light brands, white on dark ones). '' = stylesheet default.
function gym_brand_vars(array $gym): string {
$hex = strtoupper((string) ($gym['brand_color'] ?? ''));
if (!preg_match('/^#[0-9A-F]{6}$/', $hex)) return '';
[$r, $g, $b] = [hexdec(substr($hex, 1, 2)), hexdec(substr($hex, 3, 2)), hexdec(substr($hex, 5, 2))];
$shade = fn(float $k) => sprintf('#%02X%02X%02X', (int) round($r * $k), (int) round($g * $k), (int) round($b * $k));
$lum = (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255;
return '--fitme-accent:' . $hex . ';--fitme-accent-rgb:' . "$r, $g, $b" . ';--fitme-accent-dark:' . $shade(.86)
. ';--fitme-accent-ink:' . $shade($lum > .5 ? .5 : .85) . ';--fitme-on-accent:' . ($lum > .5 ? '#0B0B0D' : '#FFFFFF');
}
function gym_brand_css(array $gym): string {
$v = gym_brand_vars($gym);
return $v === '' ? '' : ':root{' . $v . '}';
}
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,
'logo' => mb_substr(trim((string) ($post['logo'] ?? '')), 0, 255),
'brand_color' => strtoupper(trim((string) ($post['brand_color'] ?? ''))),
'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['logo'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(svg|png|webp|jpe?g)$~i', $data['logo'])) {
$errors[] = 'Logótipo: caminho de uma imagem do site (ex.: site/assets/img/logos/gym2.svg).';
}
if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) {
$errors[] = 'Cor da marca: formato #RRGGBB (ex.: #C2D500).';
}
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['brand_color'], $d['logo'], $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, brand_color, logo, 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 = ?, brand_color = ?, logo = ?, 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];
}