1/** 2 * bip-network JS 3 * 4 * Handles the network listing edit modal (load + save), the public/admin 5 * network-links modals, and the uploaded-images grid. 6 * 7 * Resolves the admin-ajax URL from any context: 8 * - frontend: `bbp_site_data.ajax_url` (localized by main.js) 9 * - WP admin pages: `ajaxurl` (auto-defined by WordPress) 10 * - last-resort: '/wp-admin/admin-ajax.php' 11 */ 12jQuery(document).ready(function ($) { 13 14 const BIP_AJAX_URL = (typeof bbp_site_data !== 'undefined' && bbp_site_data && bbp_site_data.ajax_url) 15 ? bbp_site_data.ajax_url 16 : (typeof ajaxurl !== 'undefined' ? ajaxurl : '/wp-admin/admin-ajax.php'); 17 18 // Browser-side WebP converter. All image uploads (submission form, customer 19 // edit modal, wp-admin meta box) go through this so the server only ever 20 // stores .webp at ~80% quality. Already-WebP files pass through unchanged 21 // (avoids re-encode quality loss). Returns a Promise<File>; rejects if the 22 // browser can't encode WebP (very old browsers) â callers should fall 23 // back to the original file. 24 function bipConvertToWebp(file, quality) { 25 quality = (typeof quality === 'number') ? quality : 0.8; 26 return new Promise(function (resolve, reject) { 27 if (!file || !file.type || file.type.indexOf('image/') !== 0) { 28 reject(new Error('Not an image file.')); 29 return; 30 } 31 if (file.type === 'image/webp') { 32 // Already WebP â pass through. 33 resolve(file); 34 return; 35 } 36 var url = URL.createObjectURL(file); 37 var img = new Image(); 38 img.onload = function () { 39 URL.revokeObjectURL(url); 40 try { 41 var canvas = document.createElement('canvas'); 42 canvas.width = img.naturalWidth || img.width; 43 canvas.height = img.naturalHeight || img.height; 44 canvas.getContext('2d').drawImage(img, 0, 0); 45 canvas.toBlob(function (blob) { 46 if (!blob) { 47 reject(new Error('Browser could not encode WebP.')); 48 return; 49 } 50 var name = (file.name || 'image').replace(/\.[^.]+$/, '') + '.webp'; 51 resolve(new File([blob], name, { type: 'image/webp' })); 52 }, 'image/webp', quality); 53 } catch (e) { 54 reject(e); 55 } 56 }; 57 img.onerror = function () { 58 URL.revokeObjectURL(url); 59 reject(new Error('Image failed to load.')); 60 }; 61 img.src = url; 62 }); 63 } 64 window.bipConvertToWebp = bipConvertToWebp; 65 66 // Modal open/close logic now lives in assets/js/bip-modal.js â clicks on 67 // .bip-modal-trigger / .bip-modal-close / backdrop / Escape are handled 68 // there. Use window.bipModal.open / close from this file when we need to 69 // open or close a modal programmatically (e.g. after a successful save). 70 71 // set business hours values to edit form 72 function setBusinessHoursFields(businessHours) { 73 $('#business-hours-section [data-day]').each(function () { 74 const $row = $(this); 75 const day = $row.data('day').toLowerCase(); // lowercase from HTML 76 const businessHoursKeys = Object.keys(businessHours); 77 78 // Find a matching key in a case-insensitive way 79 const matchKey = businessHoursKeys.find(key => key.toLowerCase() === day); 80 81 const value = businessHours[matchKey]; 82 const $input = $row.find('.business-hour'); 83 const $checkbox = $row.find('.closed'); 84 85 if (value === 'Closed') { 86 $checkbox.prop('checked', true); 87 $input.prop('disabled', true).val('Closed'); 88 $input.css({'border-color':'red', 'background':'#ee000020', 'color':'red'}); 89 } else { 90 // $checkbox.prop('checked', false); 91 $input.val(value).prop('disabled', false); 92 $input.css({'border-color':'#f7f7f7', 'background':'#fff', 'color':'black'}); 93 } 94 }); 95 } 96 97 function setServicesFields(allServices) { 98 const serviceTitle = Object.keys(allServices)[0]; 99 const services = allServices[serviceTitle]; 100 101 if( $("#np_services_title") && $("#np_services") ) { 102 $("#np_services_title").val( serviceTitle ); 103 $("#np_services").val( services.join('\n') ); 104 } 105 } 106 107 function setKeywordsFields(keywords) { 108 if( $("#np_keywords") ) { 109 // One keyword per line â matches the textarea's expected 110 // format and the hub's PHP split/store pipeline. 111 $("#np_keywords").val( keywords.join('\n') ); 112 } 113 } 114 115 /** 116 * Populate a bip-tag-selector wrapper (the dashboard edit-modal 117 * version) with a fresh tag list, replacing whatever's currently in 118 * the chip row. Used at modal-open time to hydrate from the AJAX 119 * response's `tags` array. 120 * 121 * Works even though the tag-selector JS owns its own state â we just 122 * blow away the chip DOM, drop in new ones (each with a matching 123 * hidden input for the form submission), and trigger an `input` event 124 * on the type field so the selector re-renders its dropdown. 125 */ 126 /** 127 * Hydrate the FAQ repeater from the get_data response's `faqs` array 128 * ([{question, answer}, â¦]). Rebuilds rows from the editor's own 129 * <template> so markup stays in one place (faqs.php). 130 */ 131 function setFaqFields($wrap, faqs) { 132 if (!$wrap || !$wrap.length) return; 133 const $rows = $wrap.find('.bip-faq-rows').empty(); 134 const tpl = $wrap.find('.bip-faq-tpl').html() || '';
135 (Array.isArray(faqs) ? faqs : []).forEach(function (f, i) { 136 const $row = $(tpl.replace(/__i__/g, i)); 137 $row.find('.bip-faq-q').val((f && f.question) || ''); 138 $row.find('.bip-faq-a').val((f && f.answer) || ''); 139 $rows.append($row); 140 }); 141 } 142 143 function setTagSelectorValues($wrap, tags) { 144 if (!$wrap || !$wrap.length || !Array.isArray(tags)) return; 145 const $chips = $wrap.find('.bip-tag-chips'); 146 const $input = $chips.find('.bip-tag-input'); 147 const name = $wrap.attr('data-name') || 'tags'; 148 const max = parseInt($wrap.attr('data-max'), 10) || 5; 149 150 // Remove existing chips (keep the input). 151 $chips.find('.bip-tag-chip').remove(); 152 153 // Insert each new chip before the input. 154 tags.slice(0, max).forEach(function (tag) { 155 const v = String(tag || '').trim(); 156 if (!v) return; 157 const html = $( 158 '<span class="bip-tag-chip" data-value="' + escAttr(v) + '">' + 159 '<span class="bip-tag-chip-label">' + escHtml(v) + '</span>' + 160 '<button type="button" class="bip-tag-chip-remove" aria-label="Remove ' + escAttr(v) + '" tabindex="-1">×</button>' + 161 '<input type="hidden" name="' + escAttr(name) + '[]" value="' + escAttr(v) + '">' + 162 '</span>' 163 ); 164 $input.before(html); 165 }); 166 167 // Re-sync the counter + 'is-full' state by triggering the 168 // selector's own input handler (which calls updateCount + dropdown 169 // refresh). 170 $input.val('').trigger('input'); 171 } 172 173 function escHtml(s) { 174 return String(s == null ? '' : s) 175 .replace(/&/g, '&').replace(/</g, '<') 176 .replace(/>/g, '>').replace(/"/g, '"'); 177 } 178 function escAttr(s) { return escHtml(s); } 179 180 // network posts actions - CLICK ON EDIT BUTTON 181 const initNetworkPostActions = () => { 182 let postID; 183 184 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 185 // Syndication-in-progress poller 186 // 187 // Polls the SAME endpoint the View-Network-Links modal uses â 188 // bbp_get_network_post_links via admin-ajax â which already returns 189 // {completed, total, in_progress} populated from the per-site 190 // progress row in bip_network_posts_details (see syndication.php 191 // bip_upsert_network_data() calls inside the per-site loop). 192 // Reusing it avoids URL-resolution gotchas (subdir installs etc.) 193 // that bit the REST endpoint, and keeps the two progress surfaces 194 // reading from exactly the same source. 195 // 196 // The interval is intentionally generous (5s) â fan-outs take 197 // minutes for ~300 sites and this UI is just status surface. 198 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 199 const SYND_POLL_INTERVAL_MS = 5000; 200 const synd_polls = {}; 201 202 function bmStopSyndicationPoll(listingId) { 203 if (synd_polls[listingId]) { 204 clearInterval(synd_polls[listingId]); 205 delete synd_polls[listingId]; 206 } 207 } 208 209 // Build the "N/total (pct%)" suffix from a status response. 210 // Read `completed_count` (current fan-out's per-site progress) â 211 // NOT `completed` (cumulative link count across all runs), which 212 // would inherit the prior CREATE's 338 links on an UPDATE and 213 // falsely show 100% from the first tick. Both come from the 214 // same bbp_get_network_post_links AJAX response. 215 function bmFormatProgress(status) { 216 if (!status) return ''; 217 const done = parseInt(status.completed_count, 10) || 0; 218 const total = parseInt(status.total, 10) || 0; 219 if (!total) return ''; 220 const pct = Math.min(100, Math.round((done / total) * 100)); 221 return done + '/' + total + ' (' + pct + '%)'; 222 } 223 224 // Drives the in-flight syndication UI:
225 // - Submit button: disabled with spinner + "Publishing N/total (pct%)" 226 // while a fan-out is running; enabled with "Continue" when the last 227 // run was killed short of full coverage; otherwise idle "Save". 228 // - Progress line (#bip-modal-progress) shows the plain "N/total 229 // completed" text above the button. After a killed run, appends 230 // "Click 'Continue' for remainings." â no error type, per spec. 231 // - Form (.bm-edit-form): pointer-events disabled + dimmed so all 232 // fields inside are non-interactive during a run. 233 // - .bip-modal-header is intentionally outside the form so the "X" 234 // close button stays clickable â user can leave the modal while 235 // the fan-out keeps running on the server. 236 // Called on every poll tick (modal-open or post-save) so it's safe to 237 // re-apply the same state without flicker. 238 function bmApplySyndicationUi(listingId, status, $btn, idleLabel) { 239 const inProgress = !!(status && status.in_progress); 240 const canContinue = !!(status && status.can_continue); 241 const $form = $('#network-listing-edit-form'); 242 const $progress = $('#bip-modal-progress'); 243 244 const done = parseInt(status && status.completed_count, 10) || 0; 245 const total = parseInt(status && status.total, 10) || 0; 246 247 if (inProgress) { 248 // Live fan-out: disable submit, show spinner label + progress line. 249 const progress = bmFormatProgress(status); 250 const label = progress ? 'Publishing ' + progress : 'Publishingâ¦'; 251 $btn.prop('disabled', true) 252 .html('<i class="fa fa-circle-notch fa-spin" aria-hidden="true" style="margin-right:6px;"></i>' + label); 253 $form.addClass('bm-form-locked'); 254 if (total > 0) { 255 $progress.text(done + '/' + total + ' completed.').prop('hidden', false); 256 } else { 257 $progress.text('').prop('hidden', true); 258 } 259 } else if (canContinue) { 260 // Killed / stalled run: re-enable submit as "Continue", 261 // show progress with the Continue hint appended. 262 $btn.prop('disabled', false) 263 .html('Continue <i class="fa fa-refresh" aria-hidden="true"></i>') 264 .attr('data-bip-mode', 'continue'); 265 $form.removeClass('bm-form-locked'); 266 $progress.text(done + '/' + total + ' completed. Click \'Continue\' for remainings.').prop('hidden', false); 267 } else { 268 // Idle / fully completed: standard Save button, progress line hidden. 269 $btn.prop('disabled', false).html(idleLabel).removeAttr('data-bip-mode'); 270 $form.removeClass('bm-form-locked'); 271 $progress.text('').prop('hidden', true); 272 } 273 } 274 275 function bmStartSyndicationPoll(listingId, $btn, idleLabel) { 276 if (!listingId || !$btn || !$btn.length) return; 277 bmStopSyndicationPoll(listingId); 278 279 const tick = () => { 280 $.ajax({ 281 url: BIP_AJAX_URL, 282 type: 'POST', 283 data: { action: 'bbp_get_network_post_links', post_id: listingId }, 284 success: function (status) { 285 bmApplySyndicationUi(listingId, status, $btn, idleLabel); 286 if (!status || !status.in_progress) { 287 bmStopSyndicationPoll(listingId); 288 } 289 }, 290 error: function () { 291 // Network/permission error â back off and try again on 292 // the next interval. Don't unlock the button on a 293 // failed status read since we genuinely don't know. 294 } 295 }); 296 }; 297 298 tick(); // immediate first check so a "just finished" sync doesn't wait 5s 299 synd_polls[listingId] = setInterval(tick, SYND_POLL_INTERVAL_MS); 300 } 301 302 // When button is clicked, open modal & fetch post meta 303 $('.bm-edit-network-listing').on('click', function () { 304 postID = $(this).data('post-id'); 305 $('#network_post_id').val(postID); 306 $('#editListingModal').find('input, textarea').val(''); 307 $('#np_loader').hide(); 308 $('#np_save_notice').hide(); 309 310 $.ajax({ 311 url: BIP_AJAX_URL, 312 type: 'POST', 313 data: { 314 action: 'bbp_get_network_post_data', 315 post_id: postID 316 }, 317 beforeSend: function () { 318 $('#editListingModal').find('.bm-loading-image').show(); 319 console.log('fetching...'); 320 }, 321 success: function (response) { 322 $('#editListingModal').find('.bm-loading-image').hide(); 323 $('#np_title').val(response.title || ''); 324 $('#np_content').val(response.content || ''); 325 $('#np_email').val(response.email || ''); 326 $('#np_phone').val(response.phone || ''); 327 $('#np_street').val(response.street || ''); 328 $('#np_zip').val(response.zip || ''); 329 330 // Service-area listings hide street / zip / city-state-country 331 // note. Every row that's pin-only carries data-hide-for-sab=1
332 // on its wrapper so this stays declarative. 333 var isSAB = (response.service_area || '').toString().trim() !== ''; 334 $('[data-hide-for-sab="1"]').toggle(!isSAB); 335 336 $('#np_facebook').val(response.facebook || ''); 337 $('#np_x').val(response.x || ''); 338 $('#np_linkedin').val(response.linkedin || ''); 339 $('#np_youtube').val(response.youtube || ''); 340 $('#np_instagram').val(response.instagram || ''); 341 $('#np_tiktok').val(response.tiktok || ''); 342 $('#network_post_id').val( postID || ''); 343 344 if (typeof window.bipSetCategory === 'function') { 345 window.bipSetCategory('#np_category_parent', response.parent_category || '', response.child_category || ''); 346 } 347 348 console.log(response); 349 350 if (response.business_hours) { 351 setBusinessHoursFields(response.business_hours); 352 } 353 354 if (response.services) { 355 setServicesFields(response.services); 356 } 357 if (response.keywords) { 358 setKeywordsFields(response.keywords); 359 } 360 // Hydrate the tag-selector chip input with the listing's 361 // currently-saved tags. setTagSelectorValues clears any 362 // previous chips, drops in new ones, and re-renders the 363 // dropdown so the suggestion list reflects what's left. 364 setTagSelectorValues($('.bm-edit-tag-selector'), response.tags || []); 365 setFaqFields($('.bm-edit-faq-editor'), response.faqs || []); 366 if (typeof window.bipFaqRefresh === 'function') { 367 window.bipFaqRefresh(); // "N / 5" + the add button, for the rows just dropped in 368 } 369 setDescriptionLimit(response.custom_permissions); 370 renderUploadedImages(response.uploaded_images || [], postID); 371 // Refresh field counters once the textareas have been populated. 372 if (typeof window.bmRefreshFieldCounters === 'function') { 373 window.bmRefreshFieldCounters(); 374 } 375 376 // The listing may already be in the middle of a fan-out 377 // from an earlier save (the user closed the modal and 378 // reopened it, or saved in a different tab). Kick the 379 // poller â if not syncing, tick() finishes immediately 380 // with in_progress=false and leaves the button alone. 381 // Capture .html() (not .text()) so the trailing <i> check 382 // icon is preserved when the button is restored. 383 const $editBtn = $('#submit-edits-btn'); 384 if ($editBtn.length) { 385 bmStartSyndicationPoll(postID, $editBtn, $editBtn.data('idle-label') || $editBtn.html()); 386 } 387 }, 388 error(response) { 389 $('#editListingModal').find('.bm-loading-image').hide(); 390 console.log(response); 391 } 392 }); 393 }); 394 395 396 397 // The admin's per-listing description limit (hub: custom-permissions.php). 398 // It rides in on the get_data response and lands on the textarea's 399 // data-limit-words, which is what the counter and the submit guard read. 400 // A listing saved before this existed has no value: 100, the default. 401 function setDescriptionLimit(perms) { 402 const limit = parseInt((perms || {})['description max word limit'], 10) || 100; 403 $('#np_content').attr('data-limit-words', limit); 404 $('.bm-desc-limit').text(limit); 405 } 406 407 // ==================================================================== 408 // Field limit counters (Content / Keywords) â live count 409 // + block submit when any field exceeds its cap. 410 // Limits live on the textarea via data-limit-{words|keywords}; the 411 // description's number is per listing (setDescriptionLimit above). 412 // ==================================================================== 413 function bmCountWords(text) { 414 const t = (text || '').trim(); 415 if (!t) return 0;
416 return t.split(/\s+/).filter(Boolean).length; 417 } 418 function bmCountKeywords(text) { 419 // Keywords are one-per-line in the textarea. Split on NEWLINE 420 // ONLY â commas inside a single keyword are legal (e.g. 421 // "Personal Trainer in Holloway, London"). 422 return (text || '').split(/[\r\n]+/).map(s => s.trim()).filter(Boolean).length; 423 } 424 function bmGetCount($ta) { 425 const val = $ta.val(); 426 if ($ta.attr('data-limit-words')) return { count: bmCountWords(val), limit: parseInt($ta.attr('data-limit-words'), 10) }; 427 if ($ta.attr('data-limit-keywords')) return { count: bmCountKeywords(val), limit: parseInt($ta.attr('data-limit-keywords'), 10) }; 428 return null; 429 } 430 function bmUpdateCounter($ta) { 431 const info = bmGetCount($ta); 432 if (!info) return; 433 const id = $ta.attr('id'); 434 const $c = $('.bm-field-counter[data-for="' + id + '"]'); 435 $c.find('.bm-count').text(info.count); 436 $c.toggleClass('is-over', info.count > info.limit); 437 } 438 $(document).on('input', '#np_content, #np_keywords', function () { 439 bmUpdateCounter($(this)); 440 }); 441 // Initial pass when the modal is populated by AJAX (response handler 442 // calls .val()/.text() then we want the counters to reflect that). 443 window.bmRefreshFieldCounters = function () { 444 $('#np_content, #np_keywords').each(function () { bmUpdateCounter($(this)); }); 445 }; 446 447 // On form submit - WHEN EDIT FORM SUBMIT (My Network Posts) 448 $('#network-listing-edit-form').on('submit', function (e) { 449 e.preventDefault(); 450 451 // "Continue" mode â the last fan-out was killed short of full 452 // coverage and the poller flipped the button to Continue. 453 // Fire the resume AJAX and re-arm the poller; skip the 454 // normal save pipeline entirely (nothing new to write). 455 const $submitBtn = $('#submit-edits-btn'); 456 if ($submitBtn.attr('data-bip-mode') === 'continue') { 457 const listingId = $('#network_post_id').val(); 458 if (!listingId) return; 459 $submitBtn.prop('disabled', true) 460 .html('<i class="fa fa-circle-notch fa-spin" aria-hidden="true" style="margin-right:6px;"></i>Continuingâ¦'); 461 $.ajax({ 462 url: BIP_AJAX_URL, 463 type: 'POST', 464 data: { action: 'bbp_continue_network_sync', post_id: listingId }, 465 success: function () { 466 // Restart the poller â its next tick will pick up 467 // in_progress=true and flip the UI back to Publishing. 468 bmStartSyndicationPoll(listingId, $submitBtn, 'Save <i class="fa fa-check" aria-hidden="true"></i>'); 469 }, 470 error: function (xhr) { 471 const msg = (xhr && xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) || 'Could not resume.'; 472 alert(msg); 473 $submitBtn.prop('disabled', false) 474 .html('Continue <i class="fa fa-refresh" aria-hidden="true"></i>'); 475 } 476 }); 477 return; 478 } 479 480 // Block submit if any limit is exceeded. 481 const limitErrors = []; 482 $('#np_content, #np_keywords').each(function () { 483 const info = bmGetCount($(this)); 484 if (info && info.count > info.limit) { 485 // The description limit is per listing, so the label reads the live one. 486 const labelKey = $(this).attr('data-limit-words') ? 'Description (max ' + info.limit + ' words)' 487 : $(this).attr('data-limit-keywords') ? 'Keywords (max ' + info.limit + ')' 488 : 'Field'; 489 limitErrors.push(labelKey + ' is over the limit (' + info.count + '/' + info.limit + ').'); 490 } 491 }); 492 if (limitErrors.length) { 493 alert(limitErrors.join('\n')); 494 return; 495 } 496 497 // prepare business hours data 498 let businessHours = {}; 499 $('#business-hours-section [data-day]').each(function () { 500 const $row = $(this); 501 const day = $row.data('day'); 502 const isClosed = $row.find('.closed').prop('checked'); 503 const hour = $row.find('.business-hour').val(); 504 505 506 if (isClosed) { 507 businessHours[day] = 'Closed'; 508 } else if (hour) { 509 businessHours[day] = `${hour}`; 510 } else { 511 businessHours[day] = ''; // optional 512 } 513 }); 514 console.log(businessHours); 515 516
517 // Initialize services and keywords objects 518 let services = {}; 519 let keywords = []; 520 521 // Get the raw services and keywords input values 522 const rawServices = $('#np_services').val() || ''; 523 const rawKeywords = $('#np_keywords').val() || ''; 524 525 // Set a default title for services 526 const serviceTitle = $('#np_services_title').val() || 'Services'; 527 528 // Process services input: split by new lines, trim whitespace, and filter out empty lines 529 let servicesArray = rawServices 530 .split(/\r?\n/) // split on newline 531 .map(line => line.trim()) // trim whitespace 532 .filter(line => line.length > 0); // remove empty lines 533 534 // Add services to the services object 535 services[serviceTitle] = servicesArray; 536 537 // Process keywords input: split on NEWLINE ONLY. Commas can appear 538 // inside a keyword itself (e.g. "Personal Trainer in Holloway, 539 // London"), so treating them as separators would over-split. 540 let keywordsArray = rawKeywords 541 .split(/[\r\n]+/) // split on newline 542 .map(keyword => keyword.trim()) // trim whitespace 543 .filter(keyword => keyword.length > 0); // remove empty keywords 544 545 keywords = keywordsArray; // only direct array 546 547 // Optional: Log the services and keywords objects to the console for debugging 548 console.log(services); 549 console.log(keywords); 550 551 552 const $btn = $('#submit-edits-btn'); 553 // Capture .html() so the trailing <i class="fa fa-check"> icon 554 // is preserved when the button is restored later (.text() would 555 // strip it). 556 const originalText = $btn.html(); 557 $btn.prop('disabled', true).text('Saving...'); 558 $('#editListingModal').find('.bm-loading-image').show(); 559 560 $.ajax({ 561 url: BIP_AJAX_URL, 562 type: 'POST', 563 data: { 564 action: 'bbp_save_network_post_data', 565 post_id: $('#network_post_id').val(), 566 content: $('#np_content').val(), 567 phone: $('#np_phone').val(), 568 street: $('#np_street').val(), 569 zip: $('#np_zip').val(), 570 facebook: $('#np_facebook').val(), 571 x: $('#np_x').val(), 572 linkedin: $('#np_linkedin').val(), 573 youtube: $('#np_youtube').val(), 574 instagram: $('#np_instagram').val(), 575 tiktok: $('#np_tiktok').val(), 576 category_parent: $('#np_category_parent').val() || '', 577 // #np_category_child is the wrapper <div> of the custom 578 // sub-cat widget (sub-categories.php / sub-cat-selector.js), 579 // not an input â so .val() on it is undefined. The picked 580 // value lives in the hidden input .bip-cat-search-value 581 // inside that wrapper (and mirrored on the wrapper's 582 // data-value attribute). Read the input so the POST 583 // actually carries the sub-cat â without this, the server 584 // sees child='' and the leaf falls back to the parent. 585 category_child: $('#np_category_child .bip-cat-search-value').val() || '', 586 business_hours: businessHours, 587 services: services, 588 keywords: keywords, 589 // FAQ rows â faqs[i][question] / faqs[i][answer] (jQuery 590 // bracket-encodes nested objects). faqs_submitted=1 lets 591 // the server tell "user cleared all rows" from "field absent". 592 faqs: $('.bm-edit-faq-editor .bip-faq-row').map(function () { 593 return { 594 question: $(this).find('.bip-faq-q').val() || '', 595 answer: $(this).find('.bip-faq-a').val() || '' 596 }; 597 }).get(), 598 faqs_submitted: 1, 599 // Tags were never sent by this handler â the PHP side reads 600 // $_POST['tags'] + tags_submitted, so dashboard tag edits 601 // silently no-op'd. Same collect pattern as faqs. 602 tags: $('.bm-edit-tag-selector input[name="tags[]"]').map(function () { 603 return $(this).val(); 604 }).get(), 605 tags_submitted: 1, 606 }, 607 success: function (res) { 608 609 $('#editListingModal').find('.bm-loading-image').hide(); 610 611 if (res && res.success === false) { 612 const msg = (res.data && res.data.message) ? res.data.message : 'Save failed.'; 613 alert(msg); 614 $btn.html(originalText).prop('disabled', false); 615 return; 616 } 617 618 $btn.text('Saved!'); 619 $('#np_save_notice').fadeIn(150); 620 621 // Quick "Saved!" flash, then hand the button over to the 622 // syndication poller â the fan-out is already running in 623 // the background. The modal stays OPEN so the user can 624 // watch live N/total progress in the button label while 625 // the form is locked. The red X (modal header, outside 626 // the form) stays clickable so they can leave anytime; 627 // the fan-out keeps running on the server. 628 setTimeout(() => { 629 $('#np_save_notice').fadeOut(150); 630 $btn.data('idle-label', originalText); 631 bmStartSyndicationPoll($('#network_post_id').val(), $btn, originalText); 632 }, 1500); 633 }, 634 error(response) { 635 $('#editListingModal').find('.bm-loading-image').hide(); 636 $btn.html(originalText).prop('disabled', false); 637 $btn.text('Retry'); 638 639 console.log(response); 640 } 641 }); 642 }); 643 644 645 // ==================================================================== 646 // Uploaded Images: render grid, upload on file select, delete on click 647 // ==================================================================== 648 const UPLOAD_MAX_COUNT = 10; 649 const UPLOAD_ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp']; 650 651 const $grid = $('#np_uploaded_images_grid'); 652 const $btnAdd = $('#np_uploaded_images_btn'); 653 const $input = $('#np_uploaded_images_input'); 654 const $status = $('#np_uploaded_images_status'); 655 const $errorBox = $('#np_uploaded_images_error'); 656 657 function showUploadError(msg) { 658 $errorBox.text(msg); 659 clearTimeout(showUploadError._t); 660 showUploadError._t = setTimeout(() => $errorBox.text(''), 6000); 661 } 662 663 function clearUploadError() { 664 $errorBox.text(''); 665 } 666 667 function updateUploadStatus() { 668 const count = $grid.find('.np-uploaded-image[data-filename]').length; 669 $status.text(count + ' / ' + UPLOAD_MAX_COUNT); 670 $btnAdd.prop('disabled', count >= UPLOAD_MAX_COUNT); 671 } 672 673 function buildThumb(image, isPreview) { 674 const $tile = $('<div class="np-uploaded-image"></div>') 675 .attr('data-filename', image.filename); 676 if (isPreview) $tile.addClass('is-preview'); 677 $('<img>').attr('src', image.url).attr('alt', '').appendTo($tile); 678 679 // Star icon in top-left: filled if preview, outlined otherwise. 680 $('<span class="np-img-preview-pill"></span>') 681 .html(isPreview ? '★' : '☆') 682 .attr('title', isPreview ? 'This is the preview image' : 'Set as preview image') 683 .appendTo($tile); 684 685 $('<button type="button" class="np-img-remove" title="Remove image" aria-label="Remove image">×</button>') 686 .appendTo($tile); 687 return $tile; 688 } 689 690 function renderUploadedImages(images, postID) { 691 $grid.empty().attr('data-post-id', postID); 692 clearUploadError();
693 (images || []).forEach((img, idx) => $grid.append(buildThumb(img, idx === 0))); 694 updateUploadStatus(); 695 } 696 // Expose so the modal-open AJAX success can call it. 697 window.renderUploadedImages = renderUploadedImages; 698 699 // "Add Image" â open file picker 700 $btnAdd.on('click', function () { 701 clearUploadError(); 702 $input.val('').trigger('click'); 703 }); 704 705 // File selected â validate + upload each 706 $input.on('change', function () { 707 const files = Array.from(this.files || []); 708 if (!files.length) return; 709 710 const postID = $grid.attr('data-post-id') || $('#network_post_id').val(); 711 if (!postID) { 712 showUploadError('No listing selected.'); 713 return; 714 } 715 716 const currentCount = $grid.find('.np-uploaded-image[data-filename]').length; 717 const slotsLeft = UPLOAD_MAX_COUNT - currentCount; 718 719 if (slotsLeft <= 0) { 720 showUploadError('Limit reached: ' + UPLOAD_MAX_COUNT + ' images max.'); 721 return; 722 } 723 724 const toUpload = files.slice(0, slotsLeft); 725 if (files.length > slotsLeft) { 726 showUploadError('Only the first ' + slotsLeft + ' image(s) will upload (10 max per listing).'); 727 } 728 729 // Sequential upload â wait for each request to complete before 730 // starting the next. Avoids the read-modify-write race on the 731 // server's uploaded_images JSON when multiple files are picked. 732 (async () => { 733 for (const file of toUpload) { 734 await uploadOne(file, postID); 735 } 736 })(); 737 }); 738 739 function uploadOne(file, postID) { 740 // Client-side validation â input format check on the original. 741 if (!UPLOAD_ALLOWED_MIMES.includes(file.type)) { 742 showUploadError(file.name + ': only JPG, JPEG, PNG, or WEBP allowed.'); 743 return Promise.resolve(); 744 } 745 746 // Optimistic placeholder tile 747 const $placeholder = $('<div class="np-uploaded-image np-uploading"></div>'); 748 const objectURL = URL.createObjectURL(file); 749 $('<img>').attr('src', objectURL).appendTo($placeholder); 750 $grid.append($placeholder); 751 752 // Convert to WebP @ 80% in the browser before uploading. Falls back 753 // to the original file if the browser can't encode WebP. 754 const convert = (typeof window.bipConvertToWebp === 'function') 755 ? window.bipConvertToWebp(file, 0.8).catch(function () { return file; }) 756 : Promise.resolve(file); 757 758 return convert.then(function (uploadFile) { 759 const formData = new FormData(); 760 formData.append('action', 'bip_upload_network_image'); 761 formData.append('post_id', postID); 762 formData.append('file', uploadFile); 763 764 return $.ajax({ 765 url: BIP_AJAX_URL, 766 type: 'POST', 767 data: formData, 768 processData: false, 769 contentType: false, 770 success: function (res) { 771 URL.revokeObjectURL(objectURL); 772 if (res && res.success && res.data && res.data.image) { 773 const isPreview = $grid.find('.np-uploaded-image[data-filename]').length === 0; 774 $placeholder.replaceWith(buildThumb(res.data.image, isPreview)); 775 } else { 776 $placeholder.remove(); 777 const msg = (res && res.data && res.data.message) ? res.data.message : 'Upload failed.'; 778 showUploadError(file.name + ': ' + msg); 779 } 780 updateUploadStatus(); 781 }, 782 error: function (xhr) { 783 URL.revokeObjectURL(objectURL); 784 $placeholder.remove(); 785 let msg = 'Upload failed.'; 786 if (xhr && xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) { 787 msg = xhr.responseJSON.data.message; 788 } 789 showUploadError(file.name + ': ' + msg); 790 updateUploadStatus(); 791 } 792 }); 793 }); 794 } 795 796 // Click X (delegated, since tiles are added dynamically) 797 $grid.on('click', '.np-img-remove', function (e) { 798 e.preventDefault(); 799 const $tile = $(this).closest('.np-uploaded-image'); 800 const filename = $tile.attr('data-filename'); 801 const postID = $grid.attr('data-post-id') || $('#network_post_id').val(); 802 if (!filename || !postID) return; 803 804 // Client-side min-1 check (server enforces too). 805 const total = $grid.find('.np-uploaded-image[data-filename]').length; 806 if (total <= 1) { 807 showUploadError('A listing must have at least one image. Add another image first, then delete this one.'); 808 return; 809 } 810 811 if (!confirm('Remove this image?')) return; 812 813 $tile.css('opacity', 0.4); 814 815 $.ajax({ 816 url: BIP_AJAX_URL, 817 type: 'POST', 818 data: { 819 action: 'bip_delete_network_image', 820 post_id: postID, 821 filename: filename 822 }, 823 success: function (res) { 824 if (res && res.success) { 825 $tile.remove(); 826 // If we deleted the preview, the next one becomes preview â re-paint stars. 827 $grid.find('.np-uploaded-image').removeClass('is-preview'); 828 $grid.find('.np-uploaded-image').first().addClass('is-preview'); 829 $grid.find('.np-uploaded-image').each(function (idx) { 830 const $pill = $(this).find('.np-img-preview-pill'); 831 if (idx === 0) { 832 $pill.html('★').attr('title', 'This is the preview image'); 833 } else { 834 $pill.html('☆').attr('title', 'Set as preview image'); 835 } 836 }); 837 } else { 838 $tile.css('opacity', 1); 839 const msg = (res && res.data && res.data.message) ? res.data.message : 'Delete failed.'; 840 showUploadError(msg); 841 } 842 updateUploadStatus(); 843 }, 844 error: function () { 845 $tile.css('opacity', 1); 846 showUploadError('Delete failed (network error).'); 847 updateUploadStatus(); 848 } 849 }); 850 }); 851 852 // Click pill (set-as-preview) 853 $grid.on('click', '.np-img-preview-pill', function (e) { 854 e.preventDefault(); 855 const $tile = $(this).closest('.np-uploaded-image'); 856 if ($tile.hasClass('is-preview')) return; // already preview 857 const filename = $tile.attr('data-filename'); 858 const postID = $grid.attr('data-post-id') || $('#network_post_id').val(); 859 if (!filename || !postID) return; 860 861 $.ajax({ 862 url: BIP_AJAX_URL, 863 type: 'POST', 864 data: { 865 action: 'bip_set_preview_network_image', 866 post_id: postID, 867 filename: filename 868 }, 869 success: function (res) { 870 if (res && res.success && res.data && res.data.images) { 871 renderUploadedImages(res.data.images, postID); 872 } else { 873 const msg = (res && res.data && res.data.message) ? res.data.message : 'Could not set preview.'; 874 showUploadError(msg); 875 } 876 }, 877 error: function () { 878 showUploadError('Could not set preview (network error).'); 879 } 880 }); 881 }); 882 } 883 initNetworkPostActions(); 884 885 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 886 // WP-admin: disable the "Update" button on the network_signup edit 887 // screen while a fan-out is in flight. 888 // 889 // The CPT meta box renders a #network_post_id hidden input inside the 890 // .bip-ns-box wrapper (see cpt.php). We use that as the signal that 891 // we're on the right admin screen, then poll the same status endpoint 892 // the customer modal uses. The standard wp-admin Publish/Update 893 // controls are #publish + #save-post. We re-tag #publish with 894 // "Syndicatingâ¦" while in_progress and restore "Update" when done. 895 // A small inline notice is injected above the publish box. 896 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 897 (function bipAdminSyndicationLock() { 898 if (!$('body').hasClass('wp-admin')) return; 899 const $adminBox = $('.bip-ns-box'); 900 if (!$adminBox.length) return; 901 const adminPostId = parseInt($adminBox.find('#network_post_id').val(), 10) || 0; 902 if (!adminPostId) return; 903 904 const $publishBtn = $('#publish'); 905 const $saveBtn = $('#save-post'); 906 if (!$publishBtn.length && !$saveBtn.length) return; 907 908 const originalPublishText = $publishBtn.val() || 'Update';
909 const ADMIN_POLL_INTERVAL_MS = 5000; 910 let adminPoll = null; 911 912 // Inline notice BELOW the Update button (inserted after 913 // #major-publishing-actions, which holds the Publish/Update CTA). 914 // Two states share the notice: 915 // * inProgress â yellow, "Publishing⦠you can leave this page" 916 // * canContinue â orange, "N/587 completed. Click Continue for 917 // remaining sites." + a Continue button 918 // The #publish button itself carries the live "Publishing N/total" 919 // label while running; the Continue button is rendered in the 920 // notice (NOT bolted onto #publish) so an admin's click on Update 921 // still means "save the form", never "resume the sync". 922 const $notice = $('<p id="bip-admin-syndicating-notice" style="display:none;margin:10px 0 0;padding:8px 10px;background:#fff8e1;border-left:4px solid #ffb900;color:#856404;font-size:13px;"><span class="dashicons bip-admin-syndicating-icon" style="vertical-align:middle;margin-right:4px;"></span><span class="bip-admin-syndicating-text"></span> <button type="button" class="button button-secondary bip-admin-continue-btn" style="display:none;margin-left:8px;">Continue</button></p>'); 923 const $publishActions = $('#major-publishing-actions'); 924 if ($publishActions.length) { 925 $publishActions.after($notice); 926 } else { 927 // Fallback for themes/plugins that removed the standard wrap â 928 // drop it at the bottom of the publish meta box. 929 $('#submitdiv .inside').append($notice); 930 } 931 const $noticeIcon = $notice.find('.bip-admin-syndicating-icon'); 932 const $noticeText = $notice.find('.bip-admin-syndicating-text'); 933 const $continueBtn = $notice.find('.bip-admin-continue-btn'); 934 935 // Same shape as bmFormatProgress (inside initNetworkPostActions) 936 // but kept local since it lives in a sibling IIFE. Reads 937 // completed_count (current run only), not completed. 938 function adminFormatProgress(status) { 939 if (!status) return ''; 940 const done = parseInt(status.completed_count, 10) || 0; 941 const total = parseInt(status.total, 10) || 0; 942 if (!total) return ''; 943 const pct = Math.min(100, Math.round((done / total) * 100)); 944 return done + '/' + total + ' (' + pct + '%)'; 945 } 946 947 function applyAdmin(status) { 948 const inProgress = !!(status && status.in_progress); 949 const canContinue = !!(status && status.can_continue); 950 const done = parseInt(status && status.completed_count, 10) || 0; 951 const total = parseInt(status && status.total, 10) || 0; 952 953 if (inProgress) { 954 const progress = adminFormatProgress(status); 955 $publishBtn.val(progress ? 'Publishing⦠' + progress : 'Syndicatingâ¦') 956 .prop('disabled', true).addClass('disabled'); 957 $saveBtn.prop('disabled', true).addClass('disabled'); 958 $notice.css({ background: '#fff8e1', 'border-left-color': '#ffb900', color: '#856404' }).show(); 959 $noticeIcon.attr('class', 'dashicons bip-admin-syndicating-icon dashicons-update'); 960 $noticeText.text('Publishing this listing to the network sites. You can leave this page; it will keep running.'); 961 $continueBtn.hide().prop('disabled', false).text('Continue'); 962 } else if (canContinue) { 963 // Killed / stalled run â Update button restored (a normal 964 // save is still allowed), Continue button offered in the 965 // notice to resume the killed fan-out from where it stopped. 966 $publishBtn.val(originalPublishText).prop('disabled', false).removeClass('disabled'); 967 $saveBtn.prop('disabled', false).removeClass('disabled'); 968 $notice.css({ background: '#fef2e0', 'border-left-color': '#e07b00', color: '#8a4b00' }).show(); 969 $noticeIcon.attr('class', 'dashicons bip-admin-syndicating-icon dashicons-warning'); 970 $noticeText.text(done + '/' + total + " completed. Click 'Continue' for remaining sites."); 971 $continueBtn.show().prop('disabled', false).text('Continue'); 972 } else { 973 $publishBtn.val(originalPublishText).prop('disabled', false).removeClass('disabled'); 974 $saveBtn.prop('disabled', false).removeClass('disabled'); 975 $notice.hide(); 976 $continueBtn.hide(); 977 } 978 } 979 980 // Continue click â fires the resume AJAX, then re-arms the poll 981 // so the notice flips back to "Publishingâ¦" on the next tick. 982 $continueBtn.on('click', function () { 983 const $btn = $(this); 984 $btn.prop('disabled', true).text('Continuingâ¦'); 985 $.ajax({ 986 url: BIP_AJAX_URL, 987 type: 'POST', 988 data: { action: 'bbp_continue_network_sync', post_id: adminPostId }, 989 success: function () { 990 if (!adminPoll) { 991 adminTick(); 992 adminPoll = setInterval(adminTick, ADMIN_POLL_INTERVAL_MS); 993 } else { 994 adminTick(); 995 } 996 }, 997 error: function (xhr) { 998 const msg = (xhr && xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) || 'Could not resume.'; 999 alert(msg); 1000 $btn.prop('disabled', false).text('Continue'); 1001 } 1002 }); 1003 }); 1004 1005 function adminTick() { 1006 $.ajax({ 1007 url: BIP_AJAX_URL, 1008 type: 'POST', 1009 data: { action: 'bbp_get_network_post_links', post_id: adminPostId }, 1010 success: function (status) { 1011 applyAdmin(status); 1012 if ((!status || !status.in_progress) && adminPoll) { 1013 clearInterval(adminPoll); 1014 adminPoll = null; 1015 } 1016 }, 1017 error: function () { /* back off, retry next tick */ } 1018 }); 1019 } 1020 adminTick(); 1021 adminPoll = setInterval(adminTick, ADMIN_POLL_INTERVAL_MS); 1022 1023 // The user clicked Update â they're about to trigger a sync, so 1024 // re-arm the poll on a delay (gives WP a beat to start the fan-out 1025 // before we check again). 1026 $('#post').on('submit', function () { 1027 setTimeout(function () { 1028 if (!adminPoll) { 1029 adminTick(); 1030 adminPoll = setInterval(adminTick, ADMIN_POLL_INTERVAL_MS); 1031 } 1032 }, 2000); 1033 }); 1034 })(); 1035 1036 // Poll bbp_get_network_post_links until the server flags in_progress=false. 1037 // Calls opts.render(response) every cycle so the caller can incrementally 1038 // add newly-finished links + show a "still processing" footer. 1039 // Returns a stop() function the caller can use to cancel polling. 1040 function bipPollNetworkLinks(postID, opts) { 1041 opts = opts || {}; 1042 const POLL_MS = 10000; // every 10 s 1043 const MAX_ATTEMPTS = 90; // hard cap (~15 min) 1044 let attempts = 0; 1045 let stopped = false;
1046 let timer = null; 1047 1048 function stop() { 1049 stopped = true; 1050 if (timer) { clearTimeout(timer); timer = null; } 1051 } 1052 1053 function doFetch() { 1054 if (stopped) return; 1055 $.ajax({ 1056 url: BIP_AJAX_URL, 1057 type: 'POST', 1058 data: { action: 'bbp_get_network_post_links', post_id: postID }, 1059 success: function (response) { 1060 if (stopped) return; 1061 if (typeof opts.render === 'function') { 1062 try { opts.render(response); } catch (e) { /* ignore renderer errors */ } 1063 } 1064 if (response && response.in_progress && attempts < MAX_ATTEMPTS) { 1065 attempts++; 1066 timer = setTimeout(doFetch, POLL_MS); 1067 } else { 1068 stopped = true; 1069 } 1070 }, 1071 error: function () { 1072 if (stopped) return; 1073 if (attempts < MAX_ATTEMPTS) { 1074 attempts++; 1075 timer = setTimeout(doFetch, POLL_MS); 1076 } else { 1077 stopped = true; 1078 } 1079 } 1080 }); 1081 } 1082 doFetch(); 1083 return stop; 1084 } 1085 window.bipPollNetworkLinks = bipPollNetworkLinks; 1086 1087 // show network sites link (customer dashboard) 1088 const showNetworkSitesLink = () => { 1089 $('.bm-view-network-links').on('click', function () { 1090 const postID = $(this).data('post-id'); 1091 const $modal = $('#networkLinksModal'); 1092 const $container = $modal.find('.network-links-group'); 1093 const $loading = $modal.find('.bm-loading-image'); 1094 1095 $container.empty(); 1096 $loading.show(); 1097 1098 // Cancel any previous poll (switching listings without closing the modal). 1099 if (window.__bipLinksStop) { try { window.__bipLinksStop(); } catch (e) {} } 1100 1101 const $statusFooter = $modal.find('.bm-network-status-footer'); 1102 $statusFooter.text('').hide(); // reset between opens 1103 1104 const seen = {}; // dedupe links across poll cycles 1105 window.__bipLinksStop = bipPollNetworkLinks(postID, { 1106 render: function (response) { 1107 $loading.hide(); 1108 const links = (response && response.links) || []; 1109 const total = (response && response.total) || 0; 1110 const completed = (response && response.completed) || links.length; 1111 const inProgress = !!(response && response.in_progress); 1112 1113 let added = false; 1114 links.forEach(function (link) { 1115 if (seen[link]) return; 1116 seen[link] = true; 1117 added = true; 1118 $container.append( 1119 '<li style="margin-bottom:1rem;">' 1120 + '<a href="' + link + '" target="_blank">' + link + '</a>' 1121 + '<span data-link="' + link + '" class="bm-copy-embed-iframe" title="Click to copy embed code" style="color:#004c6d;cursor:pointer;"> | Embed</span>' 1122 + '</li>' 1123 ); 1124 }); 1125 if (added) { 1126 copyNetworkSiteEmbedCode(); 1127 } 1128 1129 // Status line now lives in the modal footer (next to the Close 1130 // button) rather than as a trailing <li> inside the list â keeps 1131 // the link list clean and the progress info always visible even 1132 // when the body is scrolled mid-list. 1133 if (inProgress) { 1134 const remaining = total > completed ? (total - completed) : 0; 1135 const remainingText = remaining 1136 ? remaining + ' still processing â come back in a while to see more.' 1137 : 'More are processing â come back in a while.'; 1138 const statusText = links.length === 0 1139 ? 'Processing network setup⦠come back in a while to see your links.' 1140 : 'Published to ' + completed + (total ? ' / ' + total : '') + ' sites s
1140o far. ' + remainingText; 1141 $statusFooter.text(statusText).show(); 1142 } else if (links.length === 0) { 1143 $statusFooter.text('No links found.').show(); 1144 } else { 1145 // All done â drop the in-progress line; the list speaks for itself. 1146 $statusFooter.text('').hide(); 1147 } 1148 } 1149 }); 1150 }); 1151 } 1152 showNetworkSitesLink(); 1153 1154 1155 1156 // show network sites link (wp-admin) â same progressive-poll pattern as the 1157 // customer modal above, just a different DOM target. 1158 const showNetworkSitesLinkAdmin = () => { 1159 $(document).on('click', '.bip-show-network-links-admin', function (e) { 1160 e.preventDefault(); 1161 1162 const postID = $(this).data('post-id'); 1163 const $modal = $('#link_modal_admin'); 1164 const $linksList = $modal.find('.link-list'); 1165 1166 $linksList.empty(); 1167 $modal.fadeIn(300); 1168 1169 // Cancel any previous poll. 1170 if (window.__bipLinksAdminStop) { try { window.__bipLinksAdminStop(); } catch (e) {} } 1171 1172 const seen = {}; 1173 window.__bipLinksAdminStop = bipPollNetworkLinks(postID, { 1174 render: function (response) { 1175 const links = (response && response.links) || []; 1176 const total = (response && response.total) || 0; 1177 const completed = (response && response.completed) || links.length; 1178 const inProgress = !!(response && response.in_progress); 1179 1180 $linksList.find('.bip-status-row').remove(); 1181 1182 links.forEach(function (link) { 1183 if (seen[link]) return; 1184 seen[link] = true; 1185 $linksList.append('<li><a href="' + link + '" target="_blank">' + link + '</a></li>'); 1186 }); 1187 1188 if (inProgress) { 1189 const remaining = total > completed ? (total - completed) : 0; 1190 const text = links.length === 0 1191 ? 'Processing network setup⦠come back in a while.' 1192 : 'Published to ' + completed + (total ? ' / ' + total : '') + ' sites so far. ' 1193 + (remaining 1194 ? remaining + ' still processing â come back in a while to see more.' 1195 : 'More are processing â come back in a while.'); 1196 $linksList.append('<li class="bip-status-row" style="list-style:none;color:#777;font-style:italic;margin-top:1rem;">' + text + '</li>'); 1197 } else if (links.length === 0) { 1198 $linksList.append('<li class="bip-status-row"><strong>No links found.</strong></li>'); 1199 } 1200 } 1201 }); 1202 }); 1203 1204 // Close modal â also stops polling so we don't keep hitting the server. 1205 $(document).on('click', '.close-modal, #link_modal_admin', function (e) { 1206 if ($(e.target).is('#link_modal_admin') || $(e.target).hasClass('close-modal')) { 1207 $('#link_modal_admin').fadeOut(300); 1208 if (window.__bipLinksAdminStop) { try { window.__bipLinksAdminStop(); } catch (err) {} } 1209 } 1210 }); 1211 } 1212 showNetworkSitesLinkAdmin(); 1213 1214 1215 const copyNetworkSiteEmbedCode = () => { 1216 $('.bm-copy-embed-iframe').on('click', function () { 1217 let link = $(this).data('link'); 1218 1219 let embedCode = ` 1220 <iframe sandbox="allow-scripts" security="restricted" 1221 src="${link}" 1222 width="100%" height="1000" 1223 title="Embedded Listing â Bipper Media" 1224 frameborder="0" marginwidth="0" marginheight="0" scrolling="yes" 1225 style="width: 100%; max-width: 100%; min-height: 1000px;" 1226 class="wp-embedded-content"> 1227 </iframe> 1228 `; 1229 navigator.clipboard.writeText(embedCode).then(function() { 1230 console.log('Copied to clipboard: ', embedCode); 1231 }, function(err) { 1232 console.error('Failed to copy: ', err); 1233 }); 1234 1235 // change button text 1236 $(this).css('color', '#459700'); 1237 $(this).html(' | <i class="fas fa-check"></i> Copied!'); 1238 1239 setTimeout(() => { 1240 $(this).css('color', '#004c6d'); 1241 $(this).html(' | Embed'); 1242 }, 3000); 1243 1244 }); 1245 } 1246 1247});
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.