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); }); - $('', { + var $addButton = $('', { type: 'button', class: 'directorist-ai-setup__tag-add', text: t('addCategory', '+ Add category'), }).appendTo($wrap); + + if (focusAddButton) { + $addButton.trigger('focus'); + } + } + + function openCategoryInput($button) { + var $entry = $('', { + class: 'directorist-ai-setup__tag-entry', + }); + var $input = $('', { + type: 'text', + class: 'directorist-ai-setup__tag-input', + maxlength: 80, + autocomplete: 'off', + placeholder: t('categoryName', 'Category name'), + 'aria-label': t('categoryName', 'Category name'), + 'aria-describedby': 'directorist-ai-setup-category-feedback', + 'aria-keyshortcuts': 'Enter Escape', + }); + var $feedback = $('', { + class: 'directorist-ai-setup__tag-feedback', + id: 'directorist-ai-setup-category-feedback', + 'aria-live': 'polite', + }); + + $entry.append($input, $feedback); + $button.replaceWith($entry); + $input.trigger('focus'); + } + + function addCategoryFromInput($input) { + var category = $.trim($input.val() || ''); + var duplicate = state.setup.categories.some( + function (existingCategory) { + return ( + $.trim(String(existingCategory)).toLowerCase() === + category.toLowerCase() + ); + } + ); + var $feedback = $input.siblings('.directorist-ai-setup__tag-feedback'); + + if (!category) { + $input.addClass('is-invalid').attr('aria-invalid', 'true'); + $feedback.text(t('categoryRequired', 'Enter a category name.')); + return; + } + + if (duplicate) { + $input + .addClass('is-invalid') + .attr('aria-invalid', 'true') + .trigger('select'); + $feedback.text( + t('categoryExists', 'This category has already been added.') + ); + return; + } + + state.setup.categories.push(category); + renderCategories(true); + persistState(); } function renderFieldsSummary() { @@ -535,7 +1057,9 @@ : t('noFields', 'No fields selected yet.') ); - $('#directorist-ai-setup-fields-preview').text(names.slice(0, 8).join(', ')); + $('#directorist-ai-setup-fields-preview').text( + names.slice(0, 8).join(', ') + ); } function renderFields() { @@ -546,7 +1070,9 @@ var selected = state.selected.indexOf(index) !== -1; var locked = isLockedField(field); var $row = $('
', { - class: 'directorist-ai-setup__field-row' + (selected ? ' selected' : ''), + class: + 'directorist-ai-setup__field-row' + + (selected ? ' selected' : ''), }).attr('data-index', index); $('', { @@ -585,7 +1111,10 @@ function renderSummary() { $('#directorist-ai-setup-name').val(state.setup.directory_name); $('#directorist-ai-setup-location').val(state.setup.default_address); - $('#directorist-ai-setup-money').prop('checked', !!state.setup.monetization); + $('#directorist-ai-setup-money').prop( + 'checked', + !!state.setup.monetization + ); $('#directorist-ai-setup-data-sharing').prop( 'checked', !!state.setup.data_sharing @@ -594,6 +1123,8 @@ 'checked', !!state.setup.demo_content ); + setLocationStatus('', ''); + setLocationDetecting(false); renderCategories(); renderFieldsSummary(); @@ -601,11 +1132,26 @@ } function syncSetupFromForm() { - state.setup.directory_name = $.trim($('#directorist-ai-setup-name').val() || ''); - state.setup.default_address = $.trim($('#directorist-ai-setup-location').val() || ''); - state.setup.monetization = $('#directorist-ai-setup-money').is(':checked'); - state.setup.data_sharing = $('#directorist-ai-setup-data-sharing').is(':checked'); - state.setup.demo_content = $('#directorist-ai-setup-demo-content').is(':checked'); + if (!state.setup) { + return; + } + + state.setup.directory_name = $.trim( + $('#directorist-ai-setup-name').val() || '' + ); + state.setup.default_address = $.trim( + $('#directorist-ai-setup-location').val() || '' + ); + state.setup.monetization = $('#directorist-ai-setup-money').is( + ':checked' + ); + state.setup.data_sharing = $('#directorist-ai-setup-data-sharing').is( + ':checked' + ); + state.setup.demo_content = $('#directorist-ai-setup-demo-content').is( + ':checked' + ); + persistState(); } function extractSetupPayload(response) { @@ -639,41 +1185,68 @@ } state.prompt = prompt; + pendingOperation = 'generate'; setButtonLoading($button, true, 'Generating...'); showScreen('loading'); + startGenerationProgress(); ajax(config.actions.generate, { prompt: prompt, }) .done(function (response) { if (!response || !response.success) { + pendingOperation = ''; showScreen('prompt'); - showNotice(getMessage(response, t('generateError', 'Could not generate setup data. Please try again.'))); - return; - } - - state.setup = normalizeSetup(extractSetupPayload(response)); - state.selected = []; - state.regenerateCount = 0; - - renderSummary(); - showScreen('summary'); - - if (response.data && response.data.fallback) { + setProgress(0, 320); showNotice( getMessage( response, t( - 'fallbackNotice', - 'AI is taking longer than expected, so we prepared a starter setup. You can edit it before launch.' + 'generateError', + 'Could not generate setup data. Please try again.' ) ) ); + return; } + + state.setup = normalizeSetup(extractSetupPayload(response)); + state.selected = []; + state.regenerateCount = 0; + pendingOperation = ''; + currentScreen = 'summary'; + persistState(); + + setProgress(75, 480, function () { + renderSummary(); + showScreen('summary'); + + if (response.data && response.data.fallback) { + showNotice( + getMessage( + response, + t( + 'fallbackNotice', + 'AI is taking longer than expected, so we prepared a starter setup. You can edit it before launch.' + ) + ) + ); + } + }); }) .fail(function (response) { + pendingOperation = ''; showScreen('prompt'); - showNotice(getMessage(response, t('generateError', 'Could not generate setup data. Please try again.'))); + setProgress(0, 320); + showNotice( + getMessage( + response, + t( + 'generateError', + 'Could not generate setup data. Please try again.' + ) + ) + ); }) .always(function () { setButtonLoading($button, false); @@ -694,6 +1267,8 @@ return; } + pendingOperation = 'regenerate'; + persistState(); $button.prop('disabled', true); $('#directorist-ai-setup-regenerate-label').text( t('regenerating', 'Regenerating...') @@ -711,7 +1286,17 @@ var nextFields = []; if (!response || !response.success) { - showNotice(getMessage(response, t('generateError', 'Could not generate setup data. Please try again.'))); + pendingOperation = ''; + persistState(); + showNotice( + getMessage( + response, + t( + 'generateError', + 'Could not generate setup data. Please try again.' + ) + ) + ); return; } @@ -723,7 +1308,14 @@ ); if (!newFields.length) { - showNotice(t('generateError', 'Could not generate setup data. Please try again.')); + pendingOperation = ''; + persistState(); + showNotice( + t( + 'generateError', + 'Could not generate setup data. Please try again.' + ) + ); return; } @@ -745,12 +1337,24 @@ state.setup.fields = dedupeLockedPresetFields(nextFields); state.selected = []; state.regenerateCount++; + pendingOperation = ''; renderFieldsSummary(); renderFields(); + persistState(); }) .fail(function (response) { - showNotice(getMessage(response, t('generateError', 'Could not generate setup data. Please try again.'))); + pendingOperation = ''; + persistState(); + showNotice( + getMessage( + response, + t( + 'generateError', + 'Could not generate setup data. Please try again.' + ) + ) + ); }) .always(function () { updateRegenerateState(); @@ -768,6 +1372,9 @@ } setButtonLoading($button, true, t('launching', 'Launching...')); + pendingOperation = 'launch'; + persistState(); + startLaunchProgress(); ajax(config.actions.launch, { setup: JSON.stringify(state.setup), @@ -776,7 +1383,18 @@ var url; if (!response || !response.success) { - showNotice(getMessage(response, t('launchError', 'Could not launch your directory. Please try again.'))); + pendingOperation = ''; + persistState(); + setProgress(75, 320); + showNotice( + getMessage( + response, + t( + 'launchError', + 'Could not launch your directory. Please try again.' + ) + ) + ); return; } @@ -785,40 +1403,92 @@ ? response.data.url : config.dashboard; - showScreen('done'); + setProgress(100, 420, function () { + persistenceEnabled = false; + clearPersistedState(); + showScreen('done', true); - window.setTimeout(function () { - window.location.href = url; - }, 900); + window.setTimeout(function () { + window.location.href = url; + }, 900); + }); }) .fail(function (response) { - showNotice(getMessage(response, t('launchError', 'Could not launch your directory. Please try again.'))); + pendingOperation = ''; + persistState(); + setProgress(75, 320); + showNotice( + getMessage( + response, + t( + 'launchError', + 'Could not launch your directory. Please try again.' + ) + ) + ); }) .always(function () { setButtonLoading($button, false); - $button.contents().filter(function () { - return this.nodeType === 3; - }).first().replaceWith(t('launch', 'Launch my directory') + ' '); + $button + .contents() + .filter(function () { + return this.nodeType === 3; + }) + .first() + .replaceWith(t('launch', 'Launch my directory') + ' '); }); } function exitSetup() { - if (window.confirm(t('exitConfirm', 'Exit setup and go to the dashboard? Your progress will not be saved.'))) { - window.location.href = config.dashboard || window.ajaxurl || '/wp-admin/'; + if ( + window.confirm( + t( + 'exitConfirm', + 'Exit setup and go to the dashboard? Your progress will not be saved.' + ) + ) + ) { + window.location.href = + config.dashboard || window.ajaxurl || '/wp-admin/'; } } $(function () { var $prompt = $('#directorist-ai-setup-prompt'); + var restored = restorePersistedState(); + + $prompt.val(state.prompt); + + if (restored && restored.screen === 'summary' && state.setup) { + renderSummary(); + showScreen('summary', true); + setProgress(75); + + if (restored.fieldsEditorOpen) { + $('#directorist-ai-setup-fields-editor').addClass('open'); + } + } else { + showScreen('prompt', true); + setProgress(0); + } updateGenerateState(); - showScreen('prompt'); + persistState(); + + if (restored && restored.notice) { + showNotice(restored.notice); + } - $prompt.on('input', updateGenerateState); + $prompt.on('input', function () { + state.prompt = $(this).val() || ''; + updateGenerateState(); + persistState(); + }); $('#directorist-ai-setup-generate').on('click', generateSetup); $('#directorist-ai-setup-back').on('click', function () { showScreen('prompt'); + setProgress(0, 320); }); $('#directorist-ai-setup-close, #directorist-ai-setup-exit').on( @@ -826,41 +1496,77 @@ exitSetup ); - $('#directorist-ai-setup-chips').on('click', '.directorist-ai-setup__chip', function () { - var preset = $(this).data('preset'); - $prompt.val(presets[preset] || $(this).text()).trigger('input').focus(); - }); + $('#directorist-ai-setup-chips').on( + 'click', + '.directorist-ai-setup__chip', + function () { + var preset = $(this).data('preset'); + $prompt + .val(presets[preset] || $(this).text()) + .trigger('input') + .focus(); + } + ); $('#directorist-ai-setup-categories') .on('click', '.directorist-ai-setup__tag-remove', function () { var index = $(this).data('index'); state.setup.categories.splice(index, 1); renderCategories(); + persistState(); }) .on('click', '.directorist-ai-setup__tag-add', function () { - var category = window.prompt( - t('addCategoryPrompt', 'Category name'), - '' - ); - - category = $.trim(category || ''); + openCategoryInput($(this)); + }) + .on('input', '.directorist-ai-setup__tag-input', function () { + $(this) + .removeClass('is-invalid') + .removeAttr('aria-invalid') + .siblings('.directorist-ai-setup__tag-feedback') + .text(''); + }) + .on( + 'keydown', + '.directorist-ai-setup__tag-input', + function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + addCategoryFromInput($(this)); + return; + } - if (category) { - state.setup.categories.push(category); - renderCategories(); + if (event.key === 'Escape') { + event.preventDefault(); + renderCategories(true); + } } + ) + .on('blur', '.directorist-ai-setup__tag-input', function () { + var input = this; + + window.setTimeout(function () { + if (document.documentElement.contains(input)) { + renderCategories(); + } + }, 0); }); $('#directorist-ai-setup-fields-edit').on('click', function () { $('#directorist-ai-setup-fields-editor').addClass('open'); + persistState(); }); $('#directorist-ai-setup-fields-done').on('click', function () { $('#directorist-ai-setup-fields-editor').removeClass('open'); renderFieldsSummary(); + persistState(); }); $('#directorist-ai-setup-regenerate').on('click', regenerateFields); + $('#directorist-ai-setup-location-detect').on( + 'click', + detectCurrentLocation + ); $('#directorist-ai-setup-launch').on('click', launchDirectory); $('#directorist-ai-setup-name, #directorist-ai-setup-location').on( @@ -868,6 +1574,12 @@ syncSetupFromForm ); + $('#directorist-ai-setup-location').on('input', function (event) { + if (event.originalEvent) { + setLocationStatus('', ''); + } + }); + $( '#directorist-ai-setup-money, #directorist-ai-setup-data-sharing, #directorist-ai-setup-demo-content' ).on('change', syncSetupFromForm); @@ -888,16 +1600,22 @@ $row.toggleClass('selected', $(this).is(':checked')); updateRegenerateState(); + persistState(); }) .on('input', '.directorist-ai-setup__field-name', function () { var index = parseInt( - $(this).closest('.directorist-ai-setup__field-row').attr('data-index'), + $(this) + .closest('.directorist-ai-setup__field-row') + .attr('data-index'), 10 ); if (state.setup && state.setup.fields[index]) { - state.setup.fields[index].label = $.trim($(this).val() || ''); + state.setup.fields[index].label = $.trim( + $(this).val() || '' + ); renderFieldsSummary(); + persistState(); } }) .on('click', '.directorist-ai-setup__field-remove', function () { @@ -908,7 +1626,9 @@ } index = parseInt( - $(this).closest('.directorist-ai-setup__field-row').attr('data-index'), + $(this) + .closest('.directorist-ai-setup__field-row') + .attr('data-index'), 10 ); @@ -916,6 +1636,16 @@ state.selected = []; renderFieldsSummary(); renderFields(); + persistState(); }); + + $(window).on('pagehide', function () { + if (state.setup && currentScreen === 'summary') { + syncSetupFromForm(); + } else { + state.prompt = $prompt.val() || ''; + persistState(); + } + }); }); })(jQuery); diff --git a/assets/src/scss/layout/admin/ai-setup-wizard.scss b/assets/src/scss/layout/admin/ai-setup-wizard.scss index 14d4e57964..2fac9a2512 100644 --- a/assets/src/scss/layout/admin/ai-setup-wizard.scss +++ b/assets/src/scss/layout/admin/ai-setup-wizard.scss @@ -49,10 +49,10 @@ } .directorist-ai-setup__progress-fill { - width: 33%; + width: 0; height: 100%; background: var(--directorist-ai-accent); - transition: width .4s ease; + will-change: width; } .directorist-ai-setup__chrome-row { @@ -65,26 +65,12 @@ .directorist-ai-setup__logo { display: flex; align-items: center; - gap: 10px; } -.directorist-ai-setup__logo-mark { - display: inline-flex; - width: 28px; - height: 28px; - align-items: center; - justify-content: center; - border-radius: 6px; - background: var(--directorist-ai-accent); - color: #fff; - font-size: 14px; - font-weight: 700; -} - -.directorist-ai-setup__logo-text { - color: var(--directorist-ai-text); - font-size: 15px; - font-weight: 600; +.directorist-ai-setup__brand-logo { + display: block; + width: 140px; + height: auto; } .directorist-ai-setup__close, @@ -442,6 +428,93 @@ padding-left: 36px; } +.directorist-ai-setup__location-input .directorist-ai-setup__text-input { + padding-right: 44px; +} + +.directorist-ai-setup__location-detect { + position: absolute; + top: 50%; + right: 4px; + display: inline-flex; + width: 30px; + height: 30px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 5px; + outline: 0; + background: transparent; + color: var(--directorist-ai-muted); + cursor: pointer; + transform: translateY(-50%); + transition: + background-color .15s, + color .15s; +} + +.directorist-ai-setup__location-detect:hover:not(:disabled) { + background: var(--directorist-ai-tag); + color: var(--directorist-ai-accent); +} + +.directorist-ai-setup__location-detect:focus-visible { + box-shadow: var(--directorist-ai-shadow-focus); + color: var(--directorist-ai-accent); +} + +.directorist-ai-setup__location-detect:disabled { + cursor: wait; + opacity: .7; +} + +.directorist-ai-setup__location-detect svg { + position: static; + color: currentColor; + pointer-events: none; + transform: none; +} + +.directorist-ai-setup__location-detect.is-loading svg { + display: none; +} + +.directorist-ai-setup__location-detect.is-loading:before { + width: 13px; + height: 13px; + border: 2px solid var(--directorist-ai-border); + border-top-color: var(--directorist-ai-accent); + border-radius: 50%; + content: ""; + animation: directoristAiSpin .7s linear infinite; +} + +@keyframes directoristAiSpin { + to { + transform: rotate(360deg); + } +} + +.directorist-ai-setup__location-status { + margin: 6px 0 0; + color: var(--directorist-ai-muted); + font-size: 12px; + line-height: 1.4; +} + +.directorist-ai-setup__location-status:empty { + display: none; +} + +.directorist-ai-setup__location-status.is-success { + color: var(--directorist-ai-success); +} + +.directorist-ai-setup__location-status.is-warning { + color: #b54708; +} + .directorist-ai-setup__tags { display: flex; flex-wrap: wrap; @@ -506,6 +579,69 @@ color: var(--directorist-ai-accent); } +.directorist-ai-setup__tag-add:focus-visible { + border-color: var(--directorist-ai-accent); + border-style: solid; + color: var(--directorist-ai-accent); + outline: 0; + box-shadow: var(--directorist-ai-shadow-focus); +} + +.directorist-ai-setup__tag-entry { + position: relative; + display: inline-flex; + max-width: 100%; +} + +.directorist-ai-setup__tag-input { + width: 174px; + max-width: 100%; + min-height: 28px; + padding: 3px 12px; + border: 1px solid var(--directorist-ai-accent); + border-radius: var(--directorist-ai-radius-pill); + outline: 0; + background: var(--directorist-ai-card); + color: var(--directorist-ai-text); + box-shadow: var(--directorist-ai-shadow-focus); + font-family: inherit; + font-size: 13px; + line-height: 20px; + transition: + border-color .15s, + box-shadow .15s; +} + +.directorist-ai-setup__tag-input::placeholder { + color: var(--directorist-ai-muted-soft); +} + +.directorist-ai-setup__tag-input.is-invalid { + border-color: #dc2626; + box-shadow: 0 0 0 3px rgba(220, 38, 38, .12); +} + +.directorist-ai-setup__tag-feedback { + position: absolute; + z-index: 2; + top: calc(100% + 5px); + left: 8px; + display: block; + padding: 4px 7px; + border: 1px solid #fecaca; + border-radius: 5px; + background: #fff; + color: #991b1b; + box-shadow: 0 3px 10px rgba(15, 23, 42, .1); + font-size: 11px; + line-height: 1.35; + white-space: nowrap; +} + +.directorist-ai-setup__tag-feedback:empty { + display: none; +} + .directorist-ai-setup__fields-summary { display: flex; align-items: flex-start; @@ -615,6 +751,7 @@ border: 1px solid transparent; border-radius: var(--directorist-ai-radius-input); outline: 0; + background: transparent; color: var(--directorist-ai-text); font-size: 13px; transition: all .15s; @@ -646,7 +783,9 @@ .directorist-ai-setup__field-remove { width: 22px; height: 22px; + padding: 0; font-size: 15px; + line-height: 1; } .directorist-ai-setup__fields-done { @@ -655,9 +794,13 @@ font-size: 13px; } +.directorist-ai-setup__toggle-group { + padding: 14px 0; +} + .directorist-ai-setup__toggle-row { display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 12px; } @@ -671,13 +814,16 @@ margin-top: 2px; } +.directorist-ai-setup__toggle-row .directorist-ai-setup__row-label { + margin-bottom: 4px; +} + .directorist-ai-setup__toggle { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; - margin-top: 2px; } .directorist-ai-setup__toggle input { @@ -768,6 +914,12 @@ text-align: center; } +@media (prefers-reduced-motion: reduce) { + .directorist-ai-setup__location-detect.is-loading:before { + animation: none; + } +} + @media (max-width: 540px) { .directorist-ai-setup__page { padding: 32px 16px 24px; diff --git a/includes/classes/class-ai-setup-wizard.php b/includes/classes/class-ai-setup-wizard.php index a5814cadb7..d839916792 100644 --- a/includes/classes/class-ai-setup-wizard.php +++ b/includes/classes/class-ai-setup-wizard.php @@ -67,6 +67,7 @@ public function __construct() { add_action( 'admin_init', [ $this, 'render_page' ], 99 ); add_action( 'wp_ajax_directorist_ai_setup_wizard_generate', [ $this, 'handle_generate' ] ); add_action( 'wp_ajax_directorist_ai_setup_wizard_regenerate_fields', [ $this, 'handle_regenerate_fields' ] ); + add_action( 'wp_ajax_directorist_ai_setup_wizard_reverse_geocode', [ $this, 'handle_reverse_geocode' ] ); add_action( 'wp_ajax_directorist_ai_setup_wizard_launch', [ $this, 'handle_launch' ] ); } @@ -122,9 +123,18 @@ private function enqueue_assets() { 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( directorist_get_nonce_key() ), 'dashboard' => admin_url( 'edit.php?post_type=' . ATBDP_POST_TYPE . '&page=atbdp-extension' ), + 'storage' => [ + 'key' => sprintf( + 'directorist-ai-setup-wizard-%d-%d-v1', + get_current_blog_id(), + get_current_user_id() + ), + 'ttl' => DAY_IN_SECONDS, + ], 'actions' => [ 'generate' => 'directorist_ai_setup_wizard_generate', 'regenerate' => 'directorist_ai_setup_wizard_regenerate_fields', + 'geocode' => 'directorist_ai_setup_wizard_reverse_geocode', 'launch' => 'directorist_ai_setup_wizard_launch', ], 'i18n' => [ @@ -132,17 +142,29 @@ private function enqueue_assets() { 'fallbackNotice' => __( 'AI is taking longer than expected, so we prepared a starter setup. You can edit it before launch.', 'directorist' ), 'launchError' => __( 'Could not launch your directory. Please try again.', 'directorist' ), 'categoryName' => __( 'Category name', 'directorist' ), - 'exitConfirm' => __( 'Exit setup and go to the dashboard? Your progress will not be saved.', 'directorist' ), + 'exitConfirm' => __( 'Exit setup and go to the dashboard? Your progress will stay available in this browser tab.', 'directorist' ), + 'requestInterrupted' => __( 'The request was interrupted by the reload. Your saved progress has been restored.', 'directorist' ), + 'launchInterrupted' => __( 'The launch was interrupted by the reload. Check your listings before trying again.', 'directorist' ), 'fieldCount' => __( '%d fields selected', 'directorist' ), 'noFields' => __( 'No fields selected yet.', 'directorist' ), 'addCategory' => __( '+ Add category', 'directorist' ), - 'addCategoryPrompt' => __( 'Category name', 'directorist' ), + 'categoryRequired' => __( 'Enter a category name.', 'directorist' ), + 'categoryExists' => __( 'This category has already been added.', 'directorist' ), 'regenerate' => __( 'Regenerate', 'directorist' ), 'regenerating' => __( 'Regenerating...', 'directorist' ), 'regenerateNote' => __( 'You can regenerate fields %d more times.', 'directorist' ), 'regenerateDone' => __( 'Regeneration limit reached.', 'directorist' ), 'launch' => __( 'Launch my directory', 'directorist' ), 'launching' => __( 'Launching...', 'directorist' ), + 'detectLocation' => __( 'Use my current location', 'directorist' ), + 'detectingLocation' => __( 'Detecting your current location...', 'directorist' ), + 'locationDetected' => __( 'Current address detected.', 'directorist' ), + 'locationFallback' => __( 'Location detected, but the exact address could not be found. Coordinates were added instead.', 'directorist' ), + 'locationInsecure' => __( 'Location detection needs HTTPS or localhost. Open this admin page over HTTPS and try again.', 'directorist' ), + 'locationBlocked' => __( 'Location access is blocked. Allow it from your browser address bar, then try again.', 'directorist' ), + 'locationUnavailable' => __( 'Your current location is unavailable. Please try again.', 'directorist' ), + 'locationTimeout' => __( 'Location detection timed out. Please try again.', 'directorist' ), + 'locationUnsupported' => __( 'Your browser does not support location detection.', 'directorist' ), ], ] ); @@ -219,6 +241,55 @@ public function handle_regenerate_fields() { wp_send_json_success( [ 'fields' => $payload['fields'] ] ); } + public function handle_reverse_geocode() { + $this->verify_ajax_request(); + + $coordinates = $this->normalize_coordinates( + [ + 'lat' => isset( $_POST['latitude'] ) ? sanitize_text_field( wp_unslash( $_POST['latitude'] ) ) : null, + 'lng' => isset( $_POST['longitude'] ) ? sanitize_text_field( wp_unslash( $_POST['longitude'] ) ) : null, + ] + ); + + if ( ! $coordinates ) { + wp_send_json_error( [ 'message' => __( 'Invalid location coordinates.', 'directorist' ) ], 400 ); + } + + $response = wp_remote_get( + add_query_arg( + [ + 'format' => 'jsonv2', + 'lat' => $coordinates['lat'], + 'lon' => $coordinates['lng'], + 'zoom' => 18, + 'addressdetails' => 0, + 'accept-language' => str_replace( '_', '-', get_user_locale() ), + ], + 'https://nominatim.openstreetmap.org/reverse' + ), + [ + 'timeout' => 8, + 'headers' => [ + 'Accept' => 'application/json', + 'User-Agent' => 'Directorist AI Setup Wizard/' . ATBDP_VERSION . '; ' . home_url( '/' ), + ], + ] + ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + wp_send_json_error( [ 'message' => __( 'The address could not be resolved.', 'directorist' ) ], 502 ); + } + + $result = json_decode( wp_remote_retrieve_body( $response ), true ); + $address = isset( $result['display_name'] ) ? sanitize_text_field( $result['display_name'] ) : ''; + + if ( '' === $address ) { + wp_send_json_error( [ 'message' => __( 'No address was found for this location.', 'directorist' ) ], 404 ); + } + + wp_send_json_success( [ 'address' => $address ] ); + } + public function handle_launch() { $this->verify_ajax_request(); diff --git a/views/admin-templates/setup-wizard/ai-setup-wizard.php b/views/admin-templates/setup-wizard/ai-setup-wizard.php index dc4b6aa5eb..b163d134bf 100644 --- a/views/admin-templates/setup-wizard/ai-setup-wizard.php +++ b/views/admin-templates/setup-wizard/ai-setup-wizard.php @@ -5,13 +5,12 @@ ?>