PageSourceSearch

https://bippermedia.com/wp-content/plugins/bippermedia/includes/bip-network/assets/network.js?ver=1789990520

js bippermedia.com collected 2026-10-09 22:55:55 UTC 60,891 bytes, 1,247 lines download raw bytes

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">&times;</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, '&amp;').replace(/</g, '&lt;')
176            .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 ? '&#9733;' : '&#9734;')
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">&times;</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('&#9733;').attr('title', 'This is the preview image');
833                            } else {
834                                $pill.html('&#9734;').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.