PageSourceSearch

https://laphotoparty.com/wp-content/plugins/lapp-ai-photo-booth/assets/js/ai-booth-prompt-flow.js

js laphotoparty.com collected 2026-10-02 06:10:29 UTC 57,403 bytes, 1,891 lines download raw bytes

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.