Files
fitme3070.pt/views/sysadmin/system_settings.php
T
andreandClaude Opus 5.5 0587be7d14 feat: borda verde a rodar nos planos, textura metálica, contactos numa só fonte
- Mensalidades: ao passar o rato, a borda do cartão ganha um brilho verde num
  conic-gradient que roda (@property --pc-a animável), centrado no cursor; o
  cartão inclina-se e desliza para o rato como os da equipa.
- «O que dizem de nós» e rodapé: textura discreta de metal preto mate (grão de
  pintura eletrostática, brilho difuso, riscas quase invisíveis), em SVG/CSS.
- Contactos: o site lê só fitme3070_info (Gestão → Contactos). Os campos
  contact_*/social_* de Sistema → Definições não eram usados e foram editados
  por engano — saem do formulário, com um aviso a apontar para Contactos.
  site_info() deixa o WhatsApp só com dígitos (wa.me não aceita + nem espaços).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:57:15 +01:00

302 lines
16 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
require __DIR__ . '/../../config/db.php';
require __DIR__ . '/../../config/functions.php';
$user = require_admin();
$logo_dir = __DIR__ . '/../../assets/img/logo/';
$allowed_ext = ['png', 'jpg', 'jpeg', 'svg', 'webp'];
$max_upload = 2 * 1024 * 1024; // 2 MB
$errors = [];
// `app_base_url` lives only in .env (per-install topology). It used to be
// editable here but the DB-backed value caused tenants to drift when a dump
// was cloned, so the field was removed — change APP_BASE_URL in .env instead.
$fields = [
'app_name' => ['label' => 'Nome da aplicação', 'type' => 'text', 'required' => true],
'email_from' => ['label' => 'Email remetente', 'type' => 'email', 'required' => true],
'email_from_name' => ['label' => 'Nome do remetente', 'type' => 'text', 'required' => true],
'email_subject_prefix' => ['label' => 'Prefixo do assunto', 'type' => 'text', 'required' => false],
'reminder_hours' => ['label' => 'Horas de reminder', 'type' => 'number','required' => true],
// Public contacts are NOT here: the site reads them from fitme3070_info,
// edited in Gestão → Contactos (views/admin/info/edit.php). The fleet
// template's contact_* / social_* settings were unused and got edited by
// mistake, so they were removed from this form.
// SEO defaults — used by site/includes/head.php when a page doesn't
// set its own $page_title / $page_description / $page_image override.
'seo_default_title' => ['label' => 'SEO — título por defeito', 'type' => 'text', 'required' => false],
'seo_default_description' => ['label' => 'SEO — descrição por defeito', 'type' => 'text', 'required' => false],
'seo_keywords' => ['label' => 'SEO — palavras-chave', 'type' => 'text', 'required' => false],
'seo_twitter_handle' => ['label' => 'SEO — Twitter handle', 'type' => 'text', 'required' => false],
'seo_locale' => ['label' => 'SEO — locale (og:locale)', 'type' => 'text', 'required' => false],
'seo_title_separator' => ['label' => 'SEO — separador do título', 'type' => 'text', 'required' => false],
];
$current = settings_all();
$values = [];
foreach ($fields as $key => $_) {
$values[$key] = (string) ($current[$key] ?? '');
}
$logo_light = (string) ($current['logo_light_filename'] ?? '');
$logo_dark = (string) ($current['logo_dark_filename'] ?? '');
$seo_image = (string) ($current['seo_default_image_filename'] ?? '');
$seo_dir = __DIR__ . '/../../assets/img/seo/';
function _settings_handle_logo_upload(array $file, string $dir, array $allowed, int $max, ?string $previous, array &$errors, string $kind): ?string {
if (!isset($file['error']) || $file['error'] === UPLOAD_ERR_NO_FILE) {
return null;
}
if ($file['error'] !== UPLOAD_ERR_OK) {
$errors[] = "Falha no upload do logo {$kind}.";
return null;
}
if ($file['size'] > $max) {
$errors[] = "O logo {$kind} excede 2 MB.";
return null;
}
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed, true)) {
$errors[] = "Tipo de ficheiro não permitido para o logo {$kind}.";
return null;
}
$name = bin2hex(random_bytes(16)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $dir . $name)) {
$errors[] = "Não foi possível guardar o logo {$kind}.";
return null;
}
if ($previous && $previous !== $name) {
@unlink($dir . $previous);
}
return $name;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
foreach ($fields as $key => $meta) {
$raw = trim((string) ($_POST[$key] ?? ''));
$values[$key] = $raw;
if ($meta['required'] && $raw === '') {
$errors[] = "O campo \"{$meta['label']}\" é obrigatório.";
continue;
}
if ($raw === '') continue;
if ($meta['type'] === 'email' && !filter_var($raw, FILTER_VALIDATE_EMAIL)) {
$errors[] = "Email inválido em \"{$meta['label']}\".";
}
if ($meta['type'] === 'url' && !filter_var($raw, FILTER_VALIDATE_URL)) {
$errors[] = "URL inválido em \"{$meta['label']}\".";
}
if ($meta['type'] === 'number' && !ctype_digit($raw)) {
$errors[] = "\"{$meta['label']}\" tem de ser um número inteiro.";
}
}
$new_light = _settings_handle_logo_upload($_FILES['logo_light'] ?? [], $logo_dir, $allowed_ext, $max_upload, $logo_light, $errors, 'claro');
$new_dark = _settings_handle_logo_upload($_FILES['logo_dark'] ?? [], $logo_dir, $allowed_ext, $max_upload, $logo_dark, $errors, 'escuro');
// SEO share image — same upload helper, separate target directory.
if (!is_dir($seo_dir)) @mkdir($seo_dir, 0755, true);
$seo_allowed = ['png', 'jpg', 'jpeg', 'webp'];
$new_seo_image = _settings_handle_logo_upload($_FILES['seo_default_image'] ?? [], $seo_dir, $seo_allowed, $max_upload, $seo_image, $errors, 'SEO');
if (!$errors) {
$uid = (int) $_SESSION['user_id'];
foreach ($fields as $key => $meta) {
db_update_setting($pdo, $key, $values[$key], $uid);
}
if ($new_light !== null) {
db_update_setting($pdo, 'logo_light_filename', $new_light, $uid);
$logo_light = $new_light;
}
if ($new_dark !== null) {
db_update_setting($pdo, 'logo_dark_filename', $new_dark, $uid);
$logo_dark = $new_dark;
}
if ($new_seo_image !== null) {
db_update_setting($pdo, 'seo_default_image_filename', $new_seo_image, $uid);
$seo_image = $new_seo_image;
}
header('Location: ' . base_url('views/sysadmin/system_settings.php?updated=1'));
exit;
}
}
include __DIR__ . '/../../includes/header.php';
include __DIR__ . '/../../includes/navbar.php';
?>
<div class="container py-4" style="max-width:900px;">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<h4 class="mb-0"><i class="bi bi-sliders me-2"></i>Definições do sistema</h4>
<a href="<?= base_url('views/admin/dashboard.php') ?>" class="btn btn-outline-secondary btn-sm">Voltar</a>
</div>
<?php if (isset($_GET['updated'])): ?>
<div class="alert alert-success">Definições atualizadas.</div>
<?php endif; ?>
<?php foreach ($errors as $e): ?>
<div class="alert alert-danger"><?= htmlspecialchars($e) ?></div>
<?php endforeach; ?>
<form method="POST" enctype="multipart/form-data">
<?= csrf_field() ?>
<div class="card shadow-sm mb-3">
<div class="card-header"><strong>Identidade</strong></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label small">Nome da aplicação</label>
<input type="text" name="app_name" class="form-control" required maxlength="150"
value="<?= htmlspecialchars($values['app_name']) ?>">
</div>
<div class="col-md-6">
<label class="form-label small">URL base da instalação</label>
<input type="url" class="form-control" readonly disabled
value="<?= htmlspecialchars((string) env('APP_BASE_URL')) ?>">
<div class="form-text">
<i class="bi bi-info-circle me-1"></i>
Definido em <code>.env</code> (<code>APP_BASE_URL</code>).
Edite o ficheiro para alterar — não é gerido aqui.
</div>
</div>
<div class="col-md-6">
<label class="form-label small">Logo claro (para fundos escuros — navbar)</label>
<?php if ($logo_light): ?>
<div class="mb-2 p-2 rounded" style="background:#161c18;">
<img src="<?= base_url('assets/img/logo/' . rawurlencode($logo_light)) ?>" alt="Logo claro" style="max-height:48px;">
</div>
<?php endif; ?>
<input type="file" name="logo_light" class="form-control" accept=".png,.jpg,.jpeg,.svg,.webp">
<div class="form-text">PNG, JPG, SVG ou WebP. Máx. 2 MB.</div>
</div>
<div class="col-md-6">
<label class="form-label small">Logo escuro (para fundos claros — emails)</label>
<?php if ($logo_dark): ?>
<div class="mb-2 p-2 rounded border" style="background:#ffffff;">
<img src="<?= base_url('assets/img/logo/' . rawurlencode($logo_dark)) ?>" alt="Logo escuro" style="max-height:48px;">
</div>
<?php endif; ?>
<input type="file" name="logo_dark" class="form-control" accept=".png,.jpg,.jpeg,.svg,.webp">
<div class="form-text">Usado no cabeçalho dos emails.</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header"><strong>Email</strong></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label small">Email remetente</label>
<input type="email" name="email_from" class="form-control" required
value="<?= htmlspecialchars($values['email_from']) ?>">
</div>
<div class="col-md-6">
<label class="form-label small">Nome do remetente</label>
<input type="text" name="email_from_name" class="form-control" required maxlength="150"
value="<?= htmlspecialchars($values['email_from_name']) ?>">
</div>
<div class="col-12">
<label class="form-label small">Prefixo do assunto</label>
<input type="text" name="email_subject_prefix" class="form-control" maxlength="100"
value="<?= htmlspecialchars($values['email_subject_prefix']) ?>">
<div class="form-text">Prefixado a todos os assuntos enviados pelo sistema.</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header"><strong>Notificações</strong></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label small">Horas de reminder</label>
<input type="number" name="reminder_hours" class="form-control" required min="1" max="168"
value="<?= htmlspecialchars($values['reminder_hours']) ?>">
<div class="form-text">Antes da sessão.</div>
</div>
</div>
</div>
</div>
<div class="alert alert-info d-flex align-items-center gap-2 mb-3">
<i class="bi bi-telephone-fill"></i>
<div>Telefone, WhatsApp, e-mail, morada e redes sociais do site editam-se em
<a href="<?= base_url('views/admin/info/edit.php') ?>" class="alert-link">Gestão → Contactos</a>.</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header"><strong>SEO &amp; partilha social</strong></div>
<div class="card-body">
<p class="text-muted small mb-3">
Valores por defeito usados pelas páginas públicas quando não definem o seu próprio título ou descrição.
O nome do site (<code>app_name</code>) é usado automaticamente no <code>og:site_name</code> e no fim dos títulos.
</p>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label small">Título por defeito</label>
<input type="text" name="seo_default_title" class="form-control" maxlength="150"
value="<?= htmlspecialchars($values['seo_default_title']) ?>"
placeholder="Ex: Fotografia profissional em Portugal">
<div class="form-text">Usado em <code>&lt;title&gt;</code> / <code>og:title</code> quando a página não define um.</div>
</div>
<div class="col-md-4">
<label class="form-label small">Separador do título</label>
<input type="text" name="seo_title_separator" class="form-control" maxlength="10"
value="<?= htmlspecialchars($values['seo_title_separator']) ?>"
placeholder="—">
<div class="form-text">Entre o título da página e o nome do site (ex: <code>—</code>, <code>|</code>).</div>
</div>
<div class="col-12">
<label class="form-label small">Descrição por defeito</label>
<textarea name="seo_default_description" class="form-control" rows="2" maxlength="300"
placeholder="Frase curta (até 160 caracteres) que descreve o site."><?= htmlspecialchars($values['seo_default_description']) ?></textarea>
<div class="form-text">Usado em <code>meta description</code> / <code>og:description</code>. Ideal: 120–160 caracteres.</div>
</div>
<div class="col-md-6">
<label class="form-label small">Palavras-chave (opcional)</label>
<input type="text" name="seo_keywords" class="form-control" maxlength="255"
value="<?= htmlspecialchars($values['seo_keywords']) ?>"
placeholder="fotografia, batizado, família, eventos">
</div>
<div class="col-md-3">
<label class="form-label small">Twitter handle</label>
<input type="text" name="seo_twitter_handle" class="form-control" maxlength="50"
value="<?= htmlspecialchars($values['seo_twitter_handle']) ?>"
placeholder="@andremoreirapt">
</div>
<div class="col-md-3">
<label class="form-label small">Locale</label>
<input type="text" name="seo_locale" class="form-control" maxlength="10"
value="<?= htmlspecialchars($values['seo_locale']) ?>"
placeholder="pt_PT">
<div class="form-text">Formato <code>xx_XX</code>.</div>
</div>
<div class="col-12">
<label class="form-label small">Imagem de partilha (Open Graph)</label>
<?php if ($seo_image): ?>
<div class="mb-2">
<img src="<?= base_url('assets/img/seo/' . rawurlencode($seo_image)) ?>"
alt="OG image" style="max-height:160px; max-width:100%; border-radius:4px;">
</div>
<?php endif; ?>
<input type="file" name="seo_default_image" class="form-control" accept=".png,.jpg,.jpeg,.webp">
<div class="form-text">
PNG, JPG ou WebP. Recomendado 1200×630 px (proporção 1.91:1). Máx. 2 MB.
Mostrada quando alguém partilha uma página do site no Facebook, WhatsApp, LinkedIn, etc.
</div>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="submit" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Guardar alterações</button>
</div>
</form>
</div>
<?php include __DIR__ . '/../../includes/footer.php'; ?>