Consumer app
Apply to a club and sign in to a protected member account.
consumer/ is the member-facing client; web/ is the backoffice. Both use React, HeroUI, React Router, React Query, and Vite. The consumer
showcases every customer-facing feature of the API: club onboardings, applicant flows, and an
authenticated member account.
Navigation
The main navigation holds four tabs, each with its own URL. On desktop they sit in the header; on phones the header keeps only the brand, theme and language controls, and the tabs move to a fixed bottom bar with icons and short labels.
| Tab | Route | Content |
|---|---|---|
| Clubs | / | Club picker, club profile, every onboarding with its steps, and its plans |
| Book online | /book | Online booking for visitors without an account |
| Applications | /applications/recover | Find and resume an application; application and signature pages |
| My account | /account | Sign in when signed out, otherwise the member area |
The member area groups its pages into four sections. Each page keeps its own URL, and a section with several pages shows them as a segmented switch:
| Section | Pages and routes |
|---|---|
| Overview | /account |
| Reservations | /account/reservations, with details at /account/reservations/<id> |
| Billing | Membership (/account/membership), Credits (/account/credits), Invoices (/account/billing) |
| Invitations | Club guests (/account/guests), Sponsorship (/account/sponsorship) |
Sponsorship lets members generate, copy and revoke one-time sponsor codes for applicants.
Messages (/account/chats) and Notifications (/account/notifications) form the Inbox,
opened from the inbox button beside sign-out; its badge counts unread conversations and
notifications. Guests only see Overview, Reservations and the Inbox; suspended, banned or
disabled members lose Reservations and Invitations. Members whose bookings are blocked keep
every section, but Reservations only lists their existing bookings with a notice instead of
the booking planner. The My profile card on Overview lets members edit their name, phone,
date of birth, gender and address; email changes still go through the club. Unavailable page
URLs return to Overview.
Password reset, set-password and guest-invitation pages belong to the My account tab.
Source is grouped by feature under consumer/src/: clubs/, applications/, account/,
reservations/, auth/, shared layout/, and graphql/.
Anonymous applications
The Clubs tab starts by choosing a club, then presents that club's profile, its named onboarding
options with their ordered steps, and its membership plans; no person or account is required.
When the club has a hosted slug and the onboarding enables hosting, the card links to its hosted page. The demo includes separate /apply/common-members
and /apply/founding-members pages. The common path offers the member plan and requires two
sponsor codes from members, while the privileged founding path offers Circle plans without sponsorship.
Each start sends the selected copyable onboarding ID and creates a new draft from that exact onboarding. The browser moves
to /applications/<id>, with a scoped access token stored for that browser session.
A new browser session can recover access by verifying the applicant email. Each step is saved through the API with optimistic revision checks.
When the consumer is reached at an enabled club address such as
51-iena.champagne.dev-ukab.fr/onboarding/common-members, it replaces the developer preview entry screen
with that onboarding's standard application landing page. The club owns the subdomain, while each
onboarding retains its enablement, font, and primary color. The shared club picker is not shown.
Club subdomains do not expose reservation conversations. The shared consumer account implements
the member side through authenticated reservation and conversation operations. The hosted
subdomain remains available for onboarding and future club-owned experiences.
Development routing sends both consumer.<worktree-host> and direct club hosts such as
ukab-clubs.<worktree-host> to the consumer app.
The required first-name/last-name block supplies the label later shown as Applicant in the back office. The required email block sends a random six-digit code through the configured mail provider and verifies it before the application can continue. Payment and document-signature steps remain visible and required after approval. A payment step shows the selected plan, opens the club merchant's Stripe Checkout, returns to the same application, and waits for the signed paid webhook before advancing. Cancellation, expiry, unavailable merchant configuration, and webhook delay remain visible and retryable. A signature step lets the applicant download the club-specific snapshotted PDF, opens Yousign's signing flow, and returns to the same application. Sandbox uses a dedicated embedded signing route so trial accounts can return automatically; production uses Yousign's hosted redirect flow. The browser event or return is only a prompt to verify the stored signer with Yousign; it cannot complete the step by itself.
When Stripe sandbox billing is configured, the membership card loads membershipOffers, lets the
person choose one of three annual IÉNA plans, and opens hosted Checkout through
createMembershipCheckout(personId, clubId, planId, requestId). The club-scoped offer
response explicitly reports ready and reason when merchant activation or catalogue
preparation is incomplete. The authenticated member interface handles pending/error/retry states and hides the
CTA for people with Stripe-backed memberships. The membership card shows three stages:
starting without a Stripe membership, paying in hosted Checkout, and receiving the signed
webhook. After a success return, purchasing is hidden while membershipCheckoutStatus
checks the exact checkout_session_id, person and club every two seconds in the foreground.
Another existing membership cannot confirm this session. Once that session's subscription
is projected, the account refreshes, polling stops and
the card says Webhook received — membership confirmed, even if the return URL is still
present. A failed refresh keeps existing data visible and is retried automatically. Redirect messages never activate membership; purchases are confirmed only by
the signed paid webhook. Maxime Borde's pre-existing membership is seeded as described below.
See sandbox billing for configuration,
the API contract and shared Connect sandbox fixtures. Club context is functional isolation,
enforced together with the authenticated member or staff identity.
In the Membership tab, subscriptions and add-ons render as separate tiles. My subscriptions lists
subscription memberships with the sandbox purchase flow; Add-ons lists the club's
purchasable one-time extras from addonOffers with a per-plan Buy button backed by
createAddonCheckout and addonCheckoutStatus, excluding already-owned plans; My add-ons
lists owned add-on memberships. The add-on success return carries kind=addon so each tile
polls only its own Checkout kind.
The Reservations tab calls myReservations(clubId) and myReservationWaitlist(clubId) and
displays the member's upcoming pending and confirmed bookings plus future waitlist entries and
their current positions. The API derives the customer from the member session; the browser
cannot request another person's reservation list. Selecting a booking opens its detail with edit
and cancellation tools. Conversation-enabled requests also show a fixed-height chat. For those
types (without sessions) the request form asks for no time: staff propose one, optionally as a
free session, and the member accepts it from the same detail page. Only that acceptance books the
reservation: a paid proposal becomes Booked · payment due, a free one is confirmed directly.
Editing a pending request clears an earlier proposal. None of this is exposed as
a /chat/<id> route on the club subdomain.
When a reservation category requires sessions, the request form lists future occurrences; members cannot submit an arbitrary time. The reservation editor keeps the occurrence dates read-only; resource selection remains available within the server-side session restrictions. A full capacity-limited occurrence remains selectable so the member can join its FIFO waitlist, see their position, or leave it. When a place opens, the first waiting member is booked automatically and notified by email; requesting the session directly also clears their waitlist entry. Flexible types keep editable local start/end fields, while server-side blocked periods prevent requests during closures or renovation.
The Ukab demo seed publishes both common-members and founding-members, gives Alex Morgan three
upcoming reservations, and enables conversation by default for Private lessons. Alex also has
two private lesson requests awaiting a time: one without a proposal yet, and one with a free trial
session proposed by the coach, ready to accept. Alex's seeded
member credentials are alex@example.com and the standard development password.
Member area
The Overview tab summarizes the membership, the next reservation and outstanding invoices, each linking to its tab, followed by the profile, additional information and club. The profile ID comes from the member session, never from the URL.
The visible account reloads automatically on tab focus. Errors remain visible alongside cached data if a refresh fails. Missing profiles, empty membership lists, and absent fields have explicit states. A person is shown as a member when a membership record exists; expiration dates are displayed separately, without inferring an active billing status.
Without a fixture manifest, seed creates no Stripe memberships. A validated non-secret
manifest under .local/ restores Maxime Borde (00000000-0000-4000-8000-000000000091)
with his paid connected Cercle + Sport membership, and selects the Ukab Clubs merchant.
Its Price ID and paid-through date come from the bootstrap's verified sandbox invoice.
The date is a snapshot, not a perpetual or automatically refreshed entitlement.
Louis retains his contact details, extra fields, and club for testing a first purchase. Edited records and paid memberships survive restarts. A previous successful test payment therefore remains confirmed on subsequent visits; choose another person without a Stripe membership for another purchase. Seeding never resets or cancels a Stripe subscription; contradictory supplied fixture associations fail explicitly.
Both apps label records without a Stripe Price ID as non-Stripe memberships. They are not missing
webhook data. Open the consumer and back office URLs printed by the same make worktree-up:
separate worktrees use separate databases, so paying in one does not update another.
Local development
Run make setup and make dev. Open the printed Consumer URL:
http://consumer.localhost:<port>. Both apps start and hot-reload together.
make logs includes the consumer, and make down stops the stack while preserving data.
In an isolated worktree, use make worktree-up and its printed consumer URL.
It uses consumer.<worktree-host> on the same allocated HTTP port. A worktree
also prints its Mailpit inbox URL for inspecting OTP and application-decision messages.
An older saved Compose snapshot is refreshed the next time the stack starts.
With a sandbox API key in .env, worktree-up also manages the local Stripe listener and
supplies its signing secret automatically. worktree-down stops the listener with that stack.
Add YOUSIGN_API_KEY to the worktree's ignored .env.local to enable document signatures. Sandbox is
used by default. The Compose-provided consumer origin is used for the Yousign return URL.
API operations
The browser sends requests to relative /api/graphql, just as web/ does.
Development Caddy routes it to the local API. The independent deployment's Caddy
forwards it to the runtime CHAMPAGNE_ORIGIN; browser CORS configuration is unnecessary.
To extend the consumer:
- Add the operation to
consumer/src/graphql/operations.graphql. - Keep document inputs scoped to each frontend output in
scripts/codegen.ts. - Run
make codegen; never edit generated code manually. - Add transport in the feature's
consumer/src/graphql/<feature>.tsmodule (built onrequest.ts) and React Query options inconsumer/src/graphql/queries.ts, passing cancellation signals and surfacing errors. - Use the query from the relevant page and test its observable behavior.
Keep consumer operations and UI independent of back-office internals. Member identity comes from Champagne's authenticated member session rather than URL parameters.
Checks
make fix, make check, make test, and make build include the consumer.
make test-consumer runs its tests alone, with COVERAGE=1 for coverage.
The existing Storybook remains associated with web/.
Deployment
Dockerfile.consumer builds a separate static Caddy image. Like web/, pushes to
main deploy production and pushes to staging deploy staging after CI passes.
Each environment deploys its own immutable consumer image digest alongside the API.
| Environment | Consumer URL | Runtime CHAMPAGNE_ORIGIN |
|---|---|---|
| Production | https://consumer.champagne.dev-ukab.fr | https://champagne.dev-ukab.fr |
| Staging | https://consumer.staging.champagne.dev-ukab.fr | https://staging.champagne.dev-ukab.fr |
Provision each environment's consumer container and domain, then configure
SCW_CONSUMER_CONTAINER_ID and CONSUMER_ORIGIN in the matching GitHub environment
before its first release.
Both variables are required: missing or partial configuration fails the release rather
than silently skipping the consumer. Deployment verifies the ready container's image
digest and smoke-tests the public consumer URL before recording success.
The consumer container has no database access or secrets. Its static page remains available when the API is unavailable; API requests preserve upstream errors.
CI smoke tests run the image against the real disposable Champagne backend and check HTML, assets, deep links, 404s, and GraphQL forwarding.
Sign in and account access
The My account tab shows the Welcome back form while signed out. It uses club
names, email and password; /login redirects to /account. After sign-in, set-password or guest
acceptance, the browser opens /account. An expired session returns to /account?session=ended. Development can display a seeded
account chooser that fills the demo credentials. Production does not expose this chooser.
The profile ID comes from the authenticated session, never from a trusted URL parameter.
Forgot password asks for club and email; reset links expire after 30 minutes and can be
used once. See Authentication for the API and session rules.
Notifications
My account → Notifications contains the member's inbox, unread count, read actions and linked reservation/account details. The Preferences tab controls in-app and email delivery separately for reservation changes, proposals, sponsored members joining, guests joining, reminders, waitlist updates, subscription renewal, session cancellations and coach messages. Both channels default to on. Reminder windows are 24 hours for confirmed reservations and 7 days before a Stripe subscription period ends. Waitlist availability invites the member to book; no automatic booking or payment occurs. See Member notifications for the contract.
Member booking flow
The member Reservations tab starts with one activity selector containing every reservation type. Scheduled activities show a Monday–Sunday planning view in the club timezone. Selecting a session opens the integrated request form with its activity and occurrence preselected. Flexible activities show resource and date fields directly; conversation-based activities let the club propose the time. A sole resource is selected automatically. Upcoming reservations remain below this booking flow. Submission stays explicit and the existing membership, capacity, waitlist and payment rules remain unchanged.
Member session isolation
Signing in, choosing an initial password and accepting a guest invitation all cancel pending queries and clear the query and mutation caches before installing the new member token. Signing out and detecting an expired session use the same transition.
Authenticated member query keys include an opaque session scope in addition to their existing club, person and resource parameters. Bearer tokens are not added to these keys. Prefix invalidation and polling remain unchanged. Public onboarding catalogs keep their existing keys.
Responses started under a previous member token are discarded after a session change, including late mutation responses. Retry actions attached to errors from removed queries or mutations cannot execute under the new member session.