Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bcfb599
feat(portal): invite participants to run their own event
JorimTheuns Sep 10, 2026
b4cf6b2
chore(i18n): refresh the catalog line references
JorimTheuns Sep 10, 2026
b5a697a
feat(portal): share a portal session as a QR code
JorimTheuns Sep 11, 2026
cc0cc1d
Merge remote-tracking branch 'origin/main' into portal-event-cta
JorimTheuns Sep 11, 2026
a6a9d39
feat(portal): a quieter header and a live meter while recording
JorimTheuns Sep 11, 2026
2747a76
feat(portal): the logo stays put when the share view opens
JorimTheuns Sep 11, 2026
16c41fc
feat(portal): tighten the welcome screen and clean up the share view
JorimTheuns Sep 11, 2026
9f515ea
feat(portal): skip the start screen when it has nothing to ask
JorimTheuns Sep 11, 2026
954b5a9
feat(portal): put the level meter above the timer, at twice the swing
JorimTheuns Sep 11, 2026
c9b6955
feat(portal): only mention the connection when something is wrong
JorimTheuns Sep 11, 2026
84a5a25
feat(portal): colour the meter by what is actually wrong
JorimTheuns Sep 11, 2026
e1a16d9
Merge remote-tracking branch 'origin/main' into portal-event-cta
JorimTheuns Sep 11, 2026
22efd65
feat(portal): the event card asks its question on the button
JorimTheuns Sep 11, 2026
7047fef
chore(i18n): refresh the catalog line references
JorimTheuns Sep 11, 2026
05abafc
feat(portal): a shorter question on the event button
JorimTheuns Sep 11, 2026
c5cdf34
feat(portal): "dembrane at your event?"
JorimTheuns Sep 11, 2026
141855b
feat(portal): the thank you page leads with the QR and keeps its ask …
JorimTheuns Sep 11, 2026
b8ef695
feat(portal): the event offer goes tertiary, and the picture comes up…
JorimTheuns Sep 11, 2026
17b83aa
chore(i18n): refresh the catalog line references
JorimTheuns Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"collection": "project",
"field": "is_dembrane_event_cta_enabled",
"type": "boolean",
"meta": {
"collection": "project",
"conditions": null,
"display": "boolean",
"display_options": null,
"field": "is_dembrane_event_cta_enabled",
"group": null,
"hidden": false,
"interface": "boolean",
"note": "Portal thank you page ends on the dembrane event invitation (book an intake call). Free tier cannot switch it off.",
"options": null,
"readonly": false,
"required": false,
"searchable": true,
"sort": 101,
"special": [
"cast-boolean"
],
"translations": null,
"validation": null,
"validation_message": null,
"width": "full"
},
"schema": {
"name": "is_dembrane_event_cta_enabled",
"table": "project",
"data_type": "boolean",
"default_value": true,
"max_length": null,
"numeric_precision": null,
"numeric_scale": null,
"is_nullable": true,
"is_unique": false,
"is_indexed": false,
"is_primary_key": false,
"is_generated": false,
"generation_expression": null,
"has_auto_increment": false,
"foreign_key_table": null,
"foreign_key_column": null
}
}
12 changes: 12 additions & 0 deletions echo/docs/portal_prefilling.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ by default, dembrane guides participants through a brief, friendly tutorial to i

appending `skipOnboarding=1` lands the participant directly on the start screen.

## sharing from an active portal session

the share icon beside settings in the portal header, and *Show QR Code* on the
thank-you screen, both open the same full-screen QR code with copy, whatsapp,
and email actions. the new link keeps reusable portal configuration, including
`skipOnboarding`, `tags`, `tag_id_list`, `mode`, and `theme`, so participants can
daisy-chain sessions with the same setup.

participant names, email addresses, prefilled feedback, and temporary screen
state are not included. every shared link starts a fresh portal session without
carrying over the current participant's personal details or content.

---

## auto-initiation and prefilling
Expand Down
Binary file added echo/frontend/src/assets/pauline-understand.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
295 changes: 295 additions & 0 deletions echo/frontend/src/components/booking/calEmbed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { BookingLinks } from "@/lib/links";

/** The cal.com inline embed, as a hook, and the plain link when it does not come.
*
* Two places book a call with the same mechanics: the pricing configurator's
* booking step ("discuss your needs") and the participant portal's closing card
* ("plan an event"). They differ in the event type, the prefill and the words
* around the calendar, so those are the caller's; everything cal.com needs to
* land is here, once.
*
* The embed is the primary route and the plain link is a first class fallback,
* not an error state. `embed.js` is loaded once for the page; each caller gets
* its own namespace so two calendars never share a queue.
*
* The wait is 8 seconds. Measured on a cold load, `linkReady` arrives at about
* 1.8 seconds, so 8 is generous rather than tight.
*/

export const CAL_ORIGIN = "https://app.cal.com";
export const EMBED_TIMEOUT_MS = 8000;

export type BookingSignal = {
signal: "postmessage" | "redirect";
/** cal.com's own booking status. Only `accepted` earns "your call is booked". */
status: string | null;
startTime: string | null;
secondsAfterOpen: number;
/** cal.com's own id for the booking. It is the one value that joins their
* record to ours, so it travels up and lands on the row. Null when the
* payload carried none, and then there is nothing to report. */
uid: string | null;
};

/** One queued call: the arguments exactly as they were passed. */
type CalCall = unknown[];

type CalNamespaceApi = ((...args: unknown[]) => void) & { q?: CalCall[] };

type CalApi = ((...args: unknown[]) => void) & {
/** cal.com's own flag: the embed script has been asked for. */
loaded?: boolean;
q?: CalCall[];
ns?: Record<string, CalNamespaceApi>;
};

const calApi = (): CalApi | undefined =>
(globalThis as { Cal?: CalApi }).Cal ?? undefined;

const queue = (target: { q?: CalCall[] }, args: CalCall) => {
target.q = target.q ?? [];
target.q.push(args);
};

const makeNamespaceApi = (): CalNamespaceApi => {
const api: CalNamespaceApi = (...args: unknown[]) => queue(api, args);
api.q = [];
return api;
};

/** cal.com's own inline queue stub, from
* https://cal.com/docs/developing/guides/embeds, and quoted in `@/lib/links`.
*
* It defines `window.Cal` synchronously and pushes every call onto a queue that
* embed.js drains when it arrives. This is the fix for `Uncaught Error: Cal is
* not defined`: the step used to append embed.js and then
* call `Cal(...)` from the script's own `load` handler, so a call that beat the
* script threw from inside an event handler, where the `try` below could never
* reach it. With the stub there is no window in which `Cal` does not exist.
*
* One deliberate difference from cal.com's published snippet: appending
* embed.js is left to the effect rather than done inside the stub. The script's
* `load` event is the only honest signal that the embed really arrived, and
* both the route report and the fallback rest on it, so the effect keeps the
* element. Everything the fix depends on, defining `Cal` and queueing, is the
* snippet unchanged.
*/
const installCalStub = (): CalApi => {
const existing = calApi();
if (existing) return existing;
const scope = globalThis as { Cal?: CalApi };

const cal: CalApi = (...args: unknown[]) => {
if (!cal.loaded) {
cal.ns = {};
cal.q = [];
cal.loaded = true;
}
const namespace = args[1];
if (args[0] === "init" && typeof namespace === "string") {
const ns = cal.ns ?? {};
cal.ns = ns;
const api = ns[namespace] ?? makeNamespaceApi();
ns[namespace] = api;
queue(api, args);
queue(cal, ["initNamespace", namespace]);
return;
}
queue(cal, args);
};

scope.Cal = cal;
return cal;
};

/** Pull the three fields out of a payload whose exact shape cal.com owns.
*
* Two of them are what the confirmation reads. The third, `uid`, is what the
* row learns: it is cal.com's own id for the booking, so a row and a booking
* can be read as one thing afterwards.
*
* Everything is optional on purpose: a missing status is read as "not
* accepted", which is the safe side, and a missing uid means nothing is
* reported rather than a row learning a blank.
*/
const readBooking = (
payload: unknown,
): { status: string | null; startTime: string | null; uid: string | null } => {
if (typeof payload !== "object" || payload === null) {
return { startTime: null, status: null, uid: null };
}
const record = payload as Record<string, unknown>;
const nested =
typeof record.booking === "object" && record.booking !== null
? (record.booking as Record<string, unknown>)
: {};
const status = record.status ?? nested.status;
const startTime = record.startTime ?? nested.startTime ?? record.date;
const uid = record.uid ?? nested.uid;
return {
startTime: typeof startTime === "string" ? startTime : null,
status: typeof status === "string" ? status : null,
uid: typeof uid === "string" && uid.trim() !== "" ? uid : null,
};
};

export type CalEmbedOptions = {
/** The event type as the embed names it, e.g. `team/dembrane/plan-event`. */
calLink: string;
/** Keeps this embed's queue separate from any other embed on the page. */
namespace: string;
/** The id of the element the calendar renders into. */
elementId: string;
/** What cal.com is told, in its own prefill keys. Read through a ref, so a
* caller passing a fresh object on every render cannot remount the embed
* underneath the person. The layout is added here, last, so a prefill key
* can never take it with it. */
prefill: Record<string, string>;
onBooked: (booking: BookingSignal) => void;
/** Which route the person actually got. */
onOpened: (route: "embed" | "fallback_link") => void;
onUnavailable: (reason: "timeout" | "blocked", secondsWaited: number) => void;
};

export const useCalEmbed = ({
calLink,
elementId,
namespace,
onBooked,
onOpened,
onUnavailable,
prefill,
}: CalEmbedOptions): { isUnavailable: boolean } => {
const [isUnavailable, setIsUnavailable] = useState(false);
const openedAtRef = useRef(Date.now());
const settledRef = useRef(false);
const routeReportedRef = useRef(false);
const bookedRef = useRef(false);

const prefillRef = useRef(prefill);
prefillRef.current = prefill;

const reportRoute = useCallback(
(route: "embed" | "fallback_link") => {
if (routeReportedRef.current) return;
routeReportedRef.current = true;
onOpened(route);
},
[onOpened],
);

const handleBooking = useCallback(
(payload: unknown) => {
if (bookedRef.current) return;
bookedRef.current = true;
const { startTime, status, uid } = readBooking(payload);
onBooked({
secondsAfterOpen: Math.round((Date.now() - openedAtRef.current) / 1000),
signal: "postmessage",
startTime,
status,
uid,
});
},
[onBooked],
);

// The raw channel. The origin and the originator are both checked, because
// any page can post a message at this window.
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.origin !== CAL_ORIGIN) return;
const data = event.data as
| { originator?: string; type?: string; data?: unknown }
| undefined;
if (data?.originator !== "CAL") return;
settledRef.current = true;
if (data.type === "bookingSuccessfulV2") handleBooking(data.data);
};
globalThis.addEventListener("message", onMessage);
return () => globalThis.removeEventListener("message", onMessage);
}, [handleBooking]);

// The embed. The script is appended once; the CSP decides whether it lands.
useEffect(() => {
const openedAt = openedAtRef.current;
let cancelled = false;

// The stub first, so `Cal` exists before anything calls it. The three
// calls below then run against the real API when embed.js is already
// here, and are queued for it when it is not.
try {
const Cal = installCalStub();
Cal("init", namespace, { origin: CAL_ORIGIN });
Cal.ns?.[namespace]?.("inline", {
calLink,
config: { ...prefillRef.current, layout: "month_view" },
elementOrSelector: `#${elementId}`,
});
Cal("on", {
action: "bookingSuccessfulV2",
callback: (event: { detail?: { data?: unknown } }) => {
settledRef.current = true;
handleBooking(event?.detail?.data);
},
});
} catch {
// A changed API must not take the step with it. The timer below still
// turns this into the fallback.
}

// The script, and its `load` is the one honest signal that the embed
// really arrived. A stub that queues forever is not something a person
// can see, so the route is reported here and nowhere else.
let script = document.querySelector<HTMLScriptElement>(
`script[src="${BookingLinks.EMBED_SCRIPT}"]`,
);
if (!script) {
const created = document.createElement("script");
created.async = true;
created.src = BookingLinks.EMBED_SCRIPT;
// The marker outlives this effect on purpose: a step that mounts again
// after the script landed must not sit out the eight seconds waiting
// for a `load` event that already fired.
created.addEventListener(
"load",
() => {
created.dataset.calEmbedLoaded = "true";
},
{ once: true },
);
document.head.appendChild(created);
script = created;
}
const element = script;
const announceEmbed = () => {
if (cancelled) return;
reportRoute("embed");
};
if (element.dataset.calEmbedLoaded === "true") announceEmbed();
else element.addEventListener("load", announceEmbed);

const timer = setTimeout(() => {
if (cancelled || settledRef.current) return;
setIsUnavailable(true);
reportRoute("fallback_link");
onUnavailable("timeout", Math.round((Date.now() - openedAt) / 1000));
}, EMBED_TIMEOUT_MS);

return () => {
cancelled = true;
clearTimeout(timer);
element.removeEventListener("load", announceEmbed);
};
}, [
calLink,
elementId,
handleBooking,
namespace,
onUnavailable,
reportRoute,
]);

return { isUnavailable };
};
Loading
Loading