| @@ -256,8 +256,20 @@ | ||
| 256 | 256 | $(document.body).on('click', '.wpfnl-bulk-trash', this.bulkTrashFunnel) |
| 257 | 257 | $(document.body).on('click', '.wpfnl-bulk-delete', this.bulkDeleteFunnel) |
| 258 | 258 | $(document.body).on('click', '.wpfnl-bulk-restore', this.bulkRestoreFunnel) |
| 259 | 259 | $(document.body).on('click', '.wpfnl-bulk-export', this.bulkExportFunnel) |
| 260 | + // Bound directly, NOT delegated through document.body: the modal wrapper has its | |
| 261 | + // own click handler calling stopPropagation() to keep backdrop clicks from closing | |
| 262 | + // the modal, so events from inside it never reach body. | |
| 263 | + $('.wpfnl-import-cancel').on('click', this.CancelImport) | |
| 264 | + $('.wpfnl-import-view-funnels').on('click', function (e) { | |
| 265 | + e.preventDefault() | |
| 266 | + window.location.reload() | |
| 267 | + }) | |
| 268 | + | |
| 269 | + // An import started in an earlier page view keeps running in the background, so | |
| 270 | + // pick it back up rather than leaving the user with no sign of it. | |
| 271 | + resumeImportIfRunning() | |
| 260 | 272 | } |
| 261 | 273 | |
| 262 | 274 | /** |
| 263 | 275 | * Select quantity |
| @@ -476,8 +488,9 @@ | ||
| 476 | 488 | status_updated: 'Funnel status has been updated successfully.', |
| 477 | 489 | bulk_trash_success: 'The selected funnels have been moved to Trash.', |
| 478 | 490 | bulk_delete_success: 'The selected funnels have been deleted permanently.', |
| 479 | 491 | bulk_restore_success: 'The selected funnels have been restored successfully.', |
| 492 | + import_success: 'The funnels have been imported successfully.', | |
| 480 | 493 | } |
| 481 | 494 | |
| 482 | 495 | const toastKey = localStorage.getItem('wpfnl_show_toast') |
| 483 | 496 | if (!toastKey || !toastMap[toastKey]) return |
| @@ -822,8 +835,14 @@ | ||
| 822 | 835 | */ |
| 823 | 836 | FunnelHandler.prototype.ShowImportFunnelModal = function (e) { |
| 824 | 837 | e.stopPropagation() |
| 825 | 838 | e.preventDefault() |
| 839 | + // Reset back to the upload form: a previous run may have left the modal showing | |
| 840 | + // progress or a result card. | |
| 841 | + $('.wpfnl-import-progress').hide().removeClass('is-stalled') | |
| 842 | + $('.wpfnl-import-result').hide() | |
| 843 | + $('#wpfnl-import-funnels').show() | |
| 844 | + $('#wpfnl-export-import-warning').css('display', 'none') | |
| 826 | 845 | $('.import-funnel-modal').addClass('show-modal') |
| 827 | 846 | } |
| 828 | 847 | |
| 829 | 848 | /** |
| @@ -854,8 +873,14 @@ | ||
| 854 | 873 | */ |
| 855 | 874 | FunnelHandler.prototype.ShowImportFunnelModal = function (e) { |
| 856 | 875 | e.stopPropagation() |
| 857 | 876 | e.preventDefault() |
| 877 | + // Reset back to the upload form: a previous run may have left the modal showing | |
| 878 | + // progress or a result card. | |
| 879 | + $('.wpfnl-import-progress').hide().removeClass('is-stalled') | |
| 880 | + $('.wpfnl-import-result').hide() | |
| 881 | + $('#wpfnl-import-funnels').show() | |
| 882 | + $('#wpfnl-export-import-warning').css('display', 'none') | |
| 858 | 883 | $('.import-funnel-modal').addClass('show-modal') |
| 859 | 884 | } |
| 860 | 885 | |
| 861 | 886 | /** |
| @@ -955,13 +980,26 @@ | ||
| 955 | 980 | handler.ShowImportWarning(handler.GetImportErrorMessage(response)) |
| 956 | 981 | return |
| 957 | 982 | } |
| 958 | 983 | $('#wpfnl-import-funnel .wpfnl-loader').css('display', 'none') |
| 959 | - $('.import-funnel-modal').removeClass('show-modal') | |
| 960 | - window.location.reload() | |
| 984 | + // Deliberately no page reload here. The job has only been *queued* | |
| 985 | + // at this point, so reloading used to land the user on an unchanged | |
| 986 | + // funnel list and force them to keep refreshing to find out what | |
| 987 | + // happened. Stay open and follow the job instead. | |
| 988 | + showImportProgress() | |
| 989 | + pollImportStatus(response?.data?.run_id || '') | |
| 961 | 990 | }, |
| 962 | 991 | error: function (jqXHR) { |
| 963 | 992 | // Validation failures are sent with a 4xx status code. |
| 993 | + const body = | |
| 994 | + jqXHR && jqXHR.responseJSON ? jqXHR.responseJSON : null | |
| 995 | + // 409: a run really is still going. Offer to clear it rather than | |
| 996 | + // leaving the user stuck behind "Import already scheduled". | |
| 997 | + if (body && body.data && body.data.can_reset) { | |
| 998 | + showImportProgress() | |
| 999 | + pollImportStatus(body.data.run_id || '') | |
| 1000 | + return | |
| 1001 | + } | |
| 964 | 1002 | handler.ShowImportWarning(handler.GetImportErrorMessage(jqXHR)) |
| 965 | 1003 | }, |
| 966 | 1004 | }) |
| 967 | 1005 | } else { |
| @@ -1016,8 +1054,322 @@ | ||
| 1016 | 1054 | } |
| 1017 | 1055 | } |
| 1018 | 1056 | |
| 1019 | 1057 | /** |
| 1058 | + * Translated string with an English fallback. | |
| 1059 | + * | |
| 1060 | + * Strings in this file are historically hardcoded; new ones go through | |
| 1061 | + * WPFunnelVars.getText so they are actually translatable. | |
| 1062 | + * | |
| 1063 | + * @param key | |
| 1064 | + * @param fallback | |
| 1065 | + * | |
| 1066 | + * @since 2.5.16 | |
| 1067 | + */ | |
| 1068 | + function importText(key, fallback) { | |
| 1069 | + const bag = window.WPFunnelVars && WPFunnelVars.getText ? WPFunnelVars.getText : {} | |
| 1070 | + return bag[key] || fallback | |
| 1071 | + } | |
| 1072 | + | |
| 1073 | + /** | |
| 1074 | + * Swap the import modal into its progress state. | |
| 1075 | + * | |
| 1076 | + * @since 2.5.16 | |
| 1077 | + */ | |
| 1078 | + function showImportProgress() { | |
| 1079 | + $('.import-funnel-modal').addClass('show-modal') | |
| 1080 | + $('#wpfnl-import-funnels').hide() | |
| 1081 | + $('.wpfnl-import-result').hide() | |
| 1082 | + $('.wpfnl-import-progress').show() | |
| 1083 | + $('.wpfnl-import-progress').removeClass('is-stalled') | |
| 1084 | + $('.wpfnl-import-progress__label').text( | |
| 1085 | + importText('import_preparing', 'Preparing import…'), | |
| 1086 | + ) | |
| 1087 | + $('.wpfnl-import-progress__bar').css('width', '0%') | |
| 1088 | + $('.wpfnl-import-progress__meta').text('') | |
| 1089 | + } | |
| 1090 | + | |
| 1091 | + /** | |
| 1092 | + * Read the current import status. | |
| 1093 | + * | |
| 1094 | + * @param runId | |
| 1095 | + * | |
| 1096 | + * @since 2.5.16 | |
| 1097 | + */ | |
| 1098 | + function fetchImportStatus(runId) { | |
| 1099 | + const base = window.WPFunnelVars ? WPFunnelVars.rest_api_url : '' | |
| 1100 | + const url = | |
| 1101 | + base + | |
| 1102 | + 'wpfunnels/v1/import-export/import-status' + | |
| 1103 | + (runId ? '?run_id=' + encodeURIComponent(runId) : '') | |
| 1104 | + | |
| 1105 | + return fetch(url, { | |
| 1106 | + method: 'GET', | |
| 1107 | + headers: { 'X-WP-Nonce': WPFunnelVars.nonce }, | |
| 1108 | + }).then(function (response) { | |
| 1109 | + if (!response.ok) { | |
| 1110 | + throw new Error('status_request_failed') | |
| 1111 | + } | |
| 1112 | + return response.json() | |
| 1113 | + }) | |
| 1114 | + } | |
| 1115 | + | |
| 1116 | + /** | |
| 1117 | + * Paint one status tick into the progress UI. | |
| 1118 | + * | |
| 1119 | + * @param status | |
| 1120 | + * | |
| 1121 | + * @since 2.5.16 | |
| 1122 | + */ | |
| 1123 | + function renderImportProgress(status) { | |
| 1124 | + const pct = Math.max(0, Math.min(100, parseInt(status.pct, 10) || 0)) | |
| 1125 | + | |
| 1126 | + $('.wpfnl-import-progress__label').text( | |
| 1127 | + status.label || importText('import_in_progress', 'Importing funnels…'), | |
| 1128 | + ) | |
| 1129 | + $('.wpfnl-import-progress__bar').css('width', pct + '%') | |
| 1130 | + | |
| 1131 | + let meta = '' | |
| 1132 | + if (status.total_funnels) { | |
| 1133 | + meta = | |
| 1134 | + status.done_funnels + | |
| 1135 | + ' / ' + | |
| 1136 | + status.total_funnels + | |
| 1137 | + ' ' + | |
| 1138 | + importText('import_funnels_word', 'funnels') | |
| 1139 | + if (status.total_steps) { | |
| 1140 | + meta += | |
| 1141 | + ' · ' + | |
| 1142 | + status.done_steps + | |
| 1143 | + ' / ' + | |
| 1144 | + status.total_steps + | |
| 1145 | + ' ' + | |
| 1146 | + importText('import_steps_word', 'steps') | |
| 1147 | + } | |
| 1148 | + } | |
| 1149 | + $('.wpfnl-import-progress__meta').text(meta) | |
| 1150 | + } | |
| 1151 | + | |
| 1152 | + /** | |
| 1153 | + * Render the terminal state: what landed, what did not. | |
| 1154 | + * | |
| 1155 | + * @param status | |
| 1156 | + * | |
| 1157 | + * @since 2.5.16 | |
| 1158 | + */ | |
| 1159 | + function renderImportResult(status) { | |
| 1160 | + const imported = Array.isArray(status.imported) ? status.imported : [] | |
| 1161 | + const failed = Array.isArray(status.failed) ? status.failed : [] | |
| 1162 | + | |
| 1163 | + $('.wpfnl-import-progress').hide() | |
| 1164 | + $('.wpfnl-import-result').show() | |
| 1165 | + | |
| 1166 | + let summary = status.message || '' | |
| 1167 | + if (!summary) { | |
| 1168 | + summary = | |
| 1169 | + 'failed' === status.state | |
| 1170 | + ? importText('import_failed', 'The import could not be completed.') | |
| 1171 | + : importText('import_completed', 'Import complete.') | |
| 1172 | + } | |
| 1173 | + $('.wpfnl-import-result__summary').text(summary) | |
| 1174 | + | |
| 1175 | + const $list = $('.wpfnl-import-result__list').empty() | |
| 1176 | + | |
| 1177 | + imported.forEach(function (item) { | |
| 1178 | + const stepsWord = importText('import_steps_word', 'steps') | |
| 1179 | + let note = item.steps + ' ' + stepsWord | |
| 1180 | + | |
| 1181 | + // A partially imported funnel is the case worth being precise about: say how | |
| 1182 | + // many steps are missing rather than just flagging it as odd. | |
| 1183 | + if ('partial' === item.status) { | |
| 1184 | + const total = (item.steps || 0) + (item.failed_steps || 0) | |
| 1185 | + note = | |
| 1186 | + item.steps + | |
| 1187 | + ' / ' + | |
| 1188 | + total + | |
| 1189 | + ' ' + | |
| 1190 | + stepsWord + | |
| 1191 | + ' · ' + | |
| 1192 | + importText('import_partial', 'some steps failed') | |
| 1193 | + } | |
| 1194 | + | |
| 1195 | + $list.append( | |
| 1196 | + $('<li/>') | |
| 1197 | + .addClass('partial' === item.status ? 'is-failed' : '') | |
| 1198 | + .append($('<span/>').text(item.name || '')) | |
| 1199 | + .append($('<span/>').addClass('wpfnl-import-result__note').text(note)), | |
| 1200 | + ) | |
| 1201 | + }) | |
| 1202 | + | |
| 1203 | + failed.forEach(function (item) { | |
| 1204 | + $list.append( | |
| 1205 | + $('<li/>') | |
| 1206 | + .addClass('is-failed') | |
| 1207 | + .append($('<span/>').text(item.name || '')) | |
| 1208 | + .append( | |
| 1209 | + $('<span/>') | |
| 1210 | + .addClass('wpfnl-import-result__note') | |
| 1211 | + .text(item.message || importText('import_failed_item', 'failed')), | |
| 1212 | + ), | |
| 1213 | + ) | |
| 1214 | + }) | |
| 1215 | + | |
| 1216 | + // Only claim success when nothing failed. A partial run has already been spelled | |
| 1217 | + // out in the list above, and a cheerful toast on top of it would misrepresent it. | |
| 1218 | + if (imported.length && !failed.length) { | |
| 1219 | + try { | |
| 1220 | + localStorage.setItem('wpfnl_show_toast', 'import_success') | |
| 1221 | + } catch (e) { | |
| 1222 | + // Private mode / blocked storage: the result card already says what happened. | |
| 1223 | + } | |
| 1224 | + } | |
| 1225 | + } | |
| 1226 | + | |
| 1227 | + /** | |
| 1228 | + * Follow a background import to completion. | |
| 1229 | + * | |
| 1230 | + * Bounded polling with backoff rather than setInterval: the job can legitimately run | |
| 1231 | + * for minutes, and a fixed short interval would hammer the REST route for the whole | |
| 1232 | + * duration. Stall detection happens server-side — the process that died cannot report | |
| 1233 | + * on itself — so this only has to react to the state it is handed. | |
| 1234 | + * | |
| 1235 | + * @param runId | |
| 1236 | + * | |
| 1237 | + * @since 2.5.16 | |
| 1238 | + */ | |
| 1239 | + async function pollImportStatus(runId) { | |
| 1240 | + const started = Date.now() | |
| 1241 | + const maxMs = 15 * 60 * 1000 | |
| 1242 | + let attempt = 0 | |
| 1243 | + let consecutiveErrors = 0 | |
| 1244 | + | |
| 1245 | + while (Date.now() - started < maxMs) { | |
| 1246 | + const delay = attempt < 5 ? 1000 : attempt < 20 ? 2000 : 5000 | |
| 1247 | + await new Promise(function (resolve) { | |
| 1248 | + setTimeout(resolve, delay) | |
| 1249 | + }) | |
| 1250 | + attempt++ | |
| 1251 | + | |
| 1252 | + let status | |
| 1253 | + try { | |
| 1254 | + status = await fetchImportStatus(runId) | |
| 1255 | + consecutiveErrors = 0 | |
| 1256 | + } catch (e) { | |
| 1257 | + // A blip should not kill a long-running import view; three in a row means | |
| 1258 | + // something is actually wrong. | |
| 1259 | + consecutiveErrors++ | |
| 1260 | + if (consecutiveErrors >= 3) { | |
| 1261 | + renderImportResult({ | |
| 1262 | + state: 'failed', | |
| 1263 | + message: importText( | |
| 1264 | + 'import_status_unavailable', | |
| 1265 | + 'Lost contact with the import. Refresh the page to check on it.', | |
| 1266 | + ), | |
| 1267 | + }) | |
| 1268 | + return | |
| 1269 | + } | |
| 1270 | + continue | |
| 1271 | + } | |
| 1272 | + | |
| 1273 | + if (!runId && status.run_id) { | |
| 1274 | + runId = status.run_id | |
| 1275 | + } | |
| 1276 | + | |
| 1277 | + // Somebody else started an import; this view is no longer authoritative. | |
| 1278 | + if ('superseded' === status.state || 'idle' === status.state) { | |
| 1279 | + return | |
| 1280 | + } | |
| 1281 | + | |
| 1282 | + if ('stalled' === status.state) { | |
| 1283 | + $('.wpfnl-import-progress').addClass('is-stalled') | |
| 1284 | + $('.wpfnl-import-progress__label').text( | |
| 1285 | + importText( | |
| 1286 | + 'import_stalled', | |
| 1287 | + 'The import seems to have stopped responding. Cancel it to try again.', | |
| 1288 | + ), | |
| 1289 | + ) | |
| 1290 | + return | |
| 1291 | + } | |
| 1292 | + | |
| 1293 | + renderImportProgress(status) | |
| 1294 | + | |
| 1295 | + if ('completed' === status.state || 'failed' === status.state) { | |
| 1296 | + renderImportResult(status) | |
| 1297 | + return | |
| 1298 | + } | |
| 1299 | + } | |
| 1300 | + | |
| 1301 | + $('.wpfnl-import-progress__label').text( | |
| 1302 | + importText( | |
| 1303 | + 'import_taking_long', | |
| 1304 | + 'This import is taking longer than expected. Refresh the page to check on it.', | |
| 1305 | + ), | |
| 1306 | + ) | |
| 1307 | + } | |
| 1308 | + | |
| 1309 | + /** | |
| 1310 | + * Resume the progress view for an import still running from an earlier page view. | |
| 1311 | + * | |
| 1312 | + * @since 2.5.16 | |
| 1313 | + */ | |
| 1314 | + function resumeImportIfRunning() { | |
| 1315 | + if (!$('.import-funnel-modal').length || !window.WPFunnelVars) { | |
| 1316 | + return | |
| 1317 | + } | |
| 1318 | + | |
| 1319 | + fetchImportStatus('') | |
| 1320 | + .then(function (status) { | |
| 1321 | + if ('running' === status.state || 'queued' === status.state) { | |
| 1322 | + showImportProgress() | |
| 1323 | + renderImportProgress(status) | |
| 1324 | + pollImportStatus(status.run_id || '') | |
| 1325 | + } | |
| 1326 | + }) | |
| 1327 | + .catch(function () { | |
| 1328 | + // No import to resume, or the route is unreachable — stay quiet. | |
| 1329 | + }) | |
| 1330 | + } | |
| 1331 | + | |
| 1332 | + /** | |
| 1333 | + * Clear a stuck import so a new one can be started. | |
| 1334 | + * | |
| 1335 | + * @param event | |
| 1336 | + * | |
| 1337 | + * @since 2.5.16 | |
| 1338 | + */ | |
| 1339 | + FunnelHandler.prototype.CancelImport = function (event) { | |
| 1340 | + event.preventDefault() | |
| 1341 | + | |
| 1342 | + const $button = $(this) | |
| 1343 | + $button.find('.wpfnl-loader').css('display', 'inline-block') | |
| 1344 | + | |
| 1345 | + fetch(WPFunnelVars.rest_api_url + 'wpfunnels/v1/import-export/import-reset', { | |
| 1346 | + method: 'POST', | |
| 1347 | + headers: { | |
| 1348 | + 'Content-Type': 'application/json', | |
| 1349 | + 'X-WP-Nonce': WPFunnelVars.nonce, | |
| 1350 | + }, | |
| 1351 | + }) | |
| 1352 | + .then(function () { | |
| 1353 | + $button.find('.wpfnl-loader').css('display', 'none') | |
| 1354 | + $('.wpfnl-import-progress').hide().removeClass('is-stalled') | |
| 1355 | + $('.import-funnel-modal').removeClass('show-modal') | |
| 1356 | + $('#wpfnl-import-funnels').show() | |
| 1357 | + showToast( | |
| 1358 | + 'success', | |
| 1359 | + importText('import_cancelled', 'The import has been cancelled.'), | |
| 1360 | + ) | |
| 1361 | + }) | |
| 1362 | + .catch(function () { | |
| 1363 | + $button.find('.wpfnl-loader').css('display', 'none') | |
| 1364 | + showToast( | |
| 1365 | + 'error', | |
| 1366 | + importText('import_cancel_failed', 'Could not cancel the import.'), | |
| 1367 | + ) | |
| 1368 | + }) | |
| 1369 | + } | |
| 1370 | + | |
| 1371 | + /** | |
| 1020 | 1372 | * Download json file |
| 1021 | 1373 | * |
| 1022 | 1374 | * @param content |
| 1023 | 1375 | * @param fileName |
| @@ -2027,34 +2379,10 @@ | ||
| 2027 | 2379 | } |
| 2028 | 2380 | } |
| 2029 | 2381 | } |
| 2030 | 2382 | |
| 2031 | - /** | |
| 2032 | - * settings page tab nav | |
| 2033 | - * | |
| 2034 | - * @since 1.0.0 | |
| 2035 | - */ | |
| 2036 | - $('.wpfn-settings__nav .nav-li:not(.disabled)').on('click', function (e) { | |
| 2037 | - var dataID = $(this).attr('data-id'); | |
| 2038 | - | |
| 2039 | - $(this).addClass('active').siblings('.nav-li').removeClass('active'); | |
| 2040 | 2383 | |
| 2041 | - // Show the selected panel, hide others | |
| 2042 | - $('#' + dataID).show().siblings('.wpfnl-funnel__single-settings').hide(); | |
| 2043 | 2384 | |
| 2044 | - // Inside the visible panel, activate the first inner-tab | |
| 2045 | - var $container = $('#' + dataID); | |
| 2046 | - $container.find('.inner-tab').removeClass('active'); | |
| 2047 | - $container.find('.wpfnl-tab-content').removeClass('active'); | |
| 2048 | - | |
| 2049 | - var $firstInnerTab = $container.find('.inner-tab').first(); | |
| 2050 | - var tabId = $firstInnerTab.data('tab'); | |
| 2051 | - | |
| 2052 | - $firstInnerTab.addClass('active'); | |
| 2053 | - $('#' + tabId).addClass('active'); | |
| 2054 | - }); | |
| 2055 | - | |
| 2056 | - | |
| 2057 | 2385 | /** |
| 2058 | 2386 | * Checkout Edit Field delete alert modal |
| 2059 | 2387 | * |
| 2060 | 2388 | * @since 1.0.0 |
| @@ -2484,21 +2812,8 @@ | ||
| 2484 | 2812 | ) |
| 2485 | 2813 | } |
| 2486 | 2814 | }, |
| 2487 | 2815 | ) |
| 2488 | - | |
| 2489 | - /** | |
| 2490 | - * Funnel window page and Empty funnel page help resource show/hide. | |
| 2491 | - * | |
| 2492 | - */ | |
| 2493 | - $(document).on('click', '.wpfnl-helper-btn', function (e) { | |
| 2494 | - e.stopPropagation() | |
| 2495 | - $(this).parent('.wpfnl-canvas-helper').toggleClass('show-helpers') | |
| 2496 | - }) | |
| 2497 | - | |
| 2498 | - $(document).on('click', 'body', function () { | |
| 2499 | - $('.wpfnl-canvas-helper').removeClass('show-helpers') | |
| 2500 | - }) | |
| 2501 | 2816 | |
| 2502 | 2817 | /** |
| 2503 | 2818 | * add new step option dropdown show/hide. |
| 2504 | 2819 | * |