Files
fitme3070.pt/views/sysadmin/system_settings.php
T
andreandClaude Opus 4.8 e68d357177 Initial import of fitme3070.pt platform
Plain-PHP fleet platform with id.andmore.pt SSO. Wires shared/platform-tools
as a submodule (platform_update, backups, git/migration tools) like the other
platforms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:44:58 +01:00

359 lines
21 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 = ["id"=>1,"name"=>"Teste","email"=>"teste@fitme3070.pt","role"=>"sysadmin"]; $_SESSION["user_id"] = 1; $_SESSION["user"] = $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],
'contact_email' => ['label' => 'Email de contacto público', 'type' => 'email', 'required' => false],
'contact_phone_display'=> ['label' => 'Telefone (texto exibido)', 'type' => 'text', 'required' => false],
'contact_phone_dial' => ['label' => 'Telefone (formato dial)', 'type' => 'text', 'required' => false],
'contact_phone_intl' => ['label' => 'Telefone (formato internacional, sem +)', 'type' => 'text', 'required' => false],
'contact_address_line1'=> ['label' => 'Morada — linha 1', 'type' => 'text', 'required' => false],
'contact_address_line2'=> ['label' => 'Morada — linha 2', 'type' => 'text', 'required' => false],
'social_facebook_url' => ['label' => 'Facebook URL', 'type' => 'url', 'required' => false],
'social_instagram_url' => ['label' => 'Instagram URL', 'type' => 'url', 'required' => false],
// 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="card shadow-sm mb-3">
<div class="card-header"><strong>Contactos públicos</strong></div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label small">Email de contacto</label>
<input type="email" name="contact_email" class="form-control"
value="<?= htmlspecialchars($values['contact_email']) ?>"
placeholder="andre@andremoreira.pt">
<div class="form-text">Mostrado no site e recebe os pedidos de orçamento e contacto.</div>
</div>
<div class="col-md-6">
<label class="form-label small">Telefone (texto exibido)</label>
<input type="text" name="contact_phone_display" class="form-control"
value="<?= htmlspecialchars($values['contact_phone_display']) ?>"
placeholder="915 435 034">
</div>
<div class="col-md-6">
<label class="form-label small">Telefone (dial / <code>tel:</code>)</label>
<input type="text" name="contact_phone_dial" class="form-control font-monospace"
value="<?= htmlspecialchars($values['contact_phone_dial']) ?>"
placeholder="+351915435034">
<div class="form-text">Formato E.164 (com <code>+</code>).</div>
</div>
<div class="col-md-6">
<label class="form-label small">Telefone (formato WhatsApp <code>wa.me</code>)</label>
<input type="text" name="contact_phone_intl" class="form-control font-monospace"
value="<?= htmlspecialchars($values['contact_phone_intl']) ?>"
placeholder="351915435034">
<div class="form-text">Sem <code>+</code> nem espaços. Usado em <code>https://wa.me/&lt;numero&gt;</code>.</div>
</div>
<div class="col-md-6">
<label class="form-label small">Morada — linha 1</label>
<input type="text" name="contact_address_line1" class="form-control"
value="<?= htmlspecialchars($values['contact_address_line1']) ?>"
placeholder="Av. Cidade de Coimbra 159">
</div>
<div class="col-md-6">
<label class="form-label small">Morada — linha 2</label>
<input type="text" name="contact_address_line2" class="form-control"
value="<?= htmlspecialchars($values['contact_address_line2']) ?>"
placeholder="3070-761 Praia de Mira">
</div>
<div class="col-md-6">
<label class="form-label small">Facebook URL</label>
<input type="url" name="social_facebook_url" class="form-control"
value="<?= htmlspecialchars($values['social_facebook_url']) ?>"
placeholder="https://www.facebook.com/...">
</div>
<div class="col-md-6">
<label class="form-label small">Instagram URL</label>
<input type="url" name="social_instagram_url" class="form-control"
value="<?= htmlspecialchars($values['social_instagram_url']) ?>"
placeholder="https://www.instagram.com/...">
</div>
</div>
</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'; ?>