Build and Push Docker Image / build (push) Successful in 1m15s
A empresa escolhe no painel por qual escolha a página pública começa
(`booking_flow`), e quem chega pode sair desse padrão pelo link abaixo da
lista. A preferência do visitante não é gravada: recarregar volta ao padrão
da casa, o que evita o problema já conhecido do localStorage por origem
(`vemmarcar.com/agendar/{slug}` e `{slug}.vemmarcar.com` são origens
diferentes).
As duas primeiras telas trocam de lugar; dia/horário e dados vêm sempre
depois, porque a API precisa dos dois ids para calcular slot. A ordem vive
em `STEPS[mode]` e viaja no history junto do passo: sem isso, voltar para
uma entrada empilhada na outra ordem renderizaria o passo sob a sequência
errada. Trocar de modo zera as escolhas, já que o profissional pode não
oferecer o serviço que estava escolhido.
O wizard passou a montar só depois da resposta da empresa, senão o reducer
nasceria na ordem errada.
Junto vai um erro que só aparecia na ordem nova: `pending` e `success`
formatavam `professional.price_cents`, que não existe quando o profissional
é escolhido antes do serviço. O preço fechado agora é resolvido no estado do
wizard na segunda escolha, seja ela qual for, e chega pronto nas duas telas.
12 KiB
12 KiB
CLAUDE.md
O que é
Frontend do schdlr (API de agendamento multi-empresa). SPA React + shadcn buildada pelo Vite para
arquivos estáticos; o backend Rust vive em ~/Development/rust/schdlr e é só API JSON (ADR 0003 de
lá). O vocabulário do domínio é o CONTEXT.md do backend: Company, Professional, Service,
Appointment, Slot, Booking link, Magic link. Use esses nomes.
Duas superfícies, mesmas do app:
/agendar/{slug}— a página pública de marcação (Booking link). É a prioridade./agendamento/{token}— a página do magic link: ver e cancelar um Appointment.
O painel (admin/professional) vem depois da página pública.
Comandos
| Comando | Para quê |
|---|---|
bun install |
dependências |
bun run dev |
dev server em :5173, com proxy de /api para o backend em :3000 |
bun run build |
tsc -b + build de produção em dist/ |
bun run lint / bun run format |
eslint / prettier |
bun run knip |
código e dependência mortos |
bun run test |
vitest |
O dev server pressupõe o backend rodando (cargo run no repo do schdlr). O proxy está em
vite.config.ts.
Decisões da v1 (2026-07-28, gravadas também no ROADMAP do backend)
- Escopo: marcar + ver/cancelar. Sem reagendar (não existe no backend).
- Piloto salão/barbearia, mobile-first. Visual (revisto 2026-07-29): clean neutro base zinc, acento azul #2563EB, Schibsted Grotesk única; substitui o "tom quente" terracota da v1, que ficou com cara de velho.
- Wizard de uma decisão por tela, na ordem que a API fixa: serviço → profissional → dia/horário → dados do cliente. Resumo persistente das escolhas.
- Dia/horário: fita horizontal de dias + chips agrupados por manhã/tarde/noite. Um request por dia visto. Sempre se escolhe um profissional ("sem preferência" pede endpoint agregado futuro).
- Tela de sucesso, nesta hierarquia: resumo, "ver meu agendamento" (o
access_tokenjá vem na resposta do POST), .ics gerado no navegador, aviso do e-mail, compartilhar no WhatsApp. - Visual único do produto com o nome da empresa em destaque; tokens de cor preparados para um dia aceitar cor por empresa. Sem assinatura de produto no rodapé.
- pt-BR fixo, sem i18n. Strings direto nos componentes.
Convenções
- Componentes shadcn em
src/components/ui/(não lintados, não contados pelo knip). Componente novo entra viabunx shadcn add; os registries@ss-*(shadcnstudio, componentes pagos) estão nocomponents.jsone pedemEMAIL/LICENSE_KEYno ambiente (ver.env.example). - Rotas file-based do TanStack Router em
src/routes/;routeTree.gen.tsé gerado, não edite. - Todo acesso à API passa por
src/lib/api.ts, que espelha os DTOs desrc/controllers/public.rsdo backend. Mexeu num DTO lá, mexa aqui no mesmo commit. - Copy de UI: "horário" lê-se como slot disponível, não como appointment. Estado vazio e contagem nomeiam o que a lista contém ("Nenhum agendamento nesse dia", não "Nenhum horário"; caso painel/agenda 2026-07-29).
- Ícone escolhido em runtime (por slug) sai de um
Recordindexado, dentro de um componente próprio. O eslint aqui rodareact-hooks/static-components, que barra componente vindo de CHAMADA de função no render: tantoiconFor(slug)quantoMAP.get(slug)dão "Cannot create components during render";MAP[slug] ?? Fallbackpassa (casoservice-icons2026-07-29, três tentativas). Módulo que exporta dados e componente junto ainda pega o warning de fast refresh: dados emlib/, componente emcomponents/. - Ícone que o lucide não tem se resolve sem trocar de biblioteca. Procure em
api.iconify.design(/search?query=..., depois/{prefixo}/{nome}.svg), que indexa ~200 mil ícones de 150+ sets. O IconPark (Apache 2.0) desenha num grid 48 com traço 4, a mesma proporção do 24 com traço 2 do lucide: basta dividir os paths por 2 (pacotesvgpath, não na mão) e montar comcreateLucideIcon, que devolve umLucideIconde verdade, com o linecap arredondado da família. Receita rodada emsrc/lib/nail-icons.ts(unha e esmalte, 2026-07-29). - Arquivo novo passa no
bun run linte ainda assim viola o prettier (o eslint daqui não checa formatação): rodebunx prettier --write <arquivo>antes de commitar, senão a reformatação volta depois num commit de estilo separado da mudança.
Armadilhas
- Slots chegam como instantes UTC e a data da URL de slots é o dia local da Company (campo
timezonedela), não o dia do navegador. 22h em São Paulo já é o dia seguinte em UTC: montar a fita de dias ou agrupar chips pelo relógio do navegador mostra horário no dia errado. Converta com o timezone da Company, sempre. - O magic link (
GET /appointment/{token}) devolve o DTO enriquecido (desde 2026-07-28):company({name, slug, timezone}),professionaleservicecomo nomes (service pode ser null, appointment de balcão) ecancellable. Cancelar fora da janela (min_lead_minutesda Company, a mesma da marcação) devolve 400. - O
price_centsda lista pública de serviços é o MENOR preço da casa, não o que o cliente vai pagar (preço por profissional, 2026-07-29): comprice_variesele é um "a partir de". O valor fechado só existe depois da SEGUNDA escolha, e ele vem de um lado diferente em cada ordem do wizard: emservice_firsté oprice_centsde/service/{id}/professionals, emprofessional_firsté o de/{slug}/professional/{id}/services(essa lista não temprice_varies, é fechada por definição). Por isso o wizard resolvepriceCentsno estado e passa pronto parapending/success, em vez de cada tela derivar do profissional (era o que fazia, e no fluxo novo o campo nem existe). O combinado fica congelado no appointment (price_centsdo magic link e do DTO do painel; null = encaixe de balcão, sem linha de preço). - A ordem do wizard é da empresa e o visitante pode sair dela (
booking_flownoPublicCompany, 2026-07-30):service_firstouprofessional_firsttrocam as DUAS primeiras telas; dia/horário e dados vêm sempre depois, porque a API precisa dos dois ids. A ordem vive emSTEPS[mode]nobooking-page, e o modo viaja no history junto do passo — sem isso, voltar para uma entrada empilhada na outra ordem renderiza o passo sob a sequência errada. O link de troca só aparece na primeira tela e zera as escolhas: o profissional pode não oferecer o serviço que já estava escolhido (o backend recusa esse par com 400). - Rate limit público: 5 req/s, burst 30, por IP. Navegação normal não encosta; prefetch
agressivo de vários dias de slots encosta. Busque slot por dia visto, não em lote. Para saber
quais dias TÊM vaga sem varrer a fita, existe o agregado
GET /{slug}/professional/{p}/service/{s}/days/{from}?days=N(1..31, default 14) →{"days":[{"date","has_slots"}]}, um request para a fita inteira (2026-07-29). - Serviço é por profissional: o backend recusa (400) slots e marcação de serviço que o
profissional não executa (
professional_offersdentro docompute_slots, vale pro fluxo público e pro painel). Seletor de serviço atrelado a um profissional buscaadminApi.professionalServices(queryKey['admin-professional-services', id], a mesma da página de equipe), nunca a lista inteira da empresa (era o bug do balcão,8c81817). - 403 de corpo VAZIO em produção é o WAF da borda (CrowdSec no kauai), não o backend. O
backend sempre responde
{"error": ...}e loga o request; 403 vazio = o request nem chegou. O CRS só libera GET/HEAD/POST/OPTIONS por padrão (regra 911100); PATCH/PUT/DELETE em/api/dos hosts vemmarcar passam pelo whitelistcustom/vemmarcar-whitelist(kauai,/opt/stacks/traefik, guia CROWDSEC-GUIA.md §9). Método ou path fora desse escopo volta a ser bloqueado na borda (caso 2026-07-29: "desmarcar" dava toast de erro sem nunca tocar o backend). Corpo vazio com 404 é outra coisa: é o 404 do axum para rota que não existe, ou seja, o binário no ar ainda não tem esse endpoint (deploy do backend não entrou). Compare com uma rota vizinha que você sabe que existe antes de suspeitar dos parâmetros. - O corpo de erro da API é
{"error": "mensagem"}e a mensagem às vezes mente (o backend mapeia falhas para variantes fixas). Debugando 4xx/5xx, leia o log do servidor Rust, não o corpo. - A superfície pública tem QUATRO pontos de entrada, não três:
agendar.$slug,agendamento.$token,confirmar.$tokene a raizindex.tsx, que é a página de agendamento quando o host é{slug}.vemmarcar.com. Mudança de shell (fundo, layout, provider) tem que entrar nos quatro; esquecer a raiz faz a mudança sumir só no subdomínio, que é justamente o link que o cliente usa (caso do fundo em onda, 2026-07-29). Os aliases curtosa.$codeec.$tokennão entram nessa conta: são sóbeforeLoadcomredirect, sem shell nenhum. - Os links que o backend manda são CURTOS, e cada um precisa de rota aqui. O que sai por
WhatsApp e e-mail é
{base}/a/{code}(o código de acesso do ADR 0005) e{base}/c/{token}(confirmação), nunca as URLs das páginas. Sem a rota, o nginx ainda serve oindex.htmlcom 200 e quem escreve "Not Found" é o router do TanStack, então backend e API parecem certos e o furo é aqui (2026-07-29: link de cliente real morto,GET /public/appointment/{code}respondendo 200 o tempo todo). Hojea.$code.tsxec.$token.tsxredirecionam (comreplace, pra não prender o botão voltar) paraagendamento.$tokeneconfirmar.$token, esrc/test/short-links.test.tsguarda isso montando o router com a árvore real. Formato de link novo no backend (format!("{}/x/{}", public_base_url, ...)) exige rota nova aqui no mesmo dia. - Deploy de asset com hash: compare CONTEÚDO, nunca o nome do arquivo. O hash do build do CI
não bate com o do build local mesmo no mesmo commit, então "o
index.htmlservido difere do meudist/" não prova nada. Para saber se uma mudança está no ar: ache o chunk pela string que só ele tem (grepnuma frase da tela) e verifique o que ele importa. E durante a troca de container o HTML de uma build convive com asset de outra: um 404 em/assets/...logo depois do deploy costuma ser corrida, não deploy quebrado — refaça a leitura numa passada só (ambos os casos morderam em 2026-07-29). routeTree.gen.tssó existe depois do primeirovite dev/vite build. Num checkout limpo otsc -bpuro falha antes disso; rodebun run dev(oubunx vite build) uma vez primeiro. O mesmo vale ao CRIAR rota nova:bun run buildroda otsc -bANTES de o plugin regenerar a árvore e quebra com "not assignable to keyof FileRoutesByPath";bunx vite builduma vez resolve. Depois disso os erros do editor ficam stale um tempo: o build é a fonte da verdade.adminApi.createClientestá tipado comoAdminClient, mas o backend responde{"message": ..., "id": <id>}(client.rs): só o.idé confiável no retorno. Pra ter o Client completo, refetch da lista (GET /client/{id}existe no backend, sem função no front).- O prefill de contato (
schdlr.contactno localStorage) é por ORIGEM, e a página pública tem duas entradas:vemmarcar.com/agendar/{slug}(uma origem pra todas as empresas) e{slug}.vemmarcar.com(uma por empresa). Dado salvo numa não aparece na outra; cruzar exigiria cookie em.vemmarcar.com(decidido não fazer, 2026-07-29). - O comentário do
theme.csscita um "script oklch->WCAG" que NÃO está no repo: pra medir contraste de token novo, escreva um descartável (oklch → sRGB linear → luminância WCAG, bun).