From f4d7871ed155ab61daf34668aa99f222ea8f28ad Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 6 Oct 2026 21:35:52 +0200 Subject: [PATCH 1/7] feat: Surveys 2.0 containers (card popover / bottom sheet, full viewport, inline page) Card: 348px corner popover with 18px radius and enter animation; on phones a bottom sheet with handle, scrim and safe-area inset. Full: covers the viewport, the messenger draws the background and close button. Page: inline frame in a host container that follows the survey's height. Card and page surveys stay invisible until the messenger reports their height. Safe-area insets are sent to the messenger (survey-safe-area). Reduced motion respected. Co-Authored-By: Claude Opus 5.5 --- src/GleapFeedbackButtonManager.js | 6 +- src/GleapFrameManager.js | 238 +++++++++++++++++++++++++++--- src/UI.js | 173 +++++++++++++++++++--- 3 files changed, 373 insertions(+), 44 deletions(-) diff --git a/src/GleapFeedbackButtonManager.js b/src/GleapFeedbackButtonManager.js index 706d6d9c..9cac4e51 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 d11e6b00..f978f8cd 100644 --- a/src/GleapFrameManager.js +++ b/src/GleapFrameManager.js @@ -43,6 +43,11 @@ 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; urlHandler = function (url, newTab) { if (url && url.length > 0) { if (newTab) { @@ -88,19 +93,171 @@ 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'; + } + + // Card and page surveys size their frame to the survey (height-update from the messenger). + isAutoHeightSurvey() { + return this.appMode === 'survey' || this.appMode === 'survey_page'; } setAppMode(appMode) { this.appMode = appMode; 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') { + 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.contains('gleap-frame-container--measuring')) { + container.classList.remove('gleap-frame-container--measuring'); + this.notifySurveyShown(); + } + } + + notifySurveyShown() { + GleapEventManager.notifyEvent('survey-shown', { + format: this.appMode === 'survey_page' ? 'page' : this.isFullSurvey() ? 'full' : 'card', + }); } // The widget is full screen at <= 450px and the expanded CSS only applies from @@ -170,11 +327,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 +372,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 +479,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 +584,7 @@ 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 classicStyle = 'gleap-frame-container--classic'; const classicStyleLeft = 'gleap-frame-container--classic-left'; const modernStyleLeft = 'gleap-frame-container--modern-left'; @@ -426,6 +599,7 @@ export default class GleapFrameManager { noButtonStyleLeft, surveyStyle, surveyFullStyle, + surveyPageStyle, ]; for (let i = 0; i < allStyles.length; i++) { this.gleapFrameContainer.classList.remove(allStyles[i]); @@ -461,9 +635,12 @@ 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.appMode === 'extended') { this.gleapFrameContainer.classList.add(extendedStyle); } @@ -490,6 +667,21 @@ export default class GleapFrameManager { this.closeTimeout = null; } this.gleapFrameContainer.classList.remove('gleap-frame-container--closing'); + const wasHidden = this.gleapFrameContainer.classList.contains('gleap-frame-container--hidden'); + 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'); + if (wasHidden && this.isSurvey()) { + this.notifySurveyShown(); + } + } this.gleapFrameContainer.classList.remove('gleap-frame-container--hidden'); if (showLoader) { this.gleapFrameContainer.classList.add(loadingClass); @@ -561,8 +753,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 +769,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,14 +910,14 @@ 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(); + } } } diff --git a/src/UI.js b/src/UI.js index 07a14247..f483e0c2 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,32 +799,78 @@ 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); - display: flex !important; - justify-content: center !important; - align-items: center !important; - max-height: 100vh; - max-height: 100dvh !important; + min-height: 0 !important; + max-height: none !important; + background-color: transparent; border-radius: 0 !important; - animation-name: none !important; + box-shadow: none !important; + transform: none; + animation: gleapWidgetFade 0.28s ease-out both !important; } .gleap-frame-container--survey-full .gleap-frame-container-inner { - max-width: 640px !important; - width: calc(100% - 24px); - border-radius: ${containerRadius}px; - overflow: hidden; + 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; + } + + /* 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 { @@ -894,6 +954,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 +1467,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 +2425,54 @@ 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; } .bb-tooltip { @@ -3046,6 +3157,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, From b70f38344e72a145cfdaba30a46fa729571a9cda Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 6 Oct 2026 21:35:52 +0200 Subject: [PATCH 2/7] feat: Gleap.showSurvey options (format, fields, personalToken, container, resume) Legacy format strings keep working. start-survey keeps `format` as the messenger app mode (survey | survey_full | survey_web | survey_page) and adds surveyFormat, fields, personalToken, resume, outboundAction and safeArea. showSurvey before the session is ready opens once it is; an inline survey page is never replaced by a triggered survey. Co-Authored-By: Claude Opus 5.5 --- index.d.ts | 22 ++++++++- src/Gleap.js | 80 ++++++++++++++++++++++++++------- src/GleapSurveyOptions.js | 93 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 17 deletions(-) create mode 100644 src/GleapSurveyOptions.js diff --git a/index.d.ts b/index.d.ts index cdcddd40..1cf8c417 100644 --- a/index.d.ts +++ b/index.d.ts @@ -276,7 +276,26 @@ 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; function on( event: | "initialized" @@ -291,6 +310,7 @@ export namespace Gleap { | "agent-error" | "agent-conversation-created" | "agent-tool-executed" + | "survey-shown" | string, callback: (data?: any) => void ): void; diff --git a/src/Gleap.js b/src/Gleap.js index f7c36e25..d269c63c 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,39 @@ 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; + } + messageData.safeArea = GleapFrameManager.getInstance().getSafeAreaInsets(); + } GleapFrameManager.getInstance().sendMessage( { name: action, - data: { - flow: feedbackFlow, - hideBackButton: hideBackButton, - format, - }, + data: messageData, }, true ); @@ -1544,7 +1584,15 @@ 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, + outboundAction: action._id || action.id, + }); } } } diff --git a/src/GleapSurveyOptions.js b/src/GleapSurveyOptions.js new file mode 100644 index 00000000..1d405588 --- /dev/null +++ b/src/GleapSurveyOptions.js @@ -0,0 +1,93 @@ +// 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; + } + + return normalized; +}; From dce4caea8c1a6728bdabf7448fc448ceea4b435c Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 6 Oct 2026 21:35:52 +0200 Subject: [PATCH 3/7] feat: survey-embed.js bundle for data-gleap-survey embeds Co-Authored-By: Claude Opus 5.5 --- src/GleapSurveyEmbed.js | 185 ++++++++++++++++++++++++++++++++++++++++ webpack.config.js | 31 ++++++- 2 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/GleapSurveyEmbed.js diff --git a/src/GleapSurveyEmbed.js b/src/GleapSurveyEmbed.js new file mode 100644 index 00000000..d72baef6 --- /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/webpack.config.js b/webpack.config.js index 194b4ed1..dbc0da11 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]; } }; From 3e02cc1ea41a446a6908b5c1cadcb724b0bb86aa Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 6 Oct 2026 21:35:52 +0200 Subject: [PATCH 4/7] docs: Surveys 2.0 release notes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81b5e4bf..5e3662af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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. New event `survey-shown`. +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. From 348b8db621c56198ae8a791661ac42d88ad525e7 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 6 Oct 2026 21:53:47 +0200 Subject: [PATCH 5/7] feat: survey lifecycle events and legacy survey chrome Handles the messenger's survey-legacy (back to the pre-2.0 card / centred full-screen chrome) and survey-shown / survey-answered / survey-completed / survey-closed, exposed as SDK events (typed in index.d.ts). survey-shown now comes from the messenger only; outbound-sent stays on notify-event. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- index.d.ts | 39 +++++++++++++++++++++++++++- src/GleapFrameManager.js | 56 ++++++++++++++++++++++++++++++---------- src/UI.js | 47 ++++++++++++++++++++++++++++++++- 4 files changed, 129 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3662af..4426575a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 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. New event `survey-shown`. +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 diff --git a/index.d.ts b/index.d.ts index 1cf8c417..1145cece 100644 --- a/index.d.ts +++ b/index.d.ts @@ -296,6 +296,44 @@ export namespace Gleap { 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" @@ -310,7 +348,6 @@ export namespace Gleap { | "agent-error" | "agent-conversation-created" | "agent-tool-executed" - | "survey-shown" | string, callback: (data?: any) => void ): void; diff --git a/src/GleapFrameManager.js b/src/GleapFrameManager.js index f978f8cd..27c835d1 100644 --- a/src/GleapFrameManager.js +++ b/src/GleapFrameManager.js @@ -48,6 +48,8 @@ export default class GleapFrameManager { // 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) { @@ -105,13 +107,20 @@ export default class GleapFrameManager { return this.appMode === 'survey_full' || this.appMode === 'survey_web'; } - // Card and page surveys size their frame to the survey (height-update from the messenger). + // 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'; + 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(); // Wait for the survey's height before showing a card/page survey in a frame that has none yet. @@ -187,7 +196,7 @@ export default class GleapFrameManager { } innerContainer.style.height = ''; - if (this.appMode === 'survey') { + 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. @@ -248,16 +257,22 @@ export default class GleapFrameManager { this.surveyRevealTimeout = null; } const container = this.gleapFrameContainer; - if (container && container.classList.contains('gleap-frame-container--measuring')) { + if (container) { container.classList.remove('gleap-frame-container--measuring'); - this.notifySurveyShown(); } } - notifySurveyShown() { - GleapEventManager.notifyEvent('survey-shown', { - format: this.appMode === 'survey_page' ? 'page' : this.isFullSurvey() ? 'full' : 'card', - }); + /** + * 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 @@ -585,6 +600,7 @@ export default class GleapFrameManager { 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'; @@ -600,6 +616,7 @@ export default class GleapFrameManager { surveyStyle, surveyFullStyle, surveyPageStyle, + surveyLegacyStyle, ]; for (let i = 0; i < allStyles.length; i++) { this.gleapFrameContainer.classList.remove(allStyles[i]); @@ -641,6 +658,9 @@ export default class GleapFrameManager { 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); } @@ -667,7 +687,6 @@ export default class GleapFrameManager { this.closeTimeout = null; } this.gleapFrameContainer.classList.remove('gleap-frame-container--closing'); - const wasHidden = this.gleapFrameContainer.classList.contains('gleap-frame-container--hidden'); if (this.isAutoHeightSurvey() && this.surveyAwaitingHeight) { this.gleapFrameContainer.classList.add('gleap-frame-container--measuring'); if (!this.surveyRevealTimeout) { @@ -678,9 +697,6 @@ export default class GleapFrameManager { } } else { this.gleapFrameContainer.classList.remove('gleap-frame-container--measuring'); - if (wasHidden && this.isSurvey()) { - this.notifySurveyShown(); - } } this.gleapFrameContainer.classList.remove('gleap-frame-container--hidden'); if (showLoader) { @@ -921,6 +937,20 @@ export default class GleapFrameManager { } } + 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/UI.js b/src/UI.js index f483e0c2..a163f623 100644 --- a/src/UI.js +++ b/src/UI.js @@ -825,7 +825,6 @@ export const injectStyledCSS = ( width: 100% !important; height: 100% !important; max-width: none !important; - max-height: none !important; border-radius: 0; } @@ -837,6 +836,34 @@ export const injectStyledCSS = ( 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; + 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--survey-legacy .gleap-frame-container-inner { + width: calc(100% - 24px) !important; + max-width: 640px !important; + 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 { @@ -2475,6 +2502,24 @@ export const injectStyledCSS = ( 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 { display: none !important; } From 62dc6c3c104fbb7ff755444287d49e79967842a2 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 6 Oct 2026 22:35:30 +0200 Subject: [PATCH 6/7] feat: forward server resume state for survey reminders Co-Authored-By: Claude Opus 5.5 --- src/Gleap.js | 4 ++++ src/GleapSurveyOptions.js | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/Gleap.js b/src/Gleap.js index d269c63c..b4ea9036 100755 --- a/src/Gleap.js +++ b/src/Gleap.js @@ -1088,6 +1088,9 @@ class Gleap { if (surveyOptions.outboundAction) { messageData.outboundAction = surveyOptions.outboundAction; } + if (surveyOptions.resumeData) { + messageData.resumeData = surveyOptions.resumeData; + } messageData.safeArea = GleapFrameManager.getInstance().getSafeAreaInsets(); } @@ -1591,6 +1594,7 @@ class Gleap { 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/GleapSurveyOptions.js b/src/GleapSurveyOptions.js index 1d405588..70d407d1 100644 --- a/src/GleapSurveyOptions.js +++ b/src/GleapSurveyOptions.js @@ -88,6 +88,10 @@ export const normalizeSurveyOptions = (formatOrOptions) => { 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; }; From 769264d1ed88c5bf5af2748bd4efb3b747ee9db6 Mon Sep 17 00:00:00 2001 From: Lukas Boehler Date: Tue, 6 Oct 2026 23:07:29 +0200 Subject: [PATCH 7/7] fix: full-screen survey covers the viewport (no 700px inner cap) and takes keyboard focus when shown by a trigger Co-Authored-By: Claude Opus 5.5 --- src/GleapFrameManager.js | 10 ++++++++++ src/UI.js | 1 + 2 files changed, 11 insertions(+) diff --git a/src/GleapFrameManager.js b/src/GleapFrameManager.js index 27c835d1..d36312bf 100644 --- a/src/GleapFrameManager.js +++ b/src/GleapFrameManager.js @@ -731,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() { diff --git a/src/UI.js b/src/UI.js index a163f623..838a21ba 100644 --- a/src/UI.js +++ b/src/UI.js @@ -825,6 +825,7 @@ export const injectStyledCSS = ( width: 100% !important; height: 100% !important; max-width: none !important; + max-height: none !important; border-radius: 0; }