PageSourceSearch

https://vozdeguanacaste.com/wp-content/themes/voz-de-guanacaste/js/memberships-v2.js?v=1.5.3

js vozdeguanacaste.com collected 2026-10-02 05:21:13 UTC 24,342 bytes, 629 lines download raw bytes

1// const GAS_URL = 'https://script.google.com/macros/s/AKfycbzigXMD-m8aNyy4juOjjG2SM7YTwRweiCV3hgALEMwyVCoM19O6RG_e9vTnedQaeJFA/exec';
2const GAS_URL = 'https://script.google.com/macros/s/AKfycbwIhT1FIjWDpyqBX_FBIMyfFSLE8Gv57wSInCikMktlTF0cSVrDj8Eo7L38FEWSH12G/exec';
3function postToSheetSimple({ amount, currency, recurrence } = {}) {
4  const countrySel = document.getElementById('country');
5
6 // Si no recibimos amount/currency, tomarlos de la selección actual
7  if (amount == null || !currency) {
8    const sel = typeof currentSelection === 'function' ? currentSelection() : null;
9    if (!sel) return;
10    currency = currency || (sel.currency || 'USD');
11    amount = (currency === 'USD') ? (+sel.amountUSD || 0) : (Math.round(sel.amountCRC) || 0);
12    recurrence = recurrence || sel.recurrence || 'unica'; // mensual | anual | unica
13  }
14
15  // Formato con símbolo según moneda
16  const amountFormatted = (currency === 'USD')
17    ? `$${(+amount).toFixed(2)}`
18    : `₡${Math.round(amount)}`;
19
20  const payload = {
21    firstName: firstName?.value?.trim() || '',
22    lastName:  lastName?.value?.trim()  || '',
23    email:     email?.value?.trim()     || '',
24    countryCode: countrySel?.value || '',
25    countryName: countrySel?.selectedOptions?.[0]?.text || '',
26    region:    region?.value?.trim() || '',
27    amount:    amountFormatted,
28    type:      recurrence,
29    phone:     phone?.value?.trim() || '',
30  };
31
32  // fire-and-forget para evitar CORS
33  fetch(GAS_URL, {
34    method: 'POST',
35    mode: 'no-cors',
36    // con no-cors solo se permiten "simple headers":
37    headers: { 'Content-Type': 'text/plain;charset=utf-8' },
38    body: JSON.stringify(payload)
39  });
40}
41
42// Helper translate
43const t = (en, es) => (/^es/i.test(document.documentElement.lang || '')) ? es : en;
44
45function resetDonationForm() {
46  try { form && form.reset(); } catch(e) {}
47
48  if (cardName)   cardName.value   = '';
49  if (cardNumber) cardNumber.value = '';
50  if (cardExpiry) cardExpiry.value = '';
51  if (cardCvc)    cardCvc.value    = '';
52  if (payErrors)  payErrors.textContent = '';
53  //validatePayment();
54}
55
56
57/* Donations form logic — works with the shortcode HTML/CSS (no markup changes) */
58(function () {
59  // ---------- helpers ----------
60  const $  = (s, r=document) => r.querySelector(s);
61  const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
62
63  // wrapper with data-*
64  const shell = document.querySelector('.shell[data-monthly-crc]') || document.getElementById('vg-donations') || document.getElementById('shell') || document.body;
65
66  // config from shortcode (fallbacks just in case)
67  const fxRate = parseFloat(shell?.dataset?.fx || '500');
68  const PRESETS = {
69    mensual: {
70      crc: parseInt(shell?.dataset?.monthlyCrc || shell?.dataset?.monthly_crc || '3500', 10),
71      usd: parseFloat(shell?.dataset?.monthlyUsd || shell?.dataset?.monthly_usd || '7')
72    },
73    anual: {
74      crc: parseInt(shell?.dataset?.yearlyCrc || shell?.dataset?.yearly_crc || '35700', 10),
75      usd: parseFloat(shell?.dataset?.yearlyUsd || shell?.dataset?.yearly_usd || '71')
76    }
77  };
78
79  // elements (step 1)
80  const presetRadios    = $$('input[name="preset"]');           // mensual | anual
81  const curCRC          = $('#curCRC');
82  const curUSD          = $('#curUSD');
83  const currencyPrefix  = $('#currencyPrefix');
84  const amountInput     = $('#amountCustom');
85  const convertedLabel  = $('#convertedAmount');
86  const customBox       = amountInput?.closest('.custom-inner') || amountInput?.closest('.custom');
87
88  const freqRadios      = $$('input[name="customFreq"]');        // mensual | anual | unica (optional)
89
90  const btnToPersonal   = $('#btnToPersonal');
91
92  // elements (step 2)
93  const firstName = $('#firstName');
94  const lastName  = $('#lastName');
95  const email     = $('#email');
96  const country   = $('#country');
97  const phone     = $('#phone');
98  const region    = $('#region');
99  const btnToPayment = $('#btnToPayment');
100
101  // elements (step 3)
102  const cardName    = $('#cardName');
103  const cardNumber  = $('#cardNumber');
104  const cardExpiry  = $('#cardExpiry');
105  const cardCvc     = $('#cardCvc');
106  const payErrors   = $('#payErrors');
107  const btnSubmit   = $('#btnSubmit');
108  const form        = $('#subscribeForm');
109  const resultBox   = $('#result');
110
111  // stepper + panels
112  const panels = [$('#panel-plan'), $('#panel-personal'), $('#panel-payment')];
113  const segs   = $$('.seg');
114
115  // ---------- state ----------
116  const state = {
117    step: 0,
118    preset: 'mensual',     // 'mensual' | 'anual' | null
119    customActive: false,   // true when “Otro monto” is the selected plan
120    customFreq: null,      // 'mensual' | 'anual' | 'unica' | null (optional)
121    currency: 'CRC',       // input currency for custom
122    amount: 0,             // numeric amount in the current input currency
123    fxRate
124  };
125
126  // ---------- formatting & conversions ----------
127  const fmtUSD = (n) => {
128    const x = (typeof n === 'number' ? n : parseFloat(n) || 0);
129    return `$${x.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
130  };
131  const fmtCRC = (n) => {
132    const x = Math.round(typeof n === 'number' ? n : parseFloat(n) || 0);
133    return `₡${x.toLocaleString('de-DE')}`;
134  };
135  const toUSD = (val, cur) => cur === 'USD' ? val : (val / state.fxRate);
136  const toCRC = (val, cur) => cur === 'CRC' ? val : Math.round(val * state.fxRate);
137
138  // ---------- stepper ----------
139  function setStep(n) {
140    state.step = Math.max(0, Math.min(2, n));
141    panels.forEach((p, i) => p && p.classList.toggle('active', i === state.step));
142    segs.forEach((s, i) => s && s.classList.toggle('active', i === state.step));
143    const first = panels[state.step]?.querySelector('input,select,button');
144    if (first) first.focus({ preventScroll: true });
145  }
146
147  // ---------- PLAN SELECTION ----------
148  // default: mensual preset active
149  const mensualRadio = $('input[name="preset"][value="mensual"]');
150  if (mensualRadio) {
151    mensualRadio.checked = true;
152    state.preset = 'mensual';
153    state.customActive = false;
154  }
155
156  function activatePreset(value) {
157    state.preset = value;               // 'mensual' | 'anual'
158    state.customActive = false;
159    // clear custom
160    state.amount = 0;
161    if (amountInput) amountInput.value = '';
162    convertedLabel && (convertedLabel.textContent = state.currency === 'CRC' ? '$0.00' : '₡0');
163    freqRadios.forEach(r => (r.checked = false));
164    updatePlanCTA();
165  }
166
167  function activateCustom() {
168    // Custom is a plan: clear preset selection
169    presetRadios.forEach(p => (p.checked = false));
170    state.preset = null;
171    state.customActive = true;
172    updatePlanCTA();
173  }
174
175  // when choosing preset tiles
176  presetRadios.forEach(r => {
177    r.addEventListener('change', () => {
178      if (r.checked) activatePreset(r.value);
179    });
180  });
181
182  // any interaction in custom area selects custom plan
183  if (customBox) {
184    customBox.addEventListener('click', (e) => {
185      // avoid fighting with input events (we just ensure it's active)
186      if (!state.customActive) activateCustom();
187    });
188  }
189
190  // custom currency toggle (radios)
191  function onCurrencyChange(newCur) {
192    if (state.currency === newCur) return;
193    // Convert the number already typed to the new currency so the value remains equivalent
194    if (state.amount > 0) {
195      const converted = newCur === 'USD'
196        ? +(toUSD(state.amount, 'CRC')).toFixed(2)      // CRC -> USD
197        : Math.round(toCRC(state.amount, 'USD'));       // USD -> CRC (round colones)
198      state.amount = converted;
199      if (amountInput) {
200        amountInput.value = newCur === 'USD'
201          ? String(converted.toFixed(2))
202          : String(converted); // integer for CRC
203      }
204    }
205    state.currency = newCur;
206    currencyPrefix && (currencyPrefix.textContent = newCur === 'USD' ? '$' : '₡');
207    updateConvertedDisplay();
208    updatePlanCTA();
209  }
210
211  curCRC && curCRC.addEventListener('change', () => { if (curCRC.checked) { activateCustom(); onCurrencyChange('CRC'); } });
212  curUSD && curUSD.addEventListener('change', () => { if (curUSD.checked) { activateCustom(); onCurrencyChange('USD'); } });
213
214  // custom amount typing
215  if (amountInput) {
216    amountInput.addEventListener('focus', activateCustom);
217    amountInput.addEventListener('input', (e) => {
218      activateCustom();
219      const raw = (e.target.value || '').replace(/[^\d.,]/g, '').replace(',', '.');
220      state.amount = parseFloat(raw) || 0;
221      updateConvertedDisplay();
222      updatePlanCTA();
223    });
224  }
225
226  // optional custom frequency
227  freqRadios.forEach(fr => {
228    fr.addEventListener('change', () => {
229      activateCustom();
230      state.customFreq = fr.value; // mensual | anual | unica
231      updatePlanCTA();
232    });
233  });
234
235  // reflect opposite currency below the input
236  function updateConvertedDisplay() {
237    if (!convertedLabel) return;
238    if (!state.amount || state.amount <= 0) {
239      convertedLabel.textContent = state.currency === 'CRC' ? '$0.00' : '₡0';
240      return;
241    }
242    convertedLabel.textContent = state.currency === 'CRC'
243      ? fmtUSD(toUSD(state.amount, 'CRC'))
244      : fmtCRC(toCRC(state.amount, 'USD'));
245  }
246
247  // enable “Continuar” rules:
248  // - preset selected -> enabled
249  // - custom selected -> enabled only if amount > 0
250  function selectionValid() {
251    if (state.customActive) return state.amount > 0;
252    return !!state.preset;
253  }
254  function updatePlanCTA() {
255    if (btnToPersonal) btnToPersonal.disabled = !selectionValid();
256  }
257
258  // ---------- STEP NAV ----------
259  $('#btnToPersonal')?.addEventListener('click', () => setStep(1));
260  $('#backToPlan')?.addEventListener('click',     () => setStep(0));
261  $('#btnToPayment')?.addEventListener('click',   () => setStep(2));
262  $('#backToPersonal')?.addEventListener('click', () => setStep(1));
263
264  // ---------- STEP 2 validation ----------
265  function validatePersonal() {
266    const ok = (firstName?.value?.trim() || '') &&
267               (lastName?.value?.trim()  || '') &&
268               (email && /.+@.+\..+/.test(email.value)) &&
269               (country && country.value) &&
270               (phone?.value?.trim() || '');
271    if (btnToPayment) btnToPayment.disabled = !ok;
272  }
273  [firstName, lastName, email, country, phone].forEach(el => el && el.addEventListener('input', validatePersonal));
274
275  // ---------- STEP 3 masks & validation ----------
276  function luhn(num) {
277    let s=0,d=false;
278    for (let i=num.length-1;i>=0;i--) {
279      let n=parseInt(num[i],10);
280      if (d){ n*=2; if (n>9) n-=9; }
281      s+=n; d=!d;
282    }
283    return s%10===0;
284  }
285  function maskCardNumber() {
286    if (!cardNumber) return;
287    const d = cardNumber.value.replace(/\D+/g,'').slice(0,19);
288    cardNumber.value = (d.match(/.{1,4}/g)||[]).join(' ');
289    validatePayment();
290  }
291  function maskExpiry() {
292    if (!cardExpiry) return;
293    let d = cardExpiry.value.replace(/\D+/g,'').slice(0,4);
294    if (d.length>=3) d = `${d.slice(0,2)}/${d.slice(2)}`;
295    cardExpiry.value = d;
296    validatePayment();
297  }
298  function maskCvc() {
299    if (!cardCvc) return;
300    cardCvc.value = cardCvc.value.replace(/\D+/g,'').slice(0,4);
301    validatePayment();
302  }
303  function validatePayment() {
304    if (!btnSubmit) return;
305    payErrors && (payErrors.textContent = '');
306    const nameOk = cardName?.value?.trim().length > 2;
307    const digits = (cardNumber?.value || '').replace(/\D+/g,'');
308    const cardOk = digits.length>=13 && digits.length<=19 && luhn(digits);
309    const exp = cardExpiry?.value || '';
310    const expOk = /^\d{2}\/\d{2}$/.test(exp) && (function(){
311      const [mm,yy]=exp.split('/').map(n=>parseInt(n,10));
312      if (mm<1 || mm>12) return false;
313      const now=new Date(); const y=2000+yy;
314      const last=new Date(y,mm,0,23,59,59);
315      return last>=now;
316    })();
317    const cvcOk = (cardCvc?.value || '').replace(/\D+/g,'').length>=3;
318    const ok = nameOk && cardOk && expOk && cvcOk;
319    btnSubmit.disabled = !ok;
320    if (!ok && cardNumber?.value && !cardOk && payErrors) payErrors.textContent = t('Invalid card number.', 'Número de tarjeta inválido.');
321  }
322  cardNumber && cardNumber.addEventListener('input', maskCardNumber);
323  cardExpiry  && cardExpiry.addEventListener('input', maskExpiry);
324  cardCvc     && cardCvc.addEventListener('input', maskCvc);
325  cardName    && cardName.addEventListener('input', validatePayment);
326
327  // ---------- selection snapshot ----------
328  function currentSelection() {
329    // returns { type:'preset'|'custom', recurrence:'mensual'|'anual'|'unica', amountUSD, amountCRC, currency }
330    if (!state.customActive && state.preset) {
331      const p = PRESETS[state.preset];
332      return {
333        type: 'preset',
334        recurrence: state.preset,   // mensual | anual
335        amountUSD: p.usd,
336        amountCRC: p.crc,
337        currency: 'USD'             // backend clarity for recurring
338      };
339    }
340    if (state.customActive) {
341      if (!(state.amount > 0)) return null;
342      const amountUSD = toUSD(state.amount, state.currency);
343      const amountCRC = toCRC(state.amount, state.currency);
344      // if user didn't pick a frequency, default to single
345      const rec = state.customFreq || 'unica';
346      return {
347        type: 'custom',
348        recurrence: rec,                           // mensual | anual | unica
349        amountUSD: +amountUSD.toFixed(2),
350        amountCRC: Math.round(amountCRC),
351        currency: state.currency
352      };
353    }
354    return null;
355  }
356
357  function expParts() {
358    const [mm, aa] = (cardExpiry?.value || '00/00').split('/');
359    return { month: parseInt(mm||'0',10), year: parseInt(aa||'0',10) };
360  }
361
362  // ---------- Ajax & crypto ----------
363  async function wpAjax(action, data = {}) {
364    const body = new URLSearchParams({ action, ...data });
365    const res  = await fetch('/wp-admin/admin-ajax.php', {
366      method: 'POST',
367      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
368      body
369    });
370    const txt = await res.text();
371    try { return JSON.parse(txt); } catch { return { raw: txt }; }
372  }
373
374  function rsaPublicKey() {
375    return "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCX8W5M3VE68PQrDir8nmkUSbMa qREAoJ5H/Nw/hSEtW/oBVRgihHfZY2Rd3p67k2pvKNRCYOlfmR/pshO6iJg7OZyT lXfT1EZDA3I+yim2a1TTi9Kb0RC/3MPgXzIO/hHi2yoEwLihoP1shpLWeCSkVbfD AulwpcdmHOO5kExj7QIDAQAB";
376  }
377  function ensureCrypto() {
378    if (typeof window.JSEncrypt === 'undefined' || typeof window.aesjs === 'undefined') {
379      throw new Error('Faltan librerías de cifrado (JSEncrypt y aes-js). Cárgalas antes de procesar pagos.');
380    }
381  }
382  function pack(obj, pair_) {
383    const aesjs = window.aesjs;
384    const JSEncrypt = window.JSEncrypt;
385    const pair = (pair_ !== undefined) ? pair_ : generateAESPairs();
386    const textBytes = aesjs.utils.utf8.toBytes(JSON.stringify(obj));
387    const aesCtr = new aesjs.ModeOfOperation.ctr(pair.k, new aesjs.Counter(pair.s));
388    const encryptedBytes = aesCtr.encrypt(textBytes);
389    const encryptedHex = aesjs.utils.hex.fromBytes(encryptedBytes);
390    const encrypt = new JSEncrypt();
391    encrypt.setPublicKey(rsaPublicKey());
392    return { ld: encryptedHex, lk: encrypt.encrypt(JSON.stringify(pair)) };
393  }
394  function generateAESPairs() {
395    const key = []; let iv = 0;
396    for (let k=0;k<16;k++) key.push(Math.floor(Math.random()*255));
397    for (let k=0;k<16;k++) iv = Math.floor(Math.random()*255);
398    return { k: key, s: iv };
399  }
400
401  // ---------- payment flows ----------
402  async function startRecurring(sel) {
403    const every = sel.recurrence === 'mensual' ? 1 : 12;
404    const { month, year } = expParts();
405    const card = (cardNumber?.value || '').replace(/\s+/g,'');
406    try {
407      ensureCrypto();
408      const enc = pack({
409        card: {
410          cardHolder: cardName.value.trim(),
411          expirationDate: { month, year },
412          cardNumber: card,
413          cvc: cardCvc.value.trim(),
414          nickname: cardName.value.trim()
415        }
416      });
417
418      // 1) session_payment
419      const sess = await wpAjax('session_payment');
420      if (!sess?.session || !sess?.token) throw new Error('No se pudo iniciar la sesión de pago');
421
422      // 2) get_token
423      const tok = await wpAjax('get_token', { session_code: sess.session, token: sess.token, ld: enc.ld, lk: enc.lk });
424      const encrypted_card = tok?.result?.token;
425      if (!encrypted_card) throw new Error('No se pudo tokenizar la tarjeta');
426      
427        const countrySel  = document.getElementById('country');
428        const countryCode = countrySel?.value || 'CR';                    // e.g. "CR"
429        const countryName = countrySel?.selectedOptions?.[0]?.text || 'Costa Rica';// e.g. "Costa Rica"
430
431
432      // 3) membership_payment (amount in USD)
433      const mp = await wpAjax('membership_payment', {
434        encrypted_card,
435        amountSelected: sel.amountUSD,
436        title: `Suscripción ${sel.recurrence}`,
437        every,
438        formName: `${firstName?.value?.trim() || ''} ${lastName?.value?.trim() || ''}`.trim(),
439        formEmail: email?.value?.trim() || '',
440        countryCode: countryCode,
441        formTelefono: phone?.value?.trim() || '',
442        formProvinceState: region?.value?.trim() || '',
443        countryName: countryName,
444        formCheckValue: 0
445      });
446
447      if (mp?.status === 500) throw new Error(t('Payment rejected', 'Pago rechazado'));
448      resultBox && (resultBox.textContent = t(
449        'Recurring payment completed! Thank you for your support.',
450        '¡Pago recurrente realizado! ¡Gracias por apoyar!'
451      ));
452      postToSheetSimple({ amount: sel.amountUSD, currency: 'USD', recurrence: sel.recurrence });
453      resetDonationForm()
454    } catch (err) {
455      console.error(err);
456      resultBox && (resultBox.textContent = t(
457        'There was a problem processing the payment. Please try again.',
458        'Ocurrió un problema procesando el pago. Inténtalo de nuevo.'
459      ));
460    }
461  }
462
463  async function startSingle(sel) {
464    const { month, year } = expParts();
465    const card = (cardNumber?.value || '').replace(/\s+/g,'');
466    const currency = sel.currency; // honor user’s toggle for custom
467    const payAmount = currency === 'CRC' ? Math.round(sel.amountCRC) : sel.amountUSD;
468    
469    
470        const countrySel  = document.getElementById('country');
471        const countryCode = countrySel?.value || 'CR';                    // e.g. "CR"
472        const countryName = countrySel?.selectedOptions?.[0]?.text || 'Costa Rica';// e.g. "Costa Rica"
473
474    try {
475      // 1) single_payment
476      const pre = await wpAjax('single_payment', {
477        amountSelected: payAmount,
478        formName: `${firstName?.value?.trim() || ''} ${lastName?.value?.trim() || ''}`.trim(),
479        formEmail: email?.value?.trim() || '',
480        title: sel.recurrence === 'unica' ? 'Aporte único' : `Suscripción ${sel.re
480currence}`,
481        countryCode: countryCode,
482        currency,
483        formTelefono: phone?.value?.trim() || '',
484        formProvinceState: region?.value?.trim() || ''
485      });
486      if (pre?.status === 500) throw new Error('Error iniciando pago único');
487
488      // 2) optional Kount
489      let kountId = null;
490      if (typeof window.GDataCollector !== 'undefined') {
491        await new Promise((resolve) => {
492          let collector = null;
493          window.GDataCollector.init('sand', null, function (res) {
494            collector = res;
495            collector.setupCallback({
496              'collect-end': function (params) { kountId = params.MercSessId; resolve(); },
497              'collect-begin': function () {}
498            });
499            collector.collectData();
500          });
501        });
502      }
503
504      // 3) encrypt card + kount
505      ensureCrypto();
506      const enc = pack({
507        card: {
508          cardHolder: cardName.value.trim(),
509          expirationDate: { month, year },
510          cardNumber: card,
511          cvc: cardCvc.value.trim()
512        },
513        kountSession: kountId
514      });
515
516      // 4) send_single_payment
517      const ssp = await wpAjax('send_single_payment', {
518        session: pre.session,
519        token: pre.token,
520        ld: enc.ld,
521        lk: enc.lk,
522        amountSelected: payAmount,
523        formName: `${firstName?.value?.trim() || ''} ${lastName?.value?.trim() || ''}`.trim(),
524        formEmail: email?.value?.trim() || '',
525        title: sel.recurrence === 'unica' ? 'Aporte único' : `Suscripción ${sel.re
525currence}`,
526        countryCode: countryCode,
527        formTelefono: phone?.value?.trim() || '',
528        formProvinceState: region?.value?.trim() || '',
529        countryName: countryName,
530        formCheckValue: 0
531      });
532
533      if (ssp?.status !== 200) throw new Error(t('One-time payment rejected', 'Pago único rechazado'));
534      resultBox && (resultBox.textContent = t('Payment successful! Thank you!', '¡Pago realizado con éxito! ¡Gracias!'));
535      postToSheetSimple({ amount: payAmount, currency, recurrence: sel.recurrence });
536      resetDonationForm()
537    } catch (err) {
538      console.error(err);
539      resultBox && (resultBox.textContent = t("Couldn't complete the one-time payment.", 'No se pudo completar el pago único.'));
540    }
541  }
542
543  // ---------- submit handler ----------
544  form && form.addEventListener('submit', (e) => {
545    e.preventDefault();
546    // postToSheetSimple();
547    const sel = currentSelection();
548    if (!sel) {
549      resultBox && (resultBox.textContent = 'Seleccioná un plan o ingresá un monto.');
550      return;
551    }
552    resultBox && (resultBox.textContent =  t('Processing payment...', 'Procesando pago…'));
553
554    // Decide flujo: presets -> recurring; custom -> by frequency (default unica)
555    if (!state.customActive) {
556      // preset mensual/anual
557      startRecurring(sel);
558    } else {
559      if ((state.customFreq || 'unica') === 'unica') startSingle(sel);
560      else startRecurring(sel);
561    }
562  });
563  
564  
565    // --- Countries (FIRST.org) -> <select id="country">
566    async function loadCountriesFIRST({ selectId = 'country', defaultISO = 'CR' } = {}) {
567      const sel = document.getElementById(selectId);
568      if (!sel) return;
569    
570      try {
571        // Get all countries (FIRST.org defaults to 100; bump to 300 to cover all)
572        const res = await fetch('https://api.first.org/data/v1/countries?limit=300');
573        const json = await res.json();
574        const data = json?.data || {};
575    
576        // Turn into array and sort by name (Spanish collation)
577        const entries = Object.entries(data)
578          .map(([iso2, obj]) => ({ code: iso2.toUpperCase(), name: obj.country }))
579          .sort((a, b) => a.name.localeCompare(b.name, 'es'));
580    
581        // Reset options
582        sel.innerHTML = '<option value="" disabled selected>Seleccioná…</option>';
583    
584        // Build options
585        const frag = document.createDocumentFragment();
586        for (const { code, name } of entries) {
587          const opt = document.createElement('option');
588          opt.value = code;        // <-- ISO-2 countryCode you’ll send in AJAX
589          opt.textContent = name;  // visible label
590          frag.appendChild(opt);
591        }
592        sel.appendChild(frag);
593    
594        // Default to Costa Rica if present
595        if (entries.some(e => e.code === defaultISO)) {
596          sel.value = defaultISO;
597        }
598    
599        // Fire events so your existing listeners (e.g., validatePersonal) run
600        sel.dispatchEvent(new Event('input',  { bubbles: true }));
601        sel.dispatchEvent(new Event('change', { bubbles: true }));
602      } catch (e) {
603        // Fallback minimal list if the API fails
604        sel.innerHTML = `
605          <option value="" disabled selected>Seleccioná…</option>
606          <option value="CR">Costa Rica</option>
607          <option value="US">United States</option>
608          <option value="MX">Mexico</option>
609          <option value="PA">Panama</option>
610          <option value="ES">Spain</option>
611        `;
612        sel.value = defaultISO;
613        sel.dispatchEvent(new Event('input',  { bubbles: true }));
614        sel.dispatchEvent(new Event('change', { bubbles: true }));
615      }
616    }
617  
618  setTimeout(function(){
619      if(document.getElementById('subscribeForm')){
620            // ---------- init ----------
621              updateConvertedDisplay();
622              updatePlanCTA();
623              validatePersonal();
624              validatePayment();
625              setStep(0);
626              loadCountriesFIRST({ selectId: 'country', defaultISO: 'CR' });
627        }
628  },500)
629})();

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.