Skip to content

feat(portal): invite participants to run their own event, and share the portal by QR - #1059

Merged
spashii merged 19 commits into
mainfrom
portal-event-cta
Sep 11, 2026
Merged

feat(portal): invite participants to run their own event, and share the portal by QR#1059
spashii merged 19 commits into
mainfrom
portal-event-cta

Conversation

@JorimTheuns

@JorimTheuns JorimTheuns commented Sep 10, 2026

Copy link
Copy Markdown
Member

Two changes to the participant portal, both landing on the same screen at the end of a session.

The event card

The participant portal's thank you page ends on a card: an invitation to run an event with dembrane, and one button. A participant who has just spent ten minutes talking into their phone is the one person in the room who knows what the tool feels like, and this is the moment to ask them.

The form

The button opens the intake form the pricing configurator and the website's needs form already use, on a new portal mount.

  • The opening asks for an email and nothing else, and that click alone writes the row as submitted. Somebody who leaves there has still asked us to get in touch.
  • The five questions come after, offered rather than required, with a "Done, reach out to me" exit on every step.
  • The calendar is cal.com's event intake call (team/dembrane/plan-event) rather than the configurator's "discuss your needs", and it is the eager path, not the only way through.
  • No voice input: transcription needs a session a participant has not got.

On the phone it is a full sheet, the ask sits in the middle where a thumb can reach it, and the controls sit along the bottom.

Server

  • POST /v2/pricing-configurations/portal, anonymous, gated by a project that exists and is open for participation, rate limited per client address. Rows carry mount = "portal", the project id, and a PTL- reference.
  • The booking forward payload carries mount and project_id, so a portal lead reads as one wherever bookings are announced. sam prints the reference verbatim and never branches on the prefix, so this needs no change there.

Nothing announces an email-only lead yet. Those rows are submitted, with an email and no booking. A pipeline for them is Sameer's to build.

The host's switch

New project field is_dembrane_event_cta_enabled, on by default, in the portal editor under the thank you content, with a row on the project overview card. Hosts on a paid plan can switch the card off per project. The free tier cannot: the participant API forces it on and the project PATCH refuses it.

Refactor

The cal.com embed mechanics move out of PricingBookingStep into components/booking/calEmbed.ts, shared by both calendars. The configurator's own booking step behaves exactly as before.

Sharing the portal

A participant standing in a room with the portal open is the shortest path to the next participant. The header carries a share icon beside settings, and the thank you page offers "Show QR Code" ahead of "Record another conversation". Both open the same full screen QR, with copy, WhatsApp, email, and the device's own share sheet where it exists.

The link the QR encodes is built from the session the participant is already in, so a daisy chained session arrives configured the same way.

  • Reusable configuration travels: skipOnboarding, tags, tag_id_list, mode, theme. Somebody who scanned a link that skips onboarding hands on a link that skips onboarding.
  • Per person state does not: participant name, email, prefilled feedback, the attribution source, and any temporary route state. Every shared link starts a fresh session.

portalSharing.ts holds that split and its tests, so the rule about what carries over lives in one place rather than in the modal. The finish route now keeps its incoming query string, which is what lets the second entry point build the same link as the header; audio and text modes both forward it on their way to finish.

The event card loses its border, its bullet list and its heading. A participant who has just used dembrane does not need to be told what it does: the illustration, and one button that asks the question itself, "dembrane at your event?", pinned to the bottom of the viewport as a footer so it stays in reach however far the page scrolls. It, "Show QR Code" and "Record another conversation" are all secondary outline buttons.

The microphone check is gone

The onboarding deck no longer tests the microphone. Nearly every phone's microphone is fine, and the check cost every participant a permission prompt, a test and a tap before they had said a word. The deck runs privacy, then a name and tags if the host asks for them, then recording; the permission prompt moves to the first tap on Record. While recording, the level meter runs blue, turns yellow when the connection is unhealthy, and red when the recording itself is in trouble. Eight seconds of a flat signal (a muted, unplugged or taken-over microphone; a quiet room still returns room tone) opens the microphone test on its own. The mic_* funnel stages are retired on both sides: the dashboard drops its three mic timeline rows, the server stops accepting them from the portal and stops counting them in the funnel summary. A visitor session written by a pre-change tab that still carries one folds forward to terms.

One column, everywhere

The header keeps to the same 42rem column as the content, so on a wide screen the logo and the actions line up with the text rather than the window.

Verification

Run locally against the dev container stack: the whole flow walked on an iPhone 14 and an iPhone SE viewport, rows checked in Directus, both the free-tier lock and the paid switch exercised in the dashboard.

  • 69 frontend tests, 94 server tests, plus the focused tests added along the way: share link, event card, level meter (colour, silence, fire-once), start-screen auto-start (one request across a remount), connection notice, and the funnel's retired-stage fold
  • Production build, tsc --noEmit, and biome clean on every file this touches
  • Share flow walked in the local portal: header icon, thank you page button, QR, and each share action
  • ruff clean on the server files this touches
  • i18n catalogs extracted, compiled, translated in all eight locales, and stable on a second extract

One pre-existing ruff formatting drift in free_tier.py (three functions this PR does not touch) is left alone rather than padding the diff.

🤖 Generated with Claude Code

JorimTheuns and others added 3 commits September 10, 2026 12:45
The thank you page ends on a card: three reasons to run an event with
dembrane, and one button. A participant who has just spent ten minutes
talking into their phone is the one person in the room who knows what
the tool feels like, and this is the moment to ask them.

The button opens the intake form the pricing configurator and the
website's needs form already use, on a new `portal` mount:

  - the opening asks for an email and nothing else, and that click alone
    writes the row as submitted. Somebody who leaves there has still
    asked us to get in touch.
  - the five questions come after, offered rather than required, with a
    "Done, reach out to me" exit on every step.
  - the calendar is cal.com's event intake call rather than the
    configurator's "discuss your needs", and it is the eager path, not
    the only way through.
  - no voice input: transcription needs a session a participant has not
    got.

On the phone the form is a full sheet, the ask sits in the middle where
a thumb can reach it, and the controls sit along the bottom.

Server:

  - `POST /v2/pricing-configurations/portal`, anonymous, gated by a
    project that exists and is open for participation, rate limited per
    client address. Rows carry `mount = "portal"`, the project id, and a
    `PTL-` reference.
  - the booking forward payload carries `mount` and `project_id`, so a
    portal lead reads as one wherever bookings are announced.

Nothing announces an email-only lead yet: those rows are `submitted`
with an email and no booking, waiting on a pipeline of their own.

Hosts on a paid plan can switch the card off per project
(`is_dembrane_event_cta_enabled`, on by default). The free tier cannot:
the participant API forces it on and the project PATCH refuses it.

The cal.com embed mechanics move out of PricingBookingStep into
`components/booking/calEmbed.ts`, shared by both calendars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `#:` comments point at the line a string is written on, so the last
round of layout edits moved them. Extract and compile were run before
those edits rather than after, which `ci-check-i18n-catalogs` catches by
re-running both on a clean checkout.

No message content changes: references only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A participant standing in a room with the portal open is the shortest
path to the next participant. The header now carries a share icon beside
settings, and the thank you page offers "Show QR Code" next to "Record
another conversation". Both open the same full screen QR, with copy,
WhatsApp, email, and the device's own share sheet where it exists.

The link the QR encodes is built from the session the participant is
already in, so a daisy chained session arrives configured the same way:

  - reusable configuration travels (`skipOnboarding`, `tags`,
    `tag_id_list`, `mode`, `theme`). Somebody who scanned a link that
    skips onboarding hands on a link that skips onboarding.
  - per person state does not (participant name, email, prefilled
    feedback, the attribution source, and any temporary route state).
    Every shared link starts a fresh session.

`portalSharing.ts` holds that split and its tests, so the rule about
what carries over lives in one place rather than in the modal.

The finish route now keeps its incoming query string, which is what lets
the second entry point build the same link as the header. Both audio and
text modes forward it on their way to finish.

The event card on the thank you page loses its border and its bullet
list: a participant who has just used dembrane does not need to be told
what it does. Heading, illustration, one button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JorimTheuns JorimTheuns changed the title feat(portal): invite participants to run their own event feat(portal): invite participants to run their own event, and share the portal by QR Sep 11, 2026
JorimTheuns and others added 16 commits September 11, 2026 14:11
# Conflicts:
#	echo/frontend/src/locales/cs-CZ.po
#	echo/frontend/src/locales/cs-CZ.ts
#	echo/frontend/src/locales/de-DE.po
#	echo/frontend/src/locales/de-DE.ts
#	echo/frontend/src/locales/en-US.po
#	echo/frontend/src/locales/en-US.ts
#	echo/frontend/src/locales/es-ES.po
#	echo/frontend/src/locales/es-ES.ts
#	echo/frontend/src/locales/fr-FR.po
#	echo/frontend/src/locales/fr-FR.ts
#	echo/frontend/src/locales/it-IT.po
#	echo/frontend/src/locales/it-IT.ts
#	echo/frontend/src/locales/nl-NL.po
#	echo/frontend/src/locales/nl-NL.ts
#	echo/frontend/src/locales/uk-UA.po
#	echo/frontend/src/locales/uk-UA.ts
The recording screen led with a decorative pattern that pulsed on a
timer. It looked like proof the microphone was working, and it was not:
it pulsed exactly the same way whether audio was reaching the browser or
not. It is gone. In its place, while recording, the portal draws the
chat composer's waveform from the mic's real level, so the animation on
screen is the one thing a participant actually wants to know.

The level the meter reads is a new non-destructive `peekAudioLevel`. The
existing `getAudioLevel` resets the accumulated peak on read and belongs
to the liveness beacon, which uses it to catch a muted mic; a second
reader would have starved it. The waveform polls its own copy in local
state, so nothing above it re-renders while somebody talks.

The header is left aligned, on the full wordmark rather than the
logomark alone, with the back and cancel buttons taking the same slot
when they appear rather than floating over it. The two actions are
bigger and further apart, share is now a QR code icon (it opens a QR
code), and settings sits rightmost.

Welcome is left aligned with everything else on the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The full screen share view replaced the header with the words "Share
portal", so opening it swapped the one fixed thing on the screen for a
label describing what the participant had just tapped. The logo takes
that slot instead, in the same place and at the same size as the
header's, which leaves the QR code as the only thing that changes.

Whitelabelled portals keep their own logo here, same as in the header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The welcome heading sat closer to the header than to the text it
introduces, which read as if it belonged to the chrome rather than to
the page. More air above it, less below, so the heading groups with the
card it heads. An empty conversation title no longer reserves a line
when a project has not set one.

The share view drops its title entirely. It had the logo in it, and the
logo is already in the middle of the QR code, so the header was saying
the same thing twice. The close button takes the settings icon's size,
so the top of the screen does not shrink when the share view opens.

The switch to text mode is Phosphor's article-ny-times, which reads as
a written response rather than as a caption track.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Ready to Begin?" collects a session name and tags. A host who has
turned both off leaves a participant looking at a heading and a single
Continue button, so the mic check now hands straight over to recording.

Fixing the screen exposed that the existing skipOnboarding auto-start
was broken in the same place: the POST landed, a conversation was
created, and the participant stayed on "Ready to Begin?" forever. The
mutation lives in the component, and this component is torn down
mid-request on every mount under React's StrictMode, so the result
arrived at a dead observer.

The auto-start now keeps its request in a module-scoped map keyed by
project, so a remounted form waits on the same promise instead of
firing a second one, and the entry is dropped as soon as it settles so
that coming back to this screen starts a new conversation rather than
re-entering the finished one. A failed auto-start puts the Continue
button back with the server's own message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The meter belongs with the other evidence that recording is happening.
It now sits at the bottom of the screen in the sticky bar, directly
above the red dot and the elapsed timer, instead of up near the header
where it read as decoration attached to the welcome text.

Its amplitude is doubled. `waveformHeights` takes a gain, default 1, so
the chat composer's meter is unchanged; the portal passes 2. The loud
end clips at full height, which is the trade: a phone lying on a table
an arm away needs visible movement more than it needs headroom. Silence
still rests on the same floor rather than being amplified off it.

The meter reads the recorder directly now that it renders in the same
component, so the outlet context goes back to carrying just the
recording state and the elapsed time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Connection healthy" told a participant, in green, that nothing was
happening. It is the expected case and carries nothing they can act on,
so the indicator now renders nothing at all when the connection is
fine, and the row it sits in collapses with it rather than holding a
reserved gap open.

The unhealthy state is unchanged: a yellow dot and "Connection
unhealthy", still there for the case that actually needs a participant
to notice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blue while everything is working, yellow when the connection is
unhealthy, red when the recording itself is in trouble. The meter is
the thing a participant is already looking at, so it carries the state
rather than making them read for it.

Red means one of two things: a run that was interrupted, or no sound
arriving at all. The second is worth catching properly. A live
microphone in a silent room still returns room tone; a muted, unplugged
or taken-over microphone returns a flat signal. Eight seconds of that
is not a pause in the conversation, it is somebody talking into
nothing, so the meter turns red and the settings modal opens itself
with the microphone test inside. It fires again only after sound has
returned, so closing it is final for as long as the microphone stays
dead.

Both colours and the pill now read one health stream. It was opened in
the body, below the bar the meter lives in, so the recording screen
takes ownership and passes the verdict down through the outlet context
instead of a second EventSource being opened alongside it.

The unhealthy notice is larger, since it only ever appears when
something is wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	echo/frontend/src/locales/cs-CZ.po
#	echo/frontend/src/locales/cs-CZ.ts
#	echo/frontend/src/locales/de-DE.po
#	echo/frontend/src/locales/de-DE.ts
#	echo/frontend/src/locales/en-US.po
#	echo/frontend/src/locales/en-US.ts
#	echo/frontend/src/locales/es-ES.po
#	echo/frontend/src/locales/es-ES.ts
#	echo/frontend/src/locales/fr-FR.po
#	echo/frontend/src/locales/fr-FR.ts
#	echo/frontend/src/locales/it-IT.po
#	echo/frontend/src/locales/it-IT.ts
#	echo/frontend/src/locales/nl-NL.po
#	echo/frontend/src/locales/nl-NL.ts
#	echo/frontend/src/locales/uk-UA.po
#	echo/frontend/src/locales/uk-UA.ts
The thank you page's last card loses its heading. It was asking "Want
to run an event with dembrane?" and then offering "Get in touch" a
picture later, which is the question and its answer split across the
card. Now the illustration sits alone and the button carries the
question: "Want to use dembrane at your next event?"

A question is longer than "Get in touch", and in Dutch or German it
runs to two lines on a phone, so the button wraps rather than clipping
its own label. Translated in all seven locales.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Extract ran before the event button grew its wrap props, so every
catalog pointed six lines above the string it describes. ci-i18n
re-extracts on a clean checkout and refuses drift.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"Want this at your event?" in place of "Want to use dembrane at your
next event?". A participant has just spent ten minutes inside the
thing; "this" is enough. It fits one line on a phone in every locale,
so the button no longer needs to wrap its own label.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The button's question, shorter again. The Dutch sets the tone, "Ook
dembrane inzetten?": one look, one question. The English follows it in
shape, lowercase dembrane as always.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…in reach

Three changes to the end of a session.

The heading no longer sits 64px below the header. Nothing was being
cleared by that gap; it was air between a participant and the words
"Thank you".

The QR code comes first, in the copy and in the buttons. Handing the
portal to the next person is the thing a participant can do for the
host right now; recording another conversation is the thing they might
do later.

The event button is a footer. It is the one ask on the page and it
used to arrive only once somebody had scrolled past the illustration
and, on some projects, an email form. It now sits at the bottom of the
viewport the whole way down, and settles into the flow once the page
runs out, the same way the recording bar behaves on the screen before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… into view

The footer button drops to a subtle text link with an arrow. The page's
job is done by the time somebody reaches it; this is a quiet offer,
not a call to action, and being pinned to the bottom of the viewport
already guarantees it is seen. A filled blue bar there was shouting.

The illustration loses the 64px it used to hang below the buttons by,
so on a phone the whole scene fits above the footer without scrolling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The event button moved a few lines when it went subtle; the catalogs
still pointed at where it was. ci-i18n refuses that drift.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@spashii
spashii merged commit 4546297 into main Sep 11, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants