Files
Alexandre Possebom 2aa4a30554
Build and Push Docker Image / build (push) Successful in 1m15s
feat(booking): wizard opens by service or professional, per company
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.
2026-07-30 07:37:40 -03:00

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_token já 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 via bunx shadcn add; os registries @ss-* (shadcnstudio, componentes pagos) estão no components.json e pedem EMAIL/LICENSE_KEY no 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 de src/controllers/public.rs do 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 Record indexado, dentro de um componente próprio. O eslint aqui roda react-hooks/static-components, que barra componente vindo de CHAMADA de função no render: tanto iconFor(slug) quanto MAP.get(slug) dão "Cannot create components during render"; MAP[slug] ?? Fallback passa (caso service-icons 2026-07-29, três tentativas). Módulo que exporta dados e componente junto ainda pega o warning de fast refresh: dados em lib/, componente em components/.
  • Í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 (pacote svgpath, não na mão) e montar com createLucideIcon, que devolve um LucideIcon de verdade, com o linecap arredondado da família. Receita rodada em src/lib/nail-icons.ts (unha e esmalte, 2026-07-29).
  • Arquivo novo passa no bun run lint e ainda assim viola o prettier (o eslint daqui não checa formatação): rode bunx 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 timezone dela), 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}), professional e service como nomes (service pode ser null, appointment de balcão) e cancellable. Cancelar fora da janela (min_lead_minutes da Company, a mesma da marcação) devolve 400.
  • O price_cents da 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): com price_varies ele é 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: em service_first é o price_cents de /service/{id}/professionals, em professional_first é o de /{slug}/professional/{id}/services (essa lista não tem price_varies, é fechada por definição). Por isso o wizard resolve priceCents no estado e passa pronto para pending/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_cents do 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_flow no PublicCompany, 2026-07-30): service_first ou professional_first trocam as DUAS primeiras telas; dia/horário e dados vêm sempre depois, porque a API precisa dos dois ids. A ordem vive em STEPS[mode] no booking-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_offers dentro do compute_slots, vale pro fluxo público e pro painel). Seletor de serviço atrelado a um profissional busca adminApi.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 whitelist custom/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.$token e a raiz index.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 curtos a.$code e c.$token não entram nessa conta: são só beforeLoad com redirect, 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 o index.html com 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). Hoje a.$code.tsx e c.$token.tsx redirecionam (com replace, pra não prender o botão voltar) para agendamento.$token e confirmar.$token, e src/test/short-links.test.ts guarda 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.html servido difere do meu dist/" não prova nada. Para saber se uma mudança está no ar: ache o chunk pela string que só ele tem (grep numa 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.ts só existe depois do primeiro vite dev/vite build. Num checkout limpo o tsc -b puro falha antes disso; rode bun run dev (ou bunx vite build) uma vez primeiro. O mesmo vale ao CRIAR rota nova: bun run build roda o tsc -b ANTES de o plugin regenerar a árvore e quebra com "not assignable to keyof FileRoutesByPath"; bunx vite build uma vez resolve. Depois disso os erros do editor ficam stale um tempo: o build é a fonte da verdade.
  • adminApi.createClient está tipado como AdminClient, 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.contact no 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.css cita 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).