Version 1 dropped the brand icons, turned aria-hidden on by default and
removed the UMD build. None of that reaches this project: it imports no
brand icon, already passes aria-hidden explicitly and bundles with Vite.
createLucideIcon survives, which matters because src/lib/nail-icons.ts
builds the nail and polish icons with it. Checked at runtime, not just
at compile time: both still come out as forwardRef components with
their display names intact.
Replaces the hand-rolled aside plus mobile tab strip with the official shadcn Sidebar: it collapses to icons with tooltips, persists that state in a cookie and swaps itself for a Sheet on phones. The header now carries the trigger and the theme switch, and sign out moved into the footer dropdown.
sidebar, sheet, tooltip and use-mobile were pulled from the @shadcn registry with their import paths rewritten by hand, so the existing button, input, separator and skeleton stayed untouched instead of being overwritten by the CLI.
use-mobile is generated code that trips react-hooks/set-state-in-effect, so it joins components/ui in the eslint ignore list.
Registers the paid @shadcnuikit registry alongside the @ss-* ones. The API key travels as a Bearer header and is read from REGISTRY_TOKEN in .env, which is gitignored; .env.example documents the variable.
Fifth tab on the professional page: webcal link for iPhone, Google
add-by-URL link, copy, send by email, and rotate behind a confirmation.
The copy states the refresh lag of each platform.
The `professional` role never reached that page: the Equipe menu is gated
by manage:Professional and the role only holds read, although the backend
authorizes the person for herself. The Agenda now carries a "Meu
calendário" shortcut to /painel/equipe/{id}?aba=calendario, and the
back-to-Equipe button is hidden from whoever cannot manage the team.
The Google cid link format comes from the docs and has not been tried in
a logged-in browser yet.
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.
Every link the backend sends by WhatsApp and email is short: /a/{code}
for the appointment page and /c/{token} for the email confirmation.
Neither path existed here, so the router answered "Not Found" and the
client never reached the appointment (production, 2026-07-29).
Both new routes redirect to the pages that already exist, with replace
so the short URL stays out of the history.
The wizard lived entirely in a reducer, so picking a service never
touched history and the browser back button (or the Android back
gesture) left the site instead of undoing the choice. Each step now
pushes a history entry carrying the step, and BACK/FORWARD/GO events
replay it into the wizard. The in-page back button and the summary edit
links go through history too, so the stack and the screen cannot drift
apart. Pending and success ignore it: the slot is already booked and
going back would only hide the link the client is waiting for.
The URL still does not carry the step, so a refresh keeps resetting the
wizard.
Where to look when lucide has no icon for something (iconify indexes
~200k across 150+ sets) and how IconPark paths fold into lucide's grid,
plus the reminder that eslint here does not check formatting, so a new
file passes the lint and still needs prettier before it is committed.
Lucide has no nail or nail polish icon, so the two closest ones come from
IconPark (ByteDance, Apache 2.0). Its 48 grid with stroke 4 matches
lucide's 24 with stroke 2, so the paths only needed halving; building
them with createLucideIcon keeps them real LucideIcons, with the rounded
caps and joins the rest of the family uses.
Notes from the 2026-07-29 sessions: the runtime icon lookup must be a
Record index (react-hooks/static-components rejects a function call), the
aggregate days endpoint that avoids sweeping the day strip, the empty 404
that means the backend deploy is missing the route, the fourth public
entry point (the subdomain root), and comparing asset content instead of
hashed filenames when checking a deploy.
The day ribbon now asks the backend which of the 14 days have a slot, in
a single request, and draws the closed ones dashed and disabled. Nothing
is greyed out until that answer arrives, and a failed call falls back to
today's behaviour, with every day clickable.
The step also opens on the first day with a slot instead of today, which
is often already closed, and says so plainly when the professional has
no slot in the whole window.
The public service list now carries the cheapest price in the house, so
the wizard reads "a partir de" when it varies and only shows the closed
value on the professional card. The pending and success screens moved to
the picked professional's price: they were formatting the service one,
which had silently become the minimum.
The magic link and the panel agenda show the snapshot frozen at booking
time, and the professional's service tab writes the authoritative
payload, with an empty field meaning the catalog price.
The panel offers a curated grid of 29 lucide icons (plus "no icon") when
creating or editing a service, and the public card shows the pick next to
the name. The value is the lucide.dev slug, stored in the backend's new
Service.icon; the backend only checks its length, so the valid list lives
here and an unknown slug falls back to a generic icon.
A service without an icon draws nothing, so nothing changes on screen
until a company picks one.
The wizard dropped the 17rem sticky sidebar: brand, progress, summary
and cards now stack in one 576px column. The brand block itself stacks
and centers from 768px up, with the logo at 112px.
The bottom half of the booking, magic link and confirmation pages now
sits on a light blue band, split from the default background by a wave.
The band is part of the document, anchored at half a viewport, so it
scrolls with the page.
Darken --muted-foreground one step (L .552 -> .52): zinc-500 dropped to
3.96:1 over the new --background-accent, below AA. It now measures
4.53:1 there and 5.53:1 over the cards.
- routeTree armadilha now covers new route files (bun run build runs
tsc before the vite plugin regenerates the tree)
- createClient response is {message, id}, only .id is reliable
- contact prefill is per-origin across the two public entry points
- UI copy: "horário" reads as available slot; empty states name what
the list holds
- theme.css mentions a contrast script that is not in the repo
The backend added Company.logo (upload-only, avatar pipeline) and
Company.address (PATCH, 200 chars max), both '' when absent.
- public: new shared CompanyHeader with the logo beside the name and
the address linking to Google Maps; used by the booking wizard
(visible through pending/success) and the magic link page
- painel/empresas: address field on create/edit and a logo upload on
edit, reusing the crop dialog with a new rect crop shape (a logo is
not a face); same ?v= cache-buster as client photos, and the super
admin dialog now reads the fresh company from the query so a new
logo shows up without reopening
- admin-api: Company.logo/address, uploadCompanyLogo; logo stays out
of the PATCH body (the backend ignores it there)
The backend now stores a client avatar (uploaded from the panel or
fetched from WhatsApp on confirmation). Surface it:
- new /painel/clientes page: card list with photo/initials fallback,
name/phone search, create dialog, and an edit dialog (PATCH with
RFC 7386 null-clears-email) with photo upload reusing the existing
crop dialog
- agenda: client avatar on the appointment card; the local Avatar
helper is now generic and still serves the professional column
- admin-api: AdminClient.avatar, updateClient, uploadClientAvatar
- same-day re-upload reuses the same file URL server-side, so the
page appends ?v= after an upload to skip the browser cache (the
professional screen has this latent bug; untouched here)
Mirror the real contract: the backend only requires an email when it
is the confirmation channel; on WhatsApp it just feeds the receipt
and the day-before reminder. The form said "required" for both.
- label flips to "E-mail (opcional)" and the description sells what
the email is for while WhatsApp is selected
- zod accepts empty and checks format only when filled; the email
channel requirement is enforced on submit via setError, cleared
when switching back to WhatsApp
- success screen: without an email there is no magic link mail, so
the closing line now tells the client to keep the button link
Save name, phone (digits only) and email to localStorage on a
successful booking POST and use them as the details form defaults,
so a returning client only reviews instead of retyping. Clients have
no account (ADR 0001), so the browser is the only memory available;
private tabs degrade to an empty form.
Align the public wizard with backend ADR 0004: appointments are born
pending and only confirm once the client proves channel ownership via
a WhatsApp message or the email link.
- details step: channel picker (WhatsApp preselected; 503 falls back
to email) and the channel field on the booking POST
- new pending step: wa.me CTA or email instructions, light expires_at
hint, 4s status polling until confirmed; expired keeps the button
and polling since the code still confirms while the slot is free
- new /confirmar/{token} route for the email link, mapping the fixed
400/401 contract errors (slot taken, cancelled, stale token)
- magic link page renders pending/expired and hides "book again" for
pending; painel agenda labels both statuses
- new --warning token (amber light/dark pair, contrast measured)
The terracotta-and-cream identity read as dated. New tokens: Tailwind
zinc base on light and dark, electric blue #2563EB accent (the exact
approved hex; Tailwind v4 blue-600 renders more saturated, so the
value is pinned in oklch), radius 14px down to 10px. Fraunces and
Nunito Sans give way to a single Schibsted Grotesk family behind both
font tokens, so no component changes. All WCAG pairs remeasured and
recorded in theme.css comments. CLAUDE.md v1 decision revised; the
brainstorm scratch dir joins .gitignore.
Progressive mask via formatPhone in src/lib/phone: (41) 3456-7890 with
ten digits, (41) 98765-4321 with eleven, never a trailing separator so
backspace cannot get stuck. Applied to the booking details step, the
walk-in dialog and both team forms; the edit form now renders the
stored number formatted. The API keeps receiving digits only, as every
form already stripped punctuation on submit.
The walk-in dialog listed every active service of the company, but the
backend rejects any service the professional does not offer, so those
options could never book. Fetch /professional/{id}/services when the
dialog opens instead, auto-select when only one service is linked, and
point to the team page when none are.
The .dark block was dead code: neutral grey with no relation to the
terracotta identity, and nothing in the app ever set the class.
Rewrite it in the same warm family as the light theme, hue 55 with low
chroma instead of grey, and a lightened terracotta so --primary holds
up on a dark background. Values were measured, not eyeballed: every
text pair clears 4.5:1 and the field border sits at 3.05:1.
Add lib/theme.ts, a light/dark/system store following the same shape as
the session store, with the switch in the sidebar footer and in the
phone header. The class goes on <html> rather than the panel shell,
since Radix dialogs and menus portal into the body; the panel layout
removes it on unmount, so the public page stays light as decided for
v1. The login screen honours the choice too, otherwise logging out
would flash a light page.
The public page was a fixed 448px strip at any width, and the summary
pushed the current step below the fold on desktop.
From md up it becomes two columns inside max-w-4xl: the company name,
the progress bar and the summary stay put on the left with sticky,
while the step gets the right column. Phones keep the single stacked
column, which is where booking actually happens.
The success screen goes back to a single centered column: with no
progress and no summary there is nothing to hold a second column, and
the end of the flow should sit in the middle of the screen.
Slot chips go to six per row from md. Four fixed chips became wide
buttons for "09:00" once the step had a column of its own.
Both screens were full-width rows, which turned into long stretched
bars once the panel got a desktop shell.
Services becomes a table from md up. Duration and price line up in a
column, which is what makes them comparable at a glance; the status
column now marks "Ativo" too, since an empty cell reads as missing
data. Phones keep the cards.
Team stays on cards, now in a grid of two columns from sm and three
from xl. A face and a name are not tabular data: the defect there was
the row spanning the full width, not the card itself.
Adds ui/table.tsx from the shadcn registry. No TanStack Table: for a
salon with a dozen services, sorting and pagination are infrastructure
without a user.
From lg up the agenda becomes the operations board of the salon: one
column per professional, so the whole house in a day is visible at
once. Below lg nothing changes, one professional at a time.
The counter-booking button moves into each column header, so it already
knows which professional it books for, and a "Hoje" button appears when
the date is not today, replacing an undiscoverable click on the date.
Super-admins now pick the company: the board covers one company at a
time, otherwise it would put different timezones side by side.
Costs one request per professional, since the API only exposes
appointments by professional.
The panel was built mobile-first and never got a desktop breakpoint,
so on a wide screen it was a max-w-3xl column floating in the middle.
Add a sidebar fixed from lg up, carrying the brand, one icon per
section and the signed-in user, and widen the content area. Below lg
nothing changes: the tab strip stays, since one tap per section beats
a hamburger for four links.
Both navigations now read the same link list, filtered by ability.
{slug}.vemmarcar.com now renders that company booking page at the
root: the wizard moved from the /agendar/$slug route into a component
taking the slug as a prop, and the root route resolves it from the
hostname. The path URL keeps working; infra side is a wildcard DNS
record plus a DNS-01 wildcard certificate on traefik.
react-easy-crop in a dialog (round mask, drag, pinch and slider zoom);
the chosen square is rendered to a 400px JPEG on a canvas and only then
posted to /upload, so the person picks the framing instead of the CSS.
Photo button on the professional editor posting multipart to
/upload/{id}; the roster and the editor header now render the avatar
when it exists, falling back to initials like the public page.
Multi-stage image (bun build, nginx serving the SPA with history
fallback and a runtime-resolved /api proxy to the schdlr service) and
a Gitea Actions workflow that pushes to the registry and triggers the
vemmarcar deploy webhook on kauai, mirroring the up-track pipeline.
Brand the tab titles, the panel eyebrow and the ics PRODID. The public
page body stays unbranded on purpose (v1 decision: company name in the
spotlight, no product signature).
/painel: JWT login with silent refresh and ability-gated navigation,
company CRUD (super-admin), team management (roles, offered services,
weekly schedule windows, time off), service CRUD and the day agenda
per professional with counter booking and cancellation. Typed client
in admin-api.ts mirrors the backend controllers; times render in the
Company timezone via new time.ts helpers.
Consume the enriched GET /appointment/{token} DTO (company, service,
professional, cancellable) shipped in the backend: identity header,
status badge, summary in the Company timezone, cancel flow behind a
confirmation dialog, and a rebook link for inactive appointments.
Mirror the DTO in api.ts and refresh the CLAUDE.md trap note.
Initial commit of the schdlr frontend: Vite + React + shadcn scaffold,
"salon terracotta" identity (warm oklch tokens, Fraunces + Nunito Sans
self-hosted, AA-checked pairs) and the public booking wizard from the v1
decisions: service, professional, day/time strip grouped by period in the
Company timezone (one request per viewed day), client details form and
the success screen (summary, magic link, browser-generated .ics, e-mail
notice, WhatsApp share). Timezone-safe slot utils covered by vitest.