1/** 2 * LAPP AI Photo Booth â frontend script. 3 * 4 * RESTYLE/RESTRUCTURE NOTE: screen flow reworked to Upload [1] -> 5 * Options [2] -> Processing [3] -> Result [4], matching a reference design. 6 * The old standalone Review screen is gone â its job (showing what you 7 * uploaded/picked, collecting consent/email, and triggering Generate) is 8 * now split between Upload (required consent) and Options (area of interest, 9 * dependent prompt choice, Generate). This was a deliberate choice, not just a 10 * cosmetic one: AiBoothHub's real POST /jobs contract requires 11 * templateKey + image + email together in a single request, so there is no 12 * way to "start generating" before all three are known â Generate has to 13 * live on the same screen as the style/email fields. 14 * 15 * 16 * No CAPTCHA is wired up here: Turnstile site/secret keys are not yet 17 * configured (LAPP_AIBOOTH_TURNSTILE_SITE_KEY), and rendering a 18 * non-functional CAPTCHA control would misrepresent a security check that 19 * isn't actually active. 20 * 21 * Every request to this site's own REST routes still includes: 22 * - 'X-WP-Nonce': defense-in-depth only. 23 * - 'X-Lapp-Ai-Session': the signed, opaque visitor session token,
23kept 24 * only in memory, never written to localStorage or sessionStorage. 25 * 26 * Vanilla JavaScript only; no jQuery dependency. Every instance is fully 27 * self-contained and scoped to its own root element. 28 */ 29 30( function () { 31 'use strict'; 32 33 console.log( 'LAPP AI Booth JS loaded â version 20260915-existing-turnstile-stage5-rate-limit-ui2' ); 34 35 /** @type {{restUrl: string, nonce: string, sessionToken: string, i18n: Object}} */ 36 var config = window.lappAiBoothConfig || { 37 restUrl: '', 38 nonce: '', 39 sessionToken: '', 40 captchaEnabled: false, 41 i18n: { 42 genericError: 'Something went wrong. Please try again.' 43 } 44 }; 45 46 var MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB 47 var MAX_LONG_EDGE_PX = 6000; 48 var MAX_SHORT_EDGE_PX = 4000; 49 var ALLOWED_MIME_TYPES = [ 'image/jpeg', 'image/png' ]; 50 var ALLOWED_EXTENSIONS = [ 'jpg', 'jpeg', 'png' ]; 51 52 var PROCESSING_MESSAGES = [ 53 'Uploading your photoâ¦', 54 'Preparing your selected styleâ¦', 55 'Generating your AI imageâ¦', 56 'Adding the finishing touchesâ¦' 57 ]; 58 59 var PROCESSING_MESSAGE_ROTATE_MS = 2000; 60 var VISIBLE_POLL_LIMIT_MS = 120000; 61 var DEFAULT_POLL_AFTER_MS = 2000; 62 63 64 var GALLERY_SLIDES = [ 65 { 66 src: 'https://laphotoparty.com/wp-content/uploads/2026/09/AI-Sample-_0011-Surfing.jpg', 67 alt: 'Surfing AI sample' 68 }, 69 { 70 src: 'https://laphotoparty.com/wp-content/uploads/2026/09/AI-Sample-_0010_-Tropical_Party.jpg', 71 alt: 'Tropical PArty AI sample' 72 }, 73 { 74 src: 'https://laphotoparty.com/wp-content/uploads/2026/09/AI-Sample-_0006_-Bobblehead.jpg', 75 alt: 'Bobblehead AI sample' 76 }, 77 { 78 src: 'https://laphotoparty.com/wp-content/uploads/2026/09/AI-Sample-_0003_-Astronaut.jpg', 79 alt: 'Astronaut AI sample' 80 }, 81 { 82 src: 'https://laphotoparty.com/wp-content/uploads/2026/09/AI-Sample-_0009_-3D_Character.jpg', 83 alt: '3D Char AI sample' 84 }, 85 { 86 src: 'https://laphotoparty.com/wp-content/uploads/2026/09/AI-Sample-_0001_-Plastic_Toy.jpg', 87 alt: 'Plastic Toy AI sample' 88 } 89 ]; 90 91 /** 92 * Maps a screen name to its number on the shared 4-step indicator. 93 * Error is intentionally absent â the stepper is hidden 94 * entirely on those screens. 95 * 96 * @type {Object<string, number>} 97 */ 98 var SCREEN_STEP_NUMBER = { 99 upload: 1, 100 options: 2, 101 processing: 3, 102 result: 4 103 }; 104 105 var ERROR_MESSAGES = { 106 INVALID_IMAGE: 'Please upload a valid JPG or PNG photo.', 107 IMAGE_TOO_LARGE: 'Your image is too large. Use a file below 20 MB and no larger than 6000 à 4000 pixels (landscape or portrait).', 108 GENERATION_FAILED: 'We couldn\u2019t generate your image. Please try again.', 109 JOB_NOT_FOUND: 'This image session has expired or is no longer available.', 110 JOB_NOT_READY: 'Your image is still being created.', 111 INVALID_TEMPLATE: 'This AI style is currently unavailable. Please choose another.', 112 RATE_LIMITED: 'You have reached the generation limit. Please wait before trying again.', 113 CAPTCHA_FAILED: 'We could not verify the security check. Please try again.', 114 EMAIL_INVALID: 'Please enter a valid email address.', 115 EMAIL_SEND_FAILED: 'We couldn\u2019t send the email, but you can still download your photo below.', 116 CAPTCHA_FAILED: 'Please complete the security check and try again.', 117 CAPTCHA_NOT_CONFIGURED: 'The security check is temporarily unavailable. Please try again later.', 118 RATE_LIMITED: 'Try Again Later', 119 SESSION_INVALID: 'Your session has expired. Please refresh and try again.' 120 }; 121 122 123 var PROMPT_GROUPS = { 124 music: { 125 label: 'Choose a style of music.', 126 options: [ 127 [ 'rock', 'Rock' ], 128 [ 'hip-hop', 'Hip-Hop' ], 129 [ 'edm', 'EDM' ], 130 [ 'classical', 'Classical' ], 131 [ 'jazz', 'Jazz' ], 132 [ 'r-and-b', 'R&B' ], 133 [ 'country', 'Country' ], 134 [ 'folk', 'Folk' ], 135 [ 'pop', 'Pop' ] 136 ] 137 }, 138 travel: { 139 label: 'Choose a travel destination.', 140 options: [ 141 [ 'paris', 'Paris' ], 142 [ 'rome', 'Rome' ], 143 [ 'barcelona', 'Barcelona' ], 144 [ 'tokyo', 'Tokyo' ], 145 [ 'cabo', 'Cabo' ], 146 [ 'london', 'London' ], 147 [ 'istanbul', 'Istanbul' ], 148 [ 'new-york-city', 'New York City' ], 149 [ 'nepal', 'Nepal' ] 150 ] 151 }, 152 sport: { 153 label: 'Choose a sport.', 154 options: [ 155 [ 'football', 'Football' ], 156 [ 'soccer', 'Soccer' ], 157 [ 'hockey', 'Hockey' ], 158 [ 'baseball', 'Baseball' ], 159 [ 'basketball', 'Basketball' ], 160 [ 'golf', 'Golf' ], 161 [ 'volleyball', 'Volleyball' ], 162 [ 'tennis', 'Tennis' ], 163 [ 'swimming', 'Swimming' ] 164 ] 165 }, 166 movie: { 167 label: 'Choose a movie genre.', 168 options: [ 169 [ 'action', 'Action' ], 170 [ 'adventure', 'Adventure' ], 171 [ 'comedy', 'Comedy' ], 172 [ 'drama', 'Drama' ], 173 [ 'horror', 'Horror' ], 174 [ 'sci-fi', 'Sci-Fi' ], 175 [ 'fantasy', 'Fantasy' ], 176 [ 'thriller', 'Thriller' ], 177 [ 'rom-com', 'Rom-Com' ] 178 ] 179 }, 180 party: { 181 label: 'Choose a party theme.', 182 options: [ 183 [ 'tropical-luau', 'Tropical Luau' ], 184 [ 'hollywood-red-carpet', 'Hollywood Red Carpet' ], 185 [ 'roaring-20s', 'Roaring 20s' ], 186 [ '1990s-throwback', "1990's Throwback" ], 187 [ 'vegas-casino-royale', 'Vegas / Casino Royale' ], 188 [ 'music-festival', 'Music Festival' ], 189 [ 'enchanted-forest', 'Enchanted Forest' ], 190 [ '80s-prom', "80's Prom" ], 191 [ 'holidays', 'Holidays' ] 192 ] 193 } 194 }; 195 196 /** 197 * One fully independent AI Photo Booth instance. 198 * 199 * @param {HTMLElement} root The .lapp-ai-booth wrapper element. 200 */ 201 function LappAiBoothInstance( root ) { 202 this.root = root; 203 204 this.state = { 205 currentScreen: 'upload', 206 selectedFile: null, 207 objectUrl: null, 208 resultObjectUrl: null, 209 selectedTemplateKey: null, 210 selectedTemplateName: null, 211 selectedTemplateDescription: null, 212 selectedTemplateThumbnailUrl: null, 213 selectedInterest: '', 214 selectedPromptChoice: '', 215 firstName: '', 216 lastName: '', 217 email: '', 218 captchaToken: '', 219 templateCatalog: [], 220 templatesLoaded: false, 221 jobId: null, 222 pollStartedAt: null, 223 pollTimeoutId: null, 224 messageRotateIntervalId: null, 225 captchaStateIntervalId: null, 226 isSubmitting: false, 227 errorReturnScreen: 'upload', 228 modalOpen: false 229 }; 230 231 this.cacheDom(); 232 this.bindEvents(); 233 this.initGalleries(); 234 } 235 236 /** 237 * Look up every element this instance needs, scoped to this.root only. 238 */ 239 LappAiBoothInstance.prototype.cacheDom = function () { 240 var root = this.root; 241 242 this.screens = {}; 243 var screenEls = root.querySelectorAll( '[data-lapp-ai-screen]' ); 244 for ( var i = 0; i < screenEls.length; i++ ) { 245 this.screens[ screenEls[ i ].getAttribute( 'data-lapp-ai-screen' ) ] = screenEls[ i ]; 246 } 247 248 this.liveRegion = root.querySelector( '[data-lapp-ai-live-region]' ); 249 250 this.triggerButton = root.querySelector( '[data-lapp-ai-action="open-modal"]' ); 251 this.modalOverlay = root.querySelector( '[data-lapp-ai-modal-overlay]' ); 252 this.modalEl = root.querySelector( '.lapp-ai-booth__modal' ); 253 254 this.stepperEl = root.querySelector( '[data-lapp-ai-stepper]' ); 255 this.stepperItems = root.querySelectorAll( '[data-lapp-ai-stepper-item]' ); 256 257 // Step 1: Upload. 258 this.fileInput = root.querySelector( '[data-lapp-ai-file-input]' ); 259 this.dropzone = root.querySelector( '[data-lapp-ai-dropzone]' ); 260 this.uploadEmptyArea = root.querySelector( '[data-lapp-ai-upload-empty]' ); 261 this.uploadFilledArea = root.querySelector( '[data-lapp-ai-upload-filled]' ); 262 this.previewImage = root.querySelector( '[data-lapp-ai-preview-image]' ); 263 this.validationMessage = root.querySelector( '[data-lapp-ai-validation-message]' ); 264 this.uploadContinueButton = root.querySelector( '[data-lapp-ai-action="upload-continue"]' ); 265 this.consentRequiredInput = root.querySelector( '[data-lapp-ai-consent-required]' ); 266 this.consentTermsInput = root.querySelector( '[data-lapp-ai-consent-terms]' ); 267 268 // Step 2: Options. 269 this.interestSelect = root.querySelector( '[data-lapp-ai-interest-select]' ); 270 this.promptField = root.querySelector( '[data-lapp-ai-prompt-field]' ); 271 this.promptLabel = root.querySelector( '[data-lapp-ai-prompt-label]' ); 272 this.promptSelect = root.querySelector( '[data-lapp-ai-prompt-select]' ); 273 this.templatesLoadingEls = root.querySelectorAll( '[data-lapp-ai-templates-loading]' ); 274 this.templatesEmptyEl = root.querySelector( '[data-lapp-ai-templates-empty]' ); 275 this.templatesErrorEl = root.querySelector( '[data-lapp-ai-templates-error]' ); 276 this.templatesRetryButton = root.querySelector( '[data-lapp-ai-action="templates-retry"]' ); 277 this.generateButton = root.querySelector( '[data-lapp-ai-action="generate"]' ); 278 279 // Step 3: Processing / contact details. 280 this.processingFormEl = root.querySelector( '[data-lapp-ai-processing-form]' ); 281 this.firstNameInput = root.querySelector( '[data-lapp-ai-first-name-input]' ); 282 this.lastNameInput = root.querySelector( '[data-lapp-ai-last-name-input]' ); 283 this.emailInput = root.querySelector( '[data-lapp-ai-email-input]' ); 284 this.emailErrorEl = root.querySelector( '[data-lapp-ai-email-error]' ); 285 this.processingSubmitButton = root.querySelector( '[data-lapp-ai-action="processing-submit"]' ); 286 this.captchaWrapEl = root.querySelector( '[data-lapp-ai-captcha-wrap]' ); 287 this.captchaEl = root.querySelector( '[data-lapp-ai-captcha]' ); 288 this.captchaErrorEl = root.querySelector( '[data-lapp-ai-captcha-error]' ); 289 this.processingImage = root.querySelector( '[data-lapp-ai-processing-image]' ); 290 this.processingTemplateName = root.querySelector( '[data-lapp-ai-processing-template-name]' ); 291 this.processingMessage = root.querySelector( '[data-lapp-ai-processing-message]' ); 292 this.processingActiveEl = root.querySelector( '[data-lapp-ai-processing-active]' ); 293 this.processingTimeoutEl = root.querySelector( '[data-lapp-ai-processing-timeout]' ); 294 295 // Shared gallery. 296 this.galleryEls = root.querySelectorAll( '[data-lapp-ai-gallery]' ); 297 this.galleryPrevButtons = root.querySelectorAll( '[data-lapp-ai-action="gallery-prev"]' ); 298 this.galleryNextButtons = root.querySelectorAll( '[data-lapp-ai-action="gallery-next"]' ); 299 300 // Step 4: Result. 301 this.resultImage = root.querySelector( '[data-lapp-ai-result-image]' ); 302 303 // Error screen. 304 this.errorTitle = root.querySelector( '[data-lapp-ai-error-title]' ); 305 this.errorMessage = root.querySelector( '[data-lapp-ai-error-message]' ); 306 this.errorTryAgainButton = root.querySelector( '[data-lapp-ai-action="error-try-again"]' ); 307 this.errorStartOverButton = root.querySelector( '[data-lapp-ai-action="error-start-over"]' ); 308 }; 309 310 /** 311 * Wire up every interactive control, scoped to this instance's root. 312 */
313 LappAiBoothInstance.prototype.bindEvents = function () { 314 this.boundHandleActionClick = this.handleActionClick.bind( this ); 315 this.root.addEventListener( 'click', this.boundHandleActionClick ); 316 console.log( 'LAPP AI Booth root click handler attached', this.root ); 317 318 this.root.addEventListener( 'change', function ( event ) { 319 var target = event.target; 320 321 if ( target && target.matches( '[data-lapp-ai-interest-select]' ) ) { 322 this.interestSelect = target; 323 this.promptField = this.root.querySelector( '[data-lapp-ai-prompt-field]' ); 324 this.promptLabel = this.root.querySelector( '[data-lapp-ai-prompt-label]' ); 325 this.promptSelect = this.root.querySelector( '[data-lapp-ai-prompt-select]' ); 326 this.handleInterestChange(); 327 return; 328 } 329 330 if ( target && target.matches( '[data-lapp-ai-prompt-select]' ) ) { 331 this.promptSelect = target; 332 this.handlePromptChoiceChange(); 333 } 334 }.bind( this ) ); 335 336 if ( this.fileInput ) { 337 this.fileInput.addEventListener( 'change', this.handleFileInputChange.bind( this ) ); 338 } 339 340 if ( this.dropzone ) { 341 this.dropzone.addEventListener( 'dragover', this.handleDragOver.bind( this ) ); 342 this.dropzone.addEventListener( 'dragleave', this.handleDragLeave.bind( this ) ); 343 this.dropzone.addEventListener( 'drop', this.handleDrop.bind( this ) ); 344 this.dropzone.addEventListener( 'keydown', this.handleDropzoneKeydown.bind( this ) ); 345 } 346 347 if ( this.consentRequiredInput ) { 348 this.consentRequiredInput.addEventListener( 'change', this.updateUploadNextButtonState.bind( this ) ); 349 } 350 if ( this.consentTermsInput ) { 351 this.consentTermsInput.addEventListener( 'change', this.updateUploadNextButtonState.bind( this ) ); 352 } 353 354 if ( this.firstNameInput ) { 355 this.firstNameInput.addEventListener( 'input', this.handleContactInput.bind( this ) ); 356 } 357 if ( this.lastNameInput ) { 358 this.lastNameInput.addEventListener( 'input', this.handleContactInput.bind( this ) ); 359 } 360 if ( this.emailInput ) { 361 this.emailInput.addEventListener( 'input', this.handleContactInput.bind( this ) ); 362 } 363 364 this.galleryPrevButtons.forEach( function ( button ) { 365 button.addEventListener( 'click', function ( event ) { 366 event.preventDefault(); 367 event.stopPropagation(); 368 this.handleGalleryNav( button, -1 ); 369 }.bind( this ) ); 370 }.bind( this ) ); 371 372 this.galleryNextButtons.forEach( function ( button ) { 373 button.addEventListener( 'click', function ( event ) { 374 event.preventDefault(); 375 event.stopPropagation(); 376 this.handleGalleryNav( button, 1 ); 377 }.bind( this ) ); 378 }.bind( this ) ); 379 380 381 if ( this.modalOverlay ) { 382 // Clicking the dimmed backdrop (not the modal panel itself) 383 // closes the modal. event.target === this.modalOverlay only 384 // when the click landed directly on the overlay element, not 385 // on any descendant (the modal panel and everything in it). 386 this.modalOverlay.addEventListener( 'click', function ( event ) { 387 if ( event.target === this.modalOverlay ) { 388 this.closeModal(); 389 } 390 }.bind( this ) ); 391 } 392 393 // Document-level so Escape works no matter what has focus; guarded 394 // by this.state.modalOpen so instances whose modal isn't open never 395 // react to it. 396 document.addEventListener( 'keydown', function ( event ) { 397 if ( 'Escape' === event.key && this.state.modalOpen ) { 398 this.closeModal(); 399 } 400 }.bind( this ) ); 401 }; 402 403 /** 404 * Single delegated handler for every data-lapp-ai-action button in this 405 * instance. 406 * 407 * @param {MouseEvent} event 408 */ 409 LappAiBoothInstance.prototype.handleActionClick = function ( event ) { 410 console.log( 'LAPP AI Booth delegated click received', event.target ); 411 412 var target = event.target.closest( '[data-lapp-ai-action]' ); 413 414 if ( ! target || ! this.root.contains( target ) ) { 415 return; 416 } 417 418 var action = target.getAttribute( 'data-lapp-ai-action' ); 419 console.log( 'LAPP AI Booth action:', action ); 420 421 switch ( action ) { 422 case 'open-modal': 423 event.preventDefault(); 424 this.handleOpenModalRequest(); 425 break; 426 427 case 'close-modal': 428 event.preventDefault(); 429 this.closeModal(); 430 break; 431 432 case 'select-photo': 433 event.preventDefault(); 434 if ( this.fileInput ) { 435 this.fileInput.click(); 436 } 437 break; 438 439 case 'replace-photo': 440 event.preventDefault(); 441 this.handleReplacePhoto(); 442 break; 443 444 case 'upload-continue': 445 event.preventDefault(); 446 if ( 447 this.state.selectedFile && 448 this.consentTermsInput && this.consentTermsInput.checked 449 ) { 450 this.populateOptionsScreen(); 451 this.goToScreen( 'options' ); 452 } 453 break; 454 455 case 'options-back': 456 event.preventDefault(); 457 this.goToScreen( 'upload' ); 458 break; 459 460 case 'templates-retry': 461 event.preventDefault(); 462 this.loadTemplates(); 463 break; 464 465 case 'generate': 466 event.preventDefault(); 467 this.handleOptionsContinue(); 468 break; 469 470 case 'processing-back': 471 event.preventDefault(); 472 if ( ! this.state.isSubmitting ) { 473 this.goToScreen( 'options' ); 474 } 475 break; 476 477 case 'processing-submit': 478 event.preventDefault(); 479 this.handleGenerateClick(); 480 break; 481 482 case 'processing-check-again': 483 event.preventDefault(); 484 this.handleCheckAgain(); 485 break; 486 487 case 'processing-start-over': 488 event.preventDefault(); 489 this.resetFlow(); 490 break; 491 492 493 case 'download': 494 event.preventDefault(); 495 this.handleDownload(); 496 break; 497 498 case 'create-another': 499 event.preventDefault(); 500 this.resetFlow(); 501 break; 502 503 case 'done': 504 event.preventDefault(); 505 this.handleDone(); 506 break; 507 508 case 'error-try-again': 509 event.preventDefault(); 510 this.goToScreen( this.state.errorReturnScreen || 'upload' ); 511 break; 512 513 case 'error-start-over': 514 event.preventDefault(); 515 if ( 'rate-limit' === this.state.errorMode ) { 516 this.closeModal(); 517 } else { 518 this.resetFlow(); 519 } 520 break; 521 522 default: 523 break; 524 } 525 }; 526 527 LappAiBoothInstance.prototype.initGalleries = function () { 528 if ( ! this.galleryEls || ! this.galleryEls.length ) { 529 return; 530 } 531
532 this.galleryEls.forEach( function ( galleryEl ) { 533 galleryEl.setAttribute( 'data-gallery-index', '0' ); 534 this.renderGallery( galleryEl ); 535 }.bind( this ) ); 536 }; 537 538 LappAiBoothInstance.prototype.renderGallery = function ( galleryEl ) { 539 if ( ! galleryEl ) { 540 return; 541 } 542 543 var index = parseInt( galleryEl.getAttribute( 'data-gallery-index' ) || '0', 10 ); 544 if ( isNaN( index ) || index < 0 ) { 545 index = 0; 546 } 547 548 index = index % GALLERY_SLIDES.length; 549 550 var imageEl = galleryEl.querySelector( '[data-lapp-ai-gallery-image]' ); 551 if ( imageEl ) { 552 imageEl.src = GALLERY_SLIDES[ index ].src; 553 imageEl.alt = GALLERY_SLIDES[ index ].alt; 554 } 555 }; 556 557 LappAiBoothInstance.prototype.handleGalleryNav = function ( buttonEl, delta ) { 558 var galleryEl = buttonEl ? buttonEl.closest( '[data-lapp-ai-gallery]' ) : null; 559 if ( ! galleryEl ) { 560 return; 561 } 562 563 var index = parseInt( galleryEl.getAttribute( 'data-gallery-index' ) || '0', 10 ); 564 if ( isNaN( index ) ) { 565 index = 0; 566 } 567 568 index = ( index + delta + GALLERY_SLIDES.length ) % GALLERY_SLIDES.length; 569 galleryEl.setAttribute( 'data-gallery-index', String( index ) ); 570 this.renderGallery( galleryEl ); 571 }; 572 573 LappAiBoothInstance.prototype.resetGalleries = function () { 574 if ( ! this.galleryEls || ! this.galleryEls.length ) { 575 return; 576 } 577 578 this.galleryEls.forEach( function ( galleryEl ) { 579 galleryEl.setAttribute( 'data-gallery-index', '0' ); 580 this.renderGallery( galleryEl ); 581 }.bind( this ) ); 582 }; 583 584 /** 585 * For a fresh start, check whether this visitor is temporarily blocked 586 * before opening the flow. If the flow already has in-progress state, 587 * just reopen it immediately. 588 */ 589 LappAiBoothInstance.prototype.handleOpenModalRequest = function () { 590 var self = this; 591 var hasInProgressState = !! ( 592 this.state.selectedFile || 593 this.state.jobId || 594 this.state.resultObjectUrl || 595 'upload' !== this.state.currentScreen 596 ); 597 598 if ( hasInProgressState ) { 599 this.openModal(); 600 return; 601 } 602 603 this.fetchJson( config.restUrl + 'availability', { method: 'GET' } ) 604 .then( function ( result ) { 605 var code = result && result.data && result.data.error ? result.data.error.code : ''; 606 607 if ( result.ok && result.data && result.data.available ) { 608 self.openModal(); 609 return; 610 } 611 612 if ( 'RATE_LIMITED' === code || 429 === result.status ) { 613 self.openModal(); 614 self.showRateLimitError(); 615 return; 616 } 617 618 self.openModal(); 619 } ) 620 .catch( function () { 621 self.openModal(); 622 } ); 623 }; 624 625 /** 626 * Open this instance's self-built modal. Locks page scroll (via the one 627 * deliberate .lapp-ai-booth-modal-open exception documented in the 628 * CSS), moves focus into the modal, and remembers the trigger button so 629 * focus can return to it on close. 630 */ 631 LappAiBoothInstance.prototype.openModal = function () { 632 if ( ! this.modalOverlay || this.state.modalOpen ) { 633 return; 634 } 635 636 this.modalOverlay.removeAttribute( 'hidden' ); 637 this.state.modalOpen = true; 638 639 document.documentElement.classList.add( 'lapp-ai-booth-modal-open' ); 640 641 this.lastFocusedBeforeModal = document.activeElement; 642 643 var heading = this.screens[ this.state.currentScreen ] 644 ? this.screens[ this.state.currentScreen ].querySelector( '.lapp-ai-booth__heading' ) 645 : null; 646 647 if ( heading ) { 648 if ( ! heading.hasAttribute( 'tabindex' ) ) { 649 heading.setAttribute( 'tabindex', '-1' ); 650 } 651 heading.focus( { preventScroll: true } ); 652 } else if ( this.modalEl ) { 653 this.modalEl.focus( { preventScroll: true } ); 654 } 655 656 this.announce( 'Dialog opened.' ); 657 }; 658 659 /** 660 * Close this instance's modal without resetting any in-progress state 661 * (an upload or a generation in progress is preserved â closing via the 662 * X button, backdrop click, or Escape is "hide, keep my place", not 663 * "start over"). Restores page scroll and returns focus to the trigger 664 * button. 665 */ 666 LappAiBoothInstance.prototype.closeModal = function () { 667 if ( ! this.modalOverlay || ! this.state.modalOpen ) { 668 return; 669 } 670 671 this.modalOverlay.setAttribute( 'hidden', '' ); 672 this.state.modalOpen = false; 673 674 document.documentElement.classList.remove( 'lapp-ai-booth-modal-open' ); 675 676 if ( this.lastFocusedBeforeModal && 'function' === typeof this.lastFocusedBeforeModal.focus ) { 677 this.lastFocusedBeforeModal.focus( { preventScroll: true } ); 678 } else if ( this.triggerButton ) { 679 this.triggerButton.focus( { preventScroll: true } ); 680 } 681 682 this.announce( 'Dialog closed.' ); 683 }; 684 685 /** 686 * Show one screen and hide all others, using the native `hidden` 687 * attribute. Updates the shared 4-step indicator, and lazily triggers 688 * the templates fetch the first time the Options screen is shown.
689 * 690 * @param {string} screenName 691 */ 692 LappAiBoothInstance.prototype.goToScreen = function ( screenName ) { 693 console.log( 'LAPP AI Booth changing screen:', screenName ); 694 695 if ( ! this.screens[ screenName ] ) { 696 return; 697 } 698 699 Object.keys( this.screens ).forEach( function ( name ) { 700 if ( name === screenName ) { 701 this.screens[ name ].removeAttribute( 'hidden' ); 702 } else { 703 this.screens[ name ].setAttribute( 'hidden', '' ); 704 } 705 }, this ); 706 707 this.state.currentScreen = screenName; 708 this.root.setAttribute( 'data-lapp-ai-step', screenName ); 709 710 this.updateStepper( screenName ); 711 712 var heading = this.screens[ screenName ].querySelector( '.lapp-ai-booth__heading' ); 713 if ( heading ) { 714 if ( ! heading.hasAttribute( 'tabindex' ) ) { 715 heading.setAttribute( 'tabindex', '-1' ); 716 } 717 heading.focus( { preventScroll: true } ); 718 } 719 720 this.announce( 'Screen changed to ' + screenName + '.' ); 721 722 if ( 'options' === screenName && ! this.state.templatesLoaded ) { 723 this.loadTemplates(); 724 } 725 }; 726 727 /** 728 * Show/hide the shared step indicator and mark each dot active/ 729 * completed relative to the current screen. 730 * 731 * @param {string} screenName 732 */ 733 LappAiBoothInstance.prototype.updateStepper = function ( screenName ) { 734 if ( ! this.stepperEl ) { 735 return; 736 } 737 738 var currentStep = SCREEN_STEP_NUMBER[ screenName ]; 739 740 if ( ! currentStep ) { 741 this.stepperEl.hidden = true; 742 return; 743 } 744 745 this.stepperEl.hidden = false; 746 747 for ( var i = 0; i < this.stepperItems.length; i++ ) { 748 var item = this.stepperItems[ i ]; 749 var itemStep = parseInt( item.getAttribute( 'data-lapp-ai-stepper-item' ), 10 ); 750 751 item.classList.remove( 'lapp-ai-booth__stepper-item--active', 'lapp-ai-booth__stepper-item--completed' ); 752 753 if ( itemStep === currentStep ) { 754 item.classList.add( 'lapp-ai-booth__stepper-item--active' ); 755 } else if ( itemStep < currentStep ) { 756 item.classList.add( 'lapp-ai-booth__stepper-item--completed' ); 757 } 758 } 759 }; 760 761 /** 762 * @param {string} message 763 */ 764 LappAiBoothInstance.prototype.announce = function ( message ) { 765 if ( this.liveRegion ) { 766 this.liveRegion.textContent = message; 767 } 768 }; 769 770 /** 771 * @return {Object} 772 */ 773 LappAiBoothInstance.prototype.restHeaders = function () { 774 return { 775 'X-WP-Nonce': config.nonce || '', 776 'X-Lapp-Ai-Session': config.sessionToken || '' 777 }; 778 }; 779 780 /** 781 * @param {string} url 782 * @param {Object} [options] 783 * @return {Promise<{ok: boolean, status: number, data: Object}>} 784 */ 785 LappAiBoothInstance.prototype.fetchJson = function ( url, options ) { 786 var fetchOptions = options || {}; 787 fetchOptions.headers = Object.assign( {}, this.restHeaders(), fetchOptions.headers || {} ); 788 789 return fetch( url, fetchOptions ) 790 .then( function ( response ) { 791 return response 792 .json() 793 .catch( function () { 794 return {}; 795 } ) 796 .then( function ( data ) { 797 return { ok: response.ok, status: response.status, data: data }; 798 } ); 799 } ); 800 }; 801 802 /** 803 * @param {string} code 804 * @return {string} 805 */ 806 LappAiBoothInstance.prototype.friendlyErrorMessage = function ( code ) { 807 return ERROR_MESSAGES[ code ] || config.i18n.genericError; 808 }; 809 810 /* --------------------------------------------------------------------- 811 Step 1: Upload + consent 812 ------------------------------------------------------------------------ */ 813 814 LappAiBoothInstance.prototype.handleFileInputChange = function ( event ) { 815 var files = event.target.files; 816 if ( files && files.length > 0 ) { 817 this.processFile( files[ 0 ] ); 818 } 819 }; 820 821 LappAiBoothInstance.prototype.handleDragOver = function ( event ) { 822 event.preventDefault(); 823 event.stopPropagation(); 824 this.dropzone.classList.add( 'lapp-ai-booth__dropzone--dragover' ); 825 }; 826 827 LappAiBoothInstance.prototype.handleDragLeave = function ( event ) { 828 event.preventDefault(); 829 event.stopPropagation(); 830 this.dropzone.classList.remove( 'lapp-ai-booth__dropzone--dragover' ); 831 }; 832 833 LappAiBoothInstance.prototype.handleDrop = function ( event ) { 834 event.preventDefault(); 835 event.stopPropagation(); 836 this.dropzone.classList.remove( 'lapp-ai-booth__dropzone--dragover' ); 837 838 var files = event.dataTransfer && event.dataTransfer.files; 839 if ( files && files.length > 0 ) { 840 this.processFile( files[ 0 ] ); 841 } 842 }; 843 844 LappAiBoothInstance.prototype.handleDropzoneKeydown = function ( event ) { 845 if ( 'Enter' === event.key || ' ' === event.key || 'Spacebar' === event.key ) { 846 event.preventDefault(); 847 if ( this.fileInput ) { 848 this.fileInput.click(); 849 } 850 } 851 }; 852 853 LappAiBoothInstance.prototype.handleReplacePhoto = function () { 854 if ( this.uploadEmptyArea ) { 855 this.uploadEmptyArea.removeAttribute( 'hidden' ); 856 } 857 if ( this.uploadFilledArea ) { 858 this.uploadFilledArea.setAttribute( 'hidden', '' ); 859 } 860 this.hideValidationMessage(); 861 if ( this.fileInput ) { 862 this.fileInput.value = ''; 863 } 864 this.state.selectedFile = null; 865 this.updateUploadNextButtonState(); 866 }; 867 868 /** 869 * @param {File} file 870 */
871 LappAiBoothInstance.prototype.processFile = function ( file ) { 872 var self = this; 873 874 this.validateFile( file ) 875 .then( function ( result ) { 876 self.acceptFile( file, result.objectUrl ); 877 } ) 878 .catch( function ( error ) { 879 self.rejectFile( error && error.message ? error.message : config.i18n.genericError ); 880 } ); 881 }; 882 883 /** 884 * @param {File} file 885 * @return {Promise<{objectUrl: string}>} 886 */ 887 LappAiBoothInstance.prototype.validateFile = function ( file ) { 888 return new Promise( function ( resolve, reject ) { 889 if ( ! file ) { 890 reject( new Error( 'Please choose a photo to upload.' ) ); 891 return; 892 } 893 894 var extension = ( file.name || '' ).split( '.' ).pop().toLowerCase(); 895 var mimeOk = ALLOWED_MIME_TYPES.indexOf( file.type ) !== -1; 896 var extensionOk = ALLOWED_EXTENSIONS.indexOf( extension ) !== -1; 897 898 if ( ! mimeOk && ! ( '' === file.type && extensionOk ) ) { 899 reject( new Error( ERROR_MESSAGES.INVALID_IMAGE ) ); 900 return; 901 } 902 903 if ( ! extensionOk ) { 904 reject( new Error( ERROR_MESSAGES.INVALID_IMAGE ) ); 905 return; 906 } 907 908 if ( file.size > MAX_FILE_SIZE_BYTES ) { 909 reject( new Error( ERROR_MESSAGES.IMAGE_TOO_LARGE ) ); 910 return; 911 } 912 913 var objectUrl = URL.createObjectURL( file ); 914 var image = new Image(); 915 916 image.onload = function () { 917 var longEdge = Math.max( image.naturalWidth, image.naturalHeight ); 918 var shortEdge = Math.min( image.naturalWidth, image.naturalHeight ); 919 920 if ( longEdge > MAX_LONG_EDGE_PX || shortEdge > MAX_SHORT_EDGE_PX ) { 921 URL.revokeObjectURL( objectUrl ); 922 reject( new Error( ERROR_MESSAGES.IMAGE_TOO_LARGE ) ); 923 return; 924 } 925 926 resolve( { objectUrl: objectUrl } ); 927 }; 928 929 image.onerror = function () { 930 URL.revokeObjectURL( objectUrl ); 931 reject( new Error( 'This file could not be read as an image. Please try a different JPG or PNG.' ) ); 932 }; 933 934 image.src = objectUrl; 935 } ); 936 }; 937 938 /** 939 * @param {File} file 940 * @param {string} objectUrl 941 */ 942 LappAiBoothInstance.prototype.acceptFile = function ( file, objectUrl ) { 943 if ( this.state.objectUrl ) { 944 URL.revokeObjectURL( this.state.objectUrl ); 945 } 946 947 this.state.selectedFile = file; 948 this.state.objectUrl = objectUrl; 949 950 if ( this.previewImage ) { 951 this.previewImage.src = objectUrl; 952 } 953 954 if ( this.uploadEmptyArea ) { 955 this.uploadEmptyArea.setAttribute( 'hidden', '' ); 956 } 957 if ( this.uploadFilledArea ) { 958 this.uploadFilledArea.removeAttribute( 'hidden' ); 959 } 960 961 this.hideValidationMessage(); 962 this.updateUploadNextButtonState(); 963 this.announce( 'Photo uploaded successfully.' ); 964 }; 965 966 /** 967 * @param {string} message 968 */ 969 LappAiBoothInstance.prototype.rejectFile = function ( message ) { 970 if ( this.validationMessage ) { 971 this.validationMessage.textContent = message; 972 this.validationMessage.removeAttribute( 'hidden' ); 973 } 974 this.announce( message ); 975 976 if ( this.fileInput ) { 977 this.fileInput.value = ''; 978 } 979 }; 980 981 LappAiBoothInstance.prototype.hideValidationMessage = function () { 982 if ( this.validationMessage ) { 983 this.validationMessage.textContent = ''; 984 this.validationMessage.setAttribute( 'hidden', '' ); 985 } 986 }; 987 988 /** 989 * "Next" on the Upload screen is only enabled once a valid photo is 990 * selected AND the required processing consent is checked â consent now 991 * lives on this screen rather than a separate Review screen. 992 */ 993 LappAiBoothInstance.prototype.updateUploadNextButtonState = function () { 994 if ( ! this.uploadContinueButton ) { 995 return; 996 } 997 998 var hasFile = !! this.state.selectedFile; 999 var termsOk = !! ( this.consentTermsInput && this.consentTermsInput.checked ); 1000 1001this.uploadContinueButton.disabled = ! ( hasFile && termsOk ); 1002 }; 1003 1004 /* --------------------------------------------------------------------- 1005 Step 2: Options â area of interest + dependent prompt choice 1006 ------------------------------------------------------------------------ */ 1007 1008 LappAiBoothInstance.prototype.populateOptionsScreen = function () { 1009 this.updateGenerateButtonState(); 1010 }; 1011 1012 /** 1013 * Fetch the live template catalog once. The visitor sees the curated 1014 * category/subcategory controls; the catalog is used behind the scenes 1015 * to resolve the chosen prompt to the corresponding AiBoothHub template. 1016 */
1017 LappAiBoothInstance.prototype.loadTemplates = function () { 1018 var self = this; 1019 1020 this.setTemplatesState( 'loading' ); 1021 1022 this.fetchJson( config.restUrl + 'templates', { method: 'GET' } ) 1023 .then( function ( result ) { 1024 if ( ! result.ok || ! result.data || ! Array.isArray( result.data.templates ) ) { 1025 var code = result.data && result.data.error ? result.data.error.code : 'GENERATION_FAILED'; 1026 self.setTemplatesState( 'error', self.friendlyErrorMessage( code ) ); 1027 return; 1028 } 1029 1030 if ( 0 === result.data.templates.length ) { 1031 self.setTemplatesState( 'empty' ); 1032 return; 1033 } 1034 1035 self.state.templateCatalog = result.data.templates.slice(); 1036 self.state.templatesLoaded = true; 1037 self.setTemplatesState( 'loaded' ); 1038 1039 // Re-resolve if the visitor already made a selection while 1040 // the catalog was loading. 1041 if ( self.state.selectedPromptChoice ) { 1042 self.resolveSelectedPromptTemplate(); 1043 } 1044 } ) 1045 .catch( function () { 1046 self.setTemplatesState( 'error', config.i18n.genericError ); 1047 } ); 1048 }; 1049 1050 LappAiBoothInstance.prototype.setTemplatesState = function ( stateName, message ) { 1051 this.templatesLoadingEls.forEach( function ( el ) { 1052 el.hidden = 'loading' !== stateName; 1053 } ); 1054 1055 if ( this.templatesEmptyEl ) { 1056 this.templatesEmptyEl.hidden = 'empty' !== stateName; 1057 } 1058 1059 if ( this.templatesErrorEl ) { 1060 this.templatesErrorEl.hidden = 'error' !== stateName; 1061 if ( 'error' === stateName ) { 1062 this.templatesErrorEl.textContent = message || config.i18n.genericError; 1063 } 1064 } 1065 1066 if ( this.templatesRetryButton ) { 1067 this.templatesRetryButton.hidden = 'error' !== stateName; 1068 } 1069 1070 if ( 'error' === stateName && message ) { 1071 this.announce( message ); 1072 } 1073 1074 this.updateGenerateButtonState(); 1075 }; 1076 1077 LappAiBoothInstance.prototype.handleInterestChange = function () { 1078 var category = this.interestSelect ? this.interestSelect.value : ''; 1079 this.state.selectedInterest = category; 1080 this.state.selectedPromptChoice = ''; 1081 this.clearSelectedTemplate(); 1082 1083 if ( ! this.promptSelect || ! this.promptField ) { 1084 return; 1085 } 1086 1087 while ( this.promptSelect.options.length > 1 ) { 1088 this.promptSelect.remove( 1 ); 1089 } 1090 this.promptSelect.value = ''; 1091 1092 if ( ! category || ! PROMPT_GROUPS[ category ] ) { 1093 this.promptField.setAttribute( 'hidden', '' ); 1094 this.updateGenerateButtonState(); 1095 return; 1096 } 1097 1098 var group = PROMPT_GROUPS[ category ]; 1099 if ( this.promptLabel ) { 1100 this.promptLabel.textContent = group.label; 1101 } 1102 1103 group.options.forEach( function ( item ) { 1104 var option = document.createElement( 'option' ); 1105 option.value = item[ 0 ]; 1106 option.textContent = item[ 1 ]; 1107 this.promptSelect.appendChild( option ); 1108 }, this ); 1109 1110 this.promptField.removeAttribute( 'hidden' ); 1111 this.promptField.style.removeProperty( 'display' ); 1112 this.updateGenerateButtonState(); 1113 }; 1114 1115 LappAiBoothInstance.prototype.handlePromptChoiceChange = function () { 1116 this.state.selectedPromptChoice = this.promptSelect ? this.promptSelect.value : ''; 1117 this.clearSelectedTemplate(); 1118 1119 if ( ! this.state.selectedPromptChoice ) { 1120 this.updateGenerateButtonState(); 1121 return; 1122 } 1123 1124 this.resolveSelectedPromptTemplate(); 1125 }; 1126 1127 LappAiBoothInstance.prototype.clearSelectedTemplate = function () { 1128 this.state.selectedTemplateKey = null; 1129 this.state.selectedTemplateName = null; 1130 this.state.selectedTemplateDescription = null; 1131 this.state.selectedTemplateThumbnailUrl = null; 1132 }; 1133 1134 LappAiBoothInstance.prototype.normaliseTemplateToken = function ( value ) { 1135 return ( value || '' ) 1136 .toLowerCase() 1137 .replace( /&/g, 'and' ) 1138 .replace( /1990['â]?s/g, '1990s' ) 1139 .replace( /80['â]?s/g, '80s' ) 1140 .replace( /[^a-z0-9]+/g, '-' ) 1141 .replace( /^-+|-+$/g, '' ); 1142 }; 1143 1144 LappAiBoothInstance.prototype.getSelectedPromptLabel = function () { 1145 if ( ! this.promptSelect || ! this.promptSelect.value ) { 1146 return ''; 1147 } 1148 var option = this.promptSelect.options[ this.promptSelect.selectedIndex ]; 1149 return option ? option.textContent : ''; 1150 }; 1151 1152 /** 1153 * Match the curated choice to a live template without hard-coding 1154 * AiBoothHub's internal template key. This accepts common key shapes 1155 * such as "country", "music-country", or displayName "Country". 1156 */
1157 LappAiBoothInstance.prototype.resolveSelectedPromptTemplate = function () { 1158 var category = this.state.selectedInterest; 1159 var choice = this.state.selectedPromptChoice; 1160 var label = this.getSelectedPromptLabel(); 1161 1162 if ( ! category || ! choice || ! this.state.templatesLoaded ) { 1163 this.updateGenerateButtonState(); 1164 return; 1165 } 1166 1167 var choiceToken = this.normaliseTemplateToken( choice ); 1168 var labelToken = this.normaliseTemplateToken( label ); 1169 var categoryToken = this.normaliseTemplateToken( category ); 1170 var combined = categoryToken + '-' + choiceToken; 1171 var catalog = this.state.templateCatalog || []; 1172 var match = null; 1173 1174 for ( var i = 0; i < catalog.length; i++ ) { 1175 var template = catalog[ i ]; 1176 var keyToken = this.normaliseTemplateToken( template.key ); 1177 var nameToken = this.normaliseTemplateToken( template.displayName ); 1178 1179 if ( 1180 keyToken === choiceToken || 1181 keyToken === labelToken || 1182 keyToken === combined || 1183 nameToken === choiceToken || 1184 nameToken === labelToken || 1185 nameToken === combined 1186 ) { 1187 match = template; 1188 break; 1189 } 1190 } 1191 1192 // Fallback for descriptive template names/keys that include the 1193 // selected choice (e.g. "Country Music Portrait"). 1194 if ( ! match ) { 1195 for ( var j = 0; j < catalog.length; j++ ) { 1196 var candidate = catalog[ j ]; 1197 var candidateKey = this.normaliseTemplateToken( candidate.key ); 1198 var candidateName = this.normaliseTemplateToken( candidate.displayName ); 1199 if ( 1200 ( choiceToken.length >= 4 && candidateKey.indexOf( choiceToken ) !== -1 ) || 1201 ( choiceToken.length >= 4 && candidateName.indexOf( choiceToken ) !== -1 ) 1202 ) { 1203 match = candidate; 1204 break; 1205 } 1206 } 1207 } 1208 1209 if ( match ) { 1210 this.state.selectedTemplateKey = match.key; 1211 this.state.selectedTemplateName = label || match.displayName || ''; 1212 this.state.selectedTemplateDescription = match.description || ''; 1213 this.state.selectedTemplateThumbnailUrl = match.thumbnailUrl || ''; 1214 if ( this.templatesErrorEl ) { 1215 this.templatesErrorEl.hidden = true; 1216 this.templatesErrorEl.textContent = ''; 1217 } 1218 this.announce( ( label || match.displayName ) + ' selected.' ); 1219 } else { 1220 this.clearSelectedTemplate(); 1221 if ( this.templatesErrorEl ) { 1222 this.templatesErrorEl.textContent = 'This option is not available from the AI service yet. Please choose another option.'; 1223 this.templatesErrorEl.hidden = false; 1224 } 1225 } 1226 1227 this.updateGenerateButtonState(); 1228 }; 1229 1230 LappAiBoothInstance.prototype.updateGenerateButtonState = function () { 1231 if ( ! this.generateButton ) { 1232 return; 1233 } 1234 1235 var promptOk = !! ( 1236 this.state.selectedInterest && 1237 this.state.selectedPromptChoice && 1238 this.state.selectedTemplateKey && 1239 this.state.templatesLoaded 1240 ); 1241 1242 this.generateButton.disabled = ! promptOk || this.state.isSubmitting; 1243 }; 1244 1245 /* --------------------------------------------------------------------- 1246 Step 3: Generate + real polling 1247 ------------------------------------------------------------------------ */ 1248 1249 LappAiBoothInstance.prototype.handleOptionsContinue = function () { 1250 if ( 1251 ! this.state.selectedFile || 1252 ! this.state.selectedTemplateKey || 1253 ! this.state.selectedInterest || 1254 ! this.state.selectedPromptChoice 1255 ) { 1256 return; 1257 } 1258 1259 this.showProcessingForm(); 1260 this.goToScreen( 'processing' ); 1261 this.startCaptchaWatcher(); 1262 }; 1263 1264 1265 1266 LappAiBoothInstance.prototype.getCaptchaToken = function () { 1267 if ( ! this.captchaWrapEl ) { 1268 return ''; 1269 } 1270 1271 var responseInput = this.captchaWrapEl.querySelector( '[name="cf-turnstile-response"]' ); 1272 return responseInput ? ( responseInput.value || '' ).trim() : ''; 1273 }; 1274 1275 LappAiBoothInstance.prototype.refreshCaptchaState = function () { 1276 this.state.captchaToken = this.getCaptchaToken(); 1277 this.updateProcessingSubmitState(); 1278 }; 1279 1280 LappAiBoothInstance.prototype.resetCaptcha = function () { 1281 this.state.captchaToken = ''; 1282 1283 // The existing Turnstile plugin owns the widget. If Cloudflare's 1284 // global reset API is available, reset all widgets so a used token 1285 // is not accidentally re-submitted. 1286 if ( window.turnstile && 'function' === typeof window.turnstile.reset ) { 1287 try { 1288 window.turnstile.reset(); 1289 } catch ( error ) { 1290 // The external plugin will re-render/reset on its own if needed. 1291 } 1292 } 1293 1294 this.hideCaptchaError(); 1295 this.updateProcessingSubmitState(); 1296 }; 1297 1298 LappAiBoothInstance.prototype.showCaptchaError = function ( message ) { 1299 if ( this.captchaErrorEl ) { 1300 this.captchaErrorEl.textContent = message; 1301 this.captchaErrorEl.removeAttribute( 'hidden' ); 1302 } 1303 this.announce( message ); 1304 }; 1305 1306 LappAiBoothInstance.prototype.hideCaptchaError = function () { 1307 if ( this.captchaErrorEl ) { 1308 this.captchaErrorEl.textContent = ''; 1309 this.captchaErrorEl.setAttribute( 'hidden', '' ); 1310 } 1311 }; 1312 1313 LappAiBoothInstance.prototype.startCaptchaWatcher = function () { 1314 var self = this; 1315 1316 this.stopCaptchaWatcher(); 1317 this.refreshCaptchaState(); 1318 1319 if ( ! config.captchaEnabled ) { 1320 return; 1321 } 1322 1323 this.state.captchaStateIntervalId = window.setInterval( function () { 1324 if ( 'processing' !== self.state.currentScreen || self.state.isSubmitting ) { 1325 return; 1326 } 1327 self.refreshCaptchaState(); 1328 }, 400 ); 1329 }; 1330 1331 LappAiBoothInstance.prototype.stopCaptchaWatcher = function () { 1332 if ( this.state.captchaStateIntervalId ) { 1333 window.clearInterval( this.state.captchaStateIntervalId ); 1334 this.state.captchaStateIntervalId = null; 1335 } 1336 }; 1337 1338 LappAiBoothInstance.prototype.isEmailFormatValid = function ( value ) { 1339 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( ( value || '' ).trim() ); 1340 }; 1341 1342 LappAiBoothInstance.prototype.handleContactInput = function () { 1343 this.state.firstName = this.firstNameInput ? this.firstNameInput.value.trim() : ''; 1344 this.state.lastName = this.lastNameInput ? this.lastNameInput.value.trim() : ''; 1345 this.state.email = this.emailInput ? this.emailInput.value.trim() : ''; 1346 this.hideEmailError(); 1347 this.updateProcessingSubmitState(); 1348 }; 1349 1350 LappAiBoothInstance.prototype.updateProcessingSubmitState = function () { 1351 if ( ! this.processingSubmitButton ) { 1352 return; 1353 } 1354 var emailOk = !! ( this.emailInput && this.isEmailFormatValid( this.emailInput.value ) ); 1355 this.state.captchaToken = this.getCaptchaToken(); 1356 var captchaOk = ! config.captchaEnabled || !! this.state.captchaToken; 1357 this.processingSubmitButton.disabled = ! emailOk || ! captchaOk || this.state.isSubmitting; 1358 }; 1359 1360 LappAiBoothInstance.prototype.showEmailError = function ( message ) { 1361 if ( this.emailErrorEl ) { 1362 this.emailErrorEl.textContent = message; 1363 this.emailErrorEl.removeAttribute( 'hidden' ); 1364 } 1365 this.announce( message ); 1366 }; 1367 1368 LappAiBoothInstance.prototype.hideEmailError = function () { 1369 if ( this.emailErrorEl ) { 1370 this.emailErrorEl.textContent = ''; 1371 this.emailErrorEl.setAttribute( 'hidden', '' ); 1372 } 1373 }; 1374 1375 LappAiBoothInstance.prototype.showProcessingForm = function () { 1376 this.stopMessageRotation(); 1377 this.clearPollTimer(); 1378 1379 if ( this.processingFormEl ) { 1380 this.processingFormEl.removeAttribute( 'hidden' ); 1381 } 1382 if ( this.processingActiveEl ) { 1383 this.processingActiveEl.setAttribute( 'hidden', '' ); 1384 } 1385 if ( this.processingTimeoutEl ) { 1386 this.processingTimeoutEl.setAttribute( 'hidden', '' ); 1387 } 1388 this.updateProcessingSubmitState(); 1389 }; 1390 1391 /** 1392 * @return {string} 1393 */ 1394 LappAiBoothInstance.prototype.generateIdempotencyKey = function () { 1395 if ( window.crypto && 'function' === typeof window.crypto.randomUUID ) { 1396 return window.crypto.randomUUID(); 1397 } 1398 1399 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( /[xy]/g, function ( c ) { 1400 var r = ( Math.random() * 16 ) | 0; 1401 var v = 'x' === c ? r : ( r & 0x3 ) | 0x8; 1402 return v.toString( 16 ); 1403 } ); 1404 }; 1405 1406 LappAiBoothInstance.prototype.handleGenerateClick = function () { 1407 if ( this.state.isSubmitting ) { 1408 return; 1409 } 1410 1411 var emailValue = this.emailInput ? this.emailInput.value.trim() : ''; 1412 if ( ! this.isEmailFormatValid( emailValue ) ) { 1413 this.showEmailError( ERROR_MESSAGES.EMAIL_INVALID ); 1414 return; 1415 } 1416 1417 this.state.captchaToken = this.getCaptchaToken(); 1418 1419 if ( config.captchaEnabled && ! this.state.captchaToken ) { 1420 this.showCaptchaError( ERROR_MESSAGES.CAPTCHA_FAILED ); 1421 return; 1422 } 1423 1424 if ( 1425 ! this.state.selectedFile || 1426 ! this.state.selectedTemplateKey || 1427 ! this.state.selectedInterest || 1428 ! this.state.selectedPromptChoice 1429 ) { 1430 return; 1431 } 1432 1433 this.state.firstName = this.firstNameInput ? this.firstNameInput.value.trim() : ''; 1434 this.state.lastName = this.lastNameInput ? this.lastNameInput.value.trim() : ''; 1435 this.state.email = emailValue; 1436 this.state.isSubmitting = true; 1437 this.stopCaptchaWatcher(); 1438 this.updateGenerateButtonState(); 1439 this.updateProcessingSubmitState(); 1440 1441 if ( this.processingFormEl ) { 1442 this.processingFormEl.setAttribute( 'hidden', '' ); 1443 } 1444 if ( this.processingImage && this.state.objectUrl ) { 1445 this.processingImage.src = this.state.objectUrl; 1446 } 1447 if ( this.processingTemplateName ) { 1448 this.processingTemplateName.textContent = this.state.selectedTemplateName || this.get
1448SelectedPromptLabel(); 1449 } 1450 1451 this.showProcessingActive(); 1452 this.startMessageRotation(); 1453 1454 var formData = new FormData(); 1455 formData.append( 'templateKey', this.state.selectedTemplateKey ); 1456 formData.append( 'image', this.state.selectedFile ); 1457 formData.append( 'marketingConsent', 'false' ); 1458 formData.append( 'promptCategory', this.state.selectedInterest ); 1459 formData.append( 'promptChoice', this.state.selectedPromptChoice ); 1460 formData.append( 'email', emailValue ); 1461 formData.append( 'captchaToken', this.state.captchaToken || '' ); 1462 1463 var self = this; 1464 var idempotencyKey = this.generateIdempotencyKey(); 1465 1466 this.fetchJson( config.restUrl + 'jobs', { 1467 method: 'POST', 1468 headers: { 'Idempotency-Key': idempotencyKey }, 1469 body: formData 1470 } ) 1471 .then( function ( result ) { 1472 if ( ! result.ok || ! result.data || ! result.data.jobId ) { 1473 var code = result.data && result.data.error ? result.data.error.code : 'GENERATION_FAILED'; 1474 self.resetCaptcha(); 1475 self.failGeneration( code ); 1476 return; 1477 } 1478 1479 self.state.jobId = result.data.jobId; 1480 self.state.pollStartedAt = Date.now(); 1481 self.pollJob( result.data.pollAfterMs || DEFAULT_POLL_AFTER_MS ); 1482 } ) 1483 .catch( function () { 1484 self.failGeneration( 'GENERATION_FAILED' ); 1485 } ); 1486 }; 1487 1488 /** 1489 * @param {number} delayMs 1490 */ 1491 LappAiBoothInstance.prototype.pollJob = function ( delayMs ) { 1492 var self = this; 1493 1494 if ( this.state.pollTimeoutId ) { 1495 window.clearTimeout( this.state.pollTimeoutId ); 1496 } 1497 1498 this.state.pollTimeoutId = window.setTimeout( function () { 1499 var elapsed = Date.now() - self.state.pollStartedAt; 1500 1501 if ( elapsed >= VISIBLE_POLL_LIMIT_MS ) { 1502 self.showProcessingTimeout(); 1503 return; 1504 } 1505 1506 self.checkJobOnce().then( function ( outcome ) { 1507 if ( 'pending' === outcome.status ) { 1508 self.pollJob( outcome.pollAfterMs || DEFAULT_POLL_AFTER_MS ); 1509 } 1510 } ); 1511 }, delayMs ); 1512 }; 1513 1514 /** 1515 * @return {Promise<{status: string, pollAfterMs?: number}>} 1516 */ 1517 LappAiBoothInstance.prototype.checkJobOnce = function () { 1518 var self = this; 1519 1520 if ( ! this.state.jobId ) { 1521 return Promise.resolve( { status: 'pending' } ); 1522 } 1523 1524 return this.fetchJson( config.restUrl + 'jobs/' + this.state.jobId, { method: 'GET' } ) 1525 .then( function ( result ) { 1526 if ( ! result.ok || ! result.data || ! result.data.status ) { 1527 return { status: 'pending', pollAfterMs: DEFAULT_POLL_AFTER_MS }; 1528 } 1529 1530 if ( 'completed' === result.data.status ) { 1531 self.completeGeneration(); 1532 return { status: 'completed' }; 1533 } 1534 1535 if ( 'failed' === result.data.status ) { 1536 var code = result.data.error ? result.data.error.code : 'GENERATION_FAILED'; 1537 self.failGeneration( code ); 1538 return { status: 'failed' }; 1539 } 1540 1541 return { status: 'pending', pollAfterMs: result.data.pollAfterMs }; 1542 } ) 1543 .catch( function () { 1544 return { status: 'pending', pollAfterMs: DEFAULT_POLL_AFTER_MS }; 1545 } ); 1546 }; 1547 1548 LappAiBoothInstance.prototype.handleCheckAgain = function () { 1549 var self = this; 1550 1551 this.checkJobOnce().then( function ( outcome ) { 1552 if ( 'pending' === outcome.status ) { 1553 self.state.pollStartedAt = Date.now(); 1554 self.showProcessingActive(); 1555 self.pollJob( outcome.pollAfterMs || DEFAULT_POLL_AFTER_MS ); 1556 } 1557 } ); 1558 }; 1559 1560 LappAiBoothInstance.prototype.showProcessingActive = function () { 1561 if ( this.processingFormEl ) { 1562 this.processingFormEl.setAttribute( 'hidden', '' ); 1563 } 1564 if ( this.processingActiveEl ) { 1565 this.processingActiveEl.removeAttribute( 'hidden' ); 1566 } 1567 if ( this.processingTimeoutEl ) { 1568 this.processingTimeoutEl.setAttribute( 'hidden', '' ); 1569 } 1570 }; 1571 1572 LappAiBoothInstance.prototype.showProcessingTimeout = function () { 1573 this.stopMessageRotation(); 1574 1575 if ( this.processingActiveEl ) { 1576 this.processingActiveEl.setAttribute( 'hidden', '' ); 1577 } 1578 if ( this.processingTimeoutEl ) { 1579 this.processingTimeoutEl.removeAttribute( 'hidden' ); 1580 } 1581 1582 this.announce( 'This is taking longer than expected. Your image may still be processing.' ); 1583 }; 1584 1585 LappAiBoothInstance.prototype.startMessageRotation = function () { 1586 var self = this; 1587 var index = 0; 1588 1589 this.stopMessageRotation(); 1590 1591 if ( this.processingMessage ) { 1592 this.processingMessage.textContent = PROCESSING_MESSAGES[ 0 ]; 1593 } 1594 1595 this.state.messageRotateIntervalId = window.setInterval( function () { 1596 index = ( index + 1 ) % PROCESSING_MESSAGES.length; 1597 if ( self.processingMessage ) { 1598 self.processingMessage.textContent = PROCESSING_MESSAGES[ index ]; 1599 } 1600 }, PROCESSING_MESSAGE_ROTATE_MS ); 1601 }; 1602 1603 LappAiBoothInstance.prototype.stopMessageRotation = function () { 1604 if ( this.state.messageRotateIntervalId ) { 1605 window.clearInterval( this.state.messageRotateIntervalId ); 1606 this.state.messageRotateIntervalId = null; 1607 } 1608 }; 1609 1610 LappAiBoothInstance.prototype.clearPollTimer = function () { 1611 if ( this.state.pollTimeoutId ) { 1612 window.clearTimeout( this.state.pollTimeoutId ); 1613 this.state.pollTimeoutId = null; 1614 } 1615 }; 1616 1617 /* --------------------------------------------------------------------- 1618 Step 4: Result 1619 ------------------------------------------------------------------------ */ 1620 1621 LappAiBoothInstance.prototype.completeGeneration = function () { 1622 var self = this; 1623 1624 this.clearPollTimer(); 1625 this.stopMessageRotation(); 1626 this.stopCaptchaWatcher(); 1627 1628 fetch( config.restUrl + 'jobs/' + this.state.jobId + '/result', { headers: this.restHeaders() } ) 1629 .then( function ( response ) { 1630 if ( ! response.ok ) { 1631 throw new Error( 'result-fetch-failed' ); 1632 } 1633 return response.blob(); 1634 } ) 1635 .then( function ( blob ) { 1636 if ( self.state.resultObjectUrl ) { 1637 URL.revokeObjectURL( self.state.resultObjectUrl ); 1638 } 1639 1640 var objectUrl = URL.createObjectURL( blob ); 1641 self.state.resultObjectUrl = objectUrl; 1642 1643 if ( self.resultImage ) { 1644 self.resultImage.src = objectUrl; 1645 } 1646 1647 self.state.isSubmitting = false;
1648 self.goToScreen( 'result' ); 1649 } ) 1650 .catch( function () { 1651 self.failGeneration( 'GENERATION_FAILED' ); 1652 } ); 1653 }; 1654 1655 /** 1656 * @param {string} code 1657 */ 1658 LappAiBoothInstance.prototype.failGeneration = function ( code ) { 1659 this.clearPollTimer(); 1660 this.stopMessageRotation(); 1661 this.state.isSubmitting = false; 1662 1663 this.updateGenerateButtonState(); 1664 1665 if ( 'RATE_LIMITED' === code ) { 1666 this.showRateLimitError(); 1667 return; 1668 } 1669 1670 this.showError( 'Something Went Wrong', this.friendlyErrorMessage( code ), 'options' ); 1671 }; 1672 1673 LappAiBoothInstance.prototype.handleDownload = function () { 1674 if ( ! this.state.resultObjectUrl ) { 1675 return; 1676 } 1677 1678 var link = document.createElement( 'a' ); 1679 link.href = this.state.resultObjectUrl; 1680 link.download = 'lapp-ai-photo-result.jpg'; 1681 document.body.appendChild( link ); 1682 link.click(); 1683 document.body.removeChild( link ); 1684 }; 1685 1686 LappAiBoothInstance.prototype.handleDone = function () { 1687 var doneEvent = new CustomEvent( 'lappAiBoothDone', { 1688 bubbles: true, 1689 detail: { boothId: this.root.getAttribute( 'data-lapp-ai-booth' ) } 1690 } ); 1691 this.root.dispatchEvent( doneEvent ); 1692 1693 this.resetFlow(); 1694 this.closeModal(); 1695 }; 1696 1697 /* --------------------------------------------------------------------- 1698 Reusable error state 1699 ------------------------------------------------------------------------ */ 1700 1701 LappAiBoothInstance.prototype.configureErrorScreen = function ( mode ) { 1702 this.state.errorMode = mode || 'generic'; 1703 1704 if ( this.errorTryAgainButton ) { 1705 if ( 'rate-limit' === this.state.errorMode ) { 1706 this.errorTryAgainButton.setAttribute( 'hidden', '' ); 1707 } else { 1708 this.errorTryAgainButton.removeAttribute( 'hidden' ); 1709 this.errorTryAgainButton.textContent = 'Try Again'; 1710 } 1711 } 1712 1713 if ( this.errorStartOverButton ) { 1714 this.errorStartOverButton.textContent = 'rate-limit' === this.state.errorMode ? 'Close' : 'Start Over'; 1715 } 1716 }; 1717 1718 /** 1719 * @param {string} title 1720 * @param {string} message 1721 * @param {string} returnToScreen 1722 */ 1723 LappAiBoothInstance.prototype.showError = function ( title, message, returnToScreen ) { 1724 this.clearPollTimer(); 1725 this.stopMessageRotation(); 1726 this.state.isSubmitting = false; 1727 this.state.errorReturnScreen = returnToScreen || 'upload'; 1728 this.configureErrorScreen( 'generic' ); 1729 1730 if ( this.errorTitle && title ) { 1731 this.errorTitle.textContent = title; 1732 } 1733 if ( this.errorMessage && message ) { 1734 this.errorMessage.textContent = message; 1735 } 1736 1737 this.goToScreen( 'error' ); 1738 }; 1739 1740 LappAiBoothInstance.prototype.showRateLimitError = function () { 1741 this.clearPollTimer(); 1742 this.stopMessageRotation(); 1743 this.state.isSubmitting = false; 1744 this.state.errorReturnScreen = 'upload'; 1745 this.state.errorMode = 'generic'; 1746 this.configureErrorScreen( 'rate-limit' ); 1747 1748 if ( this.errorTitle ) { 1749 this.errorTitle.textContent = 'Try Again Later'; 1750 } 1751 if ( this.errorMessage ) { 1752 this.errorMessage.textContent = 'Please try again later.'; 1753 } 1754 1755 this.goToScreen( 'error' ); 1756 }; 1757 1758 /* --------------------------------------------------------------------- 1759 Reset 1760 ------------------------------------------------------------------------ */ 1761 1762 LappAiBoothInstance.prototype.resetFlow = function () { 1763 this.clearPollTimer(); 1764 this.stopMessageRotation(); 1765 1766 if ( this.state.objectUrl ) { 1767 URL.revokeObjectURL( this.state.objectUrl ); 1768 } 1769 if ( this.state.resultObjectUrl ) { 1770 URL.revokeObjectURL( this.state.resultObjectUrl ); 1771 } 1772 1773 this.state.selectedFile = null; 1774 this.state.objectUrl = null; 1775 this.state.resultObjectUrl = null; 1776 this.state.selectedTemplateKey = null; 1777 this.state.selectedTemplateName = null; 1778 this.state.selectedTemplateDescription = null; 1779 this.state.selectedTemplateThumbnailUrl = null; 1780 this.state.firstName = ''; 1781 this.state.lastName = ''; 1782 this.state.email = ''; 1783 this.state.captchaToken = ''; 1784 this.state.jobId = null; 1785 this.state.pollStartedAt = null; 1786 this.state.isSubmitting = false; 1787 this.state.errorReturnScreen = 'upload'; 1788 1789 if ( this.fileInput ) { 1790 this.fileInput.value = ''; 1791 } 1792 if ( this.previewImage ) { 1793 this.previewImage.src = ''; 1794 } 1795 if ( this.processingImage ) { 1796 this.processingImage.src = ''; 1797 } 1798 if ( this.resultImage ) { 1799 this.resultImage.src = ''; 1800 } 1801 1802 if ( this.uploadEmptyArea ) { 1803 this.uploadEmptyArea.removeAttribute( 'hidden' ); 1804 } 1805 if ( this.uploadFilledArea ) { 1806 this.uploadFilledArea.setAttribute( 'hidden', '' ); 1807 } 1808 this.hideValidationMessage(); 1809 1810 if ( this.consentRequiredInput ) { 1811 this.consentRequiredInput.checked = false;
1812 } 1813 if ( this.consentTermsInput ) { 1814 this.consentTermsInput.checked = false; 1815 } 1816 this.updateUploadNextButtonState(); 1817 1818 this.state.selectedInterest = ''; 1819 this.state.selectedPromptChoice = ''; 1820 this.state.templateCatalog = this.state.templateCatalog || []; 1821 1822 if ( this.interestSelect ) { 1823 this.interestSelect.value = ''; 1824 } 1825 if ( this.promptSelect ) { 1826 while ( this.promptSelect.options.length > 1 ) { 1827 this.promptSelect.remove( 1 ); 1828 } 1829 this.promptSelect.value = ''; 1830 } 1831 if ( this.promptField ) { 1832 this.promptField.hidden = true; 1833 } 1834 if ( this.templatesErrorEl ) { 1835 this.templatesErrorEl.hidden = true; 1836 this.templatesErrorEl.textContent = ''; 1837 } 1838 this.updateGenerateButtonState(); 1839 1840 if ( this.firstNameInput ) { 1841 this.firstNameInput.value = ''; 1842 } 1843 if ( this.lastNameInput ) { 1844 this.lastNameInput.value = ''; 1845 } 1846 if ( this.emailInput ) { 1847 this.emailInput.value = ''; 1848 } 1849 this.hideEmailError(); 1850 this.resetCaptcha(); 1851 this.showProcessingForm(); 1852 this.resetGalleries(); 1853 1854 this.goToScreen( 'upload' ); 1855 }; 1856 1857 /* --------------------------------------------------------------------- 1858 Bootstrapping: initialize every instance on the page independently 1859 ------------------------------------------------------------------------ */ 1860 1861 function initAll() { 1862 var roots = document.querySelectorAll( '.lapp-ai-booth[data-lapp-ai-booth]' ); 1863 1864 for ( var i = 0; i < roots.length; i++ ) { 1865 var root = roots[ i ]; 1866 1867 if ( root.getAttribute( 'data-lapp-ai-initialized' ) === '1' ) { 1868 continue; 1869 } 1870 1871 try { 1872 var instance = new LappAiBoothInstance( root ); 1873 root.lappAiBoothInstance = instance; 1874 root.setAttribute( 'data-lapp-ai-initialized', '1' ); 1875 } catch ( error ) { 1876 root.removeAttribute( 'data-lapp-ai-initialized' ); 1877 console.error( 'LAPP AI Booth initialization failed', error, root ); 1878 } 1879 } 1880 } 1881 1882 if ( document.readyState === 'loading' ) { 1883 document.addEventListener( 'DOMContentLoaded', initAll ); 1884 } else { 1885 initAll(); 1886 } 1887 1888 if ( window.jQuery ) { 1889 window.jQuery( window ).on( 'elementor/popup/show', initAll ); 1890 } 1891} )();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.