Vendor platform-tools: everything this site runs is now in one repo

shared/platform-tools was a git submodule. On this branch it is a plain
directory of tracked files, frozen at ea3616abae30106df283e73a3e2f9e5bb0e6ae90
— the same commit fotografia.andmore.pt pins. Contents are byte-identical to
that tree, verified blob by blob.

The submodule caused every deployment failure this site has had:

  - .gitmodules named no `branch`, so the update fell back to
    refs/remotes/origin/HEAD — a ref a plain server-side clone never writes.
    "Atualizar submódulo" failed with "Unable to find refs/remotes/origin/HEAD
    revision in submodule path".
  - Its URL was http://, which the origin answers with 523.
  - This install is deployed by uploading files, and a submodule is invisible
    to that. The tree moved forward while shared/platform-tools stayed on the
    old pin, config/functions_secrets.php died on a missing compat_secrets.php,
    and the whole site — public pages included — went down.

Each of those was separately fixable. Together they mean a second repository
nobody can see from the deployment path, gating whether the first one boots. A
plain directory deploys like every other file and cannot fall out of step.

The cost is that platform-tools updates no longer arrive by bumping a pin; they
are a copy-over-and-commit. shared/platform-tools/VENDORED.md records the
upstream SHA, the procedure, and which four files differ from upstream in line
endings only (social/*.js, .css, functions_publications.php — CRLF upstream,
normalised to LF here; content otherwise identical).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
andreandClaude Opus 5 committed 2026-09-13 23:06:23 +01:00
1 parent 0e034232e9
commit 4d40adbf64
67 files changed
+17478 -5

No files matched your search

+474
View File
@@ -0,0 +1,474 @@
<?php
// ─────────────────────────────────────────────────────────────────────────────
// A bolha de feedback — incluída pelo rodapé de cada site
//
// include __DIR__ . '/../shared/platform-tools/feedback_widget.php';
//
// Põe-se no rodapé do layout (includes/footer.php, e o rodapé do site público
// onde exista), imediatamente antes do </body>. Aparece só a quem é
// administrador — para os restantes, e para visitantes, não sai daqui uma
// única linha de HTML.
//
// Não depende de nada: nem do Bootstrap (JS ou CSS), nem de ícones, nem de
// jQuery. É um ficheiro incluído no fim de páginas que já são pesadas, e a
// alternativa — assumir que o Bootstrap está carregado — partia-se no primeiro
// site da frota que o não tivesse. O ícone é SVG inline pela mesma razão.
//
// O z-index (1035) é deliberadamente ABAIXO do backdrop dos modais do
// Bootstrap (1050): com um modal aberto a bolha desaparece por baixo dele, em
// vez de ficar a flutuar por cima de um diálogo que pediu a atenção toda.
//
// ─── Dois modos ──────────────────────────────────────────────────────────────
//
// $GLOBALS['platform_feedback_mode'] = 'footer'; // antes do include
//
// 'bubble' (por omissão) é a bolha a flutuar no canto — para o backoffice, onde
// o canto está livre e quem trabalha quer o atalho sempre à vista.
//
// 'footer' é uma ligação dentro do rodapé, sem nada a flutuar — para o site
// público, onde o canto inferior direito JÁ ESTÁ OCUPADO: o dance tem lá o
// botão do WhatsApp (e uma barra fixa em telemóvel). Duas bolhas empilhadas no
// mesmo canto são duas coisas a disputar o mesmo clique, e a que interessa ao
// visitante é a outra. O painel continua a abrir fixo no canto, por cima de
// tudo isso — está aberto por um instante e tem botão de fechar.
//
// Ver functions_feedback.php para o que é enviado, e api/feedback.php para
// quem o recebe.
// ─────────────────────────────────────────────────────────────────────────────
require_once __DIR__ . '/functions_feedback.php';
require_once __DIR__ . '/functions_csrf.php';
(static function (): void {
// Uma vez por resposta. O rodapé do backoffice e o do site público podem
// acabar ambos incluídos na mesma página, e dois formulários com os mesmos
// ids não é meio widget a mais: é um widget partido.
if (!empty($GLOBALS['__platform_feedback_rendered'])) {
return;
}
$user = platform_feedback_user();
if (!$user || !platform_feedback_is_admin($user)) {
return;
}
$ligado = hub_ticket_ready();
// Sem emparelhamento não há para onde enviar. Ao administrador não se
// mostra nada — não é ele que resolve isto, e um botão que falha é pior do
// que botão nenhum. Ao sysadmin mostra-se, porque é ele que o liga: um
// canal de relatos que desaparece em silêncio quando a chave roda perde
// exactamente os relatos que mais interessavam.
if (!$ligado && !platform_feedback_is_sysadmin($user)) {
return;
}
$GLOBALS['__platform_feedback_rendered'] = true;
$h = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
$cats = platform_feedback_categories();
// Caminho absoluto, e não base_url(): o site público da fotografia não
// carrega o config/functions.php, e ali base_url() não existe (ver
// ASSETS.md). O docroot é a raiz do domínio em toda a frota.
$endpoint = '/api/feedback.php';
// A página onde o sysadmin liga isto. Cada site tem a sua árvore de vistas,
// daí poder ser sobreposta pelo layout antes do include.
$hubPage = (string) ($GLOBALS['platform_feedback_hub_url'] ?? '/views/sysadmin/hub_integration.php');
$inline = ($GLOBALS['platform_feedback_mode'] ?? 'bubble') === 'footer';
?>
<div class="ptfb<?= $inline ? ' ptfb--inline' : '' ?>" id="ptfb" data-endpoint="<?= $h($endpoint) ?>">
<div class="ptfb-panel" id="ptfb-panel" role="dialog" aria-labelledby="ptfb-title" hidden>
<div class="ptfb-head">
<div>
<p class="ptfb-title" id="ptfb-title">Reportar à andmore</p>
<p class="ptfb-sub">Alguma coisa errada na plataforma? Conta aqui.</p>
</div>
<button type="button" class="ptfb-x" id="ptfb-close" aria-label="Fechar">&times;</button>
</div>
<?php if (!$ligado): ?>
<div class="ptfb-body">
<p class="ptfb-warn">
Esta plataforma não está ligada ao balcão de apoio — nada seria enviado.
Só tu vês esta bolha.
</p>
<p class="ptfb-warn-more">
Liga-a em <a href="<?= $h($hubPage) ?>">Integração com o hub</a>: falta o
endereço do hub, a chave de emparelhamento, ou ambos.
</p>
</div>
<?php else: ?>
<form class="ptfb-body" id="ptfb-form" novalidate>
<input type="hidden" name="_pt_csrf" value="<?= $h(platform_csrf_token()) ?>">
<input type="hidden" name="page_url" id="ptfb-page">
<label class="ptfb-label" for="ptfb-cat">Tipo</label>
<select class="ptfb-input" name="category" id="ptfb-cat">
<?php foreach ($cats as $key => $label): ?>
<option value="<?= $h((string) $key) ?>"><?= $h((string) $label) ?></option>
<?php endforeach; ?>
</select>
<label class="ptfb-label" for="ptfb-subject">Assunto</label>
<input type="text" class="ptfb-input" name="title" id="ptfb-subject"
maxlength="150" placeholder="Numa linha, o que se passa">
<label class="ptfb-label" for="ptfb-desc">O que aconteceu</label>
<textarea class="ptfb-input ptfb-area" name="description" id="ptfb-desc" rows="5"
maxlength="5000"
placeholder="O que estavas a fazer, o que esperavas, e o que aconteceu."></textarea>
<label class="ptfb-check">
<input type="checkbox" name="blocking" value="1" id="ptfb-block">
<span>Está a impedir-me de trabalhar</span>
</label>
<p class="ptfb-note">
Vai junto o contexto: a página onde estás, a versão da plataforma, a tua
conta e o browser. Não precisas de escrever nada disso.
</p>
<button type="submit" class="ptfb-send" id="ptfb-send">Enviar</button>
<p class="ptfb-msg" id="ptfb-msg" role="status" hidden></p>
</form>
<?php endif; ?>
</div>
<?php if ($inline): ?>
<button type="button" class="ptfb-linkbtn" id="ptfb-open"
aria-expanded="false" aria-controls="ptfb-panel">
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4v4l5-4h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
</svg>
Reportar um problema
</button>
<?php else: ?>
<button type="button" class="ptfb-bubble" id="ptfb-open"
aria-expanded="false" aria-controls="ptfb-panel"
title="Reportar à andmore">
<svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M20 2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4v4l5-4h7a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z"/>
</svg>
<span class="ptfb-sr">Reportar à andmore</span>
</button>
<?php endif; ?>
</div>
<style>
.ptfb {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 1035; /* ver a nota no topo do ficheiro */
display: flex;
flex-direction: column;
align-items: flex-end;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.45;
}
.ptfb-sr {
position: absolute; width: 1px; height: 1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.ptfb-bubble {
display: flex; align-items: center; justify-content: center;
width: 52px; height: 52px;
border: none;
border-radius: 50%;
background: #1f2937;
color: #fff;
box-shadow: 0 6px 20px rgba(0, 0, 0, .28);
cursor: pointer;
transition: transform .15s ease, background-color .15s ease;
}
.ptfb-bubble:hover { background: #111827; transform: translateY(-2px); }
.ptfb-bubble:focus-visible { outline: 3px solid #60a5fa; outline-offset: 2px; }
.ptfb.is-open .ptfb-bubble { background: #111827; }
.ptfb-panel {
width: 340px;
max-width: calc(100vw - 36px);
/* Num portátil com o browser a meia altura o painel passava do ecrã e o
botão de enviar ficava fora de alcance. */
max-height: calc(100vh - 36px);
overflow-y: auto;
margin-bottom: 12px;
background: #fff;
color: #1f2937;
border: 1px solid rgba(0, 0, 0, .12);
border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, .28);
overflow: hidden;
text-align: left;
}
.ptfb-panel[hidden] { display: none; }
.ptfb-head {
display: flex; align-items: flex-start; gap: .5rem;
padding: .85rem 1rem;
background: #1f2937;
color: #fff;
}
.ptfb-title { margin: 0; font-size: .95rem; font-weight: 600; }
.ptfb-sub { margin: .1rem 0 0; font-size: .78rem; opacity: .75; }
.ptfb-x {
margin-left: auto;
background: none; border: none; color: #fff;
font-size: 1.4rem; line-height: 1;
padding: 0 .1rem; cursor: pointer; opacity: .8;
}
.ptfb-x:hover { opacity: 1; }
.ptfb-body { padding: .9rem 1rem 1rem; }
.ptfb-label {
display: block;
margin: .6rem 0 .25rem;
font-size: .72rem; font-weight: 600;
letter-spacing: .04em; text-transform: uppercase;
color: #6b7280;
}
.ptfb-label:first-of-type { margin-top: 0; }
.ptfb-input {
display: block; width: 100%;
box-sizing: border-box;
padding: .5rem .6rem;
font: inherit; font-size: .88rem;
color: #1f2937;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 8px;
}
.ptfb-input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, .18);
}
.ptfb-area { resize: vertical; min-height: 92px; }
.ptfb-check {
display: flex; align-items: center; gap: .45rem;
margin: .7rem 0 0;
font-size: .83rem;
cursor: pointer;
}
.ptfb-note {
margin: .7rem 0 .85rem;
font-size: .74rem;
color: #6b7280;
}
.ptfb-send {
width: 100%;
padding: .6rem 1rem;
font: inherit; font-size: .88rem; font-weight: 600;
color: #fff; background: #2563eb;
border: none; border-radius: 8px;
cursor: pointer;
}
.ptfb-send:hover:not(:disabled) { background: #1d4ed8; }
.ptfb-send:disabled { opacity: .6; cursor: default; }
.ptfb-msg {
margin: .7rem 0 0;
padding: .55rem .7rem;
font-size: .82rem;
border-radius: 8px;
}
.ptfb-msg[hidden] { display: none; }
.ptfb-msg.is-ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; }
.ptfb-msg.is-bad { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.ptfb-msg a { color: inherit; }
.ptfb-warn { margin: 0; font-size: .85rem; }
.ptfb-warn-more { margin: .6rem 0 0; font-size: .8rem; color: #6b7280; }
@media (max-width: 480px) {
.ptfb { right: 12px; bottom: 12px; left: 12px; align-items: stretch; }
.ptfb-panel { width: 100%; max-width: none; }
.ptfb-bubble { margin-left: auto; }
}
@media (prefers-reduced-motion: reduce) {
.ptfb-bubble { transition: none; }
}
/* ─── Modo rodapé ───────────────────────────────────────────────────────────
Nada a flutuar: fica a ligação na linha onde o include foi posto, a herdar
a cor e o tipo de letra do rodapé que a recebe — o da fotografia é escuro, o
do dance é claro, e um botão com cores próprias destoava num dos dois. O
painel continua fixo no canto, e vem depois destas regras no ficheiro
precisamente para ganhar às de cima. */
.ptfb--inline {
position: static;
display: inline-flex;
align-items: center;
z-index: auto;
font-family: inherit;
}
.ptfb-linkbtn {
display: inline-flex; align-items: center; gap: .4rem;
margin: 0; padding: 0;
border: none; background: none;
color: inherit; font: inherit; font-size: .85em;
line-height: inherit;
opacity: .75;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.ptfb-linkbtn:hover { opacity: 1; }
.ptfb-linkbtn:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
.ptfb-linkbtn svg { flex: 0 0 auto; }
/* Cor medida, não herdada — ver a nota no JS. Só entram quando o script
conseguiu medir o fundo; se não conseguiu, fica o `color: inherit` de cima. */
.ptfb-on-dark .ptfb-linkbtn { color: rgba(255, 255, 255, .62); opacity: 1; }
.ptfb-on-dark .ptfb-linkbtn:hover { color: #fff; }
.ptfb-on-light .ptfb-linkbtn { color: rgba(0, 0, 0, .66); opacity: 1; }
.ptfb-on-light .ptfb-linkbtn:hover { color: #000; }
.ptfb--inline .ptfb-panel {
position: fixed;
right: 18px;
bottom: 18px;
margin-bottom: 0;
z-index: 1035;
/* O `font-family: inherit` acima é para a LIGAÇÃO se fundir no rodapé; o
painel é um formulário e volta ao tipo de letra do sistema. Sem isto, o
rodapé da fotografia (Georgia) punha um formulário inteiro em serifada. */
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.45;
}
@media (max-width: 480px) {
.ptfb--inline { left: auto; right: auto; bottom: auto; }
.ptfb--inline .ptfb-panel { left: 12px; right: 12px; bottom: 12px; width: auto; }
}
</style>
<script>
(function () {
var root = document.getElementById('ptfb');
if (!root) return;
var openBtn = document.getElementById('ptfb-open');
var panel = document.getElementById('ptfb-panel');
var closeBtn = document.getElementById('ptfb-close');
var form = document.getElementById('ptfb-form');
/* ─── A cor da ligação do rodapé é medida, não herdada ──────────────────
`color: inherit` assume que o rodapé define uma cor legível para si
próprio. Há rodapés que não definem nenhuma e colorem os filhos um a um:
o do dance tem `background: var(--text)` (#1a1a1a) e cor herdada #1a1a1a,
o que dava uma ligação invisível — contraste 1:1.
Em vez de adivinhar a paleta de cada site da frota, procura-se o primeiro
antepassado com fundo opaco e escolhe-se claro ou escuro pela luminância.
Não se encontrando nenhum, não se toca em nada e fica o herdado. */
function tuneContrast() {
if (!root.classList.contains('ptfb--inline')) return;
var el = root, rgb = null;
while (el && el !== document.documentElement && !rgb) {
var m = getComputedStyle(el).backgroundColor.match(/rgba?\(([\d.,\s]+)\)/);
if (m) {
var p = m[1].split(',').map(parseFloat);
if (p.length < 4 || p[3] > 0.5) rgb = p;
}
el = el.parentElement;
}
if (!rgb) return;
// Luminância aproximada (sem correcção gama): chega de sobra para decidir
// entre texto claro e texto escuro.
var lum = (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
root.classList.remove('ptfb-on-dark', 'ptfb-on-light');
root.classList.add(lum < 0.5 ? 'ptfb-on-dark' : 'ptfb-on-light');
}
tuneContrast();
// Outra vez com tudo carregado: um site que sirva a folha de estilos no fim
// do body ainda não a tinha aplicada na primeira medição.
window.addEventListener('load', tuneContrast);
function open() {
panel.hidden = false;
root.classList.add('is-open');
openBtn.setAttribute('aria-expanded', 'true');
var first = panel.querySelector('select, input[type=text], a');
if (first) first.focus();
}
function close() {
panel.hidden = true;
root.classList.remove('is-open');
openBtn.setAttribute('aria-expanded', 'false');
openBtn.focus();
}
openBtn.addEventListener('click', function () { panel.hidden ? open() : close(); });
closeBtn.addEventListener('click', close);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !panel.hidden) close();
});
if (!form) return; // painel de aviso: não há formulário para submeter
var msg = document.getElementById('ptfb-msg');
var send = document.getElementById('ptfb-send');
// O URL da página é lido aqui e não no servidor: o rodapé pode ser incluído
// por uma página que não é a que o browser mostra na barra de endereço.
document.getElementById('ptfb-page').value = window.location.href;
// Texto por textContent e a ligação como elemento: a referência e o URL vêm
// de outro serviço, e nada do que vem de fora entra por innerHTML.
function say(text, ok, link) {
msg.className = 'ptfb-msg ' + (ok ? 'is-ok' : 'is-bad');
msg.textContent = text;
if (link && /^https?:\/\//i.test(link)) {
var a = document.createElement('a');
a.href = link;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Acompanhar';
msg.appendChild(document.createTextNode(' '));
msg.appendChild(a);
}
msg.hidden = false;
}
form.addEventListener('submit', function (e) {
e.preventDefault();
send.disabled = true;
send.textContent = 'A enviar…';
msg.hidden = true;
fetch(root.dataset.endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
body: new FormData(form)
})
.then(function (r) {
return r.json().catch(function () {
return { ok: false, error: 'O servidor respondeu de forma inesperada (HTTP ' + r.status + ').' };
});
})
.then(function (res) {
if (!res.ok) {
say(res.error || 'Não foi possível enviar.', false);
return;
}
say('Enviado. Ficou como ' + (res.ref || '—') + '.', true, res.track_url);
document.getElementById('ptfb-subject').value = '';
document.getElementById('ptfb-desc').value = '';
document.getElementById('ptfb-block').checked = false;
})
.catch(function () {
say('Sem ligação ao servidor. Tenta outra vez daqui a bocado.', false);
})
.finally(function () {
send.disabled = false;
send.textContent = 'Enviar';
});
});
})();
</script>
<?php
})();