diff --git a/CHANGELOG.md b/CHANGELOG.md
index 81b5e4b..4426575 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Changelog
+## Unreleased
+Surveys 2.0: `Gleap.showSurvey(surveyId, options)` takes `{ format: 'card' | 'full' | 'page', fields, personalToken, container, resume }`; the old format strings (`'survey'`, `'survey_full'`, `'survey_web'`) keep working. A survey shown before the SDK is ready now opens once it is.
+New survey containers: the card is a 348px corner popover with an enter animation and becomes a bottom sheet (handle, scrim, home-indicator inset) on phones; full screen covers the whole viewport and the survey draws its own background and close button (no more centred 640px card over a blurred backdrop); `page` renders inline in `container` (or `document.body`) and grows with the survey. Card and page surveys only appear once their height is known. Reduced motion is respected. Legacy surveys (not yet migrated) keep the previous card and full-screen look.
+New events `survey-shown`, `survey-answered`, `survey-completed` and `survey-closed` (with `surveyId`, `version`, `responseId`, …); `outbound-sent` keeps firing as before.
+New standalone `survey-embed.js` (~3 KB): `
` loads the SDK on demand and shows the survey inline, as a popup or behind a side tab.
+
## 19.1.1
Fixed: the product tour and tooltip visual editor now works from the dashboard on app.gleap.ai. The SDK accepts the editor's messages from exactly `https://app.gleap.ai` and `https://app.gleap.io` (no lookalike domains), loads the editor from the dashboard that opened it, and sends its replies only to that dashboard.
diff --git a/index.d.ts b/index.d.ts
index cdcddd4..1145cec 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -276,7 +276,64 @@ export namespace Gleap {
feedbackFlow: string,
showBackButton?: boolean
): void;
- function showSurvey(surveyId: string, format?: string): void;
+ type SurveyFormat = "card" | "full" | "page";
+ interface ShowSurveyOptions {
+ /** card = corner popover (bottom sheet on phones), full = full screen, page = inline in `container`. Default: card. */
+ format?: SurveyFormat;
+ /** Values for the survey's hidden fields. */
+ fields?: Record
;
+ /** Signed personal-link token: attaches the response to that contact. */
+ personalToken?: string;
+ /** Element (or CSS selector) the page format renders into. Default: document.body. */
+ container?: HTMLElement | string;
+ /** Continue a started response where it was left. */
+ resume?: boolean;
+ }
+ /**
+ * Shows a survey. The legacy format strings ("survey", "survey_full", "survey_web") keep working.
+ */
+ function showSurvey(
+ surveyId: string,
+ formatOrOptions?: "survey" | "survey_full" | "survey_web" | string | ShowSurveyOptions
+ ): void;
+ /** Surveys 2.0 events (reported by the survey renderer; not fired for legacy surveys). */
+ interface SurveyShownEvent {
+ surveyId: string;
+ version: number;
+ format: SurveyFormat;
+ responseId: string;
+ /** true when a started response was continued (resume). */
+ resumed: boolean;
+ }
+ interface SurveyAnsweredEvent {
+ surveyId: string;
+ version: number;
+ responseId: string;
+ [key: string]: any;
+ }
+ interface SurveyCompletedEvent {
+ surveyId: string;
+ version: number;
+ responseId: string;
+ endingId: string;
+ /** Answers keyed by question key. */
+ answers: Record;
+ }
+ interface SurveyClosedEvent {
+ surveyId: string;
+ version: number;
+ responseId?: string;
+ /** Response status when closed (e.g. "in_progress", "completed"); none when nothing was answered. */
+ status?: string;
+ }
+ /** The survey is visible. */
+ function on(event: "survey-shown", callback: (data: SurveyShownEvent) => void): void;
+ /** An answer was saved. */
+ function on(event: "survey-answered", callback: (data: SurveyAnsweredEvent) => void): void;
+ /** The last question was answered and an ending is shown. `outbound-sent` keeps firing as before. */
+ function on(event: "survey-completed", callback: (data: SurveyCompletedEvent) => void): void;
+ /** The survey was closed (by the user or after its ending). */
+ function on(event: "survey-closed", callback: (data: SurveyClosedEvent) => void): void;
function on(
event:
| "initialized"
diff --git a/src/Gleap.js b/src/Gleap.js
index f7c36e2..b4ea903 100755
--- a/src/Gleap.js
+++ b/src/Gleap.js
@@ -43,6 +43,7 @@ import GleapTagManager from './GleapTagManager';
import GleapThemeManager from './GleapThemeManager';
import GleapTranslationManager from './GleapTranslationManager';
import { injectStyledCSS } from './UI';
+import { normalizeSurveyOptions } from './GleapSurveyOptions';
if (
typeof window !== 'undefined' &&
@@ -339,7 +340,8 @@ class Gleap {
const surveyFlow = urlParams.get('gleap_survey');
const surveyFlowFormat = urlParams.get('gleap_survey_format');
if (surveyFlow && surveyFlow.length > 0) {
- Gleap.showSurvey(surveyFlow, surveyFlowFormat === 'survey_full' ? 'survey_full' : 'survey');
+ const fullScreen = surveyFlowFormat === 'survey_full' || surveyFlowFormat === 'full';
+ Gleap.showSurvey(surveyFlow, fullScreen ? 'survey_full' : 'survey');
}
const tourId = urlParams.get('gleap_tour');
if (tourId && tourId.length > 0) {
@@ -991,16 +993,31 @@ class Gleap {
}
/**
- * Shows a survey manually.
- * @param {*} actionType
- * @param {*} format
- */
- static showSurvey(actionType, format = 'survey') {
+ * Shows a survey.
+ *
+ * The second argument is either a legacy format string ('survey', 'survey_full', 'survey_web')
+ * or an options object: { format?: 'card' | 'full' | 'page', fields?: Record,
+ * personalToken?: string, container?: HTMLElement | string, resume?: boolean }.
+ * @param {string} surveyId
+ * @param {string|object} formatOrOptions
+ */
+ static showSurvey(surveyId, formatOrOptions = 'survey') {
+ // Called before the session is ready (right after initialize): show it once it is.
+ const sessionInstance = GleapSession.getInstance();
+ if (!sessionInstance.ready) {
+ sessionInstance.setOnSessionReady(() => {
+ Gleap.showSurvey(surveyId, formatOrOptions);
+ });
+ return;
+ }
+
+ const options = normalizeSurveyOptions(formatOrOptions);
Gleap.startFeedbackFlowWithOptions(
- actionType,
+ surveyId,
{
hideBackButton: true,
- format,
+ format: options.appMode,
+ survey: options,
},
true
);
@@ -1028,7 +1045,7 @@ class Gleap {
* Starts the bug reporting flow.
*/
static startFeedbackFlowWithOptions(feedbackFlow, options = {}, isSurvey = false) {
- const { autostartDrawing, hideBackButton, format } = options;
+ const { autostartDrawing, hideBackButton, format, survey } = options;
const sessionInstance = GleapSession.getInstance();
if (!sessionInstance.ready) {
return;
@@ -1045,16 +1062,42 @@ class Gleap {
action = 'start-survey';
}
- GleapFrameManager.getInstance().setAppMode(isSurvey ? format : 'widget');
+ const surveyOptions = isSurvey ? survey || normalizeSurveyOptions(format) : null;
+ const appMode = surveyOptions ? surveyOptions.appMode : 'widget';
+
+ // The page format renders inline: the messenger frame lives in the given container.
+ // Every other survey format (and the widget) lives in document.body.
+ GleapFrameManager.getInstance().setFrameHost(
+ surveyOptions && surveyOptions.format === 'page' ? surveyOptions.container : null
+ );
+ GleapFrameManager.getInstance().setAppMode(appMode);
+
+ const messageData = {
+ flow: feedbackFlow,
+ hideBackButton: hideBackButton,
+ // `format` stays the messenger app mode ('survey' | 'survey_full' | 'survey_web' | 'survey_page').
+ format: surveyOptions ? appMode : format,
+ };
+ if (surveyOptions) {
+ messageData.surveyFormat = surveyOptions.format;
+ messageData.fields = surveyOptions.fields;
+ if (surveyOptions.personalToken) {
+ messageData.personalToken = surveyOptions.personalToken;
+ }
+ messageData.resume = surveyOptions.resume;
+ if (surveyOptions.outboundAction) {
+ messageData.outboundAction = surveyOptions.outboundAction;
+ }
+ if (surveyOptions.resumeData) {
+ messageData.resumeData = surveyOptions.resumeData;
+ }
+ messageData.safeArea = GleapFrameManager.getInstance().getSafeAreaInsets();
+ }
GleapFrameManager.getInstance().sendMessage(
{
name: action,
- data: {
- flow: feedbackFlow,
- hideBackButton: hideBackButton,
- format,
- },
+ data: messageData,
},
true
);
@@ -1544,7 +1587,16 @@ class Gleap {
} else if (action.actionType === 'tour') {
Gleap.startProductTourWithConfig(action.outbound, action.data, false);
} else {
- Gleap.showSurvey(action.actionType, action.format);
+ // An inline survey page owns the messenger frame: never swap it for a triggered survey.
+ if (GleapFrameManager.getInstance().appMode === 'survey_page') {
+ continue;
+ }
+ Gleap.showSurvey(action.actionType, {
+ format: action.format,
+ resume: action.resume === true,
+ resumeData: action.resume === true ? action.data : undefined,
+ outboundAction: action._id || action.id,
+ });
}
}
}
diff --git a/src/GleapFeedbackButtonManager.js b/src/GleapFeedbackButtonManager.js
index 706d6d9..9cac4e5 100644
--- a/src/GleapFeedbackButtonManager.js
+++ b/src/GleapFeedbackButtonManager.js
@@ -291,11 +291,13 @@ export default class GleapFeedbackButtonManager {
this.feedbackButton.classList.add('bb-feedback-button--bottomleft');
}
- if (GleapFrameManager.getInstance().isOpened()) {
+ const appMode = GleapFrameManager.getInstance().appMode;
+
+ // An inline survey page doesn't open the messenger: the launcher keeps its state.
+ if (GleapFrameManager.getInstance().isOpened() && appMode !== 'survey_page') {
this.feedbackButton.classList.add('bb-feedback-button--open');
}
- const appMode = GleapFrameManager.getInstance().appMode;
if (appMode === 'survey' || appMode === 'survey_full' || appMode === 'survey_web') {
this.feedbackButton.classList.add('bb-feedback-button--survey');
}
diff --git a/src/GleapFrameManager.js b/src/GleapFrameManager.js
index d11e6b0..d36312b 100644
--- a/src/GleapFrameManager.js
+++ b/src/GleapFrameManager.js
@@ -43,6 +43,13 @@ export default class GleapFrameManager {
lastWidgetSizeUpdate = null;
// Hidden while the customer answers a capture request (see setCaptureHidden).
captureHidden = false;
+ // Where the messenger frame is mounted: null = document.body, else the inline survey container.
+ frameHost = null;
+ // Card and page surveys stay invisible until the messenger reports their height (no tall blank flash).
+ surveyAwaitingHeight = false;
+ surveyRevealTimeout = null;
+ // The messenger fell back to a legacy (pre Surveys 2.0) survey: use the old survey chrome.
+ surveyLegacy = false;
urlHandler = function (url, newTab) {
if (url && url.length > 0) {
if (newTab) {
@@ -88,19 +95,184 @@ export default class GleapFrameManager {
}
isSurvey() {
- return this.appMode === 'survey' || this.appMode === 'survey_full' || this.appMode === 'survey_web';
+ return (
+ this.appMode === 'survey' ||
+ this.appMode === 'survey_full' ||
+ this.appMode === 'survey_web' ||
+ this.appMode === 'survey_page'
+ );
+ }
+
+ isFullSurvey() {
+ return this.appMode === 'survey_full' || this.appMode === 'survey_web';
+ }
+
+ // Legacy full-screen surveys keep the old centred card.
+ isLegacyFullSurvey() {
+ return this.surveyLegacy && this.isFullSurvey();
+ }
+
+ // Card and page surveys (and legacy full-screen ones) size their frame to the survey (height-update).
+ isAutoHeightSurvey() {
+ return this.appMode === 'survey' || this.appMode === 'survey_page' || this.isLegacyFullSurvey();
}
setAppMode(appMode) {
this.appMode = appMode;
+ // A new survey starts as Surveys 2.0 until the messenger says otherwise (survey-legacy).
+ this.surveyLegacy = false;
this.updateFrameStyle();
- const innerContainer = document.querySelector('.gleap-frame-container-inner');
- if ((this.appMode === 'widget' || this.appMode === 'survey_full' || this.appMode === 'survey_web') && innerContainer) {
- innerContainer.style.maxHeight = `${widgetMaxHeight}px`;
+ // Wait for the survey's height before showing a card/page survey in a frame that has none yet.
+ this.surveyAwaitingHeight = this.isAutoHeightSurvey() && (!this.comReady || !this.frameHeight);
+ this.applyInnerSize();
+
+ // Lay the hidden frame out (invisibly) so the messenger can measure the survey right away.
+ const container = this.gleapFrameContainer;
+ if (this.surveyAwaitingHeight && container && container.classList.contains('gleap-frame-container--hidden')) {
+ container.classList.add('gleap-frame-container--measuring');
+ container.classList.remove('gleap-frame-container--hidden');
+ } else if (!this.surveyAwaitingHeight && container && container.classList.contains('gleap-frame-container--measuring')) {
+ // Switched to a mode that doesn't wait (widget, full screen): back to the regular hidden state.
+ container.classList.remove('gleap-frame-container--measuring');
+ if (!this.widgetOpened) {
+ container.classList.add('gleap-frame-container--hidden');
+ }
+ if (this.surveyRevealTimeout) {
+ clearTimeout(this.surveyRevealTimeout);
+ this.surveyRevealTimeout = null;
+ }
}
this.sendWidgetSizeUpdate();
+ if (this.isSurvey()) {
+ this.sendSafeAreaInsets();
+ }
+ }
+
+ /**
+ * Mounts the messenger frame in `host` (an inline survey page) or in document.body (null).
+ * Moving an iframe reloads its document, so a frame mounted elsewhere is replaced by a fresh one;
+ * queued messages (start-survey) are delivered to it once it pings.
+ */
+ setFrameHost(host) {
+ const target = host && host.nodeType === 1 ? host : null;
+ this.frameHost = target;
+
+ const container = this.gleapFrameContainer;
+ if (!container || typeof document === 'undefined') {
+ return;
+ }
+ const desiredParent = target || document.body;
+ if (container.parentNode === desiredParent) {
+ return;
+ }
+
+ if (this.closeTimeout) {
+ clearTimeout(this.closeTimeout);
+ this.closeTimeout = null;
+ }
+ this.unregisterEscListener();
+ this.destroy();
+ this.comReady = false;
+ this.frameHeight = 0;
+ this.lastWidgetSizeUpdate = null;
+ }
+
+ // Sizes the frame's inner container for the current mode.
+ applyInnerSize() {
+ const innerContainer = this.gleapFrameContainer
+ ? this.gleapFrameContainer.querySelector('.gleap-frame-container-inner')
+ : null;
+ if (!innerContainer) {
+ return;
+ }
+
+ if (this.appMode === 'survey_page') {
+ // Inline: the frame is exactly as tall as the survey.
+ innerContainer.style.maxHeight = 'none';
+ innerContainer.style.height = this.frameHeight > 0 ? `${this.frameHeight}px` : '';
+ return;
+ }
+
+ innerContainer.style.height = '';
+ if (this.appMode === 'survey' || this.isLegacyFullSurvey()) {
+ innerContainer.style.maxHeight = this.frameHeight > 0 ? `${this.frameHeight}px` : '';
+ } else if (this.isFullSurvey()) {
+ // Full screen: the messenger fills the viewport and draws the background itself.
+ innerContainer.style.maxHeight = 'none';
+ } else {
+ innerContainer.style.maxHeight = `${widgetMaxHeight}px`;
+ }
+ }
+
+ /**
+ * The page's safe-area insets (notch, home indicator) in px. env() always resolves to 0 inside the
+ * messenger iframe, so the full-screen survey gets them from here.
+ */
+ getSafeAreaInsets() {
+ const insets = { top: 0, right: 0, bottom: 0, left: 0 };
+ try {
+ if (typeof document === 'undefined' || !document.body || typeof window.getComputedStyle !== 'function') {
+ return insets;
+ }
+ const probe = document.createElement('div');
+ probe.style.position = 'fixed';
+ probe.style.top = '0';
+ probe.style.left = '0';
+ probe.style.width = '0';
+ probe.style.height = '0';
+ probe.style.visibility = 'hidden';
+ probe.style.pointerEvents = 'none';
+ probe.style.paddingTop = 'env(safe-area-inset-top, 0px)';
+ probe.style.paddingRight = 'env(safe-area-inset-right, 0px)';
+ probe.style.paddingBottom = 'env(safe-area-inset-bottom, 0px)';
+ probe.style.paddingLeft = 'env(safe-area-inset-left, 0px)';
+ document.body.appendChild(probe);
+ const style = window.getComputedStyle(probe);
+ insets.top = parseFloat(style.paddingTop) || 0;
+ insets.right = parseFloat(style.paddingRight) || 0;
+ insets.bottom = parseFloat(style.paddingBottom) || 0;
+ insets.left = parseFloat(style.paddingLeft) || 0;
+ probe.remove();
+ } catch (e) {}
+ return insets;
+ }
+
+ sendSafeAreaInsets() {
+ if (!this.comReady) {
+ return;
+ }
+ this.sendMessage({
+ name: 'survey-safe-area',
+ data: this.getSafeAreaInsets(),
+ });
+ }
+
+ // Shows a card/page survey that waited for its height (or gave up waiting).
+ revealSurvey() {
+ this.surveyAwaitingHeight = false;
+ if (this.surveyRevealTimeout) {
+ clearTimeout(this.surveyRevealTimeout);
+ this.surveyRevealTimeout = null;
+ }
+ const container = this.gleapFrameContainer;
+ if (container) {
+ container.classList.remove('gleap-frame-container--measuring');
+ }
+ }
+
+ /**
+ * The v2 survey wasn't found and the messenger runs the legacy survey flow: switch to the
+ * pre-Surveys 2.0 chrome for the current format.
+ */
+ setSurveyLegacy() {
+ if (!this.isSurvey() || this.surveyLegacy) {
+ return;
+ }
+ this.surveyLegacy = true;
+ this.updateFrameStyle();
+ this.applyInnerSize();
}
// The widget is full screen at <= 450px and the expanded CSS only applies from
@@ -170,11 +342,15 @@ export default class GleapFrameManager {
this.updateFrameStyle();
}
this.sendWidgetSizeUpdate();
+ if (this.widgetOpened && this.isFullSurvey()) {
+ this.sendSafeAreaInsets();
+ }
}
registerEscListener() {
// While a capture request hides the widget, Escape belongs to the page and the capture bar.
- if (this.escListener || this.captureHidden) {
+ // An inline survey page can't be closed.
+ if (this.escListener || this.captureHidden || this.appMode === 'survey_page') {
return;
}
@@ -211,6 +387,10 @@ export default class GleapFrameManager {
this.injectedFrame = false;
this.widgetOpened = false;
this.markerManager = undefined;
+ if (this.surveyRevealTimeout) {
+ clearTimeout(this.surveyRevealTimeout);
+ this.surveyRevealTimeout = null;
+ }
this.gleapFrameContainer = null;
this.gleapFrame = null;
this.captureHidden = false;
@@ -314,11 +494,18 @@ export default class GleapFrameManager {
// fails (e.g. CORS not available on the frameUrl), the helper falls back to direct
// src loading, preserving the original behavior.
var elem = document.createElement('div');
- elem.className = 'gleap-frame-container gleap-frame-container--hidden rr-block';
+ // A card/page survey waiting for its height starts laid out but invisible, so the messenger can
+ // measure it before it shows; everything else starts hidden.
+ const startState =
+ this.isAutoHeightSurvey() && this.surveyAwaitingHeight
+ ? 'gleap-frame-container--measuring'
+ : 'gleap-frame-container--hidden';
+ elem.className = `gleap-frame-container ${startState} rr-block`;
elem.innerHTML = `${widgetLoaderMarkup(
GleapConfigManager.getInstance().getFlowConfig()
)}
`;
- document.body.appendChild(elem);
+ const host = this.frameHost && document.body.contains(this.frameHost) ? this.frameHost : document.body;
+ host.appendChild(elem);
// Image-type loader: fade the background image in once it has loaded.
// Until then (or if it fails) the plain white fallback stays.
@@ -412,6 +599,8 @@ export default class GleapFrameManager {
const surveyStyle = 'gleap-frame-container--survey';
const extendedStyle = 'gleap-frame-container--extended';
const surveyFullStyle = 'gleap-frame-container--survey-full';
+ const surveyPageStyle = 'gleap-frame-container--survey-page';
+ const surveyLegacyStyle = 'gleap-frame-container--survey-legacy';
const classicStyle = 'gleap-frame-container--classic';
const classicStyleLeft = 'gleap-frame-container--classic-left';
const modernStyleLeft = 'gleap-frame-container--modern-left';
@@ -426,6 +615,8 @@ export default class GleapFrameManager {
noButtonStyleLeft,
surveyStyle,
surveyFullStyle,
+ surveyPageStyle,
+ surveyLegacyStyle,
];
for (let i = 0; i < allStyles.length; i++) {
this.gleapFrameContainer.classList.remove(allStyles[i]);
@@ -461,9 +652,15 @@ export default class GleapFrameManager {
if (this.appMode === 'survey') {
this.gleapFrameContainer.classList.add(surveyStyle);
}
- if (this.appMode === 'survey_full' || this.appMode === 'survey_web') {
+ if (this.isFullSurvey()) {
this.gleapFrameContainer.classList.add(surveyFullStyle);
}
+ if (this.appMode === 'survey_page') {
+ this.gleapFrameContainer.classList.add(surveyPageStyle);
+ }
+ if (this.surveyLegacy && this.isSurvey()) {
+ this.gleapFrameContainer.classList.add(surveyLegacyStyle);
+ }
if (this.appMode === 'extended') {
this.gleapFrameContainer.classList.add(extendedStyle);
}
@@ -490,6 +687,17 @@ export default class GleapFrameManager {
this.closeTimeout = null;
}
this.gleapFrameContainer.classList.remove('gleap-frame-container--closing');
+ if (this.isAutoHeightSurvey() && this.surveyAwaitingHeight) {
+ this.gleapFrameContainer.classList.add('gleap-frame-container--measuring');
+ if (!this.surveyRevealTimeout) {
+ this.surveyRevealTimeout = setTimeout(() => {
+ this.surveyRevealTimeout = null;
+ this.revealSurvey();
+ }, 1200);
+ }
+ } else {
+ this.gleapFrameContainer.classList.remove('gleap-frame-container--measuring');
+ }
this.gleapFrameContainer.classList.remove('gleap-frame-container--hidden');
if (showLoader) {
this.gleapFrameContainer.classList.add(loadingClass);
@@ -523,6 +731,16 @@ export default class GleapFrameManager {
GleapEventManager.notifyEvent('open');
this.registerEscListener();
+
+ // Full-screen surveys take the keyboard (digits, letters, Enter) right away. The
+ // messenger can't pull focus into its cross-origin frame without a user gesture
+ // (a survey shown by a trigger has none), so the host page focuses the frame.
+ if (this.isFullSurvey() && !this.isLegacyFullSurvey()) {
+ try {
+ const frame = this.gleapFrameContainer.querySelector('.gleap-frame');
+ if (frame) frame.focus({ preventScroll: true });
+ } catch (e) {}
+ }
}
updateUI() {
@@ -561,8 +779,8 @@ export default class GleapFrameManager {
}
hideWidget(resetRoutes = false) {
- // Prevent for survey web.
- if (this.appMode === 'survey_web') {
+ // The standalone survey page (legacy) and inline survey pages can't be closed.
+ if (this.appMode === 'survey_web' || this.appMode === 'survey_page') {
return;
}
@@ -577,8 +795,14 @@ export default class GleapFrameManager {
this.closeTimeout = null;
}
- // Survey-full has no open/close animation, hide it instantly.
- if (container.classList.contains('gleap-frame-container--survey-full')) {
+ if (this.surveyRevealTimeout) {
+ clearTimeout(this.surveyRevealTimeout);
+ this.surveyRevealTimeout = null;
+ }
+
+ if (container.classList.contains('gleap-frame-container--measuring')) {
+ // Never shown: nothing to animate.
+ container.classList.remove('gleap-frame-container--measuring');
container.classList.add('gleap-frame-container--hidden');
} else {
// Play the close animation, then remove from view once it finishes.
@@ -712,17 +936,31 @@ export default class GleapFrameManager {
}
if (data.name === 'height-update') {
- this.frameHeight = data.data;
-
- const innerContainer = document.querySelector('.gleap-frame-container-inner');
- if (
- (this.appMode === 'survey' || this.appMode === 'survey_full' || this.appMode === 'survey_web') &&
- innerContainer
- ) {
- innerContainer.style.maxHeight = `${this.frameHeight}px`;
+ const height = parseInt(data.data, 10);
+ if (height > 0) {
+ this.frameHeight = height;
+ // Full-screen surveys fill the viewport; only card and page surveys follow the height.
+ if (this.isAutoHeightSurvey()) {
+ this.applyInnerSize();
+ this.revealSurvey();
+ }
}
}
+ if (data.name === 'survey-legacy') {
+ this.setSurveyLegacy();
+ }
+
+ // Surveys 2.0 lifecycle, reported by the messenger. (outbound-sent keeps coming as notify-event.)
+ if (
+ data.name === 'survey-shown' ||
+ data.name === 'survey-answered' ||
+ data.name === 'survey-completed' ||
+ data.name === 'survey-closed'
+ ) {
+ GleapEventManager.notifyEvent(data.name, data.data || {});
+ }
+
if (data.name === 'notify-event') {
GleapEventManager.notifyEvent(data.data.type, data.data.data);
}
diff --git a/src/GleapSurveyEmbed.js b/src/GleapSurveyEmbed.js
new file mode 100644
index 0000000..d72baef
--- /dev/null
+++ b/src/GleapSurveyEmbed.js
@@ -0,0 +1,185 @@
+// Standalone survey embed (published as survey-embed.js, a separate small bundle).
+//
+//
+//
+//
+// data-mode: inline (default) — renders in the element, as tall as the survey
+// popup — opens as a card (data-format="full" for full screen), after data-delay seconds
+// tab — a side tab (data-label, data-color, data-position="left|right") opening the survey
+// data-api-key / data-region / data-language: used when this script boots the SDK itself. A page that
+// already runs the Gleap SDK keeps its own setup.
+// data-field-="value": hidden field values.
+//
+// The SDK keeps one messenger frame per page, so one inline survey per page.
+
+const SDK_SRC = 'https://sdk.gleap.io/latest/index.js';
+const PROCESSED_ATTR = 'data-gleap-survey-ready';
+
+const currentScript = typeof document !== 'undefined' ? document.currentScript : null;
+const scriptData = (currentScript && currentScript.dataset) || {};
+
+let sdkPromise = null;
+let inlineMounted = false;
+
+const readFields = (element) => {
+ const fields = {};
+ const data = element.dataset || {};
+ Object.keys(data).forEach((key) => {
+ if (key.indexOf('field') === 0 && key.length > 5) {
+ const fieldKey = key.charAt(5).toLowerCase() + key.slice(6);
+ fields[fieldKey] = String(data[key]);
+ }
+ });
+ return fields;
+};
+
+const loadSdk = () => {
+ if (window.Gleap && typeof window.Gleap.initialize === 'function') {
+ return Promise.resolve(window.Gleap);
+ }
+ if (sdkPromise) {
+ return sdkPromise;
+ }
+ sdkPromise = new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = scriptData.sdkSrc || SDK_SRC;
+ script.async = true;
+ if (currentScript && currentScript.nonce) {
+ script.nonce = currentScript.nonce;
+ }
+ script.onload = () => (window.Gleap ? resolve(window.Gleap) : reject(new Error('Gleap SDK missing')));
+ script.onerror = () => reject(new Error('Gleap SDK failed to load'));
+ (document.head || document.body).appendChild(script);
+ });
+ return sdkPromise;
+};
+
+const bootSdk = (Gleap, element) => {
+ const instance = typeof Gleap.getInstance === 'function' ? Gleap.getInstance() : null;
+ if (instance && instance.initialized) {
+ return true;
+ }
+
+ const apiKey = element.getAttribute('data-api-key') || scriptData.apiKey;
+ if (!apiKey) {
+ console.warn('[Gleap] survey embed: data-api-key is missing.');
+ return false;
+ }
+
+ const region = element.getAttribute('data-region') || scriptData.region;
+ if (region && region !== 'eu' && typeof Gleap.setRegion === 'function') {
+ Gleap.setRegion(region);
+ }
+ const language = element.getAttribute('data-language') || scriptData.language;
+ if (language) {
+ Gleap.setLanguage(language);
+ }
+
+ // Minimal mode: the survey only — no launcher, no in-app messages.
+ Gleap.showFeedbackButton(false);
+ Gleap.setDisableInAppNotifications(true);
+ Gleap.initialize(apiKey);
+ return true;
+};
+
+const createTab = (Gleap, element, surveyId, options) => {
+ const tab = document.createElement('button');
+ tab.type = 'button';
+ tab.textContent = element.getAttribute('data-label') || 'Feedback';
+ tab.setAttribute('aria-label', tab.textContent);
+
+ const left = element.getAttribute('data-position') === 'left';
+ const style = tab.style;
+ style.position = 'fixed';
+ style.top = '50%';
+ style[left ? 'left' : 'right'] = '0';
+ style.transform = 'translateY(-50%) rotate(180deg)';
+ style.writingMode = 'vertical-rl';
+ style.zIndex = '2147483000';
+ style.border = '0';
+ style.margin = '0';
+ style.cursor = 'pointer';
+ style.padding = '14px 9px';
+ style.borderRadius = left ? '0 10px 10px 0' : '10px 0 0 10px';
+ style.background = element.getAttribute('data-color') || '#0f7b6c';
+ style.color = element.getAttribute('data-text-color') || '#ffffff';
+ style.font = '600 13px/1 system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';
+ style.letterSpacing = '0.01em';
+ style.boxShadow = '0 6px 20px rgba(16, 24, 40, 0.18)';
+
+ tab.addEventListener('click', () => {
+ Gleap.showSurvey(surveyId, options);
+ });
+ document.body.appendChild(tab);
+};
+
+const mount = (element) => {
+ if (element.hasAttribute(PROCESSED_ATTR)) {
+ return;
+ }
+ element.setAttribute(PROCESSED_ATTR, 'true');
+
+ const surveyId = element.getAttribute('data-gleap-survey');
+ if (!surveyId) {
+ return;
+ }
+ const mode = element.getAttribute('data-mode') || 'inline';
+ const fields = readFields(element);
+
+ if (mode === 'inline') {
+ if (inlineMounted) {
+ console.warn('[Gleap] survey embed: only one inline survey per page.');
+ return;
+ }
+ inlineMounted = true;
+ }
+
+ loadSdk()
+ .then((Gleap) => {
+ if (!bootSdk(Gleap, element)) {
+ return;
+ }
+
+ if (mode === 'inline') {
+ Gleap.showSurvey(surveyId, { format: 'page', container: element, fields });
+ return;
+ }
+
+ const options = { format: element.getAttribute('data-format') === 'full' ? 'full' : 'card', fields };
+ if (mode === 'tab') {
+ createTab(Gleap, element, surveyId, options);
+ return;
+ }
+
+ const delay = parseFloat(element.getAttribute('data-delay') || '0');
+ setTimeout(
+ () => {
+ Gleap.showSurvey(surveyId, options);
+ },
+ isNaN(delay) ? 0 : Math.max(0, delay) * 1000
+ );
+ })
+ .catch((error) => {
+ console.warn('[Gleap] survey embed:', error && error.message);
+ });
+};
+
+const scan = () => {
+ const elements = document.querySelectorAll('[data-gleap-survey]');
+ for (let i = 0; i < elements.length; i++) {
+ mount(elements[i]);
+ }
+};
+
+if (typeof window !== 'undefined' && typeof document !== 'undefined') {
+ if (!window.GleapSurveyEmbed) {
+ window.GleapSurveyEmbed = { scan };
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', scan);
+ } else {
+ scan();
+ }
+ } else {
+ window.GleapSurveyEmbed.scan();
+ }
+}
diff --git a/src/GleapSurveyOptions.js b/src/GleapSurveyOptions.js
new file mode 100644
index 0000000..70d407d
--- /dev/null
+++ b/src/GleapSurveyOptions.js
@@ -0,0 +1,97 @@
+// Normalises the second argument of Gleap.showSurvey: a legacy format string ('survey',
+// 'survey_full', 'survey_web') or an options object ({ format, fields, personalToken,
+// container, resume }).
+//
+// format — what the survey looks like: 'card' (corner popover / mobile bottom sheet),
+// 'full' (full-viewport, Typeform style) or 'page' (inline in a container).
+// appMode — how the messenger frame is mounted (sent as `format` in start-survey, which is
+// what Messenger-App builds before Surveys 2.0 read).
+
+const APP_MODE_BY_FORMAT = {
+ card: 'survey',
+ full: 'survey_full',
+ page: 'survey_page',
+};
+
+const FORMAT_ALIASES = {
+ card: 'card',
+ survey: 'card',
+ full: 'full',
+ survey_full: 'full',
+ survey_web: 'full',
+ page: 'page',
+ survey_page: 'page',
+};
+
+const MAX_FIELDS = 50;
+const MAX_FIELD_KEY_LENGTH = 100;
+const MAX_FIELD_VALUE_LENGTH = 1000;
+
+const resolveContainer = (container) => {
+ if (!container || typeof document === 'undefined') {
+ return null;
+ }
+ if (typeof container === 'string') {
+ try {
+ return document.querySelector(container);
+ } catch (e) {
+ return null;
+ }
+ }
+ if (typeof container === 'object' && container.nodeType === 1) {
+ return container;
+ }
+ return null;
+};
+
+// Hidden field values are strings (spec §2 SurveyHiddenField); anything else is dropped.
+export const sanitizeSurveyFields = (fields) => {
+ const result = {};
+ if (!fields || typeof fields !== 'object' || Array.isArray(fields)) {
+ return result;
+ }
+ const keys = Object.keys(fields).slice(0, MAX_FIELDS);
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i];
+ const value = fields[key];
+ if (!key || key.length > MAX_FIELD_KEY_LENGTH) {
+ continue;
+ }
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ result[key] = String(value).slice(0, MAX_FIELD_VALUE_LENGTH);
+ }
+ }
+ return result;
+};
+
+export const normalizeSurveyOptions = (formatOrOptions) => {
+ const options =
+ formatOrOptions && typeof formatOrOptions === 'object' ? formatOrOptions : { format: formatOrOptions };
+
+ const legacyFormat = typeof options.format === 'string' ? options.format : '';
+ const format = FORMAT_ALIASES[legacyFormat] || 'card';
+
+ // 'survey_web' (the old standalone survey page) keeps its own app mode: it can't be closed.
+ const appMode = legacyFormat === 'survey_web' ? 'survey_web' : APP_MODE_BY_FORMAT[format];
+
+ const normalized = {
+ format,
+ appMode,
+ fields: sanitizeSurveyFields(options.fields),
+ resume: options.resume === true,
+ container: format === 'page' ? resolveContainer(options.container) : null,
+ };
+
+ if (typeof options.personalToken === 'string' && options.personalToken.length > 0) {
+ normalized.personalToken = options.personalToken;
+ }
+ if (typeof options.outboundAction === 'string' && options.outboundAction.length > 0) {
+ normalized.outboundAction = options.outboundAction;
+ }
+ // Server-side resume state for a reminder delivery (answers so far, lastBlockId).
+ if (options.resumeData && typeof options.resumeData === 'object' && !Array.isArray(options.resumeData)) {
+ normalized.resumeData = options.resumeData;
+ }
+
+ return normalized;
+};
diff --git a/src/UI.js b/src/UI.js
index 07a1424..838a21b 100644
--- a/src/UI.js
+++ b/src/UI.js
@@ -764,8 +764,22 @@ export const injectStyledCSS = (
object-fit: cover;
}
+ /* Surveys 2.0 — card: a compact popover in the launcher's corner. */
.gleap-frame-container--survey {
bottom: ${buttonY}px !important;
+ bottom: calc(${buttonY}px + env(safe-area-inset-bottom, 0px)) !important;
+ max-width: 348px;
+ border-radius: 18px;
+ box-shadow: 0 14px 44px rgba(16, 24, 40, 0.18), 0 0 0 1px rgba(16, 24, 40, 0.06);
+ animation: gleapSurveyCardIn 0.42s cubic-bezier(0.16, 1, 0.3, 1) both, gleapWidgetFade 0.18s ease-out both;
+ transition: none;
+ }
+
+ /* Card and page surveys stay invisible until the messenger reports their height. */
+ .gleap-frame-container--measuring {
+ visibility: hidden !important;
+ animation: none !important;
+ pointer-events: none !important;
}
.gleap-frame-container--extended {
@@ -785,34 +799,108 @@ export const injectStyledCSS = (
}
}
- .gleap-frame-container--survey-full {
+ /* Surveys 2.0 — full screen: the frame covers the viewport and the messenger draws the
+ brand-tinted, Typeform-style background and the close button itself. */
+ .gleap-frame-container--survey-full,
+ [dir=rtl].gleap-frame-container--survey-full {
position: fixed;
top: 0 !important;
left: 0 !important;
bottom: 0 !important;
right: 0 !important;
- width: 100vw !important;
- max-width: 100vw !important;
+ width: 100% !important;
+ max-width: none !important;
height: 100vh;
height: 100dvh !important;
- background-color: rgba(0, 0, 0, 0.5);
- backdrop-filter: blur(6px);
+ min-height: 0 !important;
+ max-height: none !important;
+ background-color: transparent;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ transform: none;
+ animation: gleapWidgetFade 0.28s ease-out both !important;
+ }
+
+ .gleap-frame-container--survey-full .gleap-frame-container-inner {
+ width: 100% !important;
+ height: 100% !important;
+ max-width: none !important;
+ max-height: none !important;
+ border-radius: 0;
+ }
+
+ .gleap-frame-container--survey-full .gleap-frame-container-inner:before {
+ display: none;
+ }
+
+ .gleap-frame-container--survey-full.gleap-frame-container--closing {
+ animation: gleapWidgetFadeOut 0.22s ease-in both !important;
+ }
+
+ /* Legacy surveys (the messenger fell back to the pre-2.0 flow): the old survey chrome. */
+ .gleap-frame-container--survey.gleap-frame-container--survey-legacy {
+ max-width: 410px;
+ border-radius: ${containerRadius * 1.5}px;
+ box-shadow: 0px 5px 40px 0px rgba(9, 14, 21, 0.16);
+ }
+
+ .gleap-frame-container--survey-full.gleap-frame-container--survey-legacy {
display: flex !important;
justify-content: center !important;
align-items: center !important;
- max-height: 100vh;
- max-height: 100dvh !important;
- border-radius: 0 !important;
- animation-name: none !important;
+ background-color: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
}
- .gleap-frame-container--survey-full .gleap-frame-container-inner {
+ .gleap-frame-container--survey-full.gleap-frame-container--survey-legacy .gleap-frame-container-inner {
+ width: calc(100% - 24px) !important;
max-width: 640px !important;
- width: calc(100% - 24px);
+ height: calc(100% - 40px) !important;
border-radius: ${containerRadius}px;
overflow: hidden;
}
+ .gleap-frame-container--survey-full.gleap-frame-container--survey-legacy .gleap-frame-container-inner:before {
+ display: block;
+ }
+
+ /* Surveys 2.0 — page: an inline frame in the host's container, as tall as the survey. */
+ .gleap-frame-container.gleap-frame-container--survey-page,
+ [dir=rtl].gleap-frame-container.gleap-frame-container--survey-page {
+ position: relative !important;
+ top: auto !important;
+ right: auto !important;
+ bottom: auto !important;
+ left: auto !important;
+ width: 100% !important;
+ max-width: none !important;
+ height: auto !important;
+ min-height: 0 !important;
+ max-height: none !important;
+ z-index: auto;
+ box-shadow: none !important;
+ border-radius: 0 !important;
+ background-color: transparent;
+ transform: none !important;
+ animation: gleapWidgetFade 0.3s ease-out both !important;
+ }
+
+ .gleap-frame-container--survey-page .gleap-frame-container-inner {
+ width: 100% !important;
+ height: 360px;
+ max-height: none !important;
+ }
+
+ .gleap-frame-container--survey-page.gleap-frame-container--animate .gleap-frame-container-inner {
+ transition: height 0.28s cubic-bezier(0.16, 1, 0.3, 1);
+ }
+
+ .gleap-frame-container--survey-page .gleap-frame-container-inner:before,
+ .gleap-frame-container--survey-page .gleap-frame-loader {
+ display: none;
+ }
+
.gleap-frame-container--classic {
right: ${buttonX}px;
bottom: ${buttonY}px;
@@ -894,6 +982,16 @@ export const injectStyledCSS = (
to { opacity: 0; transform: scale(0.96); }
}
+ @keyframes gleapWidgetFadeOut {
+ from { opacity: 1; }
+ to { opacity: 0; }
+ }
+
+ @keyframes gleapSurveyCardIn {
+ from { transform: translateY(14px) scale(0.98); }
+ to { transform: translateY(0) scale(1); }
+ }
+
@keyframes gleapWidgetSlideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
@@ -1397,7 +1495,13 @@ export const injectStyledCSS = (
}
.gleap-frame-container--survey .gleap-frame-container-inner {
- height: calc(100vh - 40px);
+ height: calc(100vh - ${buttonY + 24}px);
+ height: calc(100dvh - ${buttonY + 24}px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px));
+ }
+
+ /* Smooth resizes between questions (not on the first measure, which happens before --animate). */
+ .gleap-frame-container--survey.gleap-frame-container--animate .gleap-frame-container-inner {
+ transition: max-height 0.28s cubic-bezier(0.16, 1, 0.3, 1);
}
.gleap-frame-container-inner:before {
@@ -2349,19 +2453,72 @@ export const injectStyledCSS = (
height: 100%;
}
- .gleap-frame-container:not(.gleap-frame-container--survey):not(.gleap-frame-container--survey-full) .gleap-frame-container-inner {
+ .gleap-frame-container:not(.gleap-frame-container--survey):not(.gleap-frame-container--survey-full):not(.gleap-frame-container--survey-page) .gleap-frame-container-inner {
max-height: calc(var(--glvh, 1vh) * 100) !important;
}
- .gleap-frame-container--survey {
- height: auto !important;
- top: initial !important;
+ /* Card surveys become a bottom sheet: handle area, 22px top radius, scrim, home indicator inset. */
+ .gleap-frame-container--survey,
+ [dir=rtl].gleap-frame-container--survey {
+ top: auto !important;
bottom: 0px !important;
- min-height: initial !important;
+ left: 0px !important;
+ right: 0px !important;
+ width: 100% !important;
+ max-width: 100% !important;
+ height: auto !important;
+ min-height: 0 !important;
+ max-height: calc(100vh - 12px) !important;
+ max-height: calc(100dvh - 12px - env(safe-area-inset-top, 0px)) !important;
+ box-sizing: border-box;
+ padding-top: 15px;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+ background-color: ${backgroundColor};
+ border-radius: 22px 22px 0 0 !important;
+ box-shadow: 0 -6px 30px rgba(0, 0, 0, 0.18), 0 0 0 100vmax rgba(10, 12, 16, 0.28);
+ transform-origin: center bottom;
+ animation: gleapWidgetSlideUp 0.42s cubic-bezier(0.16, 1, 0.3, 1) both, gleapWidgetFade 0.2s ease-out both;
+ }
+
+ .gleap-frame-container--survey:after {
+ content: "";
+ position: absolute;
+ top: 7px;
+ left: 50%;
+ width: 36px;
+ height: 4px;
+ margin-left: -18px;
+ border-radius: 4px;
+ background-color: ${isDarkMode ? 'rgba(255, 255, 255, 0.28)' : 'rgba(16, 24, 40, 0.16)'};
+ pointer-events: none;
}
.gleap-frame-container--survey .gleap-frame-container-inner {
- height: 100vh !important;
+ width: 100% !important;
+ height: calc(100vh - 27px) !important;
+ height: calc(100dvh - 27px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)) !important;
+ }
+
+ .gleap-frame-container--survey-page .gleap-frame-container-inner {
+ width: 100% !important;
+ }
+
+ /* Legacy card surveys: the old full-width panel at the bottom (no sheet chrome). */
+ .gleap-frame-container--survey.gleap-frame-container--survey-legacy,
+ [dir=rtl].gleap-frame-container--survey.gleap-frame-container--survey-legacy {
+ max-width: 100% !important;
+ padding: 0;
+ border-radius: 0 !important;
+ box-shadow: 0px 5px 40px 0px rgba(9, 14, 21, 0.16);
+ }
+
+ .gleap-frame-container--survey.gleap-frame-container--survey-legacy:after {
+ display: none;
+ }
+
+ .gleap-frame-container--survey.gleap-frame-container--survey-legacy .gleap-frame-container-inner {
+ height: calc(100vh - 12px) !important;
+ height: calc(100dvh - 12px - env(safe-area-inset-top, 0px)) !important;
}
.bb-tooltip {
@@ -3046,6 +3203,24 @@ export const injectStyledCSS = (
}
}
+ @media (prefers-reduced-motion: reduce) {
+ .gleap-frame-container--survey,
+ .gleap-frame-container--survey-page,
+ .gleap-frame-container--survey-full {
+ animation: gleapWidgetFade 0.15s ease-out both !important;
+ }
+
+ .gleap-frame-container--survey.gleap-frame-container--closing,
+ .gleap-frame-container--survey-full.gleap-frame-container--closing {
+ animation: gleapWidgetFadeOut 0.15s ease-in both !important;
+ }
+
+ .gleap-frame-container--survey .gleap-frame-container-inner,
+ .gleap-frame-container--survey-page .gleap-frame-container-inner {
+ transition: none !important;
+ }
+ }
+
.gleap-admin-frame-container,
.gleap-frame-container,
.bb-feedback-button,
diff --git a/webpack.config.js b/webpack.config.js
index 194b4ed..dbc0da1 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -140,6 +140,35 @@ const browserConfig = {
},
};
+// survey-embed.js: a small standalone loader for embeds (loads the SDK on demand).
+const copyEmbedPlugin = {
+ apply: (compiler) => {
+ compiler.hooks.afterEmit.tap("AfterEmitEmbedPlugin", () => {
+ const nodeVersion = process.env.npm_package_version;
+ return exec(
+ `mkdir -p published/${nodeVersion} published/latest && cp ./build/embed/survey-embed.js published/${nodeVersion}/survey-embed.js && cp ./build/embed/survey-embed.js published/latest/survey-embed.js`,
+ (err, stdout, stderr) => {
+ if (stdout) process.stdout.write(stdout);
+ if (stderr) process.stderr.write(stderr);
+ }
+ );
+ });
+ },
+};
+
+const embedConfig = {
+ ...commonConfig(false, [copyEmbedPlugin]),
+ entry: {
+ "survey-embed": "./src/GleapSurveyEmbed.js",
+ },
+ devtool: false,
+ output: {
+ path: path.resolve(__dirname, "build/embed"),
+ filename: "[name].js",
+ clean: true,
+ },
+};
+
const developmentConfig = {
...commonConfig(true, []),
// ... additional development-specific settings ...
@@ -149,6 +178,6 @@ module.exports = (env) => {
if (env && env.development) {
return developmentConfig;
} else {
- return [esmConfig, cjsConfig, browserConfig];
+ return [esmConfig, cjsConfig, browserConfig, embedConfig];
}
};