PageSourceSearch

https://iitix.com/wp-content/plugins/custom-seating-map-spesific-c…/seat-number-injector.js?x58366&ver=1.5.23

js iitix.com collected 2026-09-24 13:23:49 UTC 136,178 bytes, 3,652 lines download raw bytes

1(function ($) {
2  if (!$) return;
3
4  // IITIX CSM v1.4.19: canonical Tickera seat identity + checkout menu autofill.
5
6  var CONFIG = window.IITIX_SEAT_FILTER_CONFIG || {};
7  var EXTRA_CONFIG = window.IITIX_SEAT_EXTRA_FIELDS_CONFIG || {};
8  var SEAT_FILTER_URL = CONFIG.seatFilterUrl || '/wp-json/tickera/v1/product-seat-filter';
9  var DEBUG = !!CONFIG.debug;
10  var requestSeq = 0;
11  var extraBootTimer = null;
12  var isSeatModalClosing = false;
13  var seatModalClosingTimer = null;
14  var lastSeatModalCloseAt = 0;
15  var lastSeatVariationAjaxSentAt = 0;
16
17  function nowMs() {
18    return (new Date()).getTime();
19  }
20
21  function log() {
22    if (!DEBUG || !window.console) return;
23    console.log.apply(console, arguments);
24  }
25
26  function warn() {
27    if (!window.console) return;
28    console.warn.apply(console, arguments);
29  }
30
31  function decodeHtml(raw) {
32    var txt = document.createElement('textarea');
33    txt.innerHTML = raw || '';
34    return txt.value;
35  }
36
37  function normalize(value) {
38    return String(value || '')
39      .trim()
40      .replace(/^['"]+|['"]+$/g, '')
41      .toLowerCase();
42  }
43
44  function normalizeSeat(value) {
45    return String(value || '')
46      .trim()
47      .replace(/^['"]+|['"]+$/g, '')
48      .toUpperCase();
49  }
50
51  // Tickera's modal title is rendered as "Seat FC4", while its checkout
52  // owner metadata uses the raw label "FC4". The visible prefix is not part
53  // of the mapping key; removing it keeps the chart + seat + label triple
54  // stable without ever falling back to attendee/index order.
55  function normalizeSeatIdentity(value) {
56    return normalizeSeat(value).replace(/^SEAT\s+/, '');
57  }
58
59  function esc(value) {
60    return String(value == null ? '' : value)
61      .replace(/&/g, '&')
62      .replace(/</g, '&lt;')
63      .replace(/>/g, '&gt;')
64      .replace(/"/g, '&quot;')
65      .replace(/'/g, '&#039;');
66  }
67
68  /* =========================
69   * Ambil seat number dari DOM
70   * ========================= */
71  function getSeatNumberFromSeatId(seatId) {
72    try {
73      if (!seatId) return '';
74
75      var el = document.getElementById(seatId);
76      if (!el) return '';
77
78      var p = el.querySelector('span p');
79      if (p && p.textContent) return normalizeSeat(p.textContent);
80
81      return normalizeSeat(el.textContent || '');
82    } catch (e) {
83      return '';
84    }
85  }
86
87  /* =========================
88   * Parse query string / ajax body
89   * ========================= */
90  function parseQS(qs) {
91    var obj = {};
92    if (!qs || typeof qs !== 'string') return obj;
93
94    qs.split('&').forEach(function (part) {
95      if (!part) return;
96
97      var idx = part.indexOf('=');
98      var k = idx >= 0 ? part.slice(0, idx) : part;
99      var v = idx >= 0 ? part.slice(idx + 1) : '';
100
101      k = decodeURIComponent(k.replace(/\+/g, ' '));
102      v = decodeURIComponent(v.replace(/\+/g, ' '));
103
104      obj[k] = v;
105    });
106
107    return obj;
108  }
109
110  function getSeatModal() {
111    var modal = $('#tc-modal-woobridge');
112    if (modal.length) return modal;
113    return $('.tc-modal-wrap').last();
114  }
115
116  function getVariationForm() {
117    var modal = getSeatModal();
118    var form = modal.find('.variations_form').first();
119    if (form.length) return form;
120    return $('.variations_form').first();
121  }
122
123  function isSeatModalActuallyVisible(modal) {
124    if (!modal || !modal.length) return false;
125
126    var dialog = modal.closest('.ui-dialog');
127
128    if (!modal.is(':visible')) return false;
129    if (dialog.length && !dialog.is(':visible')) return false;
130
131    return true;
132  }
133
134  /* =========================
135   * Ambil original variations JSON dari form
136   * Jangan filter dari hasil filter sebelumnya.
137   * ========================= */
138  function getOriginalVariationsJSON() {
139    var form = getVariationForm();
140    if (!form.length) return [];
141
142    var cached = form.data('iitixOriginalProductVariations');
143    var raw = form.attr('data-product_variations');
144    var parsed = [];
145
146    if (raw) {
147      try {
148        parsed = JSON.parse(decodeHtml(raw));
149      } catch (e) {
150        warn('[IITIX] Parse variations error', e);
151        parsed = [];
152      }
153    }
154
155    // Keep the biggest/original payload to avoid cumulative filtering when another seat is selected.
156    if (!cached || (parsed.length && parsed.length >= cached.length)) {
157      form.data('iitixOriginalProductVariations', parsed);
158      return parsed;
159    }
160
161    return cached;
162  }
163
164  function getProductIdFromPage(payload) {
165    var form = getVariationForm();
166    var modal = getSeatModal();
167    var candidates = [];
168
169    if (payload) {
170      candidates.push(payload.product_id);
171      candidates.push(payload.tc_product_id);
172      candidates.push(payload.post_id);
173    }
174
175    if (form.length) {
176      candidates.push(form.data('product_id'));
177      candidates.push(form.attr('data-product_id'));
178      candidates.push(form.find('input[name="product_id"]').val());
179      candidates.push(form.find('input[name="add-to-cart"]').val());
180    }
181
182    candidates.push(modal.find('.tc_cart_button').data('product_id'));
183    candidates.push(modal.find('.tc_cart_button').attr('data-product_id'));
184    candidates.push(modal.find('.tc_cart_button[name="add-to-cart"]').val());
185    candidates.push($('button[name="add-to-cart"]').val());
186    candidates.push($('input[name="add-to-cart"]').val());
187
188    var bodyClass = document.body && document.body.className ? document.body.className : '';
189    var match = bodyClass.match(/(?:^|\s)postid-(\d+)(?:\s|$)/);
190    if (match) candidates.push(match[1]);
191
192    for (var i = 0; i < candidates.length; i++) {
193      var n = parseInt(candidates[i], 10);
194      if (n > 0) return n;
195    }
196
197    return 0;
198  }
199
200  function fetchSeatFilter(productId, chartId, seatNumber) {
201    return fetch(SEAT_FILTER_URL, {
202      method: 'POST',
203      credentials: 'same-origin',
204      headers: {
205        'Content-Type': 'application/json',
206        'Accept': 'application/json',
207        'X-WP-Nonce': CONFIG.nonce || ''
208      },
209      body: JSON.stringify({
210        product_id: parseInt(productId, 10),
211        chart_id: parseInt(chartId, 10),
212        seat_label: normalizeSeat(seatNumber)
213      })
214    }).then(function (response) {
215      return response.json().catch(function () {
216        return {};
217      }).then(function (json) {
218        if (!response.ok) {
219          var message = json && json.message ? json.message : ('Seat filter API error ' + response.status);
220          throw new Error(message);
221        }
222        return json;
223      });
224    });
225  }
226
227  function getVariationIdsFromApi(apiResponse) {
228    var ids = {};
229    var groups = apiResponse && Array.isArray(apiResponse.data) ? apiResponse.data : [];
230
231    groups.forEach(function (group) {
232      var options = Array.isArray(group.options) ? group.options : [];
233      options.forEach(function (option) {
234        if (option && option.variation_id) {
235          ids[String(option.variation_id)] = true;
236        }
237      });
238    });
239
240    return ids;
241  }
242
243  function getAllowedValuesFromApi(apiResponse) {
244    var values = {};
245    var categories = Array.isArray(apiResponse && apiResponse.categories) ? apiResponse.categories : [];
246    var groups = apiResponse && Array.isArray(apiResponse.data) ? apiResponse.data : [];
247
248    categories.forEach(function (category) {
249      values[normalize(category)] = true;
250    });
251
252    groups.forEach(function (group) {
253      var options = Array.isArray(group.options) ? group.options : [];
254      options.forEach(function (option) {
255        if (option && option.value) {
256          values[normalize(option.value)] = true;
257        }
258      });
259    });
260
261    return values;
262  }
263
264  function objectHasKeys(obj) {
265    return Object.keys(obj || {}).length > 0;
266  }
267
268  function filterVariationsByApi(variations, apiResponse) {
269    var ids = getVariationIdsFromApi(apiResponse);
270    var allowedValues = getAllowedValuesFromApi(apiResponse);
271
272    if (objectHasKeys(ids)) {
273      return variations.filter(function (variation) {
274        return ids[String(variation.variation_id)] === true;
275      });
276    }
277
278    // Defensive fallback if API returns categories but no variation_id.
279    if (objectHasKeys(allowedValues)) {
280      return variations.filter(function (variation) {
281        var attrs = variation.attributes || {};
282        return Object.keys(attrs).some(function (key) {
283          return allowedValues[normalize(attrs[key])] === true;
284        });
285      });
286    }
287
288    return [];
289  }
290
291  function getReadableCategories(apiResponse) {
292    if (Array.isArray(apiResponse && apiResponse.categories)) {
293      return apiResponse.categories;
294    }
295
296    var out = [];
297    var groups = apiResponse && Array.isArray(apiResponse.data) ? apiResponse.data : [];
298    groups.forEach(function (group) {
299      var options = Array.isArray(group.options) ? group.options : [];
300      options.forEach(function (option) {
301        if (option && option.value && out.indexOf(option.value) === -1) {
302          out.push(option.value);
303        }
304      });
305    });
306    return out;
307  }
308
309  function applySeatFilterModalClass(form) {
310    var modal = getSeatModal();
311    if (!modal.length) return;
312
313    var dialog = modal.closest('.ui-dialog');
314    modal.addClass('iitix-seat-filter-modal');
315    if (dialog.length) dialog.addClass('iitix-seat-filter-dialog');
316
317    if (form && form.length) {
318      form.addClass('iitix-seat-filter-form');
319    }
320  }
321
322  function setSeatFilterModalState(form, state) {
323    var modal = getSeatModal();
324    if (!modal.length) return;
325
326    var dialog = modal.closest('.ui-dialog');
327    var classes = 'iitix-seat-filter-loading iitix-seat-filter-ready iitix-seat-filter-error';
328
329    applySeatFilterModalClass(form);
330    modal.removeClass(classes);
331    if (dialog.length) dialog.removeClass(classes);
332
333    if (state) {
334      modal.addClass('iitix-seat-filter-' + state);
335      if (dialog.length) dialog.addClass('iitix-seat-filter-' + state);
336    }
337  }
338
339  function applySeatFilterModalShellLoading() {
340    if (isSeatModalClosing) return;
341
342    var modal = getSeatModal();
343    if (!modal.length) return;
344
345    var dialog = modal.closest('.ui-dialog');
346    modal
347      .addClass('iitix-seat-filter-modal iitix-seat-filter-loading')
348      .removeClass('iitix-seat-filter-ready iitix-seat-filter-error');
349
350    if (dialog.length) {
351      dialog
352        .addClass('iitix-seat-filter-dialog iitix-seat-filter-loading')
353        .removeClass('iitix-seat-filter-ready iitix-seat-filter-error');
354    }
355  }
356
357  function clearSeatFilterModalState() {
358    var modal = getSeatModal();
359    var dialog = modal.length ? modal.closest('.ui-dialog') : $('.ui-dialog.iitix-seat-filter-dialog');
360    var classes = 'iitix-seat-filter-modal iitix-seat-filter-dialog iitix-seat-filter-loading iitix-seat-filter-ready iitix-seat-filter-error';
361
362    if (modal.length) {
363      modal.removeClass(classes);
364      modal.find('form.variations_form').removeClass('iitix-seat-filter-form');
365    }
366
367    if (dialog.length) {
368      dialog.removeClass(classes);
369    }
370  }
371
372  function setSeatFilterControlsLoading(form, isLoading) {
373    if (!form || !form.length) return;
374
375    form.find('select[name^="attribute_"]').each(function () {
376      var select = $(this);
377
378      if (isLoading) {
379        if (typeof select.data('iitix-seat-filter-was-disabled') === 'undefined') {
380          select.data('iitix-seat-filter-was-disabled', select.prop('disabled'));
381        }
382
383        select.prop('disabled', true);
384        select.addClass('iitix-seat-filter-control-loading');
385        return;
386      }
387
388      if (select.hasClass('iitix-seat-filter-control-loading')) {
389        var wasDisabled = select.data('iitix-seat-filter-was-disabled') === true;
390        select.prop('disabled', wasDisabled);
391        select.removeClass('iitix-seat-filter-control-loading');
392        select.removeData('iitix-seat-filter-was-disabled');
393      }
394    });
395  }
396
397  function ensureNotice(form) {
398    var notice = form.find('.iitix-seat-filter-notice');
399    if (notice.length) return notice;
400
401    notice = $('<div class="iitix-seat-filter-notice" aria-live="polite" />');
402
403    var table = form.find('.variations').first();
404    if (table.length) {
405      notice.insertBefore(table);
406    } else {
407      form.prepend(notice);
408    }
409
410    return notice;
411  }
412
413  function renderCategoryChips(categories) {
414    if (!categories || !categories.length) return '';
415
416    return categories.map(function (category) {
417      return '<span class="iitix-category-chip">' + esc(category) + '</span>';
418    }).join('');
419  }
420
421  function renderNotice(form, state) {
422    var notice = ensureNotice(form);
423    var categories = state.categories || [];
424    var variationCount = state.variationCount || 0;
425    var seat = state.seat || '';
426
427    applySeatFilterModalClass(form);
428    notice.removeClass('is-loading is-ready is-error');
429
430    if (state.loading) {
431      setSeatFilterModalState(form, 'loading');
432      setSeatFilterControlsLoading(form, true);
433
434      notice.addClass('is-loading');
435      notice.html(
436        '<div class="iitix-seat-filter-skeleton-card">' +
437          '<div class="iitix-shimmer-line iitix-shimmer-line-title"></div>' +
438          '<div class="iitix-shimmer-line iitix-shimmer-line-wide"></div>' +
439          '<div class="iitix-shimmer-line iitix-shimmer-line-short"></div>' +
440        '</div>' +
441        '<div class="iitix-seat-filter-loading-text">Checking available categories for Seat ' + esc(seat) + '...</div>'
442      );
443      return;
444    }
445
446    if (state.error) {
447      setSeatFilterModalState(form, 'error');
448      setSeatFilterControlsLoading(form, false);
449
450      notice.addClass('is-error');
451      notice.html(
452        '<div class="iitix-seat-filter-kicker">Seat ' + esc(seat) + '</div>' +
453        '<div class="iitix-seat-filter-title">Category check failed</div>' +
454        '<div class="iitix-seat-filter-desc">Please try selecting the seat again. ' + esc(state.error) + '</div>'
455      );
456      return;
457    }
458
459    setSeatFilterModalState(form, 'ready');
460
461    if (!categories.length || !variationCount) {
462      notice.addClass('is-error');
463      notice.html(
464        '<div class="iitix-seat-filter-kicker">Seat ' + esc(seat) + '</div>' +
465        '<div class="iitix-seat-filter-title">No available category</div>' +
466        '<div class="iitix-seat-filter-desc">This seat has no available category/variation from the API. Please choose another seat or update the chart rules.</div>'
467      );
468      return;
469    }
470
471    notice.addClass('is-ready');
472    notice.html(
473      '<div class="iitix-seat-filter-head">' +
474        '<div>' +
475          '<div class="iitix-seat-filter-kicker">Seat ' + esc(seat) + '</div>' +
476          '<div class="iitix-seat-filter-title">Available categories</div>' +
477        '</div>' +
478        '<span class="iitix-seat-filter-count">' + esc(categories.length) + ' option' + (categories.length > 1 ? 's' : '') + '</span>' +
479      '</div>' +
480      '<div class="iitix-seat-filter-chips">' + renderCategoryChips(categories) + '</div>'
481    );
482  }
483
484  function syncSelectOptionsWithApi(form, apiResponse) {
485    var allowedValues = getAllowedValuesFromApi(apiResponse);
486    var hasAllowed = objectHasKeys(allowedValues);
487
488    form.find('select[name^="attribute_"]').each(function () {
489      var select = $(this);
490      var current = normalize(select.val());
491      var currentAllowed = false;
492      var enabledCount = 0;
493      var firstAllowedValue = '';
494
495      select.find('option').each(function () {
496        var option = $(this);
497        var rawValue = option.val();
498        var value = normalize(rawValue);
499        var text = normalize(option.text());
500
501        if (!value) {
502          option.prop('disabled', false).show();
503          if (hasAllowed) option.text('Choose a category');
504          return;
505        }
506
507        var allowed = hasAllowed && (allowedValues[value] === true || allowedValues[text] === true);
508        option.prop('disabled', !allowed);
509        option.toggle(allowed);
510
511        if (allowed) {
512          enabledCount++;
513          if (!firstAllowedValue) firstAllowedValue = rawValue;
514          if (current && normalize(current) === value) currentAllowed = true;
515        }
516      });
517
518      if (!hasAllowed || !enabledCount) {
519        select.val('');
520      } else if (enabledCount === 1 && firstAllowedValue) {
521        // Important: category-only seats often return exactly 1 category from
522        // /product-seat-filter. Auto-select it so the Add to Cart button can
523        // become active without making the user open an empty-looking dropdown.
524        select.val(firstAllowedValue);
525      } else if (current && !currentAllowed) {
526        select.val('');
527      }
528
529      select.prop('disabled', !enabledCount);
530      select.trigger('change');
531    });
532  }
533
534  function applySingleVariationFallback(form, filteredVariations) {
535    if (!form || !form.length) return false;
536    if (!Array.isArray(filteredVariations) || filteredVariations.length !== 1) return false;
537
538    var variation = filteredVariations[0] || {};
539    var attrs = variation.attributes || {};
540    var applied = false;
541
542    Object.keys(attrs).forEach(function (key) {
543      var value = attrs[key];
544      if (!value) return;
545
546      var select = form.find('select[name="' + key + '"], select[data-attribute_name="' + key + '"]').first();
547      if (!select.length) return;
548
549      if (select.val() !== value) {
550        select.val(value);
551        applied = true;
552      }
553    });
554
555    if (variation.variation_id) {
556      form.find('input.variation_id').val(variation.variation_id).trigger('change');
557      applied = true;
558    }
559
560    form.find('select[name^="attribute_"]').trigger('change');
561    form.trigger('found_variation', [variation]);
562    form.trigger('check_variations');
563
564    stabilizeValidVariationUi(form);
565    scheduleStabilizeValidVariationUi(form);
566
567    // Only unlock the button automatically for normal category-only products.
568    // Whitelisted products still need Menu / Appliance / Watt validation.
569    if (!currentSeatAllowsExtraFields(form) && variation.variation_id) {
570      form.find('.tc_cart_button, .single_add_to_cart_button').first()
571        .removeClass('disabled wc-variation-selection-needed iitix-extra-invalid')
572        .prop('disabled', false)
573        .attr('aria-disabled', 'false')
574        .removeAttr('title');
575    }
576
577    return applied;
578  }
579
580
581  function forceShowVariationControlsForValidSeat(form) {
582    if (!form || !form.length) return;
583
584    var variationId = $.trim(form.find('input.variation_id').val() || '');
585    if (!variationId) return;
586
587    var table = form.find('table.variations').first();
588    if (table.length) {
589      table.css('display', 'table').show();
590      table.find('tr').css('display', 'table-row').show();
591      table.find('th,td').css('display', 'table-cell').show();
592    }
593
594    form.find('select[name^="attribute_"]').each(function () {
595      $(this)
596        .prop('disabled', false)
597        .removeClass('disabled')
598        .css('display', '')
599        .show();
600    });
601  }
602
603  function normalizeValidVariationButton(form) {
604    if (!form || !form.length) return;
605
606    var variationId = $.trim(form.find('input.variation_id').val() || '');
607    if (!variationId) return;
608
609    var btn = form.find('.tc_cart_button, .single_add_to_cart_button').first();
610    if (!btn.length) return;
611
612    var text = $.trim(btn.text() || '').toLowerCase();
613    if (text.indexOf('currently not available') !== -1 || text.indexOf('not available') !== -1) {
614      btn.text('Add to Cart');
615    }
616
617    btn.removeClass('tc-seat-unavailable tc_seat_unavailable seat-unavailable unavailable out-of-stock');
618    btn.removeAttr('data-seat-unavailable data-unavailable');
619  }
620
621  function stabilizeValidVariationUi(form) {
622    forceShowVariationControlsForValidSeat(form);
623    normalizeValidVariationButton(form);
624  }
625
626  function scheduleStabilizeValidVariationUi(form) {
627    if (!form || !form.length) return;
628    [20, 80, 180, 360, 700, 1200].forEach(function (delay) {
629      window.setTimeout(function () {
630        stabilizeValidVariationUi(form);
631      }, delay);
632    });
633  }
634
635  function updateVariationsForm(filteredVariations, apiResponse, meta) {
636    var form = getVariationForm();
637    if (!form.length) return;
638
639    form.attr('data-product_variations', JSON.stringify(filteredVariations));
640    form.data('product_variations', filteredVariations);
641
642    setSeatFilterControlsLoading(form, false);
643
644    if ($.fn.wc_variation_form) {
645      form.wc_variation_form();
646      form.trigger('reload_product_variations');
647      form.trigger('reset_data');
648    }
649
650    syncSelectOptionsWithApi(form, apiResponse);
651    applySingleVariationFallback(form, filteredVariations);
652    stabilizeValidVariationUi(form);
653    scheduleStabilizeValidVariationUi(form);
654
655    if ($.fn.wc_variation_form) {
656      form.trigger('check_variations');
657    }
658
659    renderNotice(form, {
660      seat: meta.seatNumber,
661      categories: getReadableCategories(apiResponse),
662      variationCount: filteredVariations.length
663    });
664
665    updateExtraFieldsVisibility(form);
666    validateExtraFields(form, false);
667
668    // WooCommerce can re-render variation state a few ms after reload_product_variations.
669    // Re-apply the single allowed category once more so category-only seats do not
670    // fall back to a blank dropdown / disabled Add to Cart button.
671    window.setTimeout(function () {
672      syncSelectOptionsWithApi(form, apiResponse);
673      applySingleVariationFallback(form, filteredVariations);
674      stabilizeValidVariationUi(form);
675      if ($.fn.wc_variation_form) form.trigger('check_variations');
676      validateExtraFields(form, false);
677    }, 40);
678
679    log('[IITIX] variations updated from API', {
680      api: apiResponse,
681      filteredVariations: filteredVariations
682    });
683  }
684
685  function restoreOriginalVariations() {
686    var form = getVariationForm();
687    if (!form.length) return;
688
689    var original = form.data('iitixOriginalProductVariations') || [];
690    if (!original.length) return;
691
692    form.attr('data-product_variations', JSON.stringify(original));
693    form.data('product_variations', original);
694
695    form.find('select[name^="attribute_"]').each(function () {
696      $(this).prop('disabled', false).find('option').prop('disabled', false).show();
697    });
698
699    if ($.fn.wc_variation_form) {
700      form.wc_variation_form();
701      form.trigger('reload_product_variations');
702      form.trigger('reset_data');
703    }
704  }
705
706  function updateSeatLabel(seatNumber) {
707    if (!seatNumber) return;
708    $('#tc-modal-woobridge .tc_regular_modal_seat_label').text('Seat ' + seatNumber);
709  }
710
711  function shouldHandleTickeraVariationAjax(settings) {
712    if (!settings || !settings.data || typeof settings.data !== 'string') return false;
713    return settings.data.indexOf('action=tc_seat_chart_get_wc_variations') !== -1;
714  }
715
716  /* =========================
717   * Extra fields: Menu + Appliance + Wattage
718   * ========================= */
719  function normalizeCategoryProfileKey(value) {
720    return normalize(value)
721      .replace(/[^a-z0-9]+/g, '-')
722      .replace(/^-+|-+$/g, '');
723  }
724
725  function getCategoryAttributeName() {
726    var attributeName = $.trim(String(EXTRA_CONFIG.categoryAttributeName || 'attribute_categories'));
727    return attributeName || 'attribute_categories';
728  }
729
730  function getProfilesByCategory() {
731    var profiles = EXTRA_CONFIG.profilesByCategory || EXTRA_CONFIG.categoryProfiles || {};
732    return profiles && typeof profiles === 'object' ? profiles : {};
733  }
734
735  function getSeatCategoryProfileOverrides() {
736    var overrides = EXTRA_CONFIG.seatCategoryProfileOverrides || {};
737    return overrides && typeof overrides === 'object' ? overrides : {};
738  }
739
740  function getSeatCategoryProfileOverride(form, categoryKey) {
741    if (!categoryKey) return null;
742
743    var context = getIitixSeatContextPayload(form || getVariationForm());
744    var chartId = $.trim(String(context && context.chartId || ''));
745    var seatLabel = normalizeSeatIdentity(context && context.seatLabel || '');
746    var overrides = getSeatCategoryProfileOverrides();
747
748    if (!chartId || !seatLabel || !overrides[chartId] || !overrides[chartId][seatLabel]) {
749      return null;
750    }
751
752    var seatOverrides = overrides[chartId][seatLabel];
753    if (seatOverrides && Object.prototype.hasOwnProperty.call(seatOverrides, categoryKey)) {
754      return seatOverrides[categoryKey];
755    }
756
757    return null;
758  }
759
760  function getCurrentExtraCategoryValue(form) {
761    form = form && form.length ? form : getVariationForm();
762
763    var attributeName = getCategoryAttributeName();
764    var selector = 'select[name="' + attributeName + '"], select[data-attribute_name="' + attributeName + '"]';
765    var select = form && form.length ? form.find(selector).first() : $();
766
767    // Tickera can briefly move/replace the select while rebuilding the variation
768    // form. Fall back to the active modal but never infer from product ID.
769    if (!select.length) {
770      select = getSeatModal().find(selector).first();
771    }
772
773    return select.length ? $.trim(String(select.val() || '')) : '';
774  }
775
776  function getCurrentExtraProfile(form) {
777    form = form && form.length ? form : getVariationForm();
778    var categoryValue = getCurrentExtraCategoryValue(form);
779    var categoryKey = normalizeCategoryProfileKey(categoryValue);
780    var profiles = getProfilesByCategory();
781    var seatOverride = getSeatCategoryProfileOverride(form, categoryKey);
782
783    if (seatOverride) return seatOverride;
784
785    if (categoryKey && Object.prototype.hasOwnProperty.call(profiles, categoryKey)) {
786      return profiles[categoryKey];
787    }
788
789    return null;
790  }
791
792  function getProfileSections(profile) {
793    return profile && Array.isArray(profile.sections) ? profile.sections : [];
794  }
795
796  function getProfileSection(profile, matcher) {
797    var sections = getProfileSections(profile);
798    for (var i = 0; i < sections.length; i++) {
799      if (matcher(sections[i] || {})) return sections[i];
800    }
801    return null;
802  }
803
804  function getProfileSectionByType(profile, type) {
805    return getProfileSection(profile, function (section) {
806      return section && section.type === type;
807    });
808  }
809
810  function getProfileSectionByKey(profile, key) {
811    return getProfileSection(profile, function (section) {
812      return section && section.key === key;
813    });
814  }
815
816  function getProfileField(profile, fieldName) {
817    var sections = getProfileSections(profile);
818    for (var i = 0; i < sections.length; i++) {
819      var fields = Array.isArray(sections[i].fields) ? sections[i].fields : [];
820      for (var j = 0; j < fields.length; j++) {
821        if (fields[j] && fields[j].name === fieldName) return fields[j];
822      }
823    }
824    return null;
825  }
826
827  function getActiveApplianceSection(form) {
828    var profile = getCurrentExtraProfile(form || getVariationForm());
829    return getProfileSectionByType(profile, 'appliance_slots');
830  }
831
832  function getExtraMaxWattage(form) {
833    var profile = getCurrentExtraProfile(form || getVariationForm());
834    var max = parseInt(profile && profile.max_total_wattage, 10);
835    if (max > 0) return max;
836
837    var section = getActiveApplianceSection(form || getVariationForm());
838    max = parseInt(section && section.max_total_wattage, 10);
839    if (max > 0) return max;
840
841    max = parseInt(EXTRA_CONFIG.maxTotalWattage, 10);
842    return max > 0 ? max : 1000;
843  }
844
845  function getExtraCopy(key, fallback) {
846    var profile = getCurrentExtraProfile(getVariationForm());
847    var copy = profile && profile.copy && typeof profile.copy === 'object' ? profile.copy : (EXTRA_CONFIG.copy || {});
848
849    if (copy && typeof copy[key] !== 'undefined') return copy[key];
850
851    if (profile) {
852      if (key === 'title' && profile.title) return profile.title;
853      if (key === 'description' && profile.description) return profile.description;
854
855      var menuField = getProfileField(profile, 'iitix_menu');
856      if (menuField) {
857        if (key === 'menuLabel' && menuField.label) return menuField.label;
858        if (key === 'menuHelp' && menuField.help) return menuField.help;
859        if (key === 'menuPlaceholder' && menuField.placeholder) return menuField.placeholder;
860      }
861
862      var applianceSection = getProfileSectionByType(profile, 'appliance_slots');
863      if (applianceSection) {
864        if (key === 'applianceIntro' && applianceSection.help) return applianceSection.help;
865        if (key === 'appliance1Label' && applianceSection.appliance_1_label) return applianceSection.appliance_1_label;
866        if (key === 'appliance2Label' && applianceSection.appliance_2_label) return applianceSection.appliance_2_label;
867      }
868
869      var guidelineSection = getProfileSectionByType(profile, 'guidelines');
870      if (key === 'guidelineTitle' && guidelineSection && guidelineSection.title) return guidelineSection.title;
871    }
872
873    var legacyCopy = EXTRA_CONFIG.copy || {};
874    return legacyCopy[key] || fallback;
875  }
876
877  function getExtraCopyArray(key, fallback) {
878    var profile = getCurrentExtraProfile(getVariationForm());
879
880    if (profile) {
881      var guidelineSection = getProfileSectionByType(profile, 'guidelines');
882      if (key === 'guidelines' && guidelineSection && Array.isArray(guidelineSection.items)) return guidelineSection.items;
883    }
884
885    var copy = profile && profile.copy && typeof profile.copy === 'object' ? profile.copy : (EXTRA_CONFIG.copy || {});
886    if (Array.isArray(copy[key])) return copy[key];
887
888    var legacyCopy = EXTRA_CONFIG.copy || {};
889    return Array.isArray(legacyCopy[key]) ? legacyCopy[key] : (Array.isArray(fallback) ? fallback : []);
890  }
891
892  function formatExtraCopy(template, replacements) {
893    template = String(template || '');
894    replacements = replacements || {};
895
896    Object.keys(replacements).forEach(function (key) {
897      var pattern = new RegExp('\\{' + key + '\\}', 'g');
898      template = template.replace(pattern, String(replacements[key]));
899    });
900
901    return template;
902  }
903
904  function normalizeApplianceKey(value) {
905    return normalize(value)
906      .replace(/[^a-z0-9]+/g, '_')
907      .replace(/^_+|_+$/g, '');
908  }
909
910  function getApplianceMinWatt(type) {
911    if (!type || normalize(type) === 'other' || normalize(type) === 'others') return 0;
912
913    var section = getActiveApplianceSection(getVariationForm());
914    var map = section && section.options && typeof section.options === 'object'
915      ? section.options
916      : (EXTRA_CONFIG.applianceMinWatts || EXTRA_CONFIG.applianceMinimumWatts || {});
917
918    if (!map || typeof map !== 'object') return 0;
919
920    if (Object.prototype.hasOwnProperty.call(map, type)) {
921      var direct = parseInt(map[type], 10);
922      return direct > 0 ? direct : 0;
923    }
924
925    var wanted = normalizeApplianceKey(type);
926    var keys = Object.keys(map);
927    for (var i = 0; i < keys.length; i++) {
928      if (normalizeApplianceKey(keys[i]) === wanted) {
929        var n = parseInt(map[keys[i]], 10);
930        return n > 0 ? n : 0;
931      }
932    }
933
934    return 0;
935  }
936
937  function getEffectiveApplianceWatt(type, wattage) {
938    wattage = parseInt(wattage, 10);
939    wattage = isNaN(wattage) || wattage < 0 ? 0 : wattage;
940    if (wattage <= 0) return 0;
941
942    var min = getApplianceMinWatt(type);
943    return min > 0 ? Math.max(wattage, min) : wattage;
944  }
945
946  function getEffectiveTotalWattage(data) {
947    return getEffectiveApplianceWatt(data.appliance1Type, data.appliance1Wattage) +
948      getEffectiveApplianceWatt(data.appliance2Type, data.appliance2Wattage);
949  }
950
951  function getEnabledCategoryProfileMap() {
952    var profiles = getProfilesByCategory();
953    var map = {};
954
955    Object.keys(profiles).forEach(function (key) {
956      var normalizedKey = normalizeCategoryProfileKey(key);
957      if (normalizedKey) map[normalizedKey] = true;
958    });
959
960    return map;
961  }
962
963  function getCurrentModalProductId(form) {
964    var candidates = [];
965
966    if (form && form.length) {
967      candidates.push(form.data('iitix-current-product-id'));
968      candidates.push(form.data('product_id'));
969      candidates.push(form.attr('data-product_id'));
970      candidates.push(form.find('input[name="product_id"]').val());
971      candidates.push(form.find('input[name="add-to-cart"]').val());
972    }
973
974    var modal = getSeatModal();
975    candidates.push(modal.find('.tc_regular_modal_ticket_type_id').val());
976    candidates.push(modal.find('.tc_cart_button').data('product_id'));
977    candidates.push(modal.find('.tc_cart_button').attr('data-product_id'));
978
979    // Do not use previous window.iitixSeatCategoryState as the main source here.
980    // That state can belong to the previously selected seat/product and can make
981    // a normal category-only product look like a whitelisted extra-fields product.
982    if (!form || !form.length) {
983      var state = window.iitixSeatCategoryState || {};
984      candidates.push(state.product_id);
985    }
986
987    for (var i = 0; i < candidates.length; i++) {
988      var n = parseInt(candidates[i], 10);
989      if (n > 0) return n;
990    }
991
992    return getProductIdFromPage();
993  }
994
995  function currentSeatAllowsExtraFields(form) {
996    if (EXTRA_CONFIG.enabled === false) return false;
997
998    // Extra sections are category-driven. A product may expose many category
999    // variation values; only the currently selected mapped category gets a form.
1000    var selectedCategoryKey = normalizeCategoryProfileKey(getCurrentExtraCategoryValue(form));
1001    if (!selectedCategoryKey) return false;
1002
1003    var enabledCategories = getEnabledCategoryProfileMap();
1004    return !!enabledCategories[selectedCategoryKey] && !!getCurrentExtraProfile(form);
1005  }
1006
1007  function ensureExtraFieldsStyle() {
1008    // Styles are loaded from assets/seat-number-injector.css.
1009    return;
1010  }
1011
1012  function getApplianceOptionLabel(option) {
1013    var minWatt = getApplianceMinWatt(option);
1014    return minWatt > 0 ? (option + ' (' + minWatt + ' watt)') : option;
1015  }
1016
1017  function getApplianceOptionsHtml() {
1018    var section = getActiveApplianceSection(getVariationForm());
1019    var options = [];
1020
1021    if (section && section.options && typeof section.options === 'object') {
1022      options = Object.keys(section.options);
1023    } else {
1024      options = Array.isArray(EXTRA_CONFIG.applianceOptions) ? EXTRA_CONFIG.applianceOptions : [];
1025      if (!options.length && EXTRA_CONFIG.applianceMinWatts && typeof EXTRA_CONFIG.applianceMinWatts === 'object') {
1026        options = Object.keys(EXTRA_CONFIG.applianceMinWatts);
1027      }
1028    }
1029
1030    if (!options.length) {
1031      options = ['Microwave', 'Convection oven', 'Induction Stove', 'Rice cooker', 'Air fryer', 'Fridge', 'Chiller', 'Display fridge under', 'Other'];
1032    }
1033
1034    var html = '<option value="">Choose appliance</option>';
1035    options.forEach(function (option) {
1036      if (option && typeof option === 'object') option = option.label || option.name || option.value || '';
1037      if (!option) return;
1038      html += '<option value="' + esc(option) + '">' + esc(getApplianceOptionLabel(option)) + '</option>';
1039    });
1040    return html;
1041  }
1042
1043  function getSectionStepHtml(section, fallbackNumber, fallbackTitle, fallbackBadge, fallbackClass) {
1044    section = section || {};
1045    var number = section.step_number || fallbackNumber;
1046    var title = section.step_title || fallbackTitle;
1047    var badge = section.step_badge || fallbackBadge || 'Required';
1048    var klass = section.heading_class || fallbackClass || 'iitix-step-heading';
1049    var badgeClass = normalize(String(badge || '')) === 'optional' ? ' is-optional' : '';
1050
1051    if (!number && !title) return '';
1052
1053    return '' +
1054      '<div class="' + esc(klass) + '">' +
1055        '<span class="iitix-step-number">' + esc(number) + '</span>' +
1056        '<span class="iitix-step-body">' +
1057          '<span class="iitix-step-title">' + esc(title) + '</span>' +
1058          '<span class="iitix-step-badge' + badgeClass + '">' + esc(badge) + '</span>' +
1059        '</span>' +
1060      '</div>';
1061  }
1062
1063  function getIitixFilePlaceholderHtml(field) {
1064    field = field || {};
1065    var title = field.empty_preview_title || 'No image yet';
1066    var subtitle = field.empty_preview_subtitle || 'Preview here';
1067    var icon = field.icon || '🖼';
1068
1069    return '' +
1070      '<div class="iitix-file-placeholder">' +
1071        '<span class="iitix-file-placeholder-icon">' + esc(icon) + '</span>' +
1072        '<strong>' + esc(title) + '</strong>' +
1073        '<small>' + esc(subtitle) + '</small>' +
1074      '</div>';
1075  }
1076
1077  function renderProfileFieldInput(field) {
1078    field = field || {};
1079    var type = field.type || 'text';
1080    var name = field.name || '';
1081    var inputClass = field.input_class || '';
1082    var placeholder = field.placeholder || '';
1083    var required = field.required ? ' data-iitix-required="1"' : '';
1084
1085    if (!name) return '';
1086
1087    if (type === 'textarea') {
1088      return '<textarea id="' + esc(name) + '" name="' + esc(name) + '" class="' + esc(inputClass) + '" data-iitix-field-name="' + esc(name) + '" placeholder="' + esc(placeholder) + '"' + required + '></textarea>';
1089    }
1090
1091    if (type === 'file') {
1092      var accept = field.accept || 'image/png,image/jpeg,image/webp';
1093      var maxSize = field.max_size_mb || 10;
1094      var minWidth = field.min_width || 0;
1095      var minHeight = field.min_height || 0;
1096      var aspectRatio = field.aspect_ratio || '';
1097      var aspectRatioTolerance = field.aspect_ratio_tolerance || '';
1098      var fileTypesLabel = field.file_types_label || 'PNG, JPG, or WebP';
1099      var uploadButtonLabel = field.placeholder || 'Upload image';
1100      var fileUiClass = field.file_ui_class || '';
1101      return '' +
1102        '<div class="iitix-file-upload ' + esc(fileUiClass) + '" data-iitix-file-upload data-iitix-field-name="' + esc(name) + '" data-iitix-max-size-mb="' + esc(maxSize) + '" data-iitix-min-width="' + esc(minWidth) + '" data-iitix-min-height="' + esc(minHeight) + '" data-iitix-aspect-ratio="' + esc(aspectRatio) + '" data-iitix-aspect-ratio-tolerance="' + esc(aspectRatioTolerance) + '" data-iitix-file-types-label="' + esc(fileTypesLabel) + '" data-iitix-empty-preview-title="' + esc(field.empty_preview_title || 'No image yet') + '" data-iitix-empty-preview-subtitle="' + esc(field.empty_preview_subtitle || 'Preview here') + '" data-iitix-empty-preview-icon="' + esc(field.icon || '🖼') + '">' +
1103          '<input type="file" id="' + esc(name) + '" name="' + esc(name) + '" class="iitix-file-input ' + esc(inputClass) + '" accept="' + esc(accept) + '" data-iitix-field-name="' + esc(name) + '"' + required + '>' +
1104          '<div class="iitix-file-preview is-empty" data-iitix-file-preview>' + getIitixFilePlaceholderHtml(field) + '</div>' +
1105          '<div class="iitix-file-actions">' +
1106            '<button type="button" class="iitix-file-trigger" data-iitix-file-trigger>' + esc(uploadButtonLabel) + '</button>' +
1107            '<button type="button" class="iitix-file-change" data-iitix-file-trigger>Change Image</button>' +
1108          '</div>' +
1109          '<div class="iitix-file-name" data-iitix-file-name></div>' +
1110        '</div>';
1111    }
1112
1113    if (type === 'radio') {
1114      var options = field.options && typeof field.options === 'object' ? field.options : { no: 'No', yes: 'Yes' };
1115      var defaultValue = typeof field.default !== 'undefined' ? String(field.default) : '';
1116      var radioHtml = '<div class="iitix-radio-group" data-iitix-radio-group="' + esc(name) + '">';
1117      Object.keys(options).forEach(function (value) {
1118        var id = name + '_' + value;
1119        var checked = String(value) === defaultValue ? ' checked' : '';
1120        radioHtml += '<label class="iitix-radio-card"><input type="radio" id="' + esc(id) + '" name="' + esc(name) + '" value="' + esc(value) + '" class="' + esc(inputClass) + '" data-iitix-field-name="' + esc(name) + '"' + required + checked + '> <span>' + esc(options[value]) + '</span></label>';
1121      });
1122      radioHtml += '</div>';
1123      return radioHtml;
1124    }
1125
1126    if (type === 'select') {
1127      var opts = field.options && typeof field.options === 'object' ? field.options : {};
1128      var defaultOpt = typeof field.default !== 'undefined' ? String(field.default) : '';
1129      var selectHtml = '<select id="' + esc(name) + '" name="' + esc(name) + '" class="' + esc(inputClass) + '" data-iitix-field-name="' + esc(name) + '"' + required + '>';
1130      Object.keys(opts).forEach(function (value) {
1131        selectHtml += '<option value="' + esc(value) + '"' + (String(value) === defaultOpt ? ' selected' : '') + '>' + esc(opts[value]) + '</option>';
1132      });
1133      selectHtml += '</select>';
1134      return selectHtml;
1135    }
1136
1137    if (type === 'checkbox') {
1138      return '<label class="iitix-checkbox-field"><input type="checkbox" name="' + esc(name) + '" value="1" class="' + esc(inputClass) + '" data-iitix-field-name="' + esc(name) + '"' + required + '> <span>' + esc(field.checkbox_label || field.label || '') + '</span></label>';
1139    }
1140
1141    return '<input type="' + esc(type === 'email' ? 'email' : 'text') + '" id="' + esc(name) + '" name="' + esc(name) + '" class="' + esc(inputClass) + '" data-iitix-field-name="' + esc(name) + '" placeholder="' + esc(placeholder) + '"' + required + '>';
1142  }
1143
1144  function renderProfileFieldsSection(section) {
1145    section = section || {};
1146    var fields = Array.isArray(section.fields) ? section.fields : [];
1147    if (!fields.length) return '';
1148
1149    var wrapperClass = section.wrapper_class || 'iitix-extra-section iitix-step-block';
1150    var fieldsLayoutClass = section.fields_layout_class || section.fields_layout || '';
1151    var html = '<div class="' + esc(wrapperClass) + '">';
1152    html += getSectionStepHtml(section, '', section.title || '', section.step_badge || 'Required', section.heading_class || 'iitix-step-heading iitix-step-menu-heading');
1153    if (fieldsLayoutClass) html += '<div class="' + esc(fieldsLayoutClass) + '">';
1154
1155    fields.forEach(function (field) {
1156      field = field || {};
1157      if (!field.name) return;
1158      html += '<div class="' + esc(field.wrapper_class || 'iitix-field-group') + '" data-iitix-field-wrapper="' + esc(field.name) + '">';
1159      if (field.label) html += '<label class="iitix-extra-label" for="' + esc(field.name) + '">' + esc(field.label) + '</label>';
1160      if (field.help) html += '<div class="iitix-extra-help">• ' + esc(field.help) + '</div>';
1161      html += renderProfileFieldInput(field);
1162      html += '</div>';
1163    });
1164
1165    if (fieldsLayoutClass) html += '</div>';
1166    html += '</div>';
1167    return html;
1168  }
1169
1170  function renderApplianceSlotsSection(section, profile, form) {
1171    section = section || {};
1172    if (!section || section.type !== 'appliance_slots') return '';
1173
1174    var maxWatt = parseInt(section.max_total_wattage || (profile && profile.max_total_wattage) || getExtraMaxWattage(form), 10);
1175    maxWatt = maxWatt > 0 ? maxWatt : 1000;
1176    var optionsHtml = getApplianceOptionsHtml();
1177    var wattageUnit = getExtraCopy('wattageUnit', 'W');
1178    var policyNotice = getExtraCopy('powerPolicyNotice', 'If actual power usage at the venue exceeds the declared wattage or the 1000W limit, the organizer and venue may disconnect the electricity supply without refund or compensation.');
1179    var introClass = section.wrapper_class || 'iitix-extra-section iitix-appliance-intro';
1180    var introTitle = section.section_kicker || section.title || 'Appliances';
1181    var applianceOneTitle = section.appliance_1_label || 'Appliance 1';
1182    var applianceTwoTitle = section.appliance_2_label || 'Appliance 2';
1183    var stepHtml = getSectionStepHtml(section, '', section.step_title || 'Choose appliance and fill watt', section.step_badge || 'Required', section.heading_class || 'iitix-step-heading iitix-step-appliance-heading');
1184
1185    return '' +
1186      '<div class="' + esc(introClass) + '">' +
1187        '<div class="iitix-section-kicker">' + esc(introTitle) + '</div>' +
1188        (section.help ? '<div class="iitix-extra-help">' + esc(section.help) + '</div>' : '') +
1189      '</div>' +
1190
1191      '<div class="iitix-appliance-card iitix-step-block iitix-step-appliance-required">' +
1192        stepHtml +
1193        '<div class="iitix-appliance-card-header">' +
1194          '<div>' +
1195            '<div class="iitix-appliance-card-title">' + esc(applianceOneTitle) + '</div>' +
1196            '<div class="iitix-appliance-card-subtitle">Choose appliance type and enter wattage manually.</div>' +
1197          '</div>' +
1198          '<span class="iitix-appliance-card-badge">Slot 1</span>' +
1199        '</div>' +
1200        '<div class="iitix-appliance-row">' +
1201          '<div class="iitix-field-group">' +
1202            '<label>Appliance</label>' +
1203            '<select name="iitix_appliance_1_type" class="iitix-appliance-select" data-iitix-slot="1">' + optionsHtml + '</select>' +
1204          '</div>' +
1205          '<div class="iitix-field-group">' +
1206            '<label>' + esc(getExtraCopy('wattageLabel', 'Watt')) + '</label>' +
1207            '<div class="iitix-input-with-unit iitix-watt-input-wrap">' +
1208              '<input type="number" name="iitix_appliance_1_wattage" class="iitix-wattage-input" min="0" step="1" inputmode="numeric" placeholder="0">' +
1209              '<span class="iitix-input-unit">' + esc(wattageUnit) + '</span>' +
1210            '</div>' +
1211          '</div>' +
1212        '</div>' +
1213        '<input type="text" name="iitix_appliance_1_other" class="iitix-other-appliance" data-iitix-other-slot="1" placeholder="' + esc(getExtraCopy('otherPlaceholder', 'Please specify other appliance')) + '" style="display:none;">' +
1214      '</div>' +
1215
1216      '<div class="iitix-appliance-card iitix-step-optional">' +
1217        '<div class="iitix-appliance-card-header">' +
1218          '<div>' +
1219            '<div class="iitix-appliance-card-title">' + esc(applianceTwoTitle) + '</div>' +
1220            '<div class="iitix-appliance-card-subtitle">Optional. Leave empty if you only use one appliance.</div>' +
1221          '</div>' +
1222          '<span class="iitix-appliance-card-badge">Slot 2</span>' +
1223        '</div>' +
1224        '<div class="iitix-appliance-row">' +
1225          '<div class="iitix-field-group">' +
1226            '<label>Appliance</label>' +
1227            '<select name="iitix_appliance_2_type" class="iitix-appliance-select" data-iitix-slot="2">' + optionsHtml + '</select>' +
1228          '</div>' +
1229          '<div class="iitix-field-group">' +
1230            '<label>' + esc(getExtraCopy('wattageLabel', 'Watt')) + '</label>' +
1231            '<div class="iitix-input-with-unit iitix-watt-input-wrap">' +
1232              '<input type="number" name="iitix_appliance_2_wattage" class="iitix-wattage-input" min="0" step="1" inputmode="numeric" placeholder="0">' +
1233              '<span class="iitix-input-unit">' + esc(wattageUnit) + '</span>' +
1234            '</div>' +
1235          '</div>' +
1236        '</div>' +
1237        '<input type="text" name="iitix_appliance_2_other" class="iitix-other-appliance" data-iitix-other-slot="2" placeholder="' + esc(getExtraCopy('otherPlaceholder', 'Please specify other appliance')) + '" style="display:none;">' +
1238      '</div>' +
1239
1240      '<input type="hidden" name="iitix_total_wattage" value="0">' +
1241      '<input type="hidden" name="iitix_max_wattage" value="' + esc(maxWatt) + '">' +
1242
1243      '<div class="iitix-watt-summary">' +
1244        '<span>Total Appliance 1 + Appliance 2</span>' +
1245        '<span class="iitix-watt-total"><span data-iitix-total-wattage>0</span>W / ' + esc(maxWatt) + 'W</span>' +
1246      '</div>' +
1247      (policyNotice ? '<div class="iitix-power-policy-alert" role="alert">
1247' + esc(policyNotice) + '</div>' : '');
1248  }
1249
1250  function renderProfileNoticeSection(section) {
1251    section = section || {};
1252    var wrapperClass = section.wrapper_class || 'iitix-profile-notice';
1253    var title = $.trim(String(section.title || 'Important'));
1254    var message = $.trim(String(section.message || section.intro || ''));
1255    if (!title && !message) return '';
1256
1257    return '' +
1258      '<div class="' + esc(wrapperClass) + '" role="alert">' +
1259        (title ? '<div class="iitix-profile-notice-title">' + esc(title) + '</div>' : '') +
1260        (message ? '<div class="iitix-profile-notice-message">' + esc(message) + '</div>' : '') +
1261      '</div>';
1262  }
1263
1264  function renderProfileGuidelinesSection(section) {
1265    section = section || {};
1266    var items = Array.isArray(section.items) ? section.items.filter(function (item) {
1267      return $.trim(String(item || '')) !== '';
1268    }) : [];
1269    if (!items.length && !section.title && !section.intro) return '';
1270
1271    var wrapperClass = section.wrapper_class || 'iitix-extra-guidelines';
1272    var html = '<div class="' + esc(wrapperClass) + '">';
1273    if (section.title) html += '<div class="iitix-extra-guidelines-title">' + esc(section.title) + '</div>';
1274    if (section.intro) html += '<div class="iitix-extra-help">' + esc(section.intro) + '</div>';
1275    if (items.length) {
1276      html += '<ol>' + items.map(function (item) {
1277        return '<li>' + esc(item) + '</li>';
1278      }).join('') + '</ol>';
1279    }
1280    html += '</div>';
1281    return html;
1282  }
1283
1284  /*
1285   * Mobile action-layout structure.
1286   *
1287   * The normal WooCommerce DOM has category fields, profile fields and
1288   * .single_variation_wrap as siblings. A sticky action bar inside the same
1289   * scroll area overlays the profile while the modal is at the top. Instead,
1290   * keep category/profile content in one dedicated scrolling wrapper and make
1291   * the WooCommerce action area a real footer sibling.
1292   */
1293  function ensureExtraModalActionLayout(form) {
1294    if (!form || !form.length) return $();
1295
1296    var variationWrap = form.find('.single_variation_wrap').first();
1297    if (!variationWrap.length) return $();
1298
1299    var scrollContent = form.children('.iitix-extra-scroll-content').first();
1300    if (!scrollContent.length) {
1301      scrollContent = $('<div class="iitix-extra-scroll-content" data-iitix-extra-scroll-content="1"></div>');
1302      scrollContent.insertBefore(variationWrap);
1303
1304      // Only move visual fields. Hidden WooCommerce inputs must remain direct
1305      // form children so variation/add-to-cart serialization remains unchanged.
1306      form.children('.iitix-step-heading, .iitix-seat-filter-notice, table.variations, .iitix-extra-fields')
1307        .not(scrollContent)
1308        .not(variationWrap)
1309        .appendTo(scrollContent);
1310    }
1311
1312    form.addClass('iitix-extra-action-layout');
1313    return scrollContent;
1314  }
1315
1316  function restoreExtraModalActionLayout(form) {
1317    if (!form || !form.length) return;
1318
1319    var scrollContent = form.children('.iitix-extra-scroll-content').first();
1320    if (scrollContent.length) {
1321      // Put the original UI nodes back before the WooCommerce action area
1322      // when the user switches to a category without an extra profile.
1323      scrollContent.children().insertBefore(scrollContent);
1324      scrollContent.remove();
1325    }
1326
1327    form.removeClass('iitix-extra-action-layout');
1328  }
1329
1330  function ensureExtraFields(form) {
1331    if (!form || !form.length) return $();
1332    ensureExtraFieldsStyle();
1333
1334    var existing = form.find('.iitix-extra-fields').first();
1335    if (existing.length) return existing;
1336
1337    var profile = getCurrentExtraProfile(form);
1338    if (!profile) return $();
1339
1340    // data-iitix-profile must be category-specific. Shared profile templates
1341    // have one profile.key but different title/description per selected category.
1342    var selectedCategoryKey = normalizeCategoryProfileKey(getCurrentExtraCategoryValue(form));
1343    var profileKey = selectedCategoryKey && profile
1344      ? (selectedCategoryKey + ':' + String(profile.key || profile.category_key || 'category-profile'))
1345      : (selectedCategoryKey || profile.category_key || profile.key || 'category-profile');
1346    var wrapperId = profile.wrapper_id || '';
1347    var wrapperClass = profile.wrapper_class || 'iitix-extra-fields';
1348    var sections = getProfileSections(profile);
1349    var content = '';
1350
1351    sections.forEach(function (section) {
1352      section = section || {};
1353      if (section.type === 'fields') {
1354        content += renderProfileFieldsSection(section);
1355      } else if (section.type === 'appliance_slots') {
1356        content += renderApplianceSlotsSection(section, profile, form);
1357      } else if (section.type === 'guidelines') {
1358        content += renderProfileGuidelinesSection(section);
1359      } else if (section.type === 'notice') {
1360        content += renderProfileNoticeSection(section);
1361      }
1362    });
1363
1364    var html = '' +
1365      '<div' + (wrapperId ? ' id="' + esc(wrapperId) + '"' : '') + ' class="' + esc(wrapperClass) + '" data-iitix-extra-fields="1" data-iitix-profile="' + esc(profileKey) + '" style="display:none;">' +
1366        '<div class="iitix-extra-title">' + esc(profile.title || 'Vendor Details') + '</div>' +
1367        (profile.description ? '<div class="iitix-extra-desc">' + esc(profile.description) + '</div>' : '') +
1368        content +
1369        '<div class="iitix-extra-message" data-iitix-extra-message></div>' +
1370      '</div>';
1371
1372    var block = $(html);
1373    var scrollContent = form.children('.iitix-extra-scroll-content').first();
1374    var variationWrap = form.find('.single_variation_wrap').first();
1375
1376    if (scrollContent.length) {
1377      // In the mobile footer layout, profile fields belong to the dedicated
1378      // scroll body. This keeps Add to Cart below the form instead of over it.
1379      scrollContent.append(block);
1380    } else if (variationWrap.length) {
1381      block.insertBefore(variationWrap);
1382    } else {
1383      form.append(block);
1384    }
1385
1386    return block;
1387  }
1388
1389  function autoFillMinimumWattForSelect(form, select) {
1390    if (!form || !form.length || !select || !select.length) return;
1391
1392    var slot = select.data('iitix-slot');
1393    var type = $.trim(select.val() || '');
1394    var wattInput = form.find('[name="iitix_appliance_' + slot + '_wattage"]');
1395    if (!wattInput.length) return;
1396
1397    var minWatt = getApplianceMinWatt(type);
1398    if (minWatt > 0) {
1399      wattInput.val(minWatt);
1400      return;
1401    }
1402
1403    if (!type || normalize(type) === 'other' || normalize(type) === 'others') {
1404      wattInput.val('');
1405    }
1406  }
1407
1408  function toggleOtherInputs(form) {
1409    form.find('.iitix-appliance-select').each(function () {
1410      var select = $(this);
1411      var slot = select.data('iitix-slot');
1412      var other = form.find('[data-iitix-other-slot="' + slot + '"]');
1413      var isOther = (normalize(select.val()) === 'other' || normalize(select.val()) === 'others');
1414      var minWatt = getApplianceMinWatt(select.val());
1415      var wattInput = form.find('[name="iitix_appliance_' + slot + '_wattage"]');
1416
1417      other.toggle(isOther);
1418      other.prop('disabled', !isOther);
1419      if (!isOther) other.val('');
1420
1421      if (wattInput.length) {
1422        wattInput.attr('min', minWatt > 0 ? String(minWatt) : '0');
1423        wattInput.attr('title', minWatt > 0 ? ('Minimum watt: ' + minWatt + 'W') : '');
1424      }
1425    });
1426  }
1427
1428  function ensureSeatModalBackdrop(enabled) {
1429    var id = 'iitix-seat-modal-backdrop';
1430    var backdrop = document.getElementById(id);
1431
1432    if (!enabled) {
1433      if (backdrop && backdrop.parentNode) backdrop.parentNode.removeChild(backdrop);
1434      document.documentElement.classList.remove('iitix-seat-modal-open');
1435      document.body.classList.remove('iitix-seat-modal-open');
1436      return;
1437    }
1438
1439    if (!backdrop) {
1440      backdrop = document.createElement('div');
1441      backdrop.id = id;
1442      backdrop.className = 'iitix-seat-modal-backdrop';
1443      document.body.appendChild(backdrop);
1444    }
1445
1446    document.documentElement.classList.add('iitix-seat-modal-open');
1447    document.body.classList.add('iitix-seat-modal-open');
1448  }
1449
1450  function forceWideModalInlineLayout(modal, dialog) {
1451    // Inline fallback is intentional: some WordPress/caching stacks keep old CSS,
1452    // while Tickera also writes width/top styles directly into the dialog.
1453    if (dialog && dialog.length) {
1454      dialog.css({
1455        position: 'fixed',
1456        top: '0',
1457        right: '0',
1458        bottom: '0',
1459        left: '0',
1460        width: '100vw',
1461        maxWidth: '100vw',
1462        height: '100vh',
1463        minHeight: '100vh',
1464        margin: '0',
1465        padding: '24px',
1466        display: 'flex',
1467        alignItems: 'center',
1468        justifyContent: 'center',
1469        boxSizing: 'border-box',
1470        background: 'transparent',
1471        zIndex: 999991
1472      });
1473    }
1474
1475    modal.css({
1476      width: 'min(92vw, 1240px)',
1477      maxWidth: '1240px',
1478      height: 'min(90vh, 860px)',
1479      maxHeight: 'calc(100vh - 56px)',
1480      minHeight: '0',
1481      overflow: 'hidden',
1482      boxSizing: 'border-box',
1483      padding: '0',
1484      borderRadius: '24px',
1485      background: '#fff',
1486      boxShadow: '0 30px 90px rgba(15, 23, 42, .30)',
1487      position: 'relative',
1488      zIndex: 999992
1489    });
1490
1491    modal.find('.tc-modal-woobridge').first().css({
1492      width: '100%',
1493      height: '100%',
1494      maxHeight: '100%',
1495      minHeight: '0',
1496      boxSizing: 'border-box',
1497      padding: '24px 28px 0',
1498      display: 'flex',
1499      flexDirection: 'column',
1500      overflow: 'hidden',
1501      position: 'relative'
1502    });
1503
1504    modal.find('.tc-modal-woobridge-inner').first().css({
1505      width: '100%',
1506      flex: '1 1 auto',
1507      minHeight: '0',
1508      maxHeight: 'none',
1509      overflowY: 'auto',
1510      overflowX: 'hidden',
1511      padding: '0 10px 0 0',
1512      boxSizing: 'border-box'
1513    });
1514  }
1515
1516  function clearWideModalInlineLayout(modal, dialog) {
1517    var props = 'position top right bottom left width max-width height min-height margin padding display align-items justify-content box-sizing background z-index';
1518    if (dialog && dialog.length) dialog.css(props, '');
1519
1520    modal.css('width max-width height max-height min-height overflow box-sizing padding border-radius background box-shadow position z-index', '');
1521    modal.find('.tc-modal-woobridge').first().css('width height max-height min-height box-sizing padding display flex-direction overflow position', '');
1522    modal.find('.tc-modal-woobridge-inner').first().css('width flex min-height max-height overflow-y overflow-x padding box-sizing', '');
1523  }
1524
1525  function setWhitelistedModalLayout(form, enabled) {
1526    var modal = getSeatModal();
1527    if (!modal.length) return;
1528
1529    var dialog = modal.closest('.ui-dialog');
1530
1531    if (enabled) {
1532      // Jangan hidupkan lagi kalau modal sedang proses close.
1533      if (isSeatModalClosing) return;
1534
1535      // Jangan force display:flex kalau modal sebenarnya sudah hidden.
1536      if (!isSeatModalActuallyVisible(modal)) return;
1537
1538      if (!modal.data('iitix-original-style')) {
1539        modal.data('iitix-original-style', modal.attr('style') || '');
1540      }
1541
1542      if (dialog.length && !dialog.data('iitix-original-style')) {
1543        dialog.data('iitix-original-style', dialog.attr('style') || '');
1544      }
1545
1546      modal.addClass('iitix-extra-modal-wide');
1547      if (dialog.length) dialog.addClass('iitix-extra-dialog-wide');
1548
1549      ensureSeatModalBackdrop(true);
1550      forceWideModalInlineLayout(modal, dialog);
1551
1552      return;
1553    }
1554
1555    var hasWideState = modal.hasClass('iitix-extra-modal-wide')
1556      || (dialog.length && dialog.hasClass('iitix-extra-dialog-wide'))
1557      || typeof modal.data('iitix-original-style') !== 'undefined'
1558      || (dialog.length && typeof dialog.data('iitix-original-style') !== 'undefined')
1559      || !!document.getElementById('iitix-seat-modal-backdrop')
1560      || $('body').hasClass('iitix-seat-modal-open');
1561
1562    if (hasWideState) {
1563      cleanupIitixWideModalState();
1564    }
1565  }
1566
1567
1568  function forceHideSeatModalElements() {
1569    var modal = getSeatModal();
1570    var dialog = modal.length ? modal.closest('.ui-dialog') : $();
1571
1572    if (modal.length) modal.hide();
1573    if (dialog.length) dialog.hide();
1574
1575    $('.ui-widget-overlay').remove();
1576    $('#iitix-seat-modal-backdrop').remove();
1577
1578    $('html, body').removeClass('iitix-seat-modal-open modal-open').css({
1579      overflow: '',
1580      paddingRight: ''
1581    });
1582
1583    clearSeatFilterModalState();
1584  }
1585
1586  function cleanupIitixWideModalState() {
1587    var modal = getSeatModal();
1588    if (!modal.length) {
1589      ensureSeatModalBackdrop(false);
1590      $('.ui-widget-overlay').remove();
1591      $('#iitix-seat-modal-backdrop').remove();
1592      $('html, body').removeClass('iitix-seat-modal-open modal-open').css({
1593        overflow: '',
1594        paddingRight: ''
1595      });
1596      return;
1597    }
1598
1599    var dialog = modal.closest('.ui-dialog');
1600
1601    ensureSeatModalBackdrop(false);
1602
1603    modal.removeClass('iitix-extra-modal-wide');
1604    if (dialog.length) dialog.removeClass('iitix-extra-dialog-wide');
1605
1606    var modalOriginalStyle = modal.data('iitix-original-style');
1607    var dialogOriginalStyle = dialog.data('iitix-original-style');
1608
1609    if (typeof modalOriginalStyle === 'string') {
1610      modal.attr('style', modalOriginalStyle);
1611    } else {
1612      modal.removeAttr('style');
1613    }
1614
1615    if (dialog.length) {
1616      if (typeof dialogOriginalStyle === 'string') {
1617        dialog.attr('style', dialogOriginalStyle);
1618      } else {
1619        dialog.removeAttr('style');
1620      }
1621    }
1622
1623    modal.removeData('iitix-original-style');
1624    if (dialog.length) dialog.removeData('iitix-original-style');
1625
1626    // Remove anything that can keep blocking clicks on the seating map after close.
1627    $('.ui-widget-overlay').remove();
1628    $('#iitix-seat-modal-backdrop').remove();
1629    $('.ui-dialog.iitix-extra-dialog-wide').removeClass('iitix-extra-dialog-wide');
1630    $('#tc-modal-woobridge.iitix-extra-modal-wide').removeClass('iitix-extra-modal-wide');
1631
1632    $('html, body')
1633      .removeClass('iitix-seat-modal-open modal-open')
1634      .css({
1635        overflow: '',
1636        paddingRight: ''
1637      });
1638  }
1639
1640  function closeTickeraSeatModal() {
1641    isSeatModalClosing = true;
1642    lastSeatModalCloseAt = nowMs();
1643
1644    // Invalidate pending product-seat-filter responses. This prevents an old
1645    // response from rebuilding/reopening the modal after the user closes it.
1646    requestSeq++;
1647
1648    if (extraBootTimer) {
1649      window.clearTimeout(extraBootTimer);
1650      extraBootTimer = null;
1651    }
1652
1653    if (seatModalClosingTimer) {
1654      window.clearTimeout(seatModalClosingTimer);
1655      seatModalClosingTimer = null;
1656    }
1657
1658    var modal = getSeatModal();
1659
1660    if (!modal.length) {
1661      cleanupIitixWideModalState();
1662      seatModalClosingTimer = window.setTimeout(function () {
1663        isSeatModalClosing = false;
1664      }, 350);
1665      return false;
1666    }
1667
1668    try {
1669      if (typeof modal.dialog === 'function' && modal.hasClass('ui-dialog-content')) {
1670        modal.dialog('close');
1671      }
1672    } catch (e) {}
1673
1674    cleanupIitixWideModalState();
1675    forceHideSeatModalElements();
1676
1677    // Extra guard for the old AJAX request that may complete shortly after close.
1678    // Do NOT keep suppressing for seconds, because that blocks the user's next
1679    // intentional seat click. Stop only stale reopen attempts that started before close.
1680    var closeAt = lastSeatModalCloseAt;
1681    [80, 180, 360].forEach(function (delay) {
1682      window.setTimeout(function () {
1683        var hasNewSeatRequest = lastSeatVariationAjaxSentAt > closeAt;
1684        if (isSeatModalClosing && lastSeatModalCloseAt === closeAt && !hasNewSeatRequest) {
1685          cleanupIitixWideModalState();
1686          forceHideSeatModalElements();
1687        }
1688      }, delay);
1689    });
1690
1691    seatModalClosingTimer = window.setTimeout(function () {
1692      var hasNewSeatRequest = lastSeatVariationAjaxSentAt > closeAt;
1693      if (!hasNewSeatRequest) {
1694        cleanupIitixWideModalState();
1695        forceHideSeatModalElements();
1696      }
1697
1698      if (lastSeatModalCloseAt === closeAt) {
1699        isSeatModalClosing = false;
1700      }
1701    }, 420);
1702
1703    return false;
1704  }
1705
1706
1707  function installSeatModalCloseHandler() {
1708    if (window.__IITIX_SEAT_MODAL_CLOSE_HANDLER_BOUND__) return;
1709    window.__IITIX_SEAT_MODAL_CLOSE_HANDLER_BOUND__ = true;
1710
1711    // Capture phase so the close still works even when Tickera/jQuery UI
1712    // or the map layer intercepts bubbling clicks.
1713    document.addEventListener('click', function (event) {
1714      var target = event.target && event.target.closest
1715        ? event.target.closest('#tc-modal-woobridge .tc_modal_close_dialog, .ui-dialog.iitix-extra-dialog-wide .ui-dialog-titlebar-close')
1716        : null;
1717
1718      if (!target) return;
1719
1720      event.preventDefault();
1721      event.stopPropagation();
1722      if (event.stopImmediatePropagation) event.stopImmediatePropagation();
1723
1724      closeTickeraSeatModal();
1725      return false;
1726    }, true);
1727
1728    $(document)
1729      .off('keydown.iitixSeatModalClose')
1730      .on('keydown.iitixSeatModalClose', function (e) {
1731        if (e.key === 'Escape' || e.keyCode === 27) {
1732          if ($('#tc-modal-woobridge:visible').length || $('.ui-dialog.iitix-extra-dialog-wide:visible').length) {
1733            closeTickeraSeatModal();
1734          }
1735        }
1736      });
1737  }
1738
1739  installSeatModalCloseHandler();
1740
1741  function buildIitixStepHeading(number, title, badge, extraClass) {
1742    return $(
1743      '<div class="iitix-step-heading ' + esc(extraClass || '') + '">' +
1744        '<span class="iitix-step-number">' + esc(number) + '</span>' +
1745        '<span class="iitix-step-body">' +
1746          '<span class="iitix-step-title">' + esc(title) + '</span>' +
1747          '<span class="iitix-step-badge">' + esc(badge || 'Required') + '</span>' +
1748        '</span>' +
1749      '</div>'
1750    );
1751  }
1752
1753  function ensureNumberedRequiredSteps(form) {
1754    if (!form || !form.length) return;
1755
1756    var table = form.find('table.variations').first();
1757    if (table.length && !table.prev('.iitix-step-heading.iitix-step-category').length) {
1758      buildIitixStepHeading('1', 'Choose category', 'Required', 'iitix-step-category').insertBefore(table);
1759    }
1760  }
1761
1762  function removeNumberedRequiredSteps(form) {
1763    if (!form || !form.length) return;
1764    form.find('.iitix-step-heading.iitix-step-category').remove();
1765  }
1766
1767  function updateExtraFieldsVisibility(form) {
1768    if (!form || !form.length) return;
1769
1770    var profile = getCurrentExtraProfile(form);
1771    var shouldShow = currentSeatAllowsExtraFields(form);
1772    // Use the selected category identity, not only profile.key. Several standard
1773    // categories intentionally share the same schema key (standard_vendor_details)
1774    // but must still rebuild their heading/description when the user switches
1775    // e.g. Beauty & Wellness → Home & Living.
1776    var selectedCategoryIdentity = normalizeCategoryProfileKey(getCurrentExtraCategoryValue(form));
1777    // Include the actual profile schema key. The category remains "Packaged F&B"
1778    // on the six Premium Center seats, but their restricted schema must replace
1779    // the ordinary Packaged F&B appliance schema whenever the seat changes.
1780    var expectedProfileKey = selectedCategoryIdentity && profile
1781      ? (selectedCategoryIdentity + ':' + String(profile.key || profile.category_key || 'category-profile'))
1782      : (selectedCategoryIdentity || (profile && (profile.category_key || profile.key) ? String(profile.category_key || profile.key) : ''));
1783    var block = form.find('.iitix-extra-fields').first();
1784
1785    // Category-driven profile sections use the wide modal only when a mapped
1786    // category (Food Service / Packaged F&B / Fashion with Racks) is selected.
1787    setWhitelistedModalLayout(form, shouldShow);
1788
1789    // No mapped category selected: remove any old profile completely so file
1790    // fields, radio state, and validation state cannot leak into another
1791    // variation/category's AJAX add-to-cart request.
1792    if (!shouldShow) {
1793      // Restore the ordinary Tickera/WooCommerce structure before removing the
1794      // category profile, so an unmapped category keeps its original layout.
1795      restoreExtraModalActionLayout(form);
1796      removeNumberedRequiredSteps(form);
1797      clearExtraInvalidMarkers(form);
1798      renderExtraMessage(form, '', '');
1799
1800      if (block.length) {
1801        block.remove();
1802      }
1803
1804      setExtraButtonInvalid(form, false);
1805      return;
1806    }
1807
1808    // Create the scroll-body + action-footer structure before adding/rebuilding
1809    // a mapped category profile. The footer itself remains a WooCommerce child.
1810    ensureExtraModalActionLayout(form);
1811
1812    // A single WooCommerce product can expose multiple category values. Rebuild
1813    // the profile when the selected category changes (e.g. Packaged F&B ->
1814    // Food Service), otherwise old fields and fees could remain in the form.
1815    if (block.length && String(block.attr('data-iitix-profile') || '') !== expectedProfileKey) {
1816      clearExtraInvalidMarkers(form);
1817      renderExtraMessage(form, '', '');
1818      block.remove();
1819      block = $();
1820    }
1821
1822    block = block.length ? block : ensureExtraFields(form);
1823    ensureNumberedRequiredSteps(form);
1824    form.attr('enctype', 'multipart/form-data');
1825    block.show();
1826    block.find('input, textarea, select, button').prop('disabled', false);
1827    toggleOtherInputs(form);
1828  }
1829
1830  function readIntInput(form, selector) {
1831    var raw = form.find(selector).val();
1832    var n = parseInt(raw, 10);
1833    return isNaN(n) || n < 0 ? 0 : n;
1834  }
1835
1836  function collectExtraFields(form) {
1837    var fields = {};
1838    form.find('.iitix-extra-fields [data-iitix-field-name]').each(function () {
1839      var input = $(this);
1840      var name = input.data('iitix-field-name') || input.attr('name') || '';
1841      if (!name) return;
1842
1843      if (input.attr('type') === 'checkbox') {
1844        fields[name] = input.is(':checked') ? (input.val() || 'Yes') : '';
1845      } else if (input.attr('type') === 'radio') {
1846        if (input.is(':checked')) fields[name] = $.trim(input.val() || '');
1847        else if (typeof fields[name] === 'undefined') fields[name] = '';
1848      } else if (input.attr('type') === 'file') {
1849        var nativeInput = input.get(0);
1850        fields[name] = nativeInput && nativeInput.files && nativeInput.files.length ? nativeInput.files[0].name : '';
1851      } else {
1852        fields[name] = $.trim(input.val() || '');
1853      }
1854    });
1855
1856    return {
1857      fields: fields,
1858      menu: $.trim(form.find('[name="iitix_menu"]').val() || ''),
1859      instagramUsername: $.trim(form.find('[name="iitix_instagram_username"]').val() || ''),
1860      appliance1Type: $.trim(form.find('[name="iitix_appliance_1_type"]').val() || ''),
1861      appliance1Other: $.trim(form.find('[name="iitix_appliance_1_other"]').val() || ''),
1862      appliance1Wattage: readIntInput(form, '[name="iitix_appliance_1_wattage"]'),
1863      appliance2Type: $.trim(form.find('[name="iitix_appliance_2_type"]').val() || ''),
1864      appliance2Other: $.trim(form.find('[name="iitix_appliance_2_other"]').val() || ''),
1865      appliance2Wattage: readIntInput(form, '[name="iitix_appliance_2_wattage"]')
1866    };
1867  }
1868
1869  function setExtraButtonInvalid(form, invalid, message) {
1870    var btn = form.find('.tc_cart_button, .single_add_to_cart_button').first();
1871    if (!btn.length) return;
1872
1873    var variationId = $.trim(form.find('input.variation_id').val() || '');
1874    var hasVariationSelect = form.find('select[name^="attribute_"]').length > 0;
1875
1876    if (variationId) {
1877      normalizeValidVariationButton(form);
1878    }
1879
1880    // WooCommerce still needs category/variation if variation_id is blank.
1881    // Extra-field errors stay clickable so the guard can scroll the user to the empty field.
1882    var wooStillNeedsVariation = hasVariationSelect && !variationId;
1883    var extraInvalid = !!invalid && !wooStillNeedsVariation;
1884    var shouldHardDisable = wooStillNeedsVariation;
1885
1886    // A missing WooCommerce variation can keep the button disabled, but it must not
1887    // look like a submitted validation error before the vendor clicks Add to Cart.
1888    btn.toggleClass('iitix-extra-invalid', !!invalid);
1889
1890    if (shouldHardDisable) {
1891      btn.addClass('disabled');
1892      btn.prop('disabled', true);
1893      btn.attr('aria-disabled', 'true');
1894      btn.attr('title', message || 'Please choose category first.');
1895      return;
1896    }
1897
1898    if (extraInvalid) {
1899      btn.removeClass('disabled wc-variation-selection-needed');
1900      btn.prop('disabled', false);
1901      btn.attr('aria-disabled', 'true');
1902      btn.attr('title', message || 'Please complete the required fields.');
1903      return;
1904    }
1905
1906    btn.removeClass('disabled wc-variation-selection-needed iitix-extra-invalid');
1907    btn.prop('disabled', false);
1908    btn.attr('aria-disabled', 'false');
1909    btn.removeAttr('title');
1910  }
1911
1912  function renderExtraMessage(form, type, message) {
1913    var block = form.find('.iitix-extra-fields').first();
1914    var messageEl = block.find('[data-iitix-extra-message]').first();
1915    if (!messageEl.length) return;
1916
1917    messageEl.removeClass('is-error is-ok');
1918
1919    if (!message) {
1920      messageEl.text('').hide();
1921      return;
1922    }
1923
1924    messageEl.text(message).show().addClass(type === 'ok' ? 'is-ok' : 'is-error');
1925  }
1926
1927
1928
1929  /*
1930   * Required-field highlighting
1931   *
1932   * Validation always collects every missing field in schema order, but UI only
1933   * highlights validationErrors[0]. The helper below resolves that field to the
1934   * visible UI area instead of the hidden native file input / raw form control.
1935   */
1936  function getExtraInvalidVisualTarget(target) {
1937    if (!target || !target.length) return $();
1938
1939    var fieldTarget = target;
1940    var uploadTarget = $();
1941    var fieldWrapper = $();
1942    var stepWrapper = $();
1943
1944    if (target.is('input[type="file"]')) {
1945      uploadTarget = target.closest('[data-iitix-file-upload]');
1946    }
1947
1948    fieldWrapper = target.closest(
1949      '.iitix-field-group, ' +
1950      '.iitix-field-menu, ' +
1951      '.iitix-field-instagram, ' +
1952      '.iitix-field-brand-logo, ' +
1953      '.iitix-field-promo-package, ' +
1954      '.iitix-field-remove-table, ' +
1955      '[data-iitix-field-wrapper], ' +
1956      '.iitix-radio-group, ' +
1957      '.iitix-watt-summary'
1958    );
1959
1960    stepWrapper = target.closest(
1961      '.iitix-extra-section, ' +
1962      '.iitix-appliance-card, ' +
1963      'td.value'
1964    );
1965
1966    // Prefer the complete visible step / card so it is obvious which required
1967    // item must be completed. For uploads, also outline the upload drop-zone.
1968    if (stepWrapper.length) {
1969      if (uploadTarget.length) return stepWrapper.add(uploadTarget);
1970      return stepWrapper;
1971    }
1972
1973    if (uploadTarget.length) return uploadTarget;
1974    if (fieldWrapper.length) return fieldWrapper;
1975
1976    return fieldTarget;
1977  }
1978
1979  function getExtraInvalidMessageContainer(target) {
1980    if (!target || !target.length) return $();
1981
1982    var stepWrapper = target.closest(
1983      '.iitix-extra-section, ' +
1984      '.iitix-appliance-card, ' +
1985      'td.value'
1986    );
1987    if (stepWrapper.length) return stepWrapper;
1988
1989    var visualTarget = getExtraInvalidVisualTarget(target);
1990    return visualTarget.length ? visualTarget.first() : target;
1991  }
1992
1993  /*
1994   * The red state belongs to the first failed Add to Cart attempt. It must not
1995   * disappear when WooCommerce, Tickera, MutationObserver, or a normal input
1996   * event performs a silent validation pass.
1997   *
1998   * Store a stable identity for the highlighted source control. A later silent
1999   * validation only clears the red state when that exact control has become
2000   * valid; it deliberately does not highlight the next missing field until the
2001   * vendor presses Add to Cart again.
2002   */
2003  function getExtraInvalidTargetKey(target) {
2004    target = target && target.length ? target.first() : $();
2005    if (!target.length) return '';
2006
2007    var name = $.trim(target.attr('name') || '');
2008    if (name) return 'name:' + name;
2009
2010    var fieldName = $.trim(target.attr('data-iitix-field-name') || '');
2011    if (fieldName) return 'field:' + fieldName;
2012
2013    var radioGroup = $.trim(target.attr('data-iitix-radio-group') || '');
2014    if (radioGroup) return 'radio:' + radioGroup;
2015
2016    var id = $.trim(target.attr('id') || '');
2017    if (id) return 'id:' + id;
2018
2019    if (target.hasClass('iitix-watt-summary')) return 'class:iitix-watt-summary';
2020    if (target.hasClass('iitix-extra-fields')) return 'class:iitix-extra-fields';
2021
2022    var generatedKey = target.data('iitixExtraInvalidTargetKey');
2023    if (!generatedKey) {
2024      generatedKey = 'node:' + nowMs() + ':' + Math.random().toString(36).slice(2);
2025      target.data('iitixExtraInvalidTargetKey', generatedKey);
2026    }
2027
2028    return generatedKey;
2029  }
2030
2031  function getActiveExtraInvalidState(form) {
2032    if (!form || !form.length) return null;
2033
2034    var state = form.data('iitixActiveExtraInvalidState');
2035    return state && state.key ? state : null;
2036  }
2037
2038  function setActiveExtraInvalidState(form, target, message) {
2039    if (!form || !form.length || !target || !target.length) return;
2040
2041    form.data('iitixActiveExtraInvalidState', {
2042      key: getExtraInvalidTargetKey(target),
2043      target: target.first(),
2044      message: String(message || '')
2045    });
2046  }
2047
2048  function findMatchingExtraValidationError(errors, activeState) {
2049    if (!activeState || !activeState.key || !Array.isArray(errors)) return null;
2050
2051    for (var i = 0; i < errors.length; i++) {
2052      var candidate = errors[i] || {};
2053      if (getExtraInvalidTargetKey(candidate.target) === activeState.key) {
2054        return candidate;
2055      }
2056    }
2057
2058    return null;
2059  }
2060
2061  function clearExtraInvalidMarkers(form) {
2062    if (!form || !form.length) return;
2063
2064    form.find('.iitix-is-invalid').removeClass('iitix-is-invalid');
2065    form.find('[aria-invalid="true"]').removeAttr('aria-invalid');
2066    form.find('.iitix-inline-error').remove();
2067    form.removeData('iitixActiveExtraInvalidState');
2068  }
2069
2070  function reconcileActiveExtraInvalidState(form, validationErrors) {
2071    var activeState = getActiveExtraInvalidState(form);
2072    if (!activeState) return;
2073
2074    var matchingError = findMatchingExtraValidationError(validationErrors, activeState);
2075
2076    // The source field that was highlighted is now valid. Remove all red UI,
2077    // but do not automatically jump to or highlight the next missing field.
2078    if (!matchingError) {
2079      clearExtraInvalidMarkers(form);
2080      renderExtraMessage(form, '', '');
2081      return;
2082    }
2083
2084    // Keep the same first failed field visible through silent re-validations.
2085    // Its wording can change (for example appliance type -> wattage), so retain
2086    // the current message while preserving the original user-driven target.
2087    activeState.target = matchingError.target && matchingError.target.length
2088      ? matchingError.target.first()
2089      : activeState.target;
2090    activeState.message = String(matchingError.message || activeState.message || '');
2091    form.data('iitixActiveExtraInvalidState', activeState);
2092
2093    markExtraInvalidTarget(activeState.target, activeState.message);
2094    renderExtraMessage(form, 'error', activeState.message);
2095  }
2096
2097  function markExtraInvalidTarget(target, message) {
2098    if (!target || !target.length) return;
2099
2100    var inputType = String(target.attr('type') || '').toLowerCase();
2101    var isNativeInput = target.is('input, textarea, select');
2102
2103    // Native controls receive aria-invalid for accessibility. The file input is
2104    // hidden by design, so its visible upload zone is highlighted below instead.
2105    if (isNativeInput && inputType !== 'file') {
2106      target.attr('aria-invalid', 'true');
2107    }
2108
2109    var visualTarget = getExtraInvalidVisualTarget(target);
2110    if (!visualTarget.length) visualTarget = target;
2111
2112    // This class drives the existing red border / red focus ring CSS.
2113    visualTarget.addClass('iitix-is-invalid');
2114
2115    var messageContainer = getExtraInvalidMessageContainer(target);
2116    if (!messageContainer.length) messageContainer = visualTarget.first();
2117
2118    if (message && !messageContainer.children('.iitix-inline-error').length) {
2119      messageContainer.append('<div class="iitix-inline-error">' + esc(message) + '</div>');
2120    }
2121  }
2122
2123  function scrollToFirstInvalidField(form, preferredTarget) {
2124    if (!form || !form.length) return;
2125
2126    // preferredTarget comes directly from validationErrors[0], so the first
2127    // missing required item in visible step order is the only one highlighted.
2128    var target = preferredTarget && preferredTarget.length && preferredTarget.is(':visible')
2129      ? preferredTarget
2130      : $();
2131
2132    if (!target.length) {
2133      target = form.find('[aria-invalid="true"]:visible, .iitix-is-invalid:visible, .iitix-inline-error:visible').first();
2134    }
2135    if (!target.length) {
2136      target = form.find('select[name^="attribute_"], .iitix-step-heading, .iitix-extra-fields').filter(':visible').first();
2137    }
2138
2139    var scrollTarget = getExtraInvalidMessageContainer(target);
2140    if (!scrollTarget.length) scrollTarget = getExtraInvalidVisualTarget(target);
2141    if (!scrollTarget.length) scrollTarget = target;
2142
2143    if (scrollTarget.length && scrollTarget.get(0) && typeof scrollTarget.get(0).scrollIntoView === 'function') {
2144      scrollTarget.get(0).scrollIntoView({ behavior: 'smooth', block: 'center' });
2145
2146      var focusTarget = target;
2147      if (target.is('input[type="file"]')) {
2148        focusTarget = target.closest('[data-iitix-file-upload]').find('[data-iitix-file-trigger]').first();
2149      }
2150
2151      if (focusTarget.length && focusTarget.is('input, textarea, select, button')) {
2152        window.setTimeout(function () {
2153          try { focusTarget.trigger('focus'); } catch (e) {}
2154        }, 320);
2155      }
2156    }
2157  }
2158
2159  function formatIitixFileSize(bytes) {
2160    bytes = parseInt(bytes, 10) || 0;
2161    if (bytes <= 0) return '0 KB';
2162    if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '') + ' MB';
2163    return Math.ceil(bytes / 1024) + ' KB';
2164  }
2165
2166  function getFieldDefByName(profile, fieldName) {
2167    return getProfileField(profile, fieldName) || {};
2168  }
2169
2170  function getIitixFileValidationKey(file) {
2171    if (!file) return '';
2172    return [file.name || '', file.size || 0, file.type || '', file.lastModified || 0].join('|');
2173  }
2174
2175  function parseIitixAspectRatio(value) {
2176    value = $.trim(String(value || ''));
2177    if (!value) return 0;
2178
2179    if (value.indexOf(':') !== -1) {
2180      var parts = value.split(':');
2181      var width = parseFloat(parts[0]);
2182      var height = parseFloat(parts[1]);
2183      return width > 0 && height > 0 ? width / height : 0;
2184    }
2185
2186    var ratio = parseFloat(value);
2187    return ratio > 0 ? ratio : 0;
2188  }
2189
2190  function hasIitixImageDimensionRule(field) {
2191    field = field || {};
2192    return (parseInt(field.min_width, 10) || 0) > 0 ||
2193      (parseInt(field.min_height, 10) || 0) > 0 ||
2194      parseIitixAspectRatio(field.aspect_ratio) > 0;
2195  }
2196
2197  function inspectIitixImageDimensions(input, field) {
2198    if (!input || !input.length || !hasIitixImageDimensionRule(field)) return null;
2199
2200    var el = input.get(0);
2201    var file = el && el.files && el.files.length ? el.files[0] : null;
2202    if (!file) {
2203      input.removeData('iitixImageDimensionState');
2204      return null;
2205    }
2206
2207    var fileKey = getIitixFileValidationKey(file);
2208    var cached = input.data('iitixImageDimensionState');
2209    if (cached && cached.key === fileKey) return cached;
2210
2211    var initialState = { key: fileKey, status: 'loading', width: 0, height: 0, message: '' };
2212    input.data('iitixImageDimensionState', initialState);
2213
2214    if (!window.URL || typeof window.URL.createObjectURL !== 'function') {
2215      initialState.status = 'error';
2216      initialState.message = 'Image dimensions could not be checked in this browser.';
2217      input.data('iitixImageDimensionState', initialState);
2218      return initialState;
2219    }
2220
2221    var objectUrl = window.URL.createObjectURL(file);
2222    var image = new Image();
2223    var finish = function (state) {
2224      try { window.URL.revokeObjectURL(objectUrl); } catch (e) {}
2225      input.data('iitixImageDimensionState', state);
2226
2227      var form = input.closest('form.variations_form');
2228
2229      // When the user has just selected/uploaded a file, validation must happen
2230      // immediately and the preview must only appear after the file passes all
2231      // client-side checks. Silent validation is kept for normal form refreshes.
2232      if (input.data('iitixFileValidateOnChange')) {
2233        validateIitixFileSelectionOnChange(input, true);
2234      } else if (form.length) {
2235        validateExtraFields(form, false);
2236      }
2237    };
2238
2239    image.onload = function () {
2240      finish({ key: fileKey, status: 'ready', width: image.naturalWi
2240dth || image.width || 0, height: image.naturalHeight || image.height || 0, message: '' });
2241    };
2242    image.onerror = function () {
2243      finish({ key: fileKey, status: 'error', width: 0, height: 0, message: 'Image dimensions could not be read. Please choose a valid image file.' });
2244    };
2245    image.src = objectUrl;
2246
2247    return initialState;
2248  }
2249
2250  function validateClientFileInput(input, field) {
2251    field = field || {};
2252    if (!input || !input.length) return { valid: true, message: '' };
2253
2254    var el = input.get(0);
2255    var file = el && el.files && el.files.length ? el.files[0] : null;
2256    var label = field.label || field.meta_label || 'Image';
2257    var required = !!field.required;
2258
2259    if (!file) {
2260      input.removeData('iitixImageDimensionState');
2261      return required ? { valid: false, message: 'Please upload ' + label + '.' } : { valid: true, message: '' };
2262    }
2263
2264    var upload = input.closest('[data-iitix-file-upload]');
2265    var maxMb = parseInt(field.max_size_mb || upload.data('iitix-max-size-mb') || 10, 10) || 10;
2266    if (file.size > maxMb * 1024 * 1024) {
2267      return { valid: false, message: label + ' maximum file size is ' + maxMb + ' MB.' };
2268    }
2269
2270    var accepted = String(field.accept || input.attr('accept') || 'image/png,image/jpeg,image/webp').split(',').map(function (item) {
2271      return $.trim(item).toLowerCase();
2272    });
2273    var fileType = String(file.type || '').toLowerCase();
2274    var fileName = String(file.name || '').toLowerCase();
2275    var typeAllowed = accepted.some(function (accept) {
2276      if (!accept) return false;
2277      if (accept.charAt(0) === '.') return fileName.slice(-accept.length) === accept;
2278      if (accept.slice(-2) === '/*') return fileType.indexOf(accept.slice(0, -1)) === 0;
2279      return fileType === accept;
2280    });
2281
2282    if (!typeAllowed) {
2283      var fileTypesLabel = field.file_types_label || upload.data('iitix-file-types-label') || 'PNG, JPG, or WebP';
2284      return { valid: false, message: label + ' must be a ' + fileTypesLabel + ' image.' };
2285    }
2286
2287    if (hasIitixImageDimensionRule(field)) {
2288      var dimensionState = inspectIitixImageDimensions(input, field);
2289      if (!dimensionState || dimensionState.status === 'loading') {
2290        return { valid: false, message: 'Checking ' + label + ' dimensions. Please wait.' };
2291      }
2292      if (dimensionState.status === 'error') {
2293        return { valid: false, message: dimensionState.message || (label + ' dimensions could not be checked.') };
2294      }
2295
2296      var minWidth = parseInt(field.min_width || upload.data('iitix-min-width') || 0, 10) || 0;
2297      var minHeight = parseInt(field.min_height || upload.data('iitix-min-height') || 0, 10) || 0;
2298      if (minWidth > 0 && dimensionState.width < minWidth) {
2299        return { valid: false, message: label + ' minimum width is ' + minWidth + ' px.' };
2300      }
2301      if (minHeight > 0 && dimensionState.height < minHeight) {
2302        return { valid: false, message: label + ' minimum height is ' + minHeight + ' px.' };
2303      }
2304
2305      var expectedRatio = parseIitixAspectRatio(field.aspect_ratio || upload.data('iitix-aspect-ratio') || '');
2306      if (expectedRatio > 0 && dimensionState.width > 0 && dimensionState.height > 0) {
2307        var actualRatio = dimensionState.width / dimensionState.height;
2308        var tolerance = parseFloat(field.aspect_ratio_tolerance || upload.data('iitix-aspect-ratio-tolerance') || 0.01);
2309        tolerance = tolerance > 0 ? tolerance : 0.01;
2310        if (Math.abs(actualRatio - expectedRatio) > tolerance) {
2311          return { valid: false, message: label + ' must use a ' + (field.aspect_ratio || upload.data('iitix-aspect-ratio')) + ' aspect ratio.' };
2312        }
2313      }
2314    }
2315
2316    return { valid: true, message: '' };
2317  }
2318
2319  function renderIitixFilePreview(input) {
2320    var upload = input.closest('[data-iitix-file-upload]');
2321    var preview = upload.find('[data-iitix-file-preview]').first();
2322    var fileNameEl = upload.find('[data-iitix-file-name]').first();
2323    var el = input.get(0);
2324    var file = el && el.files && el.files.length ? el.files[0] : null;
2325    var previewField = {
2326      empty_preview_title: upload.attr('data-iitix-empty-preview-title') || 'No image yet',
2327      empty_preview_subtitle: upload.attr('data-iitix-empty-preview-subtitle') || 'Preview here',
2328      icon: upload.attr('data-iitix-empty-preview-icon') || '🖼'
2329    };
2330
2331    upload.removeClass('has-file');
2332    preview.addClass('is-empty').html(getIitixFilePlaceholderHtml(previewField));
2333    fileNameEl.text('');
2334
2335    if (!file) return;
2336
2337    fileNameEl.text(file.name + ' • ' + formatIitixFileSize(file.size));
2338
2339    if (window.FileReader && file.type && file.type.indexOf('image/') === 0) {
2340      var reader = new FileReader();
2341      reader.onload = function (event) {
2342        upload.addClass('has-file');
2343        preview.removeClass('is-empty').html('<img src="' + esc(event.target.result) + '" alt="' + esc(file.name) + '">');
2344      };
2345      reader.readAsDataURL(file);
2346    } else {
2347      upload.addClass('has-file');
2348      preview.removeClass('is-empty').html('<div class="iitix-file-placeholder"><strong>' + esc(file.name) + '</strong><small>' + esc(formatIitixFileSize(file.size)) + '</small></div>');
2349    }
2350  }
2351
2352
2353  function resetIitixFilePreview(input, note) {
2354    var upload = input.closest('[data-iitix-file-upload]');
2355    var preview = upload.find('[data-iitix-file-preview]').first();
2356    var fileNameEl = upload.find('[data-iitix-file-name]').first();
2357    var previewField = {
2358      empty_preview_title: upload.attr('data-iitix-empty-preview-title') || 'No image yet',
2359      empty_preview_subtitle: note || upload.attr('data-iitix-empty-preview-subtitle') || 'Preview here',
2360      icon: upload.attr('data-iitix-empty-preview-icon') || '🖼'
2361    };
2362
2363    upload.removeClass('has-file');
2364    preview.addClass('is-empty').html(getIitixFilePlaceholderHtml(previewField));
2365    fileNameEl.text('');
2366  }
2367
2368  function setIitixFileCheckingState(input) {
2369    var upload = input.closest('[data-iitix-file-upload]');
2370    var fileNameEl = upload.find('[data-iitix-file-name]').first();
2371    var el = input.get(0);
2372    var file = el && el.files && el.files.length ? el.files[0] : null;
2373
2374    resetIitixFilePreview(input, 'Checking image dimensions...');
2375    if (file) {
2376      fileNameEl.text(file.name + ' • ' + formatIitixFileSize(file.size) + ' • Checking dimensions...');
2377    }
2378  }
2379
2380  function clearIitixFileInput(input) {
2381    input.val('');
2382    input.removeData('iitixImageDimensionState');
2383    input.removeData('iitixFileValidateOnChange');
2384    resetIitixFilePreview(input);
2385  }
2386
2387  function showIitixUploadValidationAlert(message) {
2388    var text = message || 'The selected image does not match the upload requirements.';
2389
2390    if (typeof Swal !== 'undefined' && Swal && typeof Swal.fire === 'function') {
2391      Swal.fire({
2392        icon: 'warning',
2393        title: 'Invalid upload',
2394        text: text,
2395        confirmButtonText: 'OK'
2396      });
2397      return;
2398    }
2399
2400    alert(text);
2401  }
2402
2403  function markIitixFileUploadInvalid(input, message) {
2404    var form = input.closest('form.variations_form');
2405
2406    if (form.length) {
2407      clearExtraInvalidMarkers(form);
2408      renderExtraMessage(form, '', '');
2409      markExtraInvalidTarget(input, message);
2410      setActiveExtraInvalidState(form, input, message);
2411      renderExtraMessage(form, 'error', message);
2412    }
2413  }
2414
2415  function getIitixFileFieldDefinition(form, input) {
2416    var profile = getCurrentExtraProfile(form);
2417    var fieldName = $.trim(input.attr('name') || input.attr('data-iitix-field-name') || '');
2418    return getFieldDefByName(profile, fieldName) || {};
2419  }
2420
2421  function validateIitixFileSelectionOnChange(input, dimensionReady) {
2422    if (!input || !input.length) return;
2423
2424    var form = input.closest('form.variations_form');
2425    var field = getIitixFileFieldDefinition(form, input);
2426    var el = input.get(0);
2427    var file = el && el.files && el.files.length ? el.files[0] : null;
2428
2429    if (!file) {
2430      input.removeData('iitixFileValidateOnChange');
2431      input.removeData('iitixImageDimensionState');
2432      resetIitixFilePreview(input);
2433      if (form.length) validateExtraFields(form, false);
2434      return;
2435    }
2436
2437    input.data('iitixFileValidateOnChange', true);
2438    var result = validateClientFileInput(input, field);
2439
2440    // Dimension checks are asynchronous. Until the image is decoded, keep the
2441    // placeholder visible and do not show preview yet.
2442    if (!result.valid && /^Checking\s/.test(result.message || '') && !dimensionReady) {
2443      setIitixFileCheckingState(input);
2444      return;
2445    }
2446
2447    if (!result.valid) {
2448      var message = result.message || 'The selected image does not match the upload requirements.';
2449      clearIitixFileInput(input);
2450      markIitixFileUploadInvalid(input, message);
2451      showIitixUploadValidationAlert(message);
2452      return;
2453    }
2454
2455    input.removeData('iitixFileValidateOnChange');
2456    renderIitixFilePreview(input);
2457    if (form.length) validateExtraFields(form, false);
2458  }
2459
2460  /* =========================
2461   * Add to cart success UX
2462   * ========================= */
2463  var iitixAddToCartPending = false;
2464  var iitixAddToCartPendingAt = 0;
2465  var iitixAddToCartDoneTimer = null;
2466
2467  function getIitixToastRoot() {
2468    var root = document.getElementById('iitix-seat-toast-root');
2469    if (!root) {
2470      root = document.createElement('div');
2471      root.id = 'iitix-seat-toast-root';
2472      document.body.appendChild(root);
2473    }
2474    return root;
2475  }
2476
2477  function showIitixSeatToast(message, type) {
2478    var root = getIitixToastRoot();
2479    var toast = document.createElement('div');
2480    toast.className = 'iitix-seat-toast ' + (type === 'error' ? 'is-error' : 'is-success');
2481    toast.innerHTML = '' +
2482      '<span class="iitix-seat-toast-icon">' + (type === 'error' ? '!' : '✓') + '</span>' +
2483      '<span class="iitix-seat-toast-text">' + esc(message || 'Added to cart successfully.') + '</span>';
2484
2485    root.appendChild(toast);
2486
2487    window.setTimeout(function () {
2488      toast.classList.add('is-visible');
2489    }, 20);
2490
2491    window.setTimeout(function () {
2492      toast.classList.remove('is-visible');
2493      window.setTimeout(function () {
2494        if (toast.parentNode) toast.parentNode.removeChild(toast);
2495      }, 260);
2496    }, 2600);
2497  }
2498
2499  function markIitixAddToCartPending(form) {
2500    iitixAddToCartPending = true;
2501    iitixAddToCartPendingAt = nowMs();
2502
2503    if (iitixAddToCartDoneTimer) {
2504      window.clearTimeout(iitixAddToCartDoneTimer);
2505      iitixAddToCartDoneTimer = null;
2506    }
2507
2508    if (form && form.length) {
2509      form.addClass('iitix-add-to-cart-pending');
2510    }
2511  }
2512
2513  function clearIitixAddToCartPending() {
2514    iitixAddToCartPending = false;
2515    iitixAddToCartPendingAt = 0;
2516    $('#tc-modal-woobridge form.variations_form').removeClass('iitix-add-to-cart-pending');
2517  }
2518
2519  function finishIitixAddToCartSuccess() {
2520    if (!iitixAddToCartPending) return;
2521
2522    clearIitixAddToCartPending();
2523    showIitixSeatToast('Added to cart successfully.', 'success');
2524
2525    // Give WooCommerce/Tickera a short moment to update fragments/subtotal,
2526    // then close the seat modal cleanly so the user returns to the seating chart.
2527    window.setTimeout(function () {
2528      closeTickeraSeatModal();
2529    }, 450);
2530  }
2531
2532  function scheduleIitixAddToCartSuccess() {
2533    if (!iitixAddToCartPending) return;
2534
2535    if (iitixAddToCartDoneTimer) {
2536      window.clearTimeout(iitixAddToCartDoneTimer);
2537    }
2538
2539    iitixAddToCartDoneTimer = window.setTimeout(function () {
2540      iitixAddToCartDoneTimer = null;
2541      finishIitixAddToCartSuccess();
2542    }, 350);
2543  }
2544
2545  function isLikelyWooFragmentRequest(settings) {
2546    var url = String(settings && settings.url || '');
2547    var data = String(settings && settings.data || '');
2548
2549    return url.indexOf('wc-ajax=get_refreshed_fragments') !== -1 ||
2550      data.indexOf('action=woocommerce_get_refreshed_fragments') !== -1 ||
2551      data.indexOf('wc-ajax=get_refreshed_fragments') !== -1;
2552  }
2553
2554  function getAjaxSettingValue(settings, key) {
2555    if (!settings || !key) return '';
2556
2557    var data = settings.data;
2558    if (window.FormData && data instanceof FormData) {
2559      var formDataValue = data.get(key);
2560      return formDataValue == null ? '' : String(formDataValue);
2561    }
2562
2563    if ($.isPlainObject(data)) {
2564      var objectValue = data[key];
2565      if (Array.isArray(objectValue)) return objectValue.length ? String(objectValue[0]) : '';
2566      return objectValue == null ? '' : String(objectValue);
2567    }
2568
2569    if (typeof data === 'string') {
2570      return String(parseQS(data)[key] || '');
2571    }
2572
2573    return '';
2574  }
2575
2576  function isTickeraFinalSeatAddRequest(settings) {
2577    var action = $.trim(getAjaxSettingValue(settings, 'action') || '');
2578    if (action !== 'tc_woo_update_cart_seats') return false;
2579
2580    // Removal sends quantity 0 and must not be blocked by missing upload fields.
2581    var quantity = getAjaxSettingValue(settings, 'quantity');
2582    return quantity === '' || parseInt(quantity, 10) !== 0;
2583  }
2584
2585  function isLikelyAddToCartRequest(settings) {
2586    var url = String(settings && settings.url || '');
2587    var data = settings && settings.data;
2588    var dataString = typeof data === 'string' ? data : '';
2589
2590    if (shouldHandleTickeraVariationAjax(settings)) return false;
2591    if (isTickeraFinalSeatAddRequest(settings)) return true;
2592
2593    // Keep support for a standard WooCommerce AJAX add-to-cart flow. Do not
2594    // treat Tickera availability probes as add-to-cart requests.
2595    return url.indexOf('wc-ajax=add_to_cart') !== -1 ||
2596      dataString.indexOf('add-to-cart=') !== -1 ||
2597      dataString.indexOf('add_to_cart') !== -1;
2598  }
2599
2600
2601
2602  /* =========================
2603   * Add custom extra fields into AJAX Add to Cart payload
2604   * ========================= */
2605  function getIitixSeatContextPayload(form) {
2606    var modal = form && form.length ? form.closest('.tc-modal-wrap') : $();
2607    if (!modal.length) modal = getSeatModal();
2608
2609    var seatId = $.trim(modal.find('.tc_regular_modal_seat_id').first().val() || '');
2610    var seatLabel = normalizeSeatIdentity(modal.find('.tc_regular_modal_seat_label').first().text() || '');
2611    var chartId = $.trim(modal.find('.tc_regular_modal_seating_chart_id').first().val() || '');
2612    var ticketTypeId = $.trim(modal.find('.tc_regular_modal_ticket_type_id').first().val() || '');
2613    var variationId = $.trim(form.find('input[name="variation_id"]').first().val() || '');
2614
2615    if (!ticketTypeId) ticketTypeId = variationId;
2616
2617    return {
2618      seatId: seatId,
2619      seatLabel: seatLabel,
2620      chartId: chartId,
2621      ticketTypeId: ticketTypeId,
2622      variationId: variationId
2623    };
2624  }
2625
2626  function getIitixFinalSubmissionToken(form) {
2627    var token = form.data('iitixFinalSubmissionToken');
2628    if (token) return String(token);
2629
2630    token = 'iitix-' + nowMs() + '-' + Math.random().toString(36).slice(2, 12);
2631    form.data('iitixFinalSubmissionToken', token);
2632    return token;
2633  }
2634
2635  function getIitixExtraFieldsPayload(form) {
2636    if (!form || !form.length || !currentSeatAllowsExtraFields(form)) return [];
2637
2638    // Keep hidden total wattage updated before the AJAX request is sent.
2639    validateExtraFields(form, false);
2640
2641    var fields = form.find('[name^="iitix_"]').not(':file').serializeArray();
2642    var categoryAttributeName = getCategoryAttributeName();
2643    var categoryValue = getCurrentExtraCategoryValue(form);
2644    var seatContext = getIitixSeatContextPayload(form);
2645
2646    // Tickera/WooBridge normally serializes variation attributes itself. Add the
2647    // selected category defensively when it is absent so PHP resolves the same
2648    // category profile as the visible form.
2649    if (categoryValue && !fields.some(function (field) {
2650      return field && field.name === categoryAttributeName;
2651    })) {
2652      fields.push({ name: categoryAttributeName, value: categoryValue });
2653    }
2654
2655    // This explicit context prevents a multi-seat cart from ever relying on
2656    // attendee order/index. PHP persists it with the exact cart line.
2657    fields.push({ name: 'iitix_csm_submission_context', value: 'tickera_woo_seat_add' });
2658    fields.push({ name: 'iitix_csm_submission_token', value: getIitixFinalSubmissionToken(form) });
2659    fields.push({ name: 'iitix_csm_seat_id', value: seatContext.seatId });
2660    fields.push({ name: 'iitix_csm_seat_label', value: seatContext.seatLabel });
2661    fields.push({ name: 'iitix_csm_chart_id', value: seatContext.chartId });
2662    fields.push({ name: 'iitix_csm_ticket_type_id', value: seatContext.ticketTypeId });
2663
2664    return fields;
2665  }
2666
2667  function getIitixExtraFilesPayload(form) {
2668    var files = [];
2669    if (!form || !form.length || !currentSeatAllowsExtraFields(form)) return files;
2670
2671    form.find('input[type="file"][name^="iitix_"]:not(:disabled)').each(function () {
2672      var input = this;
2673      if (input.files && input.files.length) {
2674        files.push({ name: input.name, file: input.files[0] });
2675      }
2676    });
2677
2678    return files;
2679  }
2680
2681  function appendQueryStringToFormData(formData, queryString) {
2682    String(queryString || '').split('&').forEach(function (part) {
2683      if (!part) return;
2684      var idx = part.indexOf('=');
2685      var key = idx >= 0 ? part.slice(0, idx) : part;
2686      var value = idx >= 0 ? part.slice(idx + 1) : '';
2687      try {
2688        key = decodeURIComponent(key.replace(/\+/g, ' '));
2689        value = decodeURIComponent(value.replace(/\+/g, ' '));
2690      } catch (e) {}
2691      if (key) formData.append(key, value);
2692    });
2693  }
2694
2695  function appendObjectToFormData(formData, obj) {
2696    Object.keys(obj || {}
2696).forEach(function (key) {
2697      var value = obj[key];
2698      if (Array.isArray(value)) {
2699        var arrayKey = /\[\]$/.test(key) ? key : key + '[]';
2700        value.forEach(function (item) { formData.append(arrayKey, item == null ? '' : item); });
2701      } else {
2702        formData.append(key, value == null ? '' : value);
2703      }
2704    });
2705  }
2706
2707  function replaceIitixFormDataValue(formData, name, value, filename) {
2708    if (typeof formData.delete === 'function') {
2709      formData.delete(name);
2710    }
2711
2712    if (typeof filename === 'string' && filename) {
2713      formData.append(name, value, filename);
2714    } else {
2715      formData.append(name, value == null ? '' : value);
2716    }
2717  }
2718
2719  function appendIitixFieldsAndFilesToFormData(formData, fields, files) {
2720    fields.forEach(function (field) {
2721      replaceIitixFormDataValue(formData, field.name, field.value);
2722    });
2723
2724    files.forEach(function (item) {
2725      // Some handlers have already appended an empty file input. Replace it
2726      // instead of trusting FormData.has(), otherwise PHP sees UPLOAD_ERR_NO_FILE.
2727      replaceIitixFormDataValue(formData, item.name, item.file, item.file.name);
2728    });
2729  }
2730
2731  function ajaxDataAlreadyHasField(dataString, fieldName) {
2732    dataString = String(dataString || '');
2733    var rawKey = fieldName + '=';
2734    var encodedKey = encodeURIComponent(fieldName) + '=';
2735
2736    return dataString.indexOf(rawKey) !== -1 || dataString.indexOf(encodedKey) !== -1;
2737  }
2738
2739  function appendIitixExtraFieldsToAjaxSettings(settings) {
2740    if (!settings || !isLikelyAddToCartRequest(settings)) return;
2741
2742    var form = getVariationForm();
2743    if (!form || !form.length) return;
2744
2745    var fields = getIitixExtraFieldsPayload(form);
2746    var files = getIitixExtraFilesPayload(form);
2747    if (!fields.length && !files.length) return;
2748
2749    // If there is a file upload, force FormData so PHP receives $_FILES.
2750    if (files.length && window.FormData) {
2751      var fd = settings.data instanceof FormData ? settings.data : new FormData();
2752
2753      if (!(settings.data instanceof FormData)) {
2754        if (typeof settings.data === 'string') {
2755          appendQueryStringToFormData(fd, settings.data);
2756        } else if ($.isPlainObject(settings.data)) {
2757          appendObjectToFormData(fd, settings.data);
2758        }
2759      }
2760
2761      appendIitixFieldsAndFilesToFormData(fd, fields, files);
2762      settings.data = fd;
2763      settings.processData = false;
2764      settings.contentType = false;
2765      settings.enctype = 'multipart/form-data';
2766      log('[IITIX] extra fields/files appended to FormData add-to-cart payload', { fields: fields, files: files.map(function (item) { return item.name; }) });
2767      return;
2768    }
2769
2770    // Case 1: FormData payload.
2771    if (window.FormData && settings.data instanceof FormData) {
2772      appendIitixFieldsAndFilesToFormData(settings.data, fields, files);
2773      log('[IITIX] extra fields appended to FormData add-to-cart payload', fields);
2774      return;
2775    }
2776
2777    // Case 2: query string payload. This is the usual Tickera/WooBridge admin-ajax.php flow.
2778    if (typeof settings.data === 'string') {
2779      var existing = parseQS(settings.data);
2780      fields.forEach(function (field) {
2781        existing[field.name] = field.value;
2782      });
2783      settings.data = $.param(existing);
2784
2785      log('[IITIX] extra fields appended to string add-to-cart payload', fields);
2786      return;
2787    }
2788
2789    // Case 3: plain object payload (Tickera's $.post uses this shape).
2790    if ($.isPlainObject(settings.data)) {
2791      fields.forEach(function (field) {
2792        settings.data[field.name] = field.value;
2793      });
2794
2795      log('[IITIX] extra fields appended to object add-to-cart payload', fields);
2796      return;
2797    }
2798  }
2799
2800  function addIitixValidationError(errors, target, message) {
2801    if (!message) return;
2802    errors.push({
2803      target: target && target.length ? target : $(),
2804      message: String(message)
2805    });
2806  }
2807
2808  function validateExtraFields(form, showValidationFeedback) {
2809    if (!form || !form.length || !currentSeatAllowsExtraFields(form)) {
2810      clearExtraInvalidMarkers(form);
2811      renderExtraMessage(form, '', '');
2812      setExtraButtonInvalid(form, false);
2813      return { valid: true, message: '', total: 0, max: 0, errors: [], invalidTarget: $() };
2814    }
2815
2816    var block = form.find('.iitix-extra-fields').first();
2817
2818    if (!block.length || !block.is(':visible')) {
2819      updateExtraFieldsVisibility(form);
2820      block = form.find('.iitix-extra-fields').first();
2821    }
2822
2823    if (!block.length || !block.is(':visible')) {
2824      clearExtraInvalidMarkers(form);
2825      renderExtraMessage(form, '', '');
2826      setExtraButtonInvalid(form, false);
2827      return { valid: true, message: '', total: 0, max: 0, errors: [], invalidTarget: $() };
2828    }
2829
2830    var data = collectExtraFields(form);
2831    var profile = getCurrentExtraProfile(form);
2832    var max = getExtraMaxWattage(form);
2833    var total = getEffectiveTotalWattage(data);
2834    var validationErrors = [];
2835
2836    // Normal form opening and typing should remain clean. Do not clear an
2837    // already-highlighted required field here: this function is also called by
2838    // silent WooCommerce/Tickera re-validations after the user clicks Add to Cart.
2839    // The active marker is reconciled after validationErrors are fully built.
2840
2841    block.find('[name="iitix_total_wattage"]').val(total);
2842    block.find('[data-iitix-total-wattage]').text(total);
2843
2844    /*
2845     * STEP 1 - CATEGORY
2846     * Keep this first so validationErrors[0] always respects the visible step order.
2847     */
2848    var categorySelect = form.find('select[name^="attribute_"]').first();
2849    var categoryValue = categorySelect.length ? $.trim(categorySelect.val() || '') : '';
2850    var variationId = $.trim(form.find('input.variation_id').val() || '');
2851
2852    if (categorySelect.length && !categoryValue) {
2853      addIitixValidationError(validationErrors, categorySelect, 'Step 1 is required: please choose category first.');
2854    }
2855
2856    /*
2857     * Dynamic required profile fields, e.g. Instagram and Brand Logo for Packaged F&B.
2858     * We collect every issue in order, then display and scroll only to index 0.
2859     */
2860    if (profile) {
2861      var sections = getProfileSections(profile);
2862      for (var s = 0; s < sections.length; s++) {
2863        var section = sections[s] || {};
2864        var fields = Array.isArray(section.fields) ? section.fields : [];
2865
2866        for (var f = 0; f < fields.length; f++) {
2867          var field = fields[f] || {};
2868          if (!field.name || field.name === 'iitix_menu') continue;
2869
2870          var input = form.find('[name="' + field.name + '"]').first();
2871          var value = data.fields && typeof data.fields[field.name] !== 'undefined'
2872            ? data.fields[field.name]
2873            : $.trim(input.val() || '');
2874          var stepPrefix = section.step_number ? ('Step ' + section.step_number + ' is required: ') : '';
2875          var fieldType = String(field.type || 'text').toLowerCase();
2876
2877          if (fieldType === 'file') {
2878            var fileValidation = validateClientFileInput(input, field);
2879            if (!fileValidation.valid) {
2880              addIitixValidationError(validationErrors, input, stepPrefix + fileValidation.message);
2881            }
2882            continue;
2883          }
2884
2885          if (field.required && !value) {
2886            addIitixValidationError(
2887              validationErrors,
2888              input.length ? input : form.find('[data-iitix-radio-group="' + field.name + '"]').first(),
2889              stepPrefix + 'please fill ' + (field.label || field.meta_label || field.name) + '.'
2890            );
2891          }
2892        }
2893      }
2894    }
2895
2896    /*
2897     * MENU
2898     */
2899    var menuRequired = profile
2900      ? !!(getProfileField(profile, 'iitix_menu') && getProfileField(profile, 'iitix_menu').required)
2901      : (EXTRA_CONFIG.menuRequired === true);
2902    if (menuRequired && !data.menu) {
2903      addIitixValidationError(
2904        validationErrors,
2905        form.find('[name="iitix_menu"]').first(),
2906        'Step ' + ((getProfileSectionByKey(profile, 'menu') || {}
2906).step_number || 2) + ' is required: please fill the menu list first.'
2907      );
2908    }
2909
2910    /*
2911     * APPLIANCE 1
2912     */
2913    var applianceSection = getActiveApplianceSection(form) || {};
2914    var applianceRequired = profile
2915      ? (!!profile.appliance_required || !!applianceSection.required_first_slot)
2916      : (EXTRA_CONFIG.applianceRequired === true);
2917    var applianceStep = applianceSection.step_number || 3;
2918
2919    if (applianceRequired && !data.appliance1Type) {
2920      addIitixValidationError(
2921        validationErrors,
2922        form.find('[name="iitix_appliance_1_type"]').first(),
2923        'Step ' + applianceStep + ' is required: please select Appliance 1.'
2924      );
2925    } else if (data.appliance1Type && data.appliance1Wattage <= 0) {
2926      addIitixValidationError(
2927        validationErrors,
2928        form.find('[name="iitix_appliance_1_wattage"]').first(),
2929        'Step ' + applianceStep + ' is required: please input wattage for Appliance 1.'
2930      );
2931    } else if (!data.appliance1Type && data.appliance1Wattage > 0) {
2932      addIitixValidationError(
2933        validationErrors,
2934        form.find('[name="iitix_appliance_1_type"]').first(),
2935        'Step ' + applianceStep + ' is required: please select appliance type for Appliance 1.'
2936      );
2937    } else if ((normalize(data.appliance1Type) === 'other' || normalize(data.appliance1Type) === 'others') && !data.appliance1Other) {
2938      addIitixValidationError(
2939        validationErrors,
2940        form.find('[name="iitix_appliance_1_other"]').first(),
2941        'Step ' + applianceStep + ' is required: please type the other appliance name for Appliance 1.'
2942      );
2943    } else {
2944      var appliance1MinWatt = getApplianceMinWatt(data.appliance1Type);
2945      if (data.appliance1Type && normalize(data.appliance1Type) !== 'other' && normalize(data.appliance1Type) !== 'others' && appliance1MinWatt > 0 && data.appliance1Wattage > 0 && data.appliance1Wattage < appliance1MinWatt) {
2946        addIitixValidationError(
2947          validationErrors,
2948          form.find('[name="iitix_appliance_1_wattage"]').first(),
2949          formatExtraCopy(getExtraCopy('belowMinimumMessage', 'Minimum watt for {appliance} is {min}W. Please enter at least {min}W.'), {
2950            appliance: data.appliance1Type,
2951            min: appliance1MinWatt
2952          })
2953        );
2954      }
2955    }
2956
2957    /*
2958     * OPTIONAL APPLIANCE 2
2959     * If the vendor starts filling it, type and wattage must be complete.
2960     */
2961    if (data.appliance2Type && data.appliance2Wattage <= 0) {
2962      addIitixValidationError(validationErrors, form.find('[name="iitix_appliance_2_wattage"]').first(), 'Please input wattage for Appliance 2.');
2963    } else if (!data.appliance2Type && data.appliance2Wattage > 0) {
2964      addIitixValidationError(validationErrors, form.find('[name="iitix_appliance_2_type"]').first(), 'Please select appliance type for Appliance 2.');
2965    } else if ((normalize(data.appliance2Type) === 'other' || normalize(data.appliance2Type) === 'others') && !data.appliance2Other) {
2966      addIitixValidationError(validationErrors, form.find('[name="iitix_appliance_2_other"]').first(), 'Please type the other appliance name for Appliance 2.');
2967    } else {
2968      var appliance2MinWatt = getApplianceMinWatt(data.appliance2Type);
2969      if (data.appliance2Type && normalize(data.appliance2Type) !== 'other' && normalize(data.appliance2Type) !== 'others' && appliance2MinWatt > 0 && data.appliance2Wattage > 0 && data.appliance2Wattage < appliance2MinWatt) {
2970        addIitixValidationError(
2971          validationErrors,
2972          form.find('[name="iitix_appliance_2_wattage"]').first(),
2973          formatExtraCopy(getExtraCopy('belowMinimumMessage', 'Minimum watt for {appliance} is {min}W. Please enter at least {min}W.'), {
2974            appliance: data.appliance2Type,
2975            min: appliance2MinWatt
2976          })
2977        );
2978      }
2979    }
2980
2981    /*
2982     * TOTAL WATT LIMIT
2983     */
2984    if (total > max) {
2985      addIitixValidationError(
2986        validationErrors,
2987        form.find('.iitix-watt-summary').first(),
2988        getExtraCopy('limitMessage', 'Total wattage cannot be more than 1000 watt.')
2989      );
2990    }
2991
2992    /*
2993     * Final WooCommerce safety: a selected category may still be waiting for
2994     * variation_id. It belongs after the visible category requirement.
2995     */
2996    if (categorySelect.length && categoryValue && !variationId) {
2997      addIitixValidationError(validationErrors, categorySelect, 'Step 1 is required: please choose category first.');
2998    }
2999
3000    var firstError = validationErrors.length ? validationErrors[0] : null;
3001    var valid = !firstError;
3002    var message = firstError ? firstError.message : '';
3003    var invalidTarget = firstError ? firstError.target : $();
3004
3005    if (showValidationFeedback) {
3006      // A real Add to Cart attempt always starts a fresh first-error cycle.
3007      // Clear the previous marker first, then paint only validationErrors[0].
3008      clearExtraInvalidMarkers(form);
3009      renderExtraMessage(form, '', '');
3010      setExtraButtonInvalid(form, !valid, message);
3011
3012      if (!valid) {
3013        markExtraInvalidTarget(invalidTarget, message);
3014        setActiveExtraInvalidState(form, invalidTarget, message);
3015        renderExtraMessage(form, 'error', message);
3016      } else if (total > 0) {
3017        renderExtraMessage(form, 'ok', 'Total wattage is valid: ' + total + 'W / ' + max + 'W.');
3018      }
3019    } else {
3020      // Do not surface a new required error while the vendor is typing.
3021      // Keep a previous Add to Cart marker until that exact field is valid.
3022      setExtraButtonInvalid(form, false);
3023      reconcileActiveExtraInvalidState(form, validationErrors);
3024    }
3025
3026    stabilizeValidVariationUi(form);
3027
3028    return {
3029      valid: valid,
3030      message: message,
3031      total: total,
3032      max: max,
3033      errors: validationErrors,
3034      invalidTarget: invalidTarget
3035    };
3036  }
3037
3038  function bootExtraFieldsForCurrentForm() {
3039    if (isSeatModalClosing) return;
3040
3041    var modal = getSeatModal();
3042    if (!modal.length) return;
3043    if (!isSeatModalActuallyVisible(modal)) return;
3044
3045    var form = modal.find('.variations_form').first();
3046    if (!form.length) return;
3047
3048    updateExtraFieldsVisibility(form);
3049    validateExtraFields(form, false);
3050  }
3051
3052
3053  function scheduleBootExtraFields(delay) {
3054    if (isSeatModalClosing) return;
3055
3056    if (extraBootTimer) window.clearTimeout(extraBootTimer);
3057
3058    extraBootTimer = window.setTimeout(function () {
3059      extraBootTimer = null;
3060
3061      if (isSeatModalClosing) return;
3062
3063      var modal = getSeatModal();
3064      if (!isSeatModalActuallyVisible(modal)) return;
3065
3066      bootExtraFieldsForCurrentForm();
3067    }, typeof delay === 'number' ? delay : 80);
3068  }
3069
3070
3071  $('body').on('click', '#tc-modal-woobridge [data-iitix-file-trigger]', function (event) {
3072    event.preventDefault();
3073    var upload = $(this).closest('[data-iitix-file-upload]');
3074    upload.find('input[type="file"]').first().trigger('click');
3075  });
3076
3077  $('body').on('change', '#tc-modal-woobridge input[type="file"][name^="iitix_"]', function () {
3078    var input = $(this);
3079
3080    // Validate immediately when a file is selected. Invalid files are cleared,
3081    // never previewed, and the user sees the error before Add to Cart.
3082    input.removeData('iitixImageDimensionState');
3083    input.data('iitixFileValidateOnChange', true);
3084    validateIitixFileSelectionOnChange(input, false);
3085  });
3086
3087  function formatIitixCurrency(amount) {
3088    amount = parseInt(amount, 10) || 0;
3089    return 'Rp' + String(amount).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
3090  }
3091
3092  function setIitixPromoPackageChoice(form, value) {
3093    if (!form || !form.length) return;
3094
3095    var radio = form.find('input[name="iitix_promo_package"][value="' + value + '"]').first();
3096    if (!radio.length) return;
3097
3098    radio.prop('checked', true).trigger('change', [{ iitixPromoProgrammatic: true }]);
3099  }
3100
3101  function openIitixPromoPackageConfirmation(form) {
3102    var profile = getCurrentExtraProfile(form) || {};
3103    var field = getProfileField(profile, 'iitix_promo_package') || {};
3104    var fee = parseInt(field.addon_fee, 10) || 0;
3105    var feeLabel = formatIitixCurrency(fee);
3106    var promoLabel = field.addon_label || field.label || 'IG Story & LED Backdrop';
3107
3108    var content = '' +
3109      '<div style="text-align:left;line-height:1.55">' +
3110        '<strong>' + esc(promoLabel) + '</strong>' +
3111        '<div style="margin-top:8px">Additional fee: <strong>' + esc(feeLabel) + '</strong></div>' +
3112        '<div style="margin-top:8px;color:#64748b">This fee is charged for this vendor slot / booth and will appear in Cart and Checkout.</div>' +
3113      '</div>';
3114
3115    var applyChoice = function (confirmed) {
3116      setIitixPromoPackageChoice(form, confirmed ? 'yes' : 'no');
3117    };
3118
3119    if (typeof Swal !== 'undefined' && Swal && typeof Swal.fire === 'function') {
3120      Swal.fire({
3121        icon: 'question',
3122        title: 'Add Promo Package?',
3123        html: content,
3124        showCancelButton: true,
3125        confirmButtonText: 'Yes, Add Promo Package',
3126        cancelButtonText: 'Cancel',
3127        reverseButtons: true,
3128        focusCancel: true
3129      }).then(function (result) {
3130        applyChoice(!!(result && result.isConfirmed));
3131      });
3132      return;
3133    }
3134
3135    // Fallback for pages where SweetAlert is blocked by an optimization plugin.
3136    applyChoice(window.confirm('Add ' + promoLabel + ' for ' + feeLabel + ' to this vendor slot / booth?'));
3137  }
3138
3139  $('body')
3140    .off('change.iitixPromoPackageConfirmation')
3141    .on('change.iitixPromoPackageConfirmation', '#tc-modal-woobridge input[name="iitix_promo_package"]', function (event, detail) {
3142      var radio = $(this);
3143      var form = radio.closest('form.variations_form');
3144
3145      // This is the final state after the modal has been confirmed or cancelled.
3146      if (detail && detail.iitixPromoProgrammatic) return;
3147      if (String(radio.val() || '').toLowerCase() !== 'yes') return;
3148      if (!radio.is(':checked')) return;
3149
3150      // Do not let the Yes state become final until the vendor explicitly confirms.
3151      // Return the visual selection to No while the confirmation dialog is open.
3152      form.find('input[name="iitix_promo_package"][value="no"]').first().prop('checked', true);
3153      event.stopImmediatePropagation();
3154
3155      openIitixPromoPackageConfirmation(form);
3156    });
3157
3158  $('body').on('change input', '#tc-modal-woobridge .iitix-extra-fields input, #tc-modal-woobridge .iitix-extra-fields textarea, #tc-modal-woobridge .iitix-extra-fields select', function (event) {
3159    var target = $(this);
3160    var form = target.closest('form.variations_form');
3161
3162    if (event.type === 'change' && target.hasClass('iitix-appliance-select')) {
3163      autoFillMinimumWattForSelect(form, target);
3164    }
3165
3166    toggleOtherInputs(form);
3167    validateExtraFields(form, false);
3168  });
3169  
3170  $('body')
3171  .off('submit.iitixExtraRequiredGuard')
3172  .on('submit.iitixExtraRequiredGuard', '#tc-modal-woobridge form.variations_form', function (e) {
3173    var form = $(this);
3174    var result = validateExtraFields(form, true);
3175
3176    var variationId = $.trim(form.find('input.variation_id').val() || '');
3177    var hasVariationSelect = form.find('select[name^="attribute_"]').length > 0;
3178
3179    if (!result.valid || (hasVariationSelect && !variationId)) {
3180      e.preventDefault();
3181      e.stopPropagation();
3182
3183      if (e.stopImmediatePropagation) {
3184        e.stopImmediatePropagation();
3185      }
3186
3187      scrollToFirstInvalidField(form, result.invalidTarget);
3188
3189      return false;
3190    }
3191  });
3192
3193  $('body').on('change', '#tc-modal-woobridge select[name^="attribute_"]', function () {
3194  var form = $(this).closest('form.variations_form');
3195
3196  updateExtraFieldsVisibility(form);
3197  validateExtraFields(form, false);
3198
3199  // WooCommerce biasanya set variation_id sedikit setelah select berubah.
3200  // Jadi validasi ulang setelah WC selesai proses variation.
3201  window.setTimeout(function () {
3202    validateExtraFields(form, false);
3203  }, 150);
3204});
3205
3206  if ($.ajaxPrefilter) {
3207    $.ajaxPrefilter(function (options) {
3208      appendIitixExtraFieldsToAjaxSettings(options);
3209    });
3210  }
3211
3212  /* =========================
3213   * Track Tickera variation request start time.
3214   * This lets us distinguish stale AJAX that started before close
3215   * from a fresh user click after close.
3216   * ========================= */
3217  $(document).ajaxSend(function (event, xhr, settings) {
3218    // Important: Tickera/WooBridge add-to-cart AJAX does not always serialize
3219    // every custom field from the modal form. Append the iitix_* fields here
3220    // before the request is sent so PHP can store them in cart item data.
3221    appendIitixExtraFieldsToAjaxSettings(settings);
3222
3223    if (!shouldHandleTickeraVariationAjax(settings)) {
3224      return;
3225    }
3226
3227    var sentAt = nowMs();
3228    lastSeatVariationAjaxSentAt = sentAt;
3229    settings.__iitixSeatVariationAjaxSentAt = sentAt;
3230
3231    // The first Tickera screen says "Loading options..." before the variation
3232    // form exists. Apply the shell class immediately so the loading modal keeps
3233    // the same width/centered position as the ready modal.
3234    applySeatFilterModalShellLoading();
3235    window.setTimeout(applySeatFilterModalShellLoading, 20);
3236    window.setTimeout(applySeatFilterModalShellLoading, 90);
3237  });
3238
3239  /* =========================
3240   * AFTER AJAX SUCCESS
3241   * ========================= */
3242  $(document).ajaxComplete(function (event, xhr, settings) {
3243    if (!shouldHandleTickeraVariationAjax(settings)) {
3244      return;
3245    }
3246
3247    var sentAt = parseInt(settings.__iitixSeatVariationAjaxSentAt || 0, 10) || 0;
3248
3249    // This is the main close/reopen fix:
3250    // If the request was started before the modal was closed, ignore it.
3251    // If the request was started after close, it is a real second user click, so allow it.
3252    if (lastSeatModalCloseAt && sentAt && sentAt <= lastSeatModalCloseAt) {
3253      requestSeq++;
3254      cleanupIitixWideModalState();
3255      forceHideSeatModalElements();
3256      return;
3257    }
3258
3259    isSeatModalClosing = false;
3260
3261    if (seatModalClosingTimer) {
3262      window.clearTimeout(seatModalClosingTimer);
3263      seatModalClosingTimer = null;
3264    }
3265
3266    var payload = parseQS(settings.data);
3267    var chartId = parseInt(payload.chart_id || '', 10) || 0;
3268    var seatIdFromPayload = payload.seat_id || '';
3269    var selected = $('.ui-selected').last();
3270    var seatId = selected.attr('id') || seatIdFromPayload;
3271    var seatNumber = getSeatNumberFromSeatId(seatId);
3272    var productId = getProductIdFromPage(payload);
3273    var variations = getOriginalVariationsJSON();
3274    var form = getVariationForm();
3275    var seq = ++requestSeq;
3276
3277    if (form.length && productId > 0) {
3278      form.data('iitix-current-product-id', productId);
3279      applySeatFilterModalClass(form);
3280    }
3281
3282    updateSeatLabel(seatNumber);
3283
3284    log('[IITIX] Tickera seat selected', {
3285      product_id: productId,
3286      chart_id: chartId,
3287      seat_id: seatId,
3288      seat_number: seatNumber
3289    });
3290
3291    if (!chartId || !seatNumber || !productId) {
3292      warn('[IITIX] Missing product_id/chart_id/seat_number, cannot call seat filter API', {
3293        product_id: productId,
3294        chart_id: chartId,
3295        seat_number: seatNumber
3296      });
3297      return;
3298    }
3299
3300    if (!variations.length) {
3301      warn('[IITIX] No WooCommerce variations found on form');
3302    }
3303
3304    if (form.length) {
3305      renderNotice(form, { loading: true, seat: seatNumber });
3306      updateExtraFieldsVisibility(form);
3307    }
3308
3309    fetchSeatFilter(productId, chartId, seatNumber)
3310      .then(function (apiResponse) {
3311        if (seq !== requestSeq) return;
3312
3313        var filteredVariations = filterVariationsByApi(variations, apiResponse);
3314
3315        window.iitixSeatCategoryState = {
3316          source: 'tickera-store-api-seating-sync',
3317          endpoint: SEAT_FILTER_URL,
3318          product_id: productId,
3319          chart_id: chartId,
3320          seat_id: seatId,
3321          seat_number: seatNumber,
3322          categories: getReadableCategories(apiResponse),
3323          api_response: apiResponse,
3324          variations: filteredVariations
3325        };
3326
3327        updateVariationsForm(filteredVariations, apiResponse, {
3328          seatNumber: seatNumber
3329        });
3330      })
3331      .catch(function (err) {
3332        if (seq !== requestSeq) return;
3333
3334        warn('[IITIX] seat filter API failed', err);
3335        restoreOriginalVariations();
3336
3337        if (form.length) {
3338          setSeatFilterControlsLoading(form, false);
3339          renderNotice(form, {
3340            error: err && err.message ? err.message : String(err),
3341            seat: seatNumber
3342          });
3343          updateExtraFieldsVisibility(form);
3344        }
3345      });
3346  });
3347
3348
3349  // Standard WooCommerce success event.
3350  $('body')
3351    .off('added_to_cart.iitixSeatSuccessClose')
3352    .on('added_to_cart.iitixSeatSuccessClose', function () {
3353      scheduleIitixAddToCartSuccess();
3354    });
3355
3356  // Some Tickera/WooCommerce flows do not emit added_to_cart, but they always
3357  // refresh WooCommerce fragments after the cart is updated.
3358  $(document)
3359    .off('ajaxComplete.iitixSeatAddToCartSuccess')
3360    .on('ajaxComplete.iitixSeatAddToCartSuccess', function (event, xhr, settings) {
3361      if (!iitixAddToCartPending) return;
3362      if (!xhr || xhr.status < 200 || xhr.status >= 300) return;
3363
3364      var age = nowMs() - iitixAddToCartPendingAt;
3365      if (age < 0 || age > 12000) {
3366        clearIitixAddToCartPending();
3367        return;
3368      }
3369
3370      if (isLikelyWooFragmentRequest(settings) || isLikelyAddToCartRequest(settings)) {
3371        scheduleIitixAddToCartSuccess();
3372      }
3373    });
3374
3375  $(document)
3376    .off('ajaxError.iitixSeatAddToCartError')
3377    .on('ajaxError.iitixSeatAddToCartError', function (event, xhr, settings) {
3378      if (!iitixAddToCartPending) return;
3379      if (!isLikelyAddToCartRequest(settings)) return;
3380
3381      clearIitixAddToCartPending();
3382      showIitixSeatToast('Add to cart failed. Please try again.', 'error');
3383    });
3384
3385  /* =========================
3386   * Checkout: Food Service Menu -> Tickera Goods to sell
3387   * ========================= */
3388  var MENU_AUTOFILL_CONFIG = window.IITIX_TICKERA_MENU_AUTOFILL_CONFIG || {};
3389  var tickeraMenuAutofillTimer = null;
3390
3391  function normalizeTickeraMenuIdentity(value) {
3392    return String(value || '')
3393      .replace(/\s+/g, ' ')
3394      .trim()
3395      .toUpperCase();
3396  }
3397
3398  function getTickeraMenuAutofillItems() {
3399    return MENU_AUTOFILL_CONFIG && Array.isArray(MENU_AUTOFILL_CONFIG.items)
3400      ? MENU_AUTOFILL_CONFIG.items
3401      : [];
3402  }
3403
3404  function getTickeraOwnerSeatIdentity(owner) {
3405    return {
3406      seatId: $.trim(owner.find('input[name^="owner_data_seat_id_post_meta"]').first().val() || ''),
3407      seatLabel: normalizeSeatIdentity(owner.find('input[name^="owner_data_seat_label_post_meta"]').first().val() || ''),
3408      chartId: $.trim(owner.find('input[name^="owner_data_chart_id_post_meta"]').first().val() || '')
3409    };
3410  }
3411
3412  function findTickeraGoodsToSellField(owner) {
3413    var direct = owner.find('[name^="owner_data_"][name*="goodstosell"], [name^="owner_data_"][name*="goods_to_sell"]').filter('input, textarea, select').first();
3414    if (direct.length) return direct;
3415
3416    var found = $();
3417    owner.find('.fields-wrap').each(function () {
3418      if (found.length) return;
3419      var wrap = $(this);
3420      var label = String(wrap.find('label').first().text() || '').replace(/\s+/g, '').toLowerCase();
3421      if (label.indexOf('goodstosell') === -1) return;
3422      found = wrap.find('input, textarea, select').filter('[name^="owner_data_"]').first();
3423    });
3424
3425    return found;
3426  }
3427
3428  function findTickeraMenuMapping(identity) {
3429    if (!identity || !identity.seatId || !identity.seatLabel || !identity.chartId) return null;
3430
3431    var items = getTickeraMenuAutofillItems();
3432    for (var i = 0; i < items.length; i++) {
3433      var item = items[i] || {};
3434      if (
3435        String(item.seat_id || '') === String(identity.seatId) &&
3436        normalizeSeatIdentity(item.seat_label || '') === String(identity.seatLabel) &&
3437        String(item.chart_id || '') === String(identity.chartId)
3438      ) {
3439        return item;
3440      }
3441    }
3442
3443    return null;
3444  }
3445
3446  function renderTickeraMenuAutofillNote(field, mapping, identity) {
3447    if (!field || !field.length || !mapping) return;
3448
3449    var wrap = field.closest('.fields-wrap');
3450    if (!wrap.length) wrap = field.parent();
3451    if (!wrap.length) return;
3452
3453    var note = wrap.find('[data-iitix-tickera-menu-autofill-note]').first();
3454    var template = MENU_AUTOFILL_CONFIG.note || 'Auto-filled from the Food Service menu for seat {seat}. You can still edit this field if needed.';
3455    var message = String(template).replace('{seat}', identity.seatLabel || mapping.seat_label || '');
3456
3457    if (!note.length) {
3458      note = $('<div class="iitix-tickera-menu-autofill-note" data-iitix-tickera-menu-autofill-note="1" />');
3459      field.after(note);
3460    }
3461
3462    note.text(message);
3463  }
3464
3465  function applyTickeraMenuAutofill() {
3466    if (!MENU_AUTOFILL_CONFIG || MENU_AUTOFILL_CONFIG.enabled === false) return;
3467    if (!getTickeraMenuAutofillItems().length) return;
3468
3469    $('.tickera_owner_info .owner-info-wrap, .tc-form-ticket-fields-wrap .owner-info-wrap').each(function () {
3470      var owner = $(this);
3471      var identity = getTickeraOwnerSeatIdentity(owner);
3472      var mapping = findTickeraMenuMapping(identity);
3473      if (!mapping || !$.trim(mapping.menu || '')) return;
3474
3475      var field = findTickeraGoodsToSellField(owner);
3476      if (!field.length) return;
3477
3478      // Manual input always wins. This makes Food Service + Fashion with Racks
3479      // carts safe: only the exact Food/Menu seat gets a value.
3480      if (!$.trim(field.val() || '')) {
3481        field.val(mapping.menu).attr('data-iitix-menu-autofilled', '1').trigger('input').trigger('change');
3482        renderTickeraMenuAutofillNote(field, mapping, identity);
3483      } else if (field.attr('data-iitix-menu-autofilled') === '1' || $.trim(field.val() || '') === $.trim(mapping.menu || '')) {
3484        renderTickeraMenuAutofillNote(field, mapping, identity);
3485      }
3486    });
3487  }
3488
3489  function scheduleTickeraMenuAutofill(delay) {
3490    if (tickeraMenuAutofillTimer) window.clearTimeout(tickeraMenuAutofillTimer);
3491    tickeraMenuAutofillTimer = window.setTimeout(function () {
3492      tickeraMenuAutofillTimer = null;
3493      applyTickeraMenuAutofill();
3494    }, typeof delay === 'number' ? delay : 60);
3495  }
3496
3497  if (getTickeraMenuAutofillItems().length) {
3498    $(function () {
3499      scheduleTickeraMenuAutofill(60);
3500      scheduleTickeraMenuAutofill(400);
3501    });
3502
3503    $(document.body).on('updated_checkout updated_wc_div', function () {
3504      scheduleTickeraMenuAutofill(80);
3505    });
3506
3507    if (window.MutationObserver && document.body) {
3508      var tickeraCheckoutMenuObserver = new MutationObserver(function () {
3509        scheduleTickeraMenuAutofill(80);
3510      });
3511      tickeraCheckoutMenuObserver.observe(document.body, { childList: true, subtree: true });
3512    }
3513  }
3514
3515  /* =========================
3516   * Collapsible Cart / Checkout detail cards
3517   * =========================
3518   * Delegated binding is deliberate: WooCommerce can replace checkout/cart
3519   * fragments after a payment method or quantity change.
3520   */
3521  $(document)
3522    .off('click.iitixCartExtraCollapse')
3523    .on('click.iitixCartExtraCollapse', '[data-iitix-cart-extra-card] > .iitix-cart-extra-card-summary', function () {
3524      var toggle = $(this);
3525      var card = toggle.closest('[data-iitix-cart-extra-card]');
3526      var body = card.children('.iitix-cart-extra-card-body').first();
3527      if (!body.length) return;
3528
3529      var isOpen = toggle.attr('aria-expanded') === 'true';
3530      toggle.attr('aria-expanded', isOpen ? 'false' : 'true');
3531      card.toggleClass('is-open', !isOpen);
3532      body.prop('hidden', isOpen);
3533    });
3534
3535  /* =========================
3536   * Mutation observer: modal markup is injected dynamically by Tickera.
3537   * ========================= */
3538  if (window.MutationObserver && document.body) {
3539    var mo = new MutationObserver(function () {
3540      if (isSeatModalClosing) return;
3541
3542      var modal = getSeatModal();
3543      if (!isSeatModalActuallyVisible(modal)) return;
3544
3545      if ($('#tc-modal-woobridge .variations_form').length) {
3546        scheduleBootExtraFields(120);
3547      }
3548    });
3549    mo.observe(document.body, { childList: true, subtree: true });
3550  }
3551
3552  $(function () {
3553    scheduleBootExtraFields(250);
3554  });
3555
3556  /* =========================
3557   * Override alert bawaan WooCommerce
3558   * ========================= */
3559  (function () {
3560    if (typeof Swal === 'undefined') return;
3561
3562    window.alert = function (msg) {
3563      Swal.fire({
3564        icon: 'warning',
3565        title: 'Select a variation first',
3566        text: msg,
3567        confirmButtonText: 'OK'
3568      });
3569    };
3570  })();
3571
3572  /* =========================
3573   * Intercept klik Add to Cart
3574   * ========================= */
3575
3576  $('body')
3577    .off('click.iitixSeatModalClose')
3578    .on(
3579      'click.iitixSeatModalClose',
3580      '#tc-modal-woobridge .tc_modal_close_dialog, .ui-dialog.iitix-extra-dialog-wide .ui-dialog-titlebar-close',
3581      function (e) {
3582        e.preventDefault();
3583        e.stopPropagation();
3584        e.stopImmediatePropagation();
3585        return closeTickeraSeatModal();
3586      }
3587    );
3588
3589  $('body')
3590    .off('click.iitixExtraRequiredClick')
3591    .on('click.iitixExtraRequiredClick', '.tc-modal-wrap .tc_cart_button', function (e) {
3592      var btn = $(this);
3593      var form = btn.closest('form.variations_form');
3594
3595      if (form.length) {
3596        var extraValidation = validateExtraFields(form, true);
3597        var variationId = $.trim(form.find('input.variation_id').val() || '');
3598        var hasVariationSelect = form.find('select[name^="attribute_"]').length > 0;
3599
3600        if (!extraValidation.valid || (hasVariationSelect && !variationId)) {
3601          e.preventDefault();
3602          e.stopImmediatePropagation();
3603
3604          scrollToFirstInvalidField(form, extraValidation.invalidTarget);
3605
3606          if (typeof Swal !== 'undefined') {
3607            Swal.fire({
3608              icon: 'warning',
3609              title: 'Please complete the highlighted field',
3610              text: extraValidation.message || 'Please complete all required steps before adding this item to cart.',
3611              confirmButtonText: 'OK'
3612            });
3613          } else {
3614            alert(extraValidation.message || 'Please complete all required steps before adding this item to cart.');
3615          }
3616
3617          return false;
3618        }
3619      }
3620
3621      if (
3622        btn.hasClass('wc-variation-selection-needed') ||
3623        (btn.hasClass('disabled') && !btn.hasClass('iitix-extra-invalid'))
3624      ) {
3625        e.preventDefault();
3626        e.stopImmediatePropagation();
3627
3628        if (typeof Swal !== 'undefined') {
3629          Swal.fire({
3630            icon: 'warning',
3631            title: 'Select a variation first',
3632            text: 'Please choose a category or variation before adding this item to the cart.',
3633            confirmButtonText: 'OK'
3634          });
3635        } else {
3636          alert('Please choose a category or variation before adding this item to the cart.');
3637        }
3638
3639        return false;
3640      }
3641
3642      markIitixAddToCartPending(form);
3643
3644      // Fallback: if the site finishes add-to-cart without firing the standard
3645      // Woo event, fragment refresh/ajaxComplete handler above will still close it.
3646      window.setTimeout(function () {
3647        if (iitixAddToCartPending && nowMs() - iitixAddToCartPendingAt > 9000) {
3648          clearIitixAddToCartPending();
3649        }
3650      }, 9500);
3651    });
3652})(jQuery);

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.