47 Commits
Author SHA1 Message Date
Alexandre Possebom 1adf0ade7b chore(deps): bump lucide-react to 1.x
Build and Push Docker Image / build (push) Successful in 2m6s
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.
2026-08-01 13:58:46 -03:00
Alexandre Possebom 8f35bdd10f feat(painel): collapsible shadcn sidebar shell
Build and Push Docker Image / build (push) Successful in 1m21s
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.
2026-08-01 13:52:59 -03:00
Alexandre Possebom a6190a9b2a chore: add shadcnuikit registry config
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.
2026-08-01 13:52:33 -03:00
Alexandre Possebom ec98466633 docs: add pilot sales pitch for salon owners 2026-07-30 09:48:42 -03:00
Alexandre Possebom 96fd728628 feat(painel): calendar feed tab for professional
Build and Push Docker Image / build (push) Successful in 53s
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.
2026-07-30 09:35:42 -03:00
Alexandre Possebom 2aa4a30554 feat(booking): wizard opens by service or professional, per company
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.
2026-07-30 07:37:40 -03:00
Alexandre Possebom 18e4bbad04 docs: note the short-link contract with the backend
Build and Push Docker Image / build (push) Successful in 1m6s
2026-07-30 07:11:06 -03:00
Alexandre Possebom d0f4d6f8d4 fix(routing): serve the short /a and /c links
Build and Push Docker Image / build (push) Successful in 1m2s
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.
2026-07-29 17:12:04 -03:00
Alexandre Possebom a2d8c4c7f1 fix(booking): step back on browser back button
Build and Push Docker Image / build (push) Successful in 49s
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.
2026-07-29 16:45:11 -03:00
Alexandre Possebom 4e4254fcda docs: note the iconify route and the prettier gap
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.
2026-07-29 16:45:06 -03:00
Alexandre Possebom 1294294cd5 feat(icons): add nail polish and fingernail icons
Build and Push Docker Image / build (push) Successful in 54s
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.
2026-07-29 16:30:23 -03:00
Alexandre Possebom dc67009e79 docs: record deploy, routing and eslint gotchas
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.
2026-07-29 16:30:19 -03:00
Alexandre Possebom 7a5e7620a8 feat(booking): grey out the days with no free slot
Build and Push Docker Image / build (push) Successful in 56s
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.
2026-07-29 16:21:09 -03:00
Alexandre Possebom 98798ff08a feat(pricing): show and edit the per-professional price
Build and Push Docker Image / build (push) Successful in 1m5s
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.
2026-07-29 16:11:23 -03:00
Alexandre Possebom 5b708a5f0c style(booking): enlarge the company logo on phones
Build and Push Docker Image / build (push) Successful in 53s
2026-07-29 15:52:07 -03:00
Alexandre Possebom e7a542d9b1 fix(booking): show the picked icon in the summary
The summary row for the service always drew scissors; it now uses the
icon chosen for that service and keeps scissors only as the fallback.
2026-07-29 15:52:07 -03:00
Alexandre Possebom 66714d8b3b feat: let each service carry a lucide icon
Build and Push Docker Image / build (push) Successful in 55s
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.
2026-07-29 15:43:32 -03:00
Alexandre Possebom 6a3f8b9b9a fix(booking): show the backdrop on company subdomains
Build and Push Docker Image / build (push) Successful in 49s
The subdomain entry point renders BookingPage from the root route,
which the wave backdrop wrapper had missed.
2026-07-29 14:26:13 -03:00
Alexandre Possebom ba1bcadb26 refactor(booking): use a single column on desktop
Build and Push Docker Image / build (push) Successful in 48s
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.
2026-07-29 14:20:50 -03:00
Alexandre Possebom 9584e6b224 style: apply prettier formatting 2026-07-29 14:20:50 -03:00
Alexandre Possebom 2ba2e7b532 feat(booking): add wave backdrop to public pages
Build and Push Docker Image / build (push) Successful in 55s
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.
2026-07-29 14:13:05 -03:00
Alexandre Possebom 21a0f33110 style(booking): enlarge the company logo in the header
Build and Push Docker Image / build (push) Successful in 47s
2026-07-29 14:03:13 -03:00
Alexandre Possebom 51a6315d31 docs: record session pitfalls and copy convention
Build and Push Docker Image / build (push) Successful in 1m3s
- 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
2026-07-29 13:42:46 -03:00
Alexandre Possebom cf4485ecd5 feat: show the company logo and address
Build and Push Docker Image / build (push) Successful in 1m3s
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)
2026-07-29 13:41:02 -03:00
Alexandre Possebom 060b5e01cd feat(painel): add client photos and a Clientes page
Build and Push Docker Image / build (push) Successful in 49s
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)
2026-07-29 12:52:39 -03:00
Alexandre Possebom e1334816af feat(booking): make email optional on the WhatsApp channel
Build and Push Docker Image / build (push) Successful in 46s
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
2026-07-29 11:49:04 -03:00
Alexandre Possebom cdd26dd389 feat(booking): prefill client details from the last booking
Build and Push Docker Image / build (push) Successful in 1m26s
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.
2026-07-29 11:31:41 -03:00
Alexandre Possebom 7953b3a0ee fix(painel): say "no appointments" in the empty agenda column
Build and Push Docker Image / build (push) Successful in 56s
"Nenhum horário nesse dia" read as "no available slots", the exact
opposite of an empty (fully free) agenda. Name what the list holds:
appointments.
2026-07-29 11:24:28 -03:00
Alexandre Possebom c00dd0a2e7 feat: confirm public bookings by channel proof
Build and Push Docker Image / build (push) Successful in 55s
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)
2026-07-29 11:12:21 -03:00
Alexandre Possebom 1ba5dbf4fc feat(theme): go clean neutral with a blue accent
Build and Push Docker Image / build (push) Successful in 1m9s
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.
2026-07-29 10:49:55 -03:00
Alexandre Possebom a8497ad696 docs: add service-link and edge WAF pitfalls
Build and Push Docker Image / build (push) Successful in 51s
2026-07-29 10:03:42 -03:00
Alexandre Possebom 97cb37c5f9 feat: mask phone inputs with the BR format
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.
2026-07-29 10:03:41 -03:00
Alexandre Possebom 8c81817c4a fix(painel): filter services by professional
Build and Push Docker Image / build (push) Successful in 47s
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.
2026-07-29 08:08:14 -03:00
Alexandre Possebom 8dd282dc97 feat(painel): add a dark theme with a toggle
Build and Push Docker Image / build (push) Successful in 1m18s
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.
2026-07-29 07:49:06 -03:00
Alexandre Possebom 6fa3917c83 feat(booking): two columns on wide screens
Build and Push Docker Image / build (push) Successful in 1m6s
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.
2026-07-29 07:40:16 -03:00
Alexandre Possebom 761fd028ce feat(painel): table for services, grid for team
Build and Push Docker Image / build (push) Successful in 52s
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.
2026-07-29 07:37:21 -03:00
Alexandre Possebom dda5e070d7 feat(painel): show the team's day in columns
Build and Push Docker Image / build (push) Successful in 1m1s
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.
2026-07-29 07:31:59 -03:00
Alexandre Possebom 193db24821 feat(painel): add desktop sidebar layout
Build and Push Docker Image / build (push) Successful in 1m29s
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.
2026-07-29 07:15:19 -03:00
Alexandre Possebom 6fdfa26966 feat(painel): show the subdomain as the public link
Build and Push Docker Image / build (push) Successful in 2m18s
The companies list links and displays {slug}.vemmarcar.com (falling
back to the path URL on dev hosts), and the slug field hint follows.
2026-07-28 21:40:09 -03:00
Alexandre Possebom f83ce27765 feat: serve each company at its own subdomain
Build and Push Docker Image / build (push) Successful in 1m51s
{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.
2026-07-28 21:26:22 -03:00
Alexandre Possebom 16ef6932d8 feat(painel): crop the avatar before uploading
Build and Push Docker Image / build (push) Successful in 1m20s
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.
2026-07-28 20:33:59 -03:00
Alexandre Possebom 1bfeda363e feat(painel): upload professional avatar
Build and Push Docker Image / build (push) Successful in 1m54s
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.
2026-07-28 20:17:43 -03:00
Alexandre Possebom ddd01a461f feat: dockerize and auto-deploy on push
Build and Push Docker Image / build (push) Successful in 55s
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.
2026-07-28 19:40:09 -03:00
Alexandre Possebom 1d093d2d57 feat: adopt the Vem Marcar product name
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).
2026-07-28 18:59:45 -03:00
Alexandre Possebom 4a7b966002 feat: admin panel with auth, roster and day agenda
/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.
2026-07-28 16:33:43 -03:00
Alexandre Possebom d3a5f76b10 feat: magic link page with view and cancel
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.
2026-07-28 16:05:58 -03:00
Alexandre Possebom 1e1f28bc5d feat: booking page v1 with warm identity and wizard
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.
2026-07-28 15:51:43 -03:00