diff --git a/assets/src/js/admin/ai-setup-wizard.js b/assets/src/js/admin/ai-setup-wizard.js index 9fb3f8e310..2bc5671d2c 100644 --- a/assets/src/js/admin/ai-setup-wizard.js +++ b/assets/src/js/admin/ai-setup-wizard.js @@ -4,6 +4,17 @@ var config = window.directoristAiSetup || {}; var i18n = config.i18n || {}; var maxRegenerations = 3; + var progressValue = 0; + var storageConfig = config.storage || {}; + var storageKey = + storageConfig.key || 'directorist-ai-setup-wizard-session-v1'; + var storageTtl = Math.max( + 0, + parseInt(storageConfig.ttl, 10) || 24 * 60 * 60 + ); + var currentScreen = 'prompt'; + var pendingOperation = ''; + var persistenceEnabled = true; var state = { prompt: '', @@ -115,6 +126,184 @@ return fallback; } + function getSessionStorage() { + try { + var storage = window.sessionStorage; + var testKey = storageKey + '-test'; + + storage.setItem(testKey, '1'); + storage.removeItem(testKey); + + return storage; + } catch (error) { + return null; + } + } + + function clearPersistedState() { + var storage = getSessionStorage(); + + if (storage) { + storage.removeItem(storageKey); + } + } + + function persistState() { + var storage = getSessionStorage(); + var snapshot; + + if (!persistenceEnabled || !storage) { + return; + } + + snapshot = { + version: 1, + savedAt: Date.now(), + screen: currentScreen, + pendingOperation: pendingOperation, + fieldsEditorOpen: $('#directorist-ai-setup-fields-editor').hasClass( + 'open' + ), + state: { + prompt: state.prompt, + setup: state.setup, + selected: state.selected, + regenerateCount: state.regenerateCount, + }, + }; + + try { + storage.setItem(storageKey, JSON.stringify(snapshot)); + } catch (error) { + // Storage can be disabled or full. The wizard must remain usable without it. + } + } + + function readPersistedState() { + var storage = getSessionStorage(); + var snapshot; + + if (!storage) { + return null; + } + + try { + snapshot = JSON.parse(storage.getItem(storageKey) || 'null'); + } catch (error) { + clearPersistedState(); + return null; + } + + if ( + !snapshot || + snapshot.version !== 1 || + !snapshot.savedAt || + (storageTtl && Date.now() - snapshot.savedAt > storageTtl * 1000) || + !snapshot.state || + typeof snapshot.state !== 'object' + ) { + clearPersistedState(); + return null; + } + + return snapshot; + } + + function restorePersistedState() { + var snapshot = readPersistedState(); + var restoredState; + var fieldCount; + var interruptedOperation; + var restoredScreen; + + if (!snapshot) { + return null; + } + + restoredState = snapshot.state; + state.prompt = + typeof restoredState.prompt === 'string' + ? restoredState.prompt + : ''; + state.setup = restoredState.setup + ? normalizeSetup(restoredState.setup) + : null; + + if (state.setup) { + if ( + Object.prototype.hasOwnProperty.call( + restoredState.setup, + 'directory_name' + ) + ) { + state.setup.directory_name = $.trim( + restoredState.setup.directory_name || '' + ); + } + + if (Array.isArray(restoredState.setup.categories)) { + state.setup.categories = restoredState.setup.categories + .map(function (category) { + return $.trim(category || ''); + }) + .filter(Boolean); + } + } + + fieldCount = state.setup ? state.setup.fields.length : 0; + state.selected = Array.isArray(restoredState.selected) + ? restoredState.selected.filter(function (index) { + return ( + typeof index === 'number' && + isFinite(index) && + Math.floor(index) === index && + index >= 0 && + index < fieldCount && + !isLockedField(state.setup.fields[index]) + ); + }) + : []; + state.regenerateCount = Math.max( + 0, + Math.min( + maxRegenerations, + parseInt(restoredState.regenerateCount, 10) || 0 + ) + ); + + interruptedOperation = snapshot.pendingOperation || ''; + restoredScreen = + snapshot.screen === 'summary' && state.setup ? 'summary' : 'prompt'; + + if ( + interruptedOperation === 'regenerate' || + interruptedOperation === 'launch' + ) { + restoredScreen = state.setup ? 'summary' : 'prompt'; + } + + currentScreen = restoredScreen; + pendingOperation = ''; + + return { + screen: restoredScreen, + fieldsEditorOpen: + !!snapshot.fieldsEditorOpen && restoredScreen === 'summary', + notice: + interruptedOperation === 'launch' + ? t( + 'launchInterrupted', + 'The launch was interrupted by the reload. Check your listings before trying again.' + ) + : interruptedOperation + ? t( + 'requestInterrupted', + 'The request was interrupted by the reload. Your saved progress has been restored.' + ) + : '', + }; + } + function fieldTypeLabel(type) { type = type || 'text'; @@ -122,15 +311,15 @@ return typeLabels[type]; } - return type - .replace(/_/g, ' ') - .replace(/\b\w/g, function (letter) { - return letter.toUpperCase(); - }); + return type.replace(/_/g, ' ').replace(/\b\w/g, function (letter) { + return letter.toUpperCase(); + }); } function isLockedField(field) { - return field && (field.type === 'title' || field.type === 'description'); + return ( + field && (field.type === 'title' || field.type === 'description') + ); } function isSecondaryPhoneLabel(label) { @@ -144,12 +333,17 @@ } function normalizeFieldType(type, label) { - var rawType = $.trim(type || 'text').toLowerCase().replace(/[\s-]+/g, '_'); + var rawType = $.trim(type || 'text') + .toLowerCase() + .replace(/[\s-]+/g, '_'); var labelText = $.trim(label || ''); var labelKey = labelText.toLowerCase(); - var socialPattern = /\b(social|facebook|twitter|x profile|instagram|linkedin|youtube|tiktok|pinterest|snapchat)\b/i; + var socialPattern = + /\b(social|facebook|twitter|x profile|instagram|linkedin|youtube|tiktok|pinterest|snapchat)\b/i; - rawType = Object.prototype.hasOwnProperty.call(typeAliases, rawType) ? typeAliases[rawType] : rawType; + rawType = Object.prototype.hasOwnProperty.call(typeAliases, rawType) + ? typeAliases[rawType] + : rawType; if (socialPattern.test(labelText + ' ' + rawType)) { return 'social_info'; @@ -192,7 +386,9 @@ return; } - if (normalizeFieldType(field.type || '', field.label || '') === type) { + if ( + normalizeFieldType(field.type || '', field.label || '') === type + ) { count++; } }); @@ -213,7 +409,10 @@ return nextField; } - if (nextField.type === 'phone' && isSecondaryPhoneLabel(nextField.label)) { + if ( + nextField.type === 'phone' && + isSecondaryPhoneLabel(nextField.label) + ) { nextField.label = 'Phone'; } @@ -236,7 +435,9 @@ if ( type === 'phone' || type === 'phone2' || - /\b(phone|telephone|contact number|mobile)\b/i.test(field.label || '') + /\b(phone|telephone|contact number|mobile)\b/i.test( + field.label || '' + ) ) { result = index; return true; @@ -248,7 +449,11 @@ return result; } - function preserveRegeneratedSelectedFieldContext(newFields, existingFields, selectedFields) { + function preserveRegeneratedSelectedFieldContext( + newFields, + existingFields, + selectedFields + ) { var selectedField; var selectedType; var existingPhoneCount; @@ -258,12 +463,19 @@ var targetLabel; var targetIndex; - if (!Array.isArray(selectedFields) || selectedFields.length !== 1 || !newFields.length) { + if ( + !Array.isArray(selectedFields) || + selectedFields.length !== 1 || + !newFields.length + ) { return newFields; } selectedField = selectedFields[0]; - selectedType = normalizeFieldType(selectedField.type || '', selectedField.label || ''); + selectedType = normalizeFieldType( + selectedField.type || '', + selectedField.label || '' + ); if (selectedType !== 'phone' && selectedType !== 'phone2') { return newFields; @@ -272,7 +484,10 @@ existingPhoneCount = countFieldsOfType(existingFields, 'phone'); selectedPhoneCount = countFieldsOfType(selectedFields, 'phone'); hasUnselectedPhone = existingPhoneCount > selectedPhoneCount; - targetType = selectedType === 'phone2' && hasUnselectedPhone ? 'phone2' : 'phone'; + targetType = + selectedType === 'phone2' && hasUnselectedPhone + ? 'phone2' + : 'phone'; targetLabel = targetType === 'phone2' ? selectedField.label || 'Alternative Phone' @@ -325,7 +540,9 @@ normalized.push({ label: label, type: type, - group: $.trim(field.group || field.group_name || 'General Information'), + group: $.trim( + field.group || field.group_name || 'General Information' + ), options: Array.isArray(field.options) ? field.options : [], }); }); @@ -336,27 +553,31 @@ function dedupeLockedPresetFields(fields) { var seenLocked = {}; - return normalizeDependentPresetFields((fields || []).filter(function (field) { - var type = field && field.type; + return normalizeDependentPresetFields( + (fields || []).filter(function (field) { + var type = field && field.type; - if (!presetTypes[type]) { - return true; - } + if (!presetTypes[type]) { + return true; + } - if (seenLocked[type]) { - return false; - } + if (seenLocked[type]) { + return false; + } - seenLocked[type] = true; - return true; - })); + seenLocked[type] = true; + return true; + }) + ); } function normalizeSetup(setup) { setup = setup && typeof setup === 'object' ? setup : {}; var fields = normalizeFields(setup.fields || []); - var categories = Array.isArray(setup.categories) ? setup.categories : []; + var categories = Array.isArray(setup.categories) + ? setup.categories + : []; categories = categories .map(function (category) { @@ -370,18 +591,28 @@ return { directory_name: $.trim( - setup.directory_name || setup.name || setup.type || 'Business Directory' + setup.directory_name || + setup.name || + setup.type || + 'Business Directory' ), categories: categories, default_address: $.trim( - setup.default_address || setup.default_location || setup.location || '' + setup.default_address || + setup.default_location || + setup.location || + '' ), fields: fields, monetization: !!setup.monetization || !!setup.payment, data_sharing: - typeof setup.data_sharing === 'undefined' ? true : !!setup.data_sharing, + typeof setup.data_sharing === 'undefined' + ? true + : !!setup.data_sharing, demo_content: - typeof setup.demo_content === 'undefined' ? true : !!setup.demo_content, + typeof setup.demo_content === 'undefined' + ? true + : !!setup.demo_content, }; } @@ -397,11 +628,69 @@ }); } - function setProgress(width) { - $('#directorist-ai-setup-progress').css('width', width + '%'); + function prefersReducedMotion() { + return ( + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); } - function showScreen(screen) { + function setProgress(width, duration, callback) { + var target = Math.max(0, Math.min(100, width)); + var $progress = $('#directorist-ai-setup-progress'); + var $track = $('#directorist-ai-setup-progress-track'); + var animationDuration = prefersReducedMotion() ? 0 : duration || 0; + + $progress.stop(true, false); + + if (!animationDuration) { + progressValue = target; + $progress.css('width', target + '%'); + $track.attr('aria-valuenow', Math.round(target)); + + if (callback) { + callback(); + } + + return; + } + + $progress.animate( + { width: target + '%' }, + { + duration: animationDuration, + easing: 'linear', + step: function (value) { + progressValue = value; + $track.attr('aria-valuenow', Math.round(value)); + }, + complete: function () { + progressValue = target; + $track.attr('aria-valuenow', Math.round(target)); + + if (callback) { + callback(); + } + }, + } + ); + } + + function startGenerationProgress() { + setProgress(5, 220, function () { + setProgress(68, 12000); + }); + } + + function startLaunchProgress() { + setProgress(Math.max(progressValue, 78), 180, function () { + setProgress(96, 10000); + }); + } + + function showScreen(screen, skipPersist) { + currentScreen = screen; + $( '#directorist-ai-setup-screen-prompt, #directorist-ai-setup-loading, #directorist-ai-setup-screen-summary, #directorist-ai-setup-done' ).addClass('directorist-ai-setup__hidden'); @@ -412,33 +701,36 @@ $('#directorist-ai-setup-screen-prompt').removeClass( 'directorist-ai-setup__hidden' ); - setProgress(33); + $('#directorist-ai-setup-prompt').trigger('focus'); } if (screen === 'loading') { $('#directorist-ai-setup-loading').removeClass( 'directorist-ai-setup__hidden' ); - setProgress(50); } if (screen === 'summary') { $('#directorist-ai-setup-screen-summary').removeClass( 'directorist-ai-setup__hidden' ); - setProgress(75); } if (screen === 'done') { $('#directorist-ai-setup-done').removeClass( 'directorist-ai-setup__hidden' ); - setProgress(100); + } + + if (!skipPersist) { + persistState(); } } function showNotice(message) { - var $screen = $('.directorist-ai-setup__screen:not(.directorist-ai-setup__hidden)'); + var $screen = $( + '.directorist-ai-setup__screen:not(.directorist-ai-setup__hidden)' + ); $('.directorist-ai-setup__notice').remove(); @@ -461,16 +753,175 @@ if (loading) { $button.data('label', $.trim($button.text())); $button.prop('disabled', true).addClass('is-loading'); - $button.contents().filter(function () { - return this.nodeType === 3; - }).first().replaceWith(label + ' '); + $button + .contents() + .filter(function () { + return this.nodeType === 3; + }) + .first() + .replaceWith(label + ' '); return; } $button.prop('disabled', false).removeClass('is-loading'); - $button.contents().filter(function () { - return this.nodeType === 3; - }).first().replaceWith(($button.data('label') || '') + ' '); + $button + .contents() + .filter(function () { + return this.nodeType === 3; + }) + .first() + .replaceWith(($button.data('label') || '') + ' '); + } + + function setLocationStatus(type, message) { + $('#directorist-ai-setup-location-status') + .removeClass('is-success is-warning') + .addClass(type ? 'is-' + type : '') + .text(message || ''); + } + + function setLocationDetecting(detecting) { + var $button = $('#directorist-ai-setup-location-detect'); + var label = detecting + ? t('detectingLocation', 'Detecting your current location...') + : t('detectLocation', 'Use my current location'); + + $button + .prop('disabled', detecting) + .toggleClass('is-loading', detecting) + .attr('aria-busy', detecting ? 'true' : 'false') + .attr('aria-label', label) + .attr('title', label); + $('#directorist-ai-setup-location').attr( + 'aria-busy', + detecting ? 'true' : 'false' + ); + } + + function locationErrorMessage(error) { + if (window.isSecureContext === false) { + return t( + 'locationInsecure', + 'Location detection needs HTTPS or localhost. Open this admin page over HTTPS and try again.' + ); + } + + if (!error || typeof error.code === 'undefined') { + return t( + 'locationUnavailable', + 'Your current location is unavailable. Please try again.' + ); + } + + if (error.code === error.PERMISSION_DENIED) { + return t( + 'locationBlocked', + 'Location access is blocked. Allow it from your browser address bar, then try again.' + ); + } + + if (error.code === error.TIMEOUT) { + return t( + 'locationTimeout', + 'Location detection timed out. Please try again.' + ); + } + + return t( + 'locationUnavailable', + 'Your current location is unavailable. Please try again.' + ); + } + + function applyDetectedLocation(address, type, message) { + $('#directorist-ai-setup-location').val(address).trigger('input'); + setLocationStatus(type, message); + } + + function resolveCurrentAddress(latitude, longitude) { + var coordinateAddress = + latitude.toFixed(6) + ', ' + longitude.toFixed(6); + + ajax(config.actions.geocode, { + latitude: latitude, + longitude: longitude, + }) + .done(function (response) { + var address = + response && response.success && response.data + ? $.trim(response.data.address || '') + : ''; + + if (!address) { + applyDetectedLocation( + coordinateAddress, + 'warning', + t( + 'locationFallback', + 'Location detected, but the exact address could not be found. Coordinates were added instead.' + ) + ); + return; + } + + applyDetectedLocation( + address, + 'success', + t('locationDetected', 'Current address detected.') + ); + }) + .fail(function () { + applyDetectedLocation( + coordinateAddress, + 'warning', + t( + 'locationFallback', + 'Location detected, but the exact address could not be found. Coordinates were added instead.' + ) + ); + }) + .always(function () { + setLocationDetecting(false); + }); + } + + function detectCurrentLocation() { + setLocationStatus('', ''); + + if (!navigator.geolocation) { + setLocationStatus( + 'warning', + t( + 'locationUnsupported', + 'Your browser does not support location detection.' + ) + ); + return; + } + + if (window.isSecureContext === false) { + setLocationStatus('warning', locationErrorMessage()); + return; + } + + setLocationDetecting(true); + navigator.geolocation.getCurrentPosition( + function (position) { + resolveCurrentAddress( + position.coords.latitude, + position.coords.longitude + ); + }, + function (error) { + setLocationDetecting(false); + setLocationStatus('warning', locationErrorMessage(error)); + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 60000, + } + ); } function updateGenerateState() { @@ -483,15 +934,23 @@ var canRegenerate = state.selected.length > 0 && remaining > 0; $('#directorist-ai-setup-regenerate').prop('disabled', !canRegenerate); - $('#directorist-ai-setup-regenerate-label').text(t('regenerate', 'Regenerate')); + $('#directorist-ai-setup-regenerate-label').text( + t('regenerate', 'Regenerate') + ); $('#directorist-ai-setup-regenerate-note').text( remaining > 0 - ? sprintf(t('regenerateNote', 'You can regenerate fields %d more times.'), remaining) + ? sprintf( + t( + 'regenerateNote', + 'You can regenerate fields %d more times.' + ), + remaining + ) : t('regenerateDone', 'Regeneration limit reached.') ); } - function renderCategories() { + function renderCategories(focusAddButton) { var $wrap = $('#directorist-ai-setup-categories'); $wrap.empty(); @@ -516,11 +975,74 @@ $tag.appendTo($wrap); }); - $(' -

+

+