# Consumer app

Apply to a club and sign in to a protected member account.

Source: https://docs.champagne.dev-ukab.fr/fr/consumer

`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 [#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 [#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](/fr/graphql/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 [#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 [#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 [#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:

1. Add the operation to `consumer/src/graphql/operations.graphql`.
2. Keep document inputs scoped to each frontend output in `scripts/codegen.ts`.
3. Run `make codegen`; never edit generated code manually.
4. Add transport in the feature's `consumer/src/graphql/<feature>.ts` module (built on
   `request.ts`) and React Query options in `consumer/src/graphql/queries.ts`, passing
   cancellation signals and surfacing errors.
5. 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 [#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 [#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 [#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](/fr/graphql/auth) for the API and session rules.

## Notifications [#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](/fr/graphql/notifications) for the contract.

## Member booking flow [#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 [#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.
