Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ jobs:
- name: Testes das mascaras (JavaScript)
run: node tests/js/masks.test.js

- name: Testes do envio de formulario (JavaScript)
run: node tests/js/forms.test.js

# o public/js/masks.js e versionado e gerado do src/ts/masks.ts: se alguem mexer no .ts e esquecer
# de rodar o tsc (ou editar o .js na mao), o compilado deixa de bater com o fonte e o job falha.
mascaras:
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já

**Padrão de controller** — cada rota tem um `renderX()` público: no GET chama `View::render(...)`, no POST delega para um `handleX()` privado. Todo `handle*` começa com `Csrf::verify()` (aborta com 403). Rotas privadas começam com `$merchantId = $this->authGuard()`; o `merchant_id` vem **sempre da sessão**, nunca do formulário, e todo model filtra por ele (isolamento entre lojas).

**Post/Redirect/Get com códigos de flash** — handlers terminam com `redirect('rota', ['error' => 'codigo'])` ou `['success' => 'codigo']` (`redirect()` tem tipo `never`, faz `exit`). `views/partials/flash.php` traduz o código via um mapa fixo; **código novo precisa ser adicionado nesse mapa**, senão nada aparece. Nunca exibir o valor cru da query string.
**Post/Redirect/Get com códigos de flash** — handlers terminam com `redirect('rota', ['error' => 'codigo'])` ou `['success' => 'codigo']` (`redirect()` tem tipo `never`, faz `exit`). `views/partials/flash.php` traduz o código via um mapa fixo; **código novo precisa ser adicionado nesse mapa**, senão nada aparece. Erro que aponta um campo também entra no mapa de `field_error_attr()` (`helpers.php`), e o `<input>` leva `<?= field_error_attr('nome_do_campo') ?>`: o campo sai com `aria-invalid` (borda de erro) ligado à mensagem. Nunca exibir o valor cru da query string.

**Views** — PHP puro em `views/`, recebem variáveis via `extract()` do `View::render($view, $data)`. Toda saída passa por `e()`. Links e actions usam `url('rota', [...])`; formulários POST incluem `<?= Csrf::field() ?>`. Páginas do painel incluem `partials/merchant-header.php` / `merchant-footer.php` (definir `$title` antes). Helpers globais (`e`, `redirect`, `url`, `format_phone`, `format_document`, `format_datetime`) ficam em `src/Support/helpers.php`, carregado pelo autoload `files` do Composer.

Expand Down Expand Up @@ -69,6 +69,7 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já
- Commits no padrão `tipo(escopo): descrição` (`feat`, `fix`, `refactor`, `docs`, `test`, `chore`); branches `tipo/nome` com o mesmo tipo do commit (ex.: `feat/logout`, `fix/csrf-token`). Nunca commitar direto na `main`: tudo entra por PR, e o PR referencia a issue com `close #N` quando houver.
- SQL sempre com prepared statements; o PDO usa `ATTR_EMULATE_PREPARES = false`, então o mesmo placeholder não pode aparecer duas vezes na query (use `:p1`, `:p2`).
- Máscaras de telefone e CPF/CNPJ ficam em `src/ts/masks.ts` (`data-mask="phone"` / `"document"` no input), compiladas com `tsc` para `public/js/masks.js`, que é versionado: rode `tsc` ao mexer no `.ts`. É só apresentação; o back-end continua limpando os dígitos.
- `src/ts/forms.ts` (compilado para `public/js/forms.js`, também versionado) vale para todo `<form>`: no envio, o botão clicado ganha a classe `is-loading` (`components.css`) e um segundo envio do mesmo formulário é barrado. Página nova com formulário carrega `/js/forms.js`; o botão não recebe `disabled`.

## Backlog

Expand Down
16 changes: 1 addition & 15 deletions public/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ span.btn-secondary {
}
}

/* progresso ate o proximo premio: barra fina com o preenchimento na cor da marca */
/* progresso ate o proximo premio: frase em cima da barra (a barra em si e o .progress do components.css) */
.reward-progress {
margin-top: 0.75rem;
}
Expand All @@ -571,20 +571,6 @@ span.btn-secondary {
margin-bottom: 0.4rem;
}

.progress {
height: 0.6rem;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 999px;
overflow: hidden;
}

.progress-bar {
height: 100%;
background: var(--primary);
border-radius: 999px;
}

/* cartaz: folha clara (--paper) com nome da loja e qr code; centralizado e legivel de longe */
.poster {
max-width: 520px;
Expand Down
107 changes: 105 additions & 2 deletions public/css/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
outline: none;
/* contorno transparente em vez de "none": no modo de alto contraste do windows a sombra do
foco some e o navegador pinta este contorno, entao o foco nunca fica invisivel */
outline: 2px solid transparent;
transition: all 0.2s ease-in-out;
background: var(--bg-card);
/* Fundo do input levemente mais claro que o body */
Expand Down Expand Up @@ -58,6 +60,43 @@
background: var(--primary-hover);
transform: translateY(-1px);
}

/* botao de um formulario que ja foi enviado (classe posta pelo public/js/forms.js): fica apagado,
ganha uma rodinha girando ao lado do texto e para de receber clique. vale para qualquer botao
(.btn-primary, .btn-secondary, o "Sair" do menu), por isso usa a cor do proprio texto (currentColor) */
.is-loading {
opacity: 0.7;
cursor: progress;
pointer-events: none;
}

.is-loading::after {
content: "";
display: inline-block;
flex-shrink: 0;
width: 1em;
height: 1em;
margin-left: 0.5em;
vertical-align: -0.15em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: btn-spin 0.7s linear infinite;
}

@keyframes btn-spin {
to {
transform: rotate(360deg);
}
}

/* quem pediu menos movimento no sistema ve a rodinha parada (o botao apagado ja avisa) */
@media (prefers-reduced-motion: reduce) {
.is-loading::after {
animation: none;
}
}

/* Selects e textareas com o mesmo visual dos inputs */
.form-group select,
.form-group textarea {
Expand All @@ -68,7 +107,7 @@
border: 1px solid var(--border);
border-radius: 8px;
font-size: 1rem;
outline: none;
outline: 2px solid transparent;
}

.form-group select:focus,
Expand All @@ -77,6 +116,29 @@
box-shadow: 0 0 0 3px var(--focus-ring);
}

/* campo com erro (aria-invalid="true", posto pelo field_error_attr() do helpers.php): borda vermelha
mais grossa (a sombra de 1px engrossa sem mexer no tamanho do campo). vem depois das regras de
foco de proposito: com o foco no campo, a borda continua vermelha e o anel fica em volta */
.form-group input[aria-invalid="true"],
.form-group select[aria-invalid="true"],
.form-group textarea[aria-invalid="true"] {
border-color: var(--error);
box-shadow: 0 0 0 1px var(--error);
}

.form-group input[aria-invalid="true"]:focus,
.form-group select[aria-invalid="true"]:focus,
.form-group textarea[aria-invalid="true"]:focus {
border-color: var(--error);
box-shadow: 0 0 0 1px var(--error), 0 0 0 4px var(--focus-ring);
}

/* checkbox nao tem borda pra pintar: o erro vira um contorno em volta da caixinha */
.form-group .checkbox input[aria-invalid="true"] {
outline: 2px solid var(--error);
outline-offset: 2px;
}

/* Linha com 2+ campos: um embaixo do outro no celular, lado a lado a partir de 640px */
.form-row {
display: grid;
Expand Down Expand Up @@ -170,3 +232,44 @@
border-color: var(--warning);
color: var(--warning-text);
}

/* botao de envio que vem logo depois de uma linha de campos: um respiro a mais em cima */
.form-submit {
margin-top: 1rem;
}

/* card de formulario longo (cadastro da loja): no celular segue 100%, em tela grande vai ate 600px.
o seletor duplo vence o max-width que o auth.css da ao .auth-container nas telas maiores */
.auth-container.auth-container-wide {
max-width: 600px;
}

/* barra de progresso: <progress max="100" value="N">. o preenchimento vem do atributo value,
entao a view nao precisa de style="width". cada navegador desenha a barra com um
pseudo-elemento proprio, e eles nao podem dividir a mesma regra (um seletor desconhecido anula a regra toda) */
.progress {
display: block;
width: 100%;
height: 0.6rem;
-webkit-appearance: none;
appearance: none;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 999px;
overflow: hidden;
}

.progress::-webkit-progress-bar {
background: var(--bg-body);
border-radius: 999px;
}

.progress::-webkit-progress-value {
background: var(--primary);
border-radius: 999px;
}

.progress::-moz-progress-bar {
background: var(--primary);
border-radius: 999px;
}
9 changes: 9 additions & 0 deletions public/css/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

Foco
--focus-ring anel de foco de inputs (acessibilidade: o foco precisa ser visivel)
o contorno de foco dos demais elementos (:focus-visible) usa --primary-hover

Papel (cartaz impresso)
--paper fundo da folha do cartaz e da impressao (qr code precisa de fundo claro)
Expand Down Expand Up @@ -93,3 +94,11 @@ a {
a:hover {
color: var(--primary-hover);
}

/* foco do teclado sempre visivel: links, botoes, menu, <summary>... tudo que recebe tab ganha o
mesmo contorno. :focus-visible so vale pra quem navega pelo teclado (clique do mouse nao acende).
campos de formulario tem o proprio anel de foco (components.css). */
:focus-visible {
outline: 2px solid var(--primary-hover);
outline-offset: 2px;
}
47 changes: 47 additions & 0 deletions public/js/forms.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use strict";
// envio de formulario: o botao clicado mostra "carregando" e um segundo envio do mesmo
// formulario e barrado (clique duplo ou enter repetido lancaria pontos duas vezes).
// e so conforto: sem js o formulario segue funcionando, e o servidor continua validando tudo.
//
// vale para todo <form> da pagina, sem atributo nenhum no html.
// sem import/export de proposito: o arquivo e carregado como script comum (<script src>).
// o botao nao recebe "disabled": botao desabilitado nao e enviado junto com o formulario e perde
// o foco do teclado. quem barra o segundo envio e a marca no <form>; o css (.is-loading) so mostra.
function startSubmit(form, button) {
if (form.dataset.submitting === "1")
return false;
form.dataset.submitting = "1";
if (button) {
button.classList.add("is-loading");
button.setAttribute("aria-busy", "true");
button.setAttribute("aria-disabled", "true");
}
return true;
}
function resetSubmit(form) {
delete form.dataset.submitting;
form.querySelectorAll(".is-loading").forEach((button) => {
button.classList.remove("is-loading");
button.removeAttribute("aria-busy");
button.removeAttribute("aria-disabled");
});
}
// escuta no document: roda depois do onsubmit do proprio formulario, entao um confirm()
// cancelado ja chega aqui com defaultPrevented e o botao nao fica preso em "carregando".
document.addEventListener("submit", (event) => {
var _a;
if (event.defaultPrevented)
return;
const form = event.target;
// submitter e o botao que disparou o envio; no enter dentro de um campo, cai no primeiro botao de envio
const button = (_a = event.submitter) !== null && _a !== void 0 ? _a : form.querySelector('button[type="submit"], button:not([type])');
if (!startSubmit(form, button))
event.preventDefault();
});
// voltar do navegador pode trazer a pagina guardada como estava (bfcache), com o botao
// ainda em "carregando": nesse caso libera tudo de novo.
window.addEventListener("pageshow", (event) => {
if (!event.persisted)
return;
document.querySelectorAll("form").forEach(resetSubmit);
});
34 changes: 34 additions & 0 deletions src/Support/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,40 @@ function url(string $route, array $params = []): string {
return '/index.php?' . http_build_query(array_merge(['url' => $route], $params));
}

// marca o campo que causou o erro: devolve os atributos pra colar dentro do <input> quando o
// codigo de erro da tela aponta pro campo $field (pelo name), ou texto vazio.
// aria-invalid pinta a borda (components.css) e avisa o leitor de tela; aria-describedby liga o
// campo a mensagem (o id="flash-error" do partials/flash.php).
// $code: por padrao o ?error= da url; tela que nao usa flash (consulta publica) passa o proprio codigo.
// codigo novo de erro ligado a um campo entra neste mapa.
function field_error_attr(string $field, ?string $code = null): string {
static $fields = [
'documento_invalido' => ['document'],
'email_invalido' => ['email'],
'senha_curta' => ['password', 'new_password'],
'senhas_diferentes' => ['password', 'password_confirm', 'new_password', 'new_password_confirm'],
'ja_cadastrado' => ['email', 'document'],
'credenciais_invalidas' => ['email', 'password'],
'senha_atual_incorreta' => ['current_password'],
'telefone_invalido' => ['phone'],
'cliente_nao_encontrado' => ['phone'],
'loja_invalida' => ['loja'],
'nome_obrigatorio' => ['name'],
'consentimento_obrigatorio' => ['consent'],
'confirmacao_obrigatoria' => ['confirm'],
'pontos_invalidos' => ['points'],
'valor_invalido' => ['amount'],
'valor_sem_pontos' => ['amount'],
'regra_invalida' => ['rule'],
];

$code ??= $_GET['error'] ?? null;
if (!is_string($code) || !in_array($field, $fields[$code] ?? [], true)) {
return '';
}
return ' aria-invalid="true" aria-describedby="flash-error"';
}

// como cada tipo de movimentacao aparece no extrato e nos relatorios: [rotulo, classe do selo, sinal]
function log_type_view(string $type): array {
return match ($type) {
Expand Down
50 changes: 50 additions & 0 deletions src/ts/forms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// envio de formulario: o botao clicado mostra "carregando" e um segundo envio do mesmo
// formulario e barrado (clique duplo ou enter repetido lancaria pontos duas vezes).
// e so conforto: sem js o formulario segue funcionando, e o servidor continua validando tudo.
//
// vale para todo <form> da pagina, sem atributo nenhum no html.
// sem import/export de proposito: o arquivo e carregado como script comum (<script src>).

// o botao nao recebe "disabled": botao desabilitado nao e enviado junto com o formulario e perde
// o foco do teclado. quem barra o segundo envio e a marca no <form>; o css (.is-loading) so mostra.
function startSubmit(form: HTMLFormElement, button: HTMLElement | null): boolean {
if (form.dataset.submitting === "1") return false;

form.dataset.submitting = "1";
if (button) {
button.classList.add("is-loading");
button.setAttribute("aria-busy", "true");
button.setAttribute("aria-disabled", "true");
}
return true;
}

function resetSubmit(form: HTMLFormElement): void {
delete form.dataset.submitting;
form.querySelectorAll<HTMLElement>(".is-loading").forEach((button) => {
button.classList.remove("is-loading");
button.removeAttribute("aria-busy");
button.removeAttribute("aria-disabled");
});
}

// escuta no document: roda depois do onsubmit do proprio formulario, entao um confirm()
// cancelado ja chega aqui com defaultPrevented e o botao nao fica preso em "carregando".
document.addEventListener("submit", (event) => {
if (event.defaultPrevented) return;

const form = event.target as HTMLFormElement;
// submitter e o botao que disparou o envio; no enter dentro de um campo, cai no primeiro botao de envio
const button =
event.submitter ??
form.querySelector<HTMLElement>('button[type="submit"], button:not([type])');

if (!startSubmit(form, button)) event.preventDefault();
});

// voltar do navegador pode trazer a pagina guardada como estava (bfcache), com o botao
// ainda em "carregando": nesse caso libera tudo de novo.
window.addEventListener("pageshow", (event) => {
if (!event.persisted) return;
document.querySelectorAll<HTMLFormElement>("form").forEach(resetSubmit);
});
Loading
Loading