|
| 1 | +const localStorageKey = "gleap-tour-data"; |
| 2 | +const pointerContainerId = "copilot-pointer-container"; |
| 3 | +const styleId = "copilot-tour-styles"; |
| 4 | +const copilotInfoContainerId = "copilot-info-container"; |
| 5 | + |
| 6 | +function estimateReadTime(text) { |
| 7 | + const wordsPerSecond = 3.5; // Average reading speed |
| 8 | + const wordCount = text.split(/\s+/).filter((word) => word.length > 0).length; |
| 9 | + const readTimeInSeconds = Math.ceil(wordCount / wordsPerSecond); |
| 10 | + return readTimeInSeconds + 1.5; |
| 11 | +} |
| 12 | + |
| 13 | +function htmlToPlainText(html) { |
| 14 | + const tempDiv = document.createElement("div"); // Create a temporary div |
| 15 | + tempDiv.innerHTML = html; // Set the HTML content |
| 16 | + return tempDiv.textContent || ""; // Extract and return plain text |
| 17 | +} |
| 18 | + |
| 19 | +export default class GleapCopilotTours { |
| 20 | + productTourData = undefined; |
| 21 | + productTourId = undefined; |
| 22 | + currentActiveIndex = undefined; |
| 23 | + lastArrowPositionX = undefined; |
| 24 | + lastArrowPositionY = undefined; |
| 25 | + |
| 26 | + // GleapReplayRecorder singleton |
| 27 | + static instance; |
| 28 | + static getInstance() { |
| 29 | + if (!this.instance) { |
| 30 | + this.instance = new GleapCopilotTours(); |
| 31 | + return this.instance; |
| 32 | + } else { |
| 33 | + return this.instance; |
| 34 | + } |
| 35 | + } |
| 36 | + |
| 37 | + constructor() {} |
| 38 | + |
| 39 | + startWithConfig(tourId, config, delay = 0) { |
| 40 | + // Prevent multiple tours from being started. |
| 41 | + if (this.productTourId) { |
| 42 | + return; |
| 43 | + } |
| 44 | + |
| 45 | + this.productTourId = tourId; |
| 46 | + this.productTourData = config; |
| 47 | + this.currentActiveIndex = 0; |
| 48 | + |
| 49 | + const self = this; |
| 50 | + |
| 51 | + if (delay > 0) { |
| 52 | + return setTimeout(() => { |
| 53 | + self.start(); |
| 54 | + }, delay); |
| 55 | + } else { |
| 56 | + return this.start(); |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + loadUncompletedTour() { |
| 61 | + try { |
| 62 | + const data = JSON.parse(localStorage.getItem(localStorageKey)); |
| 63 | + if (data?.tourData && data?.tourId) { |
| 64 | + return data; |
| 65 | + } |
| 66 | + } catch (e) {} |
| 67 | + |
| 68 | + return null; |
| 69 | + } |
| 70 | + |
| 71 | + storeUncompletedTour() { |
| 72 | + if (this.productTourId && this.productTourData) { |
| 73 | + try { |
| 74 | + let data = JSON.parse( |
| 75 | + JSON.stringify({ |
| 76 | + tourData: this.productTourData, |
| 77 | + tourId: this.productTourId, |
| 78 | + }) |
| 79 | + ); |
| 80 | + |
| 81 | + data.tourData.steps = data.tourData.steps.slice( |
| 82 | + this.currentActiveIndex || 0, |
| 83 | + data.tourData.steps.length |
| 84 | + ); |
| 85 | + |
| 86 | + console.log("Storing uncompleted tour:", data); |
| 87 | + |
| 88 | + localStorage.setItem(localStorageKey, JSON.stringify(data)); |
| 89 | + } catch (e) {} |
| 90 | + } else { |
| 91 | + this.clearUncompletedTour(); |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + clearUncompletedTour() { |
| 96 | + try { |
| 97 | + localStorage.removeItem(localStorageKey); |
| 98 | + } catch (e) {} |
| 99 | + } |
| 100 | + |
| 101 | + updatePointerPosition(anchor) { |
| 102 | + const container = document.getElementById(pointerContainerId); |
| 103 | + if (!container) { |
| 104 | + return; |
| 105 | + } |
| 106 | + |
| 107 | + const infoBubble = container.querySelector("#info-bubble"); |
| 108 | + |
| 109 | + // If no anchor, center on screen. |
| 110 | + if (!anchor) { |
| 111 | + const scrollX = window.scrollX || 0; |
| 112 | + const scrollY = window.scrollY || 0; |
| 113 | + |
| 114 | + // The center of the *viewport* in document coordinates: |
| 115 | + const centerX = scrollX + window.innerWidth / 2; |
| 116 | + const centerY = scrollY + window.innerHeight / 2; |
| 117 | + |
| 118 | + container.style.position = "absolute"; |
| 119 | + container.style.left = `${centerX}px`; |
| 120 | + container.style.top = `${centerY}px`; |
| 121 | + container.style.transform = `translate(-50%, -50%)`; |
| 122 | + return; |
| 123 | + } |
| 124 | + |
| 125 | + // 1) Calculate the anchor’s position on the page (not just viewport). |
| 126 | + const anchorRect = anchor.getBoundingClientRect(); |
| 127 | + const containerRect = container.getBoundingClientRect(); |
| 128 | + |
| 129 | + // Suppose the arrow’s tip is ~15px from the top and left (tweak as needed). |
| 130 | + const arrowTipOffsetX = 15; |
| 131 | + const arrowTipOffsetY = 15; |
| 132 | + |
| 133 | + // Center of anchor: |
| 134 | + const anchorCenterX = |
| 135 | + anchorRect.left + anchorRect.width / 2 + window.scrollX; |
| 136 | + const anchorCenterY = |
| 137 | + anchorRect.top + anchorRect.height / 2 + window.scrollY; |
| 138 | + |
| 139 | + // We want the arrow’s tip at the anchorCenter. |
| 140 | + // So offset the pointer container so that (container’s top-left + arrowTipOffset) = anchor center |
| 141 | + const containerLeft = anchorCenterX - arrowTipOffsetX; |
| 142 | + const containerTop = anchorCenterY - arrowTipOffsetY; |
| 143 | + |
| 144 | + // Position the pointer container (arrow + bubble). |
| 145 | + container.style.left = `${containerLeft}px`; |
| 146 | + container.style.top = `${containerTop}px`; |
| 147 | + container.style.transform = ""; // no translate needed, or clear any you might have |
| 148 | + |
| 149 | + // 2) Check if the info bubble goes off the right edge |
| 150 | + if (infoBubble) { |
| 151 | + // Reset bubble style so we can measure it properly |
| 152 | + infoBubble.style.marginLeft = "10px"; // default to the right side |
| 153 | + infoBubble.style.marginRight = ""; // clear any previous override |
| 154 | + infoBubble.style.transform = "none"; |
| 155 | + |
| 156 | + const bubbleRect = infoBubble.getBoundingClientRect(); |
| 157 | + const bubbleRightEdge = bubbleRect.right; |
| 158 | + const windowWidth = window.innerWidth; |
| 159 | + |
| 160 | + // If bubble extends past the right edge by any amount, flip it to the left side |
| 161 | + if (bubbleRightEdge > windowWidth) { |
| 162 | + // Move bubble to the left side of the arrow |
| 163 | + // One approach: negative margin-left by the bubble’s width + some padding |
| 164 | + // Another approach: transform: translateX(-100%) etc. |
| 165 | + |
| 166 | + infoBubble.style.marginLeft = ""; |
| 167 | + infoBubble.style.marginRight = "10px"; |
| 168 | + // Or do something like: |
| 169 | + // infoBubble.style.transform = `translateX(-${bubbleRect.width + 10}px)`; |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + console.log("Pointer placed at:", containerLeft, containerTop); |
| 174 | + } |
| 175 | + |
| 176 | + cleanup() { |
| 177 | + this.removePointerUI(); |
| 178 | + this.clearUncompletedTour(); |
| 179 | + } |
| 180 | + |
| 181 | + removePointerUI() { |
| 182 | + const container = document.getElementById(pointerContainerId); |
| 183 | + if (container) { |
| 184 | + container.remove(); |
| 185 | + } |
| 186 | + |
| 187 | + // Remove style node. |
| 188 | + const styleNode = document.getElementById(styleId); |
| 189 | + if (styleNode) { |
| 190 | + styleNode.remove(); |
| 191 | + } |
| 192 | + |
| 193 | + // Remove copilot info container. |
| 194 | + const copilotInfoContainer = document.getElementById( |
| 195 | + copilotInfoContainerId |
| 196 | + ); |
| 197 | + if (copilotInfoContainer) { |
| 198 | + copilotInfoContainer.remove(); |
| 199 | + } |
| 200 | + } |
| 201 | + |
| 202 | + setupCopilotTour() { |
| 203 | + // Inject CSS into the document head |
| 204 | + let styleNode = document.getElementById(styleId); |
| 205 | + if (!styleNode) { |
| 206 | + styleNode = document.createElement("style"); |
| 207 | + styleNode.id = styleId; |
| 208 | + styleNode.type = "text/css"; |
| 209 | + styleNode.textContent = ` |
| 210 | + #${pointerContainerId} { |
| 211 | + position: absolute; |
| 212 | + top: 0; |
| 213 | + left: 0; |
| 214 | + display: flex; |
| 215 | + align-items: center; |
| 216 | + pointer-events: none; |
| 217 | + z-index: 9999; |
| 218 | + transition: transform 0.5s ease, top 0.5s ease, left 0.5s ease;; |
| 219 | + } |
| 220 | + |
| 221 | + #${pointerContainerId} svg { |
| 222 | + width: 20px; |
| 223 | + height: auto; |
| 224 | + fill: none; |
| 225 | + } |
| 226 | + |
| 227 | + #info-bubble { |
| 228 | + margin-left: 10px; |
| 229 | + padding: 10px 15px; |
| 230 | + border-radius: 20px; |
| 231 | + background-color: black; |
| 232 | + color: white; |
| 233 | + font-family: Arial, sans-serif; |
| 234 | + font-size: 14px; |
| 235 | + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); |
| 236 | + } |
| 237 | +
|
| 238 | + body::before { |
| 239 | + content: ""; |
| 240 | + position: fixed; |
| 241 | + top: 0; |
| 242 | + left: 0; |
| 243 | + width: 100vw; |
| 244 | + height: 100vh; |
| 245 | + pointer-events: all; |
| 246 | + z-index: 2147483610; |
| 247 | + box-sizing: border-box; |
| 248 | + border: 6px solid transparent; |
| 249 | + filter: blur(15px); |
| 250 | + border-image-slice: 1; |
| 251 | + border-image-source: linear-gradient(45deg, #2142e7, #e721b3); |
| 252 | + animation: animateBorder 4s infinite alternate ease-in-out; |
| 253 | + } |
| 254 | + |
| 255 | + body::after { |
| 256 | + content: ""; |
| 257 | + position: fixed; |
| 258 | + top: 0; |
| 259 | + left: 0; |
| 260 | + width: 100vw; |
| 261 | + height: 100vh; |
| 262 | + pointer-events: all; |
| 263 | + z-index: 2147483610; |
| 264 | + opacity: 0.5; |
| 265 | + box-sizing: border-box; |
| 266 | + border: 2px solid transparent; |
| 267 | + border-image-slice: 1; |
| 268 | + border-image-source: linear-gradient(45deg, #2142e7, #e721b3); |
| 269 | + animation: animateBorder 4s infinite alternate ease-in-out; |
| 270 | + } |
| 271 | + |
| 272 | + @keyframes animateBorder { |
| 273 | + 0% { |
| 274 | + border-image-source: linear-gradient(45deg, #2142e7, #e721b3); |
| 275 | + } |
| 276 | + 50% { |
| 277 | + border-image-source: linear-gradient(135deg, #e721b3, #ff8a00); |
| 278 | + } |
| 279 | + 100% { |
| 280 | + border-image-source: linear-gradient(225deg, #ff8a00, #2142e7); |
| 281 | + } |
| 282 | + } |
| 283 | +
|
| 284 | + .copilot-info-container { |
| 285 | + position: fixed; |
| 286 | + top: 20px; |
| 287 | + right: 20px; |
| 288 | + z-index: 2147483610; |
| 289 | + background: #fff; |
| 290 | + padding: 5px; |
| 291 | + padding-left: 10px; |
| 292 | + border-radius: 10px; |
| 293 | + box-shadow: 0 0 20px 0 #e721b263; |
| 294 | + font-family: sans-serif; |
| 295 | + font-size: 13px; |
| 296 | + color: #000; |
| 297 | + display: flex; |
| 298 | + align-items: center; |
| 299 | + gap: 10px; |
| 300 | + border: 1px solid #e721b3; |
| 301 | + } |
| 302 | +
|
| 303 | + .copilot-info-container svg { |
| 304 | + width: 24px; |
| 305 | + height: 24px; |
| 306 | + } |
| 307 | + `; |
| 308 | + document.head.appendChild(styleNode); |
| 309 | + } |
| 310 | + |
| 311 | + // Create the container div |
| 312 | + const container = document.createElement("div"); |
| 313 | + container.id = pointerContainerId; |
| 314 | + container.style.opacity = 0; |
| 315 | + |
| 316 | + // Create the SVG mouse pointer |
| 317 | + const svgMouse = document.createElementNS( |
| 318 | + "http://www.w3.org/2000/svg", |
| 319 | + "svg" |
| 320 | + ); |
| 321 | + svgMouse.setAttribute("viewBox", "0 0 380 476"); |
| 322 | + svgMouse.innerHTML = |
| 323 | + '<path d="M352.595 268.315L352.581 268.302L352.566 268.29L78.6092 24.7278C71.6245 18.433 62.5487 15 53.2 15C32.1157 15 15 32.1157 15 53.2V424C15 444.34 31.4714 461 52 461C62.6797 461 72.8089 456.467 79.8863 448.38C79.8871 448.379 79.8879 448.378 79.8886 448.378L180.804 333.1H327.9C348.384 333.1 365 316.484 365 296C365 285.404 360.46 275.344 352.595 268.315Z" fill="black" stroke="white" stroke-width="30"/>'; |
| 324 | + |
| 325 | + // Create the info bubble |
| 326 | + const infoBubble = document.createElement("div"); |
| 327 | + infoBubble.id = "info-bubble"; |
| 328 | + infoBubble.textContent = ""; |
| 329 | + |
| 330 | + // Append elements |
| 331 | + container.appendChild(svgMouse); |
| 332 | + container.appendChild(infoBubble); |
| 333 | + document.body.appendChild(container); |
| 334 | + } |
| 335 | + |
| 336 | + start() { |
| 337 | + const config = this.productTourData; |
| 338 | + if (!config) { |
| 339 | + return; |
| 340 | + } |
| 341 | + |
| 342 | + // Setup the copilot tour. |
| 343 | + this.setupCopilotTour(); |
| 344 | + |
| 345 | + // Render the first step. |
| 346 | + this.renderNextStep(); |
| 347 | + } |
| 348 | + |
| 349 | + renderNextStep() { |
| 350 | + const config = this.productTourData; |
| 351 | + const steps = config.steps; |
| 352 | + const self = this; |
| 353 | + |
| 354 | + // Check if we have reached the end of the tour. |
| 355 | + if (this.currentActiveIndex >= steps.length) { |
| 356 | + this.cleanup(); |
| 357 | + return; |
| 358 | + } |
| 359 | + |
| 360 | + const currentStep = steps[this.currentActiveIndex]; |
| 361 | + const element = document.querySelector(currentStep.selector); |
| 362 | + |
| 363 | + // Wait for the pointer to be rendered. (by checking if the pointer container exists) |
| 364 | + setTimeout(() => { |
| 365 | + this.updatePointerPosition(element); |
| 366 | + }, 100); |
| 367 | + |
| 368 | + const message = |
| 369 | + currentStep && currentStep.message |
| 370 | + ? htmlToPlainText(currentStep.message) |
| 371 | + : "🤔"; |
| 372 | + |
| 373 | + // Set content of info bubble. |
| 374 | + document.getElementById("info-bubble").textContent = message; |
| 375 | + document.getElementById(pointerContainerId).style.opacity = 1; |
| 376 | + |
| 377 | + // Estimate readtime in seconds. |
| 378 | + const readTime = estimateReadTime(message); |
| 379 | + |
| 380 | + // Automatically move to next step after 3 seconds. |
| 381 | + setTimeout(() => { |
| 382 | + self.currentActiveIndex++; |
| 383 | + self.renderNextStep(); |
| 384 | + self.storeUncompletedTour(); |
| 385 | + |
| 386 | + if (currentStep.mode === "CLICK") { |
| 387 | + // Perform click on element. |
| 388 | + setTimeout(() => { |
| 389 | + try { |
| 390 | + element.click(); |
| 391 | + } catch (e) {} |
| 392 | + }, 1000 * 60); |
| 393 | + } |
| 394 | + }, readTime * 1000); |
| 395 | + } |
| 396 | +} |
0 commit comments