Problem
Deeva sends hairdressers, beauticians and make-up artists to the customer’s home. Bookings used to happen on WhatsApp. The customer wrote a message, and the staff checked availability and confirmed by hand.
The goal was a booking flow that customers complete on their own. They pick an address, the services, a professional and a time slot, and they can use a coupon or a gift card. A group flow covers bookings for more people at the same address. Payment happens at the appointment, by card or Satispay, so the app does not handle it. Customers also get a personal area with their bookings and details.
Architecture
The app is a single-page React app hosted on Render. It talks to Deeva’s REST API with a bearer token. The router loads the session before the first render. Google Places completes the address, GTM and VWO handle tracking and A/B tests, and Trustpilot shows reviews. When a booking is created, the API returns the URL of the confirmation page.
Scope
Mine
- The booking app: the four flows, the checkout and the personal area
- A typed client with Zod validation on about 20 endpoints
- Session and data loading in the router
- Analytics events tied to each step of the funnel
Other teams
- REST API and data model (Deeva)
- Tag configuration and A/B tests (Linko Digital)
Key decisions
One pattern per domain: schema, service, hook
Why. The API changed often during the project. Every response goes through an Axios wrapper that parses it with Zod, so the types match what the server really sends. Each domain has the same three files, so a new endpoint is quick to add and easy to review.
Trade-off. Each endpoint needs more boilerplate, and a failed parse stops the screen. A mismatch with the backend has to be fixed before it ships.
Generate the funnel routes from a typed list
Why. The four flows share most steps, but not all of them. The routes come from one list, and TypeScript derives the valid URLs from it. A link to a step that doesn’t exist fails the type check. A flow drops a shared step with one line.
Trade-off. The configuration is abstract. A new developer has to read the types to see the full list of URLs.
Load the session in the router
Why. The router loader fetches the user before the page renders and stores it in the TanStack Query cache. Pages read it from the cache, so there is no loading flash and no second request.
Trade-off. The token lives in localStorage. It is simple, but any script on the page can read it.
In code
The funnel routes, simplified:
const mainRoutes = [
"location",
"participants",
"participants-salotto",
"category-salotto",
"category",
] as const;
const flows = [
{
flow: "beauty",
routes: [
...withoutSteps(mainRoutes, ["category-salotto", "participants-salotto"]),
"/booking/beauty/service",
"/booking/beauty/address",
"/booking/beauty/data",
"/booking/beauty/checkout",
],
},
// hair, make-up and the group flow follow the same shape
] as const;
// Every valid URL, derived from the list above
type FlowRoute = (typeof flows)[number]["routes"][number];
const next: FlowRoute = "/booking/beauty/checkout"; // ok
const typo: FlowRoute = "/booking/beauty/payment"; // type errorResults
- 4
- booking flows (hair, beauty, make-up, group) from one typed route list
- ~20
- API endpoints behind a validated client
- 713 / 751
- commits on the frontend are mine
Customers now book without writing to the staff. The app has been in production since 2024, and the client’s own developer joined the codebase in 2026. Since March 2026 an A/B test runs on the funnel. I can’t share usage numbers.