diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d9d8e4..6ed5048 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index 2c8eb99..3d0194c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `` leva ``: 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 ``. 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. @@ -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 `
`: 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 diff --git a/public/css/app.css b/public/css/app.css index c0179fe..a90266c 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -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; } @@ -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; diff --git a/public/css/components.css b/public/css/components.css index b33a781..e266cc0 100644 --- a/public/css/components.css +++ b/public/css/components.css @@ -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 */ @@ -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 { @@ -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, @@ -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; @@ -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: . 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; +} diff --git a/public/css/global.css b/public/css/global.css index f37a3c4..5669564 100644 --- a/public/css/global.css +++ b/public/css/global.css @@ -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) @@ -93,3 +94,11 @@ a { a:hover { color: var(--primary-hover); } + +/* foco do teclado sempre visivel: links, botoes, menu, ... 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; +} diff --git a/public/js/forms.js b/public/js/forms.js new file mode 100644 index 0000000..363c469 --- /dev/null +++ b/public/js/forms.js @@ -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 da pagina, sem atributo nenhum no html. +// sem import/export de proposito: o arquivo e carregado como script comum ( diff --git a/views/auth/merchant/register.php b/views/auth/merchant/register.php index 563c063..50913dc 100644 --- a/views/auth/merchant/register.php +++ b/views/auth/merchant/register.php @@ -12,7 +12,8 @@ -
+
+

Registre sua empresa no ecossistema FidelityX

@@ -46,13 +47,13 @@
- + placeholder="000.000.000-00 ou 00.000.000/0000-00" inputmode="numeric" maxlength="18" data-mask="document" required>
- + placeholder="(11) 99999-9999" maxlength="15" data-mask="phone" required>
@@ -82,22 +83,22 @@
- + placeholder="seu@email.com" maxlength="255" required>
- + placeholder="Mínimo 6 dígitos" minlength="6" required>
- + placeholder="Repita a senha" minlength="6" required>
- +
@@ -106,5 +107,6 @@
+ diff --git a/views/customer/balance.php b/views/customer/balance.php index 7b12736..29eebed 100644 --- a/views/customer/balance.php +++ b/views/customer/balance.php @@ -26,7 +26,7 @@ -
Código de loja não encontrado. Confira o código no cartaz da loja.
+

Aponte a câmera do celular para o QR code no balcão da loja, ou digite o código que aparece embaixo dele.

@@ -35,14 +35,14 @@
- maxlength="12" autocomplete="off" autocapitalize="characters" placeholder="Ex: 7K2M9QXA" required autofocus>
-
Informe um telefone válido com DDD.
+
@@ -51,7 +51,7 @@
- placeholder="(11) 99999-9999" maxlength="15" data-mask="phone" value="" required>
@@ -93,5 +93,6 @@
+ diff --git a/views/merchant/customer-new.php b/views/merchant/customer-new.php index 59c76f4..500e4a0 100644 --- a/views/merchant/customer-new.php +++ b/views/merchant/customer-new.php @@ -12,13 +12,13 @@
- placeholder="Ex: Maria Silva" maxlength="255" autocomplete="off" required autofocus>
diff --git a/views/merchant/customer.php b/views/merchant/customer.php index 14a1767..2585a72 100644 --- a/views/merchant/customer.php +++ b/views/merchant/customer.php @@ -49,7 +49,7 @@
@@ -76,7 +76,7 @@
- inputmode="decimal" autocomplete="off" placeholder="0,00" maxlength="15" data-rule-cents="" autofocus>

A cada em compras, 1 ponto (arredonda pra baixo). @@ -86,7 +86,7 @@

- min="1" max="10000" inputmode="numeric" placeholder="0">
@@ -145,7 +145,7 @@
diff --git a/views/merchant/dashboard.php b/views/merchant/dashboard.php index 1fb29cd..7a46ff1 100644 --- a/views/merchant/dashboard.php +++ b/views/merchant/dashboard.php @@ -30,7 +30,7 @@
- inputmode="numeric" maxlength="15" data-mask="phone" autocomplete="off" placeholder="(11) 99999-9999" required autofocus>
diff --git a/views/merchant/points-rule.php b/views/merchant/points-rule.php index bc4bf17..e08cb1b 100644 --- a/views/merchant/points-rule.php +++ b/views/merchant/points-rule.php @@ -16,7 +16,7 @@
- inputmode="decimal" autocomplete="off" maxlength="15" placeholder="1,00" value="" required autofocus>

Ex.: com R$ 1,00, uma compra de R$ 12,90 dá 12 pontos. Com R$ 5,00, dá 2 pontos.

diff --git a/views/merchant/profile.php b/views/merchant/profile.php index 6f29aec..474bf96 100644 --- a/views/merchant/profile.php +++ b/views/merchant/profile.php @@ -32,7 +32,7 @@
- + maxlength="15" data-mask="phone" value="" required>
@@ -84,18 +84,18 @@
- + autocomplete="current-password" required>
- + minlength="" autocomplete="new-password" required>
- + minlength="" autocomplete="new-password" required>

Mínimo de caracteres. Ao trocar, os outros aparelhos conectados nesta conta precisam entrar de novo.

diff --git a/views/partials/flash.php b/views/partials/flash.php index 7571a03..5b95067 100644 --- a/views/partials/flash.php +++ b/views/partials/flash.php @@ -56,9 +56,11 @@ ], ]; -foreach (['error', 'success'] as $flashType): +// role faz o leitor de tela ler a mensagem ao abrir a pagina (alert interrompe, status espera a vez). +// o id e o alvo do aria-describedby dos campos marcados por field_error_attr() (helpers.php). +foreach (['error' => 'alert', 'success' => 'status'] as $flashType => $flashRole): $flashCode = $_GET[$flashType] ?? null; if (is_string($flashCode) && isset($flashMessages[$flashType][$flashCode])): ?> -
+
+ diff --git a/views/partials/reward-progress.php b/views/partials/reward-progress.php index 3de49fe..608b31c 100644 --- a/views/partials/reward-progress.php +++ b/views/partials/reward-progress.php @@ -14,8 +14,6 @@ para

-
-
-
+ %