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..8f316e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ composer test -- --testsuite Unit # só os testes sem banco tsc # compila src/ts -> public/js (tsconfig.json) php bin/migrate.php # roda as migrations pendentes (status | baseline NNN) php bin/backup.php # backup do banco (mysqldump .sql.gz em BACKUP_DIR) +php bin/expire-points.php # vence o saldo parado alem do prazo de cada loja (--dry-run so mostra) docker compose up -d --build # aplicacao + mysql em docker (docs/operacao.md) ``` @@ -35,9 +36,9 @@ 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. +**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`, `format_date`) ficam em `src/Support/helpers.php`, carregado pelo autoload `files` do Composer. **Fuso horário** — `app_timezone()` (`APP_TIMEZONE` do `.env`, padrão `America/Sao_Paulo`) é aplicado no PHP pelo `index.php` e pelo `tests/bootstrap.php`, e o `Database` faz `SET time_zone` com o mesmo offset em toda conexão. Não dependa do fuso do `php.ini` nem do servidor MySQL. @@ -53,7 +54,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já - `customers` é **só o telefone**, global e único (só dígitos, 10–11, via `PhoneValidator::sanitize`); o mesmo cliente pode ter cartão em várias lojas. - `loyalty_cards` = par (merchant, customer) único. Guarda o que o cliente deu **a esta loja** (`customer_name`, `consent_at`, `consent_version`), o saldo (`current_points`, `total_accumulated`) e `anonymized_at`. `findOrCreate` usa `ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)` e nunca troca nome nem consentimento de cartão existente. - **LGPD (regra da task 11, `docs/adr/002-lgpd-dados-por-loja.md`)**: nenhuma tela de uma loja mostra dado vindo de outra. Telefone sem cartão nesta loja vai sempre para o cadastro rápido, seja novo ou de outra loja: nunca crie caminho que diferencie os dois. Consentimento é gravado no cadastro com `Privacy::VERSION` (mudou o texto de `views/privacy.php` de forma relevante, troque a versão). Exclusão = `LoyaltyCardModel::anonymize` (cartão fica só para os relatórios, telefone some quando não há mais cartão). -- `points_log` é o histórico (`earn`/`redeem`/`reversal`, `reward_id` nos resgates, `reverses_id` nos estornos). Toda mudança de saldo passa por `LoyaltyCardModel::addPoints` / `redeem` / `reverse`, que atualizam o cartão e gravam o log na mesma transação; `redeem` usa `SELECT ... FOR UPDATE` para impedir gasto duplo. Não altere saldo fora desses métodos. +- `points_log` é o histórico (`earn`/`redeem`/`reversal`/`expire`, `reward_id` nos resgates, `reverses_id` nos estornos). Toda mudança de saldo passa por `LoyaltyCardModel::addPoints` / `redeem` / `reverse`, que atualizam o cartão e gravam o log na mesma transação; `redeem` usa `SELECT ... FOR UPDATE` para impedir gasto duplo. Não altere saldo fora desses métodos. +- **Validade dos pontos (task 31)**: cada loja tem `merchants.points_expiry_months` (padrão 12, `NULL` = não vence, escolhido na tela Regra de pontos). O saldo inteiro vence depois desse tempo sem movimentação, contado de `loyalty_cards.last_use_at`; a data não é gravada, é sempre calculada. Quem vence é `LoyaltyCardModel::expire` / `expireIdle` (linha `expire` no `points_log`), chamado pelo `bin/expire-points.php`, que precisa estar agendado (`docs/operacao.md`). A data aparece para o cliente e para o lojista pelo `views/partials/points-expiry.php`, a partir do `expires_at` de `findByMerchantAndPhone`. - `merchants` guarda CPF **ou** CNPJ (normalizados, UNIQUE). Validação só por dígito verificador, sem API externa (ver `docs/adr/001-documents-validation.md`). **Área pública** — a home (`HomeController`, view `views/home.php`, `public/css/home.css`) apresenta o produto e leva ao cadastro; lojista logado vai direto ao painel. `customer/balance&loja=CODIGO` consulta o saldo pelo telefone sem login, **só na loja do código** (`merchants.public_code`, impresso no cartaz e embutido no QR), com limite por IP (5/min → 429) e exibindo só o primeiro nome do cartão daquela loja. Sem código válido, a página pede o código (nunca lista lojas). `privacy` é a política de privacidade (rascunho pendente de revisão jurídica). @@ -69,6 +71,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/bin/expire-points.php b/bin/expire-points.php new file mode 100644 index 0000000..cfa9b89 --- /dev/null +++ b/bin/expire-points.php @@ -0,0 +1,54 @@ +countExpirable(); + printf("venceriam agora: %d cartao(oes), %d ponto(s). nada foi alterado.\n", $preview['cards'], $preview['points']); + exit(0); + } + + $result = $cards->expireIdle(); + echo $result['cards'] === 0 + ? "nada a vencer.\n" + : sprintf("%d cartao(oes) com saldo vencido, %d ponto(s) no total.\n", $result['cards'], $result['points']); +} catch (Throwable $e) { + // vai pro terminal e, se houver LOG_FILE, pro log de erros (o agendador nem sempre guarda a saida) + fwrite(STDERR, 'erro ao vencer pontos: ' . $e->getMessage() . "\n"); + if ($logToFile) { + error_log('[expire-points] ' . $e->getMessage()); + } + exit(1); +} diff --git a/database/migrations/007_validade_pontos.sql b/database/migrations/007_validade_pontos.sql new file mode 100644 index 0000000..3423186 --- /dev/null +++ b/database/migrations/007_validade_pontos.sql @@ -0,0 +1,16 @@ +-- fidelityx - migration 007 (validade dos pontos, task 31) +-- para bancos criados antes desta versao do schema.sql. rodar uma unica vez: +-- php bin/migrate.php +-- (ou, a mao: mysql -u root -p fidelityx < database/migrations/007_validade_pontos.sql) + +USE fidelityx; + +-- prazo de validade por loja: o saldo do cliente vence depois de points_expiry_months meses sem +-- nenhuma movimentacao naquela loja (loyalty_cards.last_use_at). NULL = os pontos nao vencem. +-- o padrao e 12 meses, e as lojas que ja existem tambem comecam com ele. +ALTER TABLE merchants + ADD COLUMN points_expiry_months TINYINT UNSIGNED NULL DEFAULT 12 AFTER points_rule_cents; + +-- o vencimento vira uma linha propria no historico ('expire'), como o resgate e o estorno +ALTER TABLE points_log + MODIFY type ENUM('earn', 'redeem', 'reversal', 'expire') NOT NULL; diff --git a/database/schema.sql b/database/schema.sql index 0bb6c4b..cebd734 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -26,6 +26,9 @@ CREATE TABLE IF NOT EXISTS merchants ( -- regra de pontos pelo valor da compra: a cada points_rule_cents centavos, 1 ponto (arredonda pra baixo). -- NULL = sem regra (o lojista digita os pontos direto) points_rule_cents INT UNSIGNED NULL, + -- validade dos pontos: o saldo vence depois deste tanto de meses sem movimentacao do cliente na loja + -- (loyalty_cards.last_use_at). NULL = os pontos nao vencem + points_expiry_months TINYINT UNSIGNED NULL DEFAULT 12, status ENUM('active', 'inactive') NOT NULL DEFAULT 'active', -- para futuras implementações (NULL) @@ -89,8 +92,10 @@ CREATE TABLE IF NOT EXISTS loyalty_cards ( current_points INT NOT NULL DEFAULT 0, total_accumulated INT NOT NULL DEFAULT 0, - -- para futuras implementações (NULL) + -- ultima movimentacao (ganho, resgate ou estorno). e daqui que conta a validade dos pontos: + -- vencimento = last_use_at + merchants.points_expiry_months (calculado na hora, nao fica gravado) last_use_at TIMESTAMP NULL, + -- sem uso: a data de vencimento e calculada (acima), pra nao ficar errada quando a loja muda o prazo expiration_date TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -140,7 +145,7 @@ CREATE TABLE IF NOT EXISTS points_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, card_id BIGINT UNSIGNED NOT NULL, - type ENUM('earn', 'redeem', 'reversal') NOT NULL, -- reversal = estorno de um earn + type ENUM('earn', 'redeem', 'reversal', 'expire') NOT NULL, -- reversal = estorno de um earn, expire = saldo vencido quantity INT NOT NULL, description VARCHAR(255) NOT NULL, reward_id BIGINT UNSIGNED NULL, -- preenchido quando type = 'redeem' @@ -207,4 +212,5 @@ INSERT IGNORE INTO schema_migrations (version) VALUES ('003_lgpd'), ('004_estorno'), ('005_regra_pontos'), - ('006_points_log_extrato'); + ('006_points_log_extrato'), + ('007_validade_pontos'); diff --git a/docs/db/schema-explanation.md b/docs/db/schema-explanation.md index e6aa59c..db3d6ef 100644 --- a/docs/db/schema-explanation.md +++ b/docs/db/schema-explanation.md @@ -24,6 +24,7 @@ merchants 1───N loyalty_cards N───1 customers | Coluna | Observação | |---|---| | `points_rule_cents` | Regra de pontos pelo valor da compra: a cada tantos centavos, 1 ponto (sempre arredonda para baixo, `Money::pointsFor`). `NULL` = sem regra, o lojista digita os pontos. | +| `points_expiry_months` | Validade dos pontos (task 31): o saldo do cliente vence depois deste tanto de meses sem movimentação na loja, contados de `loyalty_cards.last_use_at`. Padrão 12; `NULL` = os pontos não vencem. O lojista escolhe na tela Regra de pontos, entre os prazos de `MerchantController::EXPIRY_MONTHS_OPTIONS`. | | `public_code` | `UNIQUE`, 8 caracteres. Código público da loja: vai no QR do cartaz e identifica a loja na consulta de saldo. Gerado no cadastro (`App\Support\PublicCode`). | | `email`, `cpf`, `cnpj` | `UNIQUE`. Só um dos dois documentos é preenchido, apenas com números. | | `password_hash` | BCRYPT (`password_hash`). | @@ -47,7 +48,8 @@ merchants 1───N loyalty_cards N───1 customers | `anonymized_at` | Exclusão a pedido do cliente: o cartão perde nome, telefone, consentimento e saldo e fica só para os relatórios. | | `current_points` | Saldo disponível. Aumenta ao ganhar pontos e diminui ao resgatar. | | `total_accumulated` | Tudo que o cliente já ganhou nesta loja. Só aumenta. | -| `last_use_at` | Última movimentação. | +| `last_use_at` | Última movimentação (ganho, resgate ou estorno). É daqui que conta a validade dos pontos: vencimento = `last_use_at` + `merchants.points_expiry_months`. | +| `expiration_date` | Sem uso. A data de vencimento é calculada na hora (linha acima) em vez de gravada, para não ficar errada quando a loja muda o prazo. | ### `rewards` — catálogo de prêmios (novo no MVP) | Coluna | Observação | @@ -59,7 +61,7 @@ merchants 1───N loyalty_cards N───1 customers ### `points_log` — histórico | Coluna | Observação | |---|---| -| `type` | `earn` (ganho), `redeem` (resgate) ou `reversal` (estorno de um ganho). `quantity` é sempre positivo. | +| `type` | `earn` (ganho), `redeem` (resgate), `reversal` (estorno de um ganho) ou `expire` (saldo vencido por falta de movimentação). `quantity` é sempre positivo. | | `reward_id` | Preenchido em resgates. Vira `NULL` se o prêmio for apagado. | | `reverses_id` | Preenchido em estornos (`reversal`): o lançamento estornado. `UNIQUE`, então cada lançamento é estornado no máximo uma vez. | | `ip_address` | IP de quem fez a operação (o do cliente, mesmo atrás de proxy confiável: `TRUSTED_PROXIES`). | diff --git a/docs/operacao.md b/docs/operacao.md index cff0e11..fe8cd18 100644 --- a/docs/operacao.md +++ b/docs/operacao.md @@ -118,6 +118,34 @@ gunzip -c fidelityx-20261005-030000.sql.gz | docker compose exec -T db sh -c 'my A restauração **substitui** as tabelas do banco de destino pelo conteúdo do backup. Depois dela, rode `php bin/migrate.php` (o backup pode ser anterior à última migration). +## Vencimento dos pontos + +Cada loja tem um prazo de validade (`merchants.points_expiry_months`, padrão 12 meses; o lojista muda ou desliga na tela Regra de pontos). O saldo de um cliente vence quando ele fica esse tempo sem nenhuma movimentação naquela loja. Quem tira os pontos vencidos do saldo é este comando: + +```bash +php bin/expire-points.php # vence os saldos parados +php bin/expire-points.php --dry-run # só mostra quantos cartões e pontos venceriam, sem alterar nada +``` + +Cada saldo vencido vira uma linha `expire` no histórico (`points_log`), que aparece no extrato do cliente e nos relatórios da loja. Rodar de novo não vence nada em dobro. Lojas inativas e cartões anonimizados ficam de fora. Em caso de falha o comando sai com código 1. + +**Sem o agendamento nada vence**: a data aparece para o cliente, mas o saldo continua lá. Agende uma vez por dia, como o backup: + +``` +# cron no servidor (4h da manhã), com Docker +0 4 * * * cd /caminho/do/fidelityx && docker compose exec -T app php bin/expire-points.php >> /var/log/fidelityx-expire.log 2>&1 + +# cron sem Docker +0 4 * * * cd /caminho/do/fidelityx && php bin/expire-points.php >> /var/log/fidelityx-expire.log 2>&1 +``` + +No Windows (XAMPP), use o Agendador de Tarefas chamando `C:\xampp\php\php.exe bin\expire-points.php` na pasta do projeto. + +Cuidados: + +- O vencimento não tem "desfazer". Antes da primeira execução em um banco com dados reais, rode com `--dry-run` e faça um backup. +- Se a loja diminuir o prazo, os saldos que já passam do prazo novo vencem na próxima execução. + ## Registro de erros - Exceção não tratada e erro fatal viram uma linha no log e a página 500. A página mostra um código curto (ex.: `ab12cd34`) e a mesma sequência aparece na linha do log: quando um lojista relatar o erro, procure por ela. diff --git a/public/css/app.css b/public/css/app.css index c0179fe..6a54115 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -286,6 +286,12 @@ th.num { color: var(--warning-text); } +/* pontos vencidos por falta de movimentacao */ +.badge-expire { + background: var(--error-soft); + color: var(--error-text); +} + .btn-secondary { min-height: 44px; background: transparent; @@ -561,7 +567,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 +577,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..59ea1e8 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,51 @@ border-color: var(--warning); color: var(--warning-text); } + +/* validade do saldo (partials/points-expiry.php): linha pequena embaixo dos pontos, + na consulta publica e na tela do cliente no painel */ +.points-expiry { + font-size: 0.85rem; + margin-top: 0.4rem; +} + +/* 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..2a833ec 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>
@@ -70,6 +70,7 @@ pts
+ @@ -93,5 +94,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..c3fea30 100644 --- a/views/merchant/customer.php +++ b/views/merchant/customer.php @@ -8,6 +8,7 @@
Ver extrato +
@@ -49,7 +50,7 @@
@@ -76,7 +77,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 +87,7 @@

- min="1" max="10000" inputmode="numeric" placeholder="0">
@@ -145,7 +146,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..20b8528 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.

@@ -34,4 +34,35 @@ +
+

Validade dos pontos

+

O saldo do cliente vence quando ele fica esse tempo sem nenhuma movimentação na sua loja (ganhar, resgatar ou estornar pontos). Cada movimentação renova o prazo do saldo inteiro.

+ +

+ + Validade atual: meses sem movimentação. + + Validade atual: os pontos não vencem. + +

+ + + + + +
+ + +

A mudança vale para todos os clientes da loja, inclusive para o saldo que eles já têm.

+
+ + + +
+ 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..6e08979 100644 --- a/views/partials/flash.php +++ b/views/partials/flash.php @@ -27,6 +27,7 @@ 'confirmacao_obrigatoria' => 'Marque a confirmação de que o cliente pediu a exclusão.', 'estorno_invalido' => 'Lançamento não encontrado para este cliente.', 'regra_invalida' => 'Informe um valor de R$ 0,01 a R$ 1.000.000,00.', + 'validade_invalida' => 'Escolha um dos prazos de validade da lista.', 'valor_invalido' => 'Informe o valor da compra, ex.: 12,90.', 'valor_sem_pontos' => 'O valor da compra não chega a 1 ponto pela regra da loja.', 'estorno_repetido' => 'Este lançamento já foi estornado.', @@ -51,14 +52,17 @@ 'lancamento_estornado' => 'Lançamento estornado. Os pontos saíram do saldo.', 'regra_salva' => 'Regra de pontos salva.', 'regra_removida' => 'Regra de pontos removida.', + 'validade_salva' => 'Validade dos pontos salva.', 'perfil_atualizado' => 'Dados da loja atualizados.', 'senha_alterada' => 'Senha alterada. Os outros aparelhos conectados precisam entrar de novo.', ], ]; -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/points-expiry.php b/views/partials/points-expiry.php new file mode 100644 index 0000000..98113c7 --- /dev/null +++ b/views/partials/points-expiry.php @@ -0,0 +1,16 @@ + +

+ + Pontos vencidos em por falta de movimentação. + + Pontos válidos até . Cada compra ou resgate renova o prazo. + +

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

-
-
-
+ %