Champagne

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.

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.

TabRouteContent
Clubs/Club picker, club profile, every onboarding with its steps, and its plans
Book online/bookOnline booking for visitors without an account
Applications/applications/recoverFind and resume an application; application and signature pages
My account/accountSign 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:

SectionPages and routes
Overview/account
Reservations/account/reservations, with details at /account/reservations/<id>
BillingMembership (/account/membership), Credits (/account/credits), Invoices (/account/billing)
InvitationsClub 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:

  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

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.

EnvironmentConsumer URLRuntime CHAMPAGNE_ORIGIN
Productionhttps://consumer.champagne.dev-ukab.frhttps://champagne.dev-ukab.fr
Staginghttps://consumer.staging.champagne.dev-ukab.frhttps://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.

On this page