feat(portal): invite participants to run their own event, and share the portal by QR - #1059
Merged
Conversation
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>
# 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
portalmount.submitted. Somebody who leaves there has still asked us to get in touch.team/dembrane/plan-event) rather than the configurator's "discuss your needs", and it is the eager path, not the only way through.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 carrymount = "portal", the project id, and aPTL-reference.mountandproject_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
PricingBookingStepintocomponents/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.
skipOnboarding,tags,tag_id_list,mode,theme. Somebody who scanned a link that skips onboarding hands on a link that skips onboarding.portalSharing.tsholds 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 toterms.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.
tsc --noEmit, and biome clean on every file this touchesOne 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