diff --git a/.env.example b/.env.example index 1769c19..060002f 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,12 @@ BACKUP_DIR= BACKUP_KEEP_DAYS= MYSQLDUMP_BIN= +# e-mail transacional (recuperacao de senha, confirmacao de e-mail). Vazio = nenhum e-mail sai e o log avisa +# (o provedor de producao ainda nao foi escolhido). Em desenvolvimento use MAIL_DRIVER=log: cada e-mail vira uma +# linha json em MAIL_LOG_FILE (vazio = storage/mail.log), com os links. Nunca use "log" em producao. +MAIL_DRIVER= +MAIL_LOG_FILE= + # so para o docker compose: senha do root do MySQL do container e porta publicada no host (vazio = 8080). # No compose, DB_PASS acima e a senha do usuario "fidelityx" do banco e nao pode ficar vazia. DB_ROOT_PASS= diff --git a/CLAUDE.md b/CLAUDE.md index 0106999..1599541 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,11 +16,16 @@ composer dump-autoload # obrigatorio apos criar classe nova em s php -S localhost:8000 -t public/ # servidor local (document root = public/) find src views public tests bin -name "*.php" -exec php -l {} \; # checagem de sintaxe composer test # phpunit (unidade + integração + fluxo); recria o banco fidelityx_test +composer analyse # phpstan nível 5 (phpstan.neon) com baseline; erro novo quebra +composer audit # dependências com falha de segurança conhecida composer test -- --testsuite Unit # só os testes sem banco node tests/js/masks.test.js # testes das mascaras (public/js/masks.js), sem navegador +node tests/js/forms.test.js # testes do public/js/forms.js (envio e campo com erro), sem navegador +node tests/js/customer.test.js # conta do valor da compra do public/js/customer.js (igual ao Money::toCents) 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/create-admin.php email "Nome" # cria um administrador (senha pela entrada padrão) docker compose up -d --build # aplicacao + mysql em docker (docs/operacao.md) ``` @@ -28,9 +33,11 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Testes (PHPUnit 10.5)** em `tests/`: `Unit` (validators e classes de `src/Support`, sem banco), `Integration` (models, `RateLimiter`, `Migrator` e backup contra o banco) e `Feature` (fluxo completo por HTTP: o teste sobe um `php -S` próprio numa porta livre e usa cookie + `_csrf` como o navegador). O bootstrap **apaga e recria** o banco `fidelityx_test` a partir do `schema.sql` (o `phpunit.xml` força esse nome e o bootstrap recusa nome que não termine em `_test`); credenciais vêm do `.env` local ou das variáveis de ambiente no CI. Cada teste começa com as tabelas vazias (`DatabaseTestCase`). Regra nova de negócio ou bug corrigido = teste junto. O GitHub Actions (`.github/workflows/ci.yml`) roda lint + PHPUnit + `tests/js/masks.test.js` em PHP 8.1 e 8.3 com MySQL 8 em todo PR e push na `main`, **sem `.env`** (só variáveis de ambiente, como em produção), e um job à parte recompila o `masks.ts` e falha se o `masks.js` versionado não bater. O MySQL do XAMPP roda sem `sql_mode` estrito (trunca texto longo sem erro), o do CI é estrito: não escreva teste que dependa disso. +**Análise estática** (task 51): PHPStan nível 5 em `src`, `views`, `bin` e `public`, com `phpstan-baseline.neon` guardando o que já existia (hoje só os "might not be defined" das views, que recebem as variáveis pelo `extract()`). Código novo não entra na baseline: corrija o erro. Variável nova numa view ou erro antigo corrigido: `composer analyse -- --generate-baseline`. Rodar no CI, `composer audit` no workflow e Dependabot ainda dependem de mudar o `.github/` (pendente). + ## Arquitetura -**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503. +**Roteamento** — tudo entra por `public/index.php` com `?url=dominio/acao`. Um `switch` no domínio (`merchant`, `customer`, `admin`, mais `home` e `privacy` sem ação) instancia o controller e um `match` na ação chama o método `render*()`. Rota nova = novo braço no `match` + método no controller. A exceção é `?url=health` (`App\Support\Health`, task 56): responde antes da sessão, só `ok`/`erro` em JSON, conferindo o banco. O `index.php` abre a conexão com o banco antes de rotear, então sem MySQL (ou sem as variáveis obrigatórias do banco) toda rota devolve 503. **Configuração** — `App\Support\Env::load()` (chamado pelo `index.php`, pelo bootstrap dos testes e pelos scripts de `bin/`) põe tudo no `$_ENV`: variável de ambiente real primeiro, e o `.env`, se existir, só completa o que falta (`safeLoad`: a aplicação sobe sem o arquivo). Variável nova entra em `Env::KEYS` e no `.env.example`; leia com `Env::get()` ou `$_ENV`, nunca com `getenv()` direto. @@ -42,13 +49,17 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **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. -**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. O login guarda na sessão `password_sig` (`SessionGuard::passwordSignature`) e o `authGuard` compara com a senha do banco: senha trocada derruba as sessões abertas com a antiga. Quem altera `password_hash` precisa atualizar o `password_sig` da própria sessão (ver `handlePasswordChange`), senão se desloga. +**Sessão do lojista** — o `authGuard` confere a cada requisição se a conta ainda existe e está `active` (desativada encerra a sessão na hora, com `conta_inativa`), expira a sessão parada há mais de `SessionGuard::IDLE_SECONDS` (8 h) e atualiza nome da loja e `last_seen`. O login guarda na sessão `password_sig` (`SessionGuard::passwordSignature`) e o `authGuard` compara com a senha do banco: senha trocada derruba as sessões abertas com a antiga. Quem altera `password_hash` precisa atualizar o `password_sig` da própria sessão (ver `handlePasswordChange`), senão se desloga. Conta com `merchants.email_verified_at` vazio (cadastro novo, task 50) entra, mas o `authGuard` manda toda rota para `merchant/confirm-email` (reenvia o link, 3/h); só essa tela chama `authGuard(true)`. O link `merchant/verify-email&token=...` (`EmailVerificationModel`, 24 h, uma vez) não exige login. Os links por e-mail (confirmação e nova senha) herdam de `MerchantTokenModel`. -**Balcão** — a home do painel (`merchant/dashboard`) busca o cliente pelo telefone e mostra os 5 últimos atendidos (só cartão com movimentação). Na tela do cliente (`merchant/customer`) o lançamento é em pontos ou, se a loja tem regra (`merchants.points_rule_cents`, configurada em `merchant/points-rule`), pelo valor da compra: `Money::toCents` lê o valor digitado e `Money::pointsFor` converte arredondando para baixo. Depois de lançar, a tela mostra o novo saldo com **Desfazer** por `UNDO_SECONDS` (30 s; o aviso só aparece se o lançamento tiver até `UNDO_BANNER_SECONDS`). O estorno (`action=reverse`, do Desfazer ou do extrato `merchant/statement`) vale por `LoyaltyCardModel::REVERSAL_WINDOW_HOURS` (24 h) e uma vez por movimentação: lançamento só se o saldo atual cobrir os pontos; resgate (task 44, só pelo extrato) devolve os pontos ao saldo sem mexer no total acumulado e deixa de contar nos relatórios. A tela do cliente também corrige o nome e move o cartão para um telefone novo (`LoyaltyCardModel::rename` / `changePhone`, task 43): só o cartão desta loja muda, telefone que já tem cartão nesta loja é recusado e o número antigo some quando não sobra cartão dele. Listas longas (clientes, extrato, relatórios) paginam com `App\Support\Paginator` + `views/partials/pagination.php`, 20 por página. +**Balcão** — a home do painel (`merchant/dashboard`) busca o cliente pelo telefone e mostra os 5 últimos atendidos (só cartão com movimentação). Na tela do cliente (`merchant/customer`) o lançamento é em pontos ou, se a loja tem regra (`merchants.points_rule_cents`, configurada em `merchant/points-rule`), pelo valor da compra: `Money::toCents` lê o valor digitado e `Money::pointsFor` converte arredondando para baixo. Depois de lançar, a tela mostra o novo saldo com **Desfazer** por `UNDO_SECONDS` (30 s; o aviso só aparece se o lançamento tiver até `UNDO_BANNER_SECONDS`). O estorno (`action=reverse`, do Desfazer ou do extrato `merchant/statement`) vale por `LoyaltyCardModel::REVERSAL_WINDOW_HOURS` (24 h) e uma vez por movimentação: lançamento só se o saldo atual cobrir os pontos; resgate (task 44, só pelo extrato) devolve os pontos ao saldo sem mexer no total acumulado e deixa de contar nos relatórios. A tela do cliente também corrige o nome e move o cartão para um telefone novo (`LoyaltyCardModel::rename` / `changePhone`, task 43): só o cartão desta loja muda, telefone que já tem cartão nesta loja é recusado e o número antigo some quando não sobra cartão dele. Listas longas (clientes, extrato, relatórios) paginam com `App\Support\Paginator` + `views/partials/pagination.php`, 20 por página. Os relatórios filtram por período (`App\Support\Period`: hoje, 7 dias, mês, intervalo; o saldo em circulação é sempre o atual) e `merchant/export` baixa CSV do histórico do período ou da lista de clientes (`App\Support\Csv`: `;` + BOM, texto que começa com `=`/`+`/`-`/`@` ganha `'` contra injeção de fórmula), sempre só desta loja (task 59). **Perfil** — `merchant/profile` tem dois formulários na mesma rota: dados da loja (padrão) e troca de senha (`action=password`, exige a senha atual, com limite de erros no bucket `password_change`, e a nova não pode ser igual à atual). Cadastro e troca seguem `App\Support\PasswordPolicy`: de 8 a 72 **bytes** (o limite do bcrypt); o login não confere tamanho, então senha antiga fora da regra continua entrando. E-mail e CPF/CNPJ aparecem só para leitura e nunca são lidos do POST. A sessão usa `use_strict_mode` (id inventado não é aceito), `cookie_secure` quando a requisição é HTTPS e `gc_maxlifetime` igual ao limite de inatividade. -**Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`), `Cache-Control: no-store` em toda rota `merchant/*` e `customer/*` (`RequestGuard::sendNoStore`, depois do `session_start`) e parâmetros em formato de lista (`campo[]=`) viram texto vazio, então nenhum `(string)` de `$_GET`/`$_POST` gera warning. O logout é POST com CSRF (o "Sair" do menu é um formulário). Os testes de fluxo rodam com `display_errors` ligado (`tests/Support/router.php`): qualquer warning aparece no HTML e quebra o teste. +**Administração** (task 30) — `admin/login` e `admin/dashboard` (`AdminController`): tabela `admins` própria, conta criada só pela linha de comando (`php bin/create-admin.php email "Nome"`, senha pela entrada padrão). Sessão separada da do lojista (`admin_id`, `admin_sig`, expira com 1 h parada) e o mesmo limite de tentativas do login do lojista (buckets `admin_login_*`). O admin vê as lojas (dados da loja e quantos clientes têm, nunca os clientes) e ativa/desativa uma loja (`MerchantModel::setStatus`); desativada perde o acesso na hora. + +**Esqueci a senha** (task 21) — `merchant/forgot` manda por e-mail um link `merchant/reset&token=...` (`PasswordResetModel`, sobre o `MerchantTokenModel`: token de 32 bytes aleatórios, só o SHA-256 no banco, vale `TTL_SECONDS` = 1 h e uma vez; pedir outro cancela o anterior). A resposta do pedido é sempre a mesma, exista o e-mail ou não; limite de 10 pedidos/h por IP e 3/h por e-mail. Trocar a senha pelo link derruba as sessões abertas (mesmo `password_sig`). + +**Proteções de toda requisição** — o `index.php` chama `RequestGuard` antes de rotear: cabeçalhos de segurança (`X-Frame-Options: DENY`, CSP `RequestGuard::CSP` com `default-src 'self'` e sem nada inline, HSTS em HTTPS via `RequestGuard::sendHsts` depois do `Env::load`, `nosniff`, `Referrer-Policy: same-origin`, sem `X-Powered-By`), `Cache-Control: no-store` em toda rota `merchant/*` e `customer/*` (`RequestGuard::sendNoStore`, depois do `session_start`) e parâmetros em formato de lista (`campo[]=`) viram texto vazio, então nenhum `(string)` de `$_GET`/`$_POST` gera warning. O logout é POST com CSRF (o "Sair" do menu é um formulário). Os testes de fluxo rodam com `display_errors` ligado (`tests/Support/router.php`): qualquer warning aparece no HTML e quebra o teste. **Erros** — `ErrorController::handle($code)` renderiza `views/errors/{code}.php` (fallback `default.php`) e entrega às views o botão de voltar (`$backUrl`, `$backLabel`): lojista logado volta ao painel, qualquer outra pessoa à home pública; página de erro nova usa essas variáveis, nunca link fixo. Exceções não tratadas e erros fatais são logados e viram 500 pelo `App\Support\ErrorLog` (registrado no `index.php`): uma linha por erro com um código curto, que a página 500 também mostra. Do contexto da requisição o log leva só método e rota, nunca query string, corpo ou argumentos (têm telefone e senha). Detalhes técnicos vão só para `error_log`, cujo destino é `LOG_FILE` ou o do `php.ini`. @@ -63,6 +74,8 @@ Banco: `mysql -u root -p < database/schema.sql` em instalação nova. Banco já **Limite de tentativas** — `App\Support\RateLimiter` conta tentativas na tabela `rate_limit_hits` (chave guardada só como hash SHA-256), então o limite sobrevive a apagar o cookie. Usos: login (em 15 min, 5 erros do mesmo e-mail vindos do mesmo IP ou 20 erros do mesmo IP em qualquer conta → 429), troca de senha (5 senhas atuais erradas por conta) e consulta pública (5/min por IP). **O login nunca bloqueia só pelo e-mail**: isso deixaria qualquer pessoa trancar a dona da conta errando a senha de propósito (task 38). O bucket `login_account` (por conta, somando todos os IPs) só gera aviso no log ao chegar em `LoginGuard::MAX_FAILURES_PER_ACCOUNT` (50); o CAPTCHA que deve entrar aí ainda não foi escolhido (task 45). E-mail inexistente roda `password_verify` contra `LoginGuard::DUMMY_HASH` (mesmo tempo da senha errada), e o login gera token CSRF novo (`Csrf::regenerate`). Nos buckets a chave do IP é `RateLimiter::clientKey()`, que em IPv6 vale pela rede /64 (um assinante tem o bloco inteiro e trocaria de endereço a cada tentativa). O endereço exato vem de `RateLimiter::clientIp()` (`App\Support\ClientIp`): é o `REMOTE_ADDR`, e o `X-Forwarded-For` só vale quando a requisição chega de um proxy listado em `TRUSTED_PROXIES` (o mesmo vale para o `X-Forwarded-Proto` em `RequestGuard::isHttps()`). Nunca leia esses cabeçalhos direto. +**E-mail** (task 55) — envie sempre por `App\Mail\MailerFactory::fromEnv()->send(new Message(...))`, nunca com `mail()` direto. `MAIL_DRIVER` vazio = `NullMailer` (nada sai, o log registra só assunto e hash do destinatário); `log` = `LogMailer`, que grava o e-mail inteiro em `MAIL_LOG_FILE` (dev e testes: o `HttpTestCase` liga e o teste lê com `sentMails()`). O provedor de produção ainda não foi escolhido. + **Migrations** — `App\Support\Migrator` + `bin/migrate.php` registram em `schema_migrations` o nome de cada arquivo aplicado (o controle é pelo nome, não pelo maior número: migration de outro PR com número menor ainda roda). Migration nova = arquivo `NNN_descricao.sql` + a mesma mudança no `schema.sql` + o nome no `INSERT INTO schema_migrations` do fim do `schema.sql` (o `MigratorTest` cobra). **Produção** — `Dockerfile` + `docker-compose.yml` (Apache, PHP 8.3, MySQL 8; `docker/php.ini` desliga `display_errors`), backup por `bin/backup.php` (`App\Support\Backup`) e o guia em `docs/operacao.md`. Mudou variável de ambiente, comando de operação ou volume: atualize esse guia. @@ -72,6 +85,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` (→ `public/js/forms.js`, em toda página com formulário) trava o clique duplo com "carregando" no botão e marca o campo apontado pelo erro: o `views/partials/flash.php` tem o mapa `$flashFields` (código de erro → `name` dos campos). Erro novo que é de um campo: adicione lá também. Nada de `style=""`, `', $this->lastBody, $route); + } + + $this->loginAs('loja@teste.test'); + $this->get('merchant/dashboard'); + $this->assertStringContainsString('', $this->lastBody); + } + + public function testErroApontaOCampoESucessoNao(): void { + $this->get('merchant/register&error=documento_invalido'); + $this->assertStringContainsString( + ' + diff --git a/views/merchant/confirm-email.php b/views/merchant/confirm-email.php new file mode 100644 index 0000000..e5df12d --- /dev/null +++ b/views/merchant/confirm-email.php @@ -0,0 +1,16 @@ + + + + +
+

Enviamos um link de confirmação para . Abra o e-mail e clique no link + para liberar o painel (ele vale por 24 horas).

+

Não chegou? Confira a caixa de spam ou peça outro link. O link anterior deixa de valer.

+ +
+ + +
+
+ + diff --git a/views/merchant/customer.php b/views/merchant/customer.php index 275107d..c5fcbdc 100644 --- a/views/merchant/customer.php +++ b/views/merchant/customer.php @@ -182,81 +182,6 @@ - + diff --git a/views/merchant/customers.php b/views/merchant/customers.php index 31e64a7..323f592 100644 --- a/views/merchant/customers.php +++ b/views/merchant/customers.php @@ -23,6 +23,7 @@

total ?> total === 1 ? 'cliente' : 'clientes' ?>

+

Baixar planilha (CSV)

diff --git a/views/merchant/points-rule.php b/views/merchant/points-rule.php index bc4bf17..2d4cf89 100644 --- a/views/merchant/points-rule.php +++ b/views/merchant/points-rule.php @@ -26,7 +26,7 @@ + data-confirm="Remover a regra? No balcão, os pontos voltam a ser digitados direto."> diff --git a/views/merchant/poster.php b/views/merchant/poster.php index 4d3eb1b..f589ddc 100644 --- a/views/merchant/poster.php +++ b/views/merchant/poster.php @@ -4,7 +4,7 @@

Imprima e deixe no balcão: o cliente aponta a câmera do celular e vê os pontos dele, sem instalar nada. Na janela de impressão, escolha "Salvar como PDF" se quiser o arquivo.

- +
diff --git a/views/merchant/reports.php b/views/merchant/reports.php index c76c12d..94557c3 100644 --- a/views/merchant/reports.php +++ b/views/merchant/reports.php @@ -1,10 +1,40 @@ + + + +
+ + +
+ + +
+
+ + +
+
+ + +
+ + + invalid): ?> + + isFiltered()): ?> +

De start))) ?> a end))) ?>. O saldo em circulação é sempre o de agora.

+ +
-
Clientes
+
isFiltered() ? 'Clientes novos' : 'Clientes' ?>
@@ -22,6 +52,7 @@

Histórico de movimentações

+

Baixar planilha (CSV) do período

Nenhuma movimentação ainda. Os pontos lançados e os resgates aparecem aqui.

@@ -54,7 +85,7 @@ query(); require __DIR__ . '/../partials/pagination.php'; ?> diff --git a/views/merchant/rewards.php b/views/merchant/rewards.php index 3dc323e..8c4c7b0 100644 --- a/views/merchant/rewards.php +++ b/views/merchant/rewards.php @@ -68,7 +68,7 @@
+ data-confirm="Excluir este prêmio? Se ele já foi resgatado, será só desativado."> diff --git a/views/merchant/statement.php b/views/merchant/statement.php index a6835ff..fa0a63a 100644 --- a/views/merchant/statement.php +++ b/views/merchant/statement.php @@ -37,7 +37,7 @@ ? 'Estornar este resgate? Os pontos voltam ao saldo do cliente.' : 'Estornar este lançamento? Os pontos saem do saldo do cliente.'; ?> + data-confirm=""> diff --git a/views/partials/flash.php b/views/partials/flash.php index 8339b9a..034b6fb 100644 --- a/views/partials/flash.php +++ b/views/partials/flash.php @@ -40,10 +40,18 @@ 'estorno_expirado' => 'Só dá para estornar lançamentos e resgates das últimas 24 horas.', 'estorno_sem_saldo' => 'Não dá para estornar: o cliente já usou parte desses pontos.', 'senha_atual_incorreta' => 'A senha atual não confere.', + 'loja_invalida' => 'Loja não encontrada.', + 'confirmacao_invalida' => 'Este link de confirmação é inválido, já foi usado ou venceu. Entre no painel e peça outro.', + 'link_invalido' => 'Este link de nova senha é inválido, já foi usado ou venceu. Peça outro.', + 'muitos_pedidos' => 'Muitos pedidos seguidos. Aguarde um pouco e tente de novo.', 'muitas_tentativas' => 'Muitas tentativas com a senha atual errada. Aguarde alguns minutos e tente de novo.', ], 'success' => [ - 'cadastrado' => 'Cadastro realizado! Faça login para continuar.', + 'cadastrado' => 'Cadastro realizado! Enviamos um link de confirmação para o seu e-mail. Faça login para continuar.', + 'confirmacao_reenviada' => 'Enviamos um novo link de confirmação. O anterior deixou de valer.', + 'loja_ativada' => 'Loja ativada. O lojista já pode entrar.', + 'loja_desativada' => 'Loja desativada. O lojista perdeu o acesso na hora.', + 'email_confirmado' => 'E-mail confirmado. Bem-vindo ao FidelityX!', 'logged' => 'Bem-vindo de volta!', 'logout' => 'Você saiu da sua conta.', 'cliente_cadastrado' => 'Cliente cadastrado. Já pode lançar os pontos.', @@ -62,13 +70,41 @@ 'regra_salva' => 'Regra de pontos salva.', 'regra_removida' => 'Regra de pontos removida.', 'perfil_atualizado' => 'Dados da loja atualizados.', + 'reset_enviado' => 'Se o e-mail for de uma loja cadastrada, enviamos um link para criar uma nova senha. Ele vale por 1 hora.', + 'senha_redefinida' => 'Senha nova criada. Entre com ela.', 'senha_alterada' => 'Senha alterada. Os outros aparelhos conectados precisam entrar de novo.', ], ]; +// campos que cada erro aponta (pelo name do input): o public/js/forms.js marca, liga a mensagem e poe o foco. +// erro de login nao aponta campo de proposito (nao diz se o errado foi o e-mail ou a senha). +$flashFields = [ + 'telefone_invalido' => ['phone'], + 'email_invalido' => ['email'], + 'documento_invalido' => ['document'], + 'senha_curta' => ['password', 'password_confirm', 'new_password', 'new_password_confirm'], + 'senha_longa' => ['password', 'password_confirm', 'new_password', 'new_password_confirm'], + 'senhas_diferentes' => ['password_confirm', 'new_password_confirm'], + 'senha_igual' => ['new_password', 'new_password_confirm'], + 'senha_atual_incorreta' => ['current_password'], + 'nome_obrigatorio' => ['name'], + 'nome_invalido' => ['name'], + 'consentimento_obrigatorio' => ['consent'], + 'confirmacao_obrigatoria' => ['confirm'], + 'pontos_invalidos' => ['points'], + 'valor_invalido' => ['amount'], + 'valor_sem_pontos' => ['amount'], + 'valor_pontos_demais' => ['amount'], + 'regra_invalida' => ['rule'], + 'telefone_novo_invalido' => ['new_phone'], + 'telefone_igual' => ['new_phone'], + 'telefone_ja_cliente' => ['new_phone'], +]; + foreach (['error', 'success'] as $flashType): $flashCode = $_GET[$flashType] ?? null; - if (is_string($flashCode) && isset($flashMessages[$flashType][$flashCode])): ?> -
+ if (is_string($flashCode) && isset($flashMessages[$flashType][$flashCode])): + $fields = $flashType === 'error' ? ($flashFields[$flashCode] ?? []) : []; ?> +
data-fields="">
+ diff --git a/views/partials/merchant-header.php b/views/partials/merchant-header.php index dac5f79..783282d 100644 --- a/views/partials/merchant-header.php +++ b/views/partials/merchant-header.php @@ -20,8 +20,8 @@ - - + +
-

diff --git a/views/partials/reward-progress.php b/views/partials/reward-progress.php index 3de49fe..cb2d1f8 100644 --- a/views/partials/reward-progress.php +++ b/views/partials/reward-progress.php @@ -16,6 +16,7 @@
-
+ +