PageSourceSearch

https://www.ecoflix.com/assets/i18n.js

js ecoflix.com collected 2026-09-24 19:52:53 UTC 33,381 bytes, 718 lines download raw bytes

1/* =====================================================================
2   Ecoflix i18n + currency layer
3   ---------------------------------------------------------------------
4   Detects visitor locale + currency from navigator.language (+ optional
5   Vercel geo header passed via window.__ECOFLIX_GEO__), applies
6   translations to elements marked data-i18n="key", converts amounts
7   on elements marked data-money="49.99" (always USD-as-source), and —
8   crucially — shows a slim "Showing in Français · EUR. Switch to
9   English · USD" toggle at the top of the page when the auto-detected
10   locale is NOT en + USD.
11
12   Why client-side: the rest of the site is plain static HTML hosted on
13   Vercel, so there's no server-side rendering to thread through. We
14   keep the translation surface small: nav + CTAs + key h1s + the
15   donation amount picker. Body copy stays English for now and can grow.
16
17   How to use in markup:
18   - Translate: <a data-i18n="nav.watch">Watch</a>
19   - Convert money: <span data-money="50">$50</span>
20     (the data-money attribute is the canonical USD amount; the textContent
21      is replaced with the converted local currency display.)
22
23   The current locale is exposed at window.__ECOFLIX_LOCALE__ and a
24   helper window.__ECOFLIX_CONVERT__(usd) returns the local-currency
25   amount as a number for use in other scripts (e.g. /donate/).
26   ===================================================================== */
27(function () {
28  'use strict';
29
30  // ---- Currency rates (USD-base, updated periodically; tolerate stale) -----
31  const RATES = {
32    USD: 1.00,
33    EUR: 0.92,
34    GBP: 0.79,
35    CAD: 1.36,
36    AUD: 1.51,
37    JPY: 156,
38    INR: 83,
39    BRL: 5.05,
40    MXN: 17.20,
41    CHF: 0.88,
42    SEK: 10.55,
43    NOK: 10.85,
44    DKK: 6.86,
45    NZD: 1.65,
46    SGD: 1.34,
47    ZAR: 18.40,
48  };
49
50  const SYMBOLS = {
51    USD: '$',  EUR: '€',   GBP: '£',   CAD: 'CA$', AUD: 'A$',
52    JPY: '¥',  INR: '₹',   BRL: 'R$',  MXN: 'MX$', CHF: 'CHF ',
53    SEK: 'kr', NOK: 'kr',  DKK: 'kr',  NZD: 'NZ$', SGD: 'S$', ZAR: 'R',
54  };
55
56  // Country → [lat, lon] approximate centroid + display name + flag emoji.
57  // Used by the locale-bar globe widget to drop a leaf pin on the
58  // visitor's country. Only the ~60 most likely visitor countries are
59  // listed — unknown countries fall back to a globe with no pin.
60  const COUNTRIES = {
61    US: { lat: 39.5,  lon: -98.5,  name: 'United States',   flag: '🇺🇸' },
62    GB: { lat: 54.0,  lon: -2.0,   name: 'United Kingdom',  flag: '🇬🇧' },
63    UK: { lat: 54.0,  lon: -2.0,   name: 'United Kingdom',  flag: '🇬🇧' },
64    IE: { lat: 53.3,  lon: -7.9,   name: 'Ireland',         flag: '🇮🇪' },
65    FR: { lat: 46.6,  lon: 2.2,    name: 'France',          flag: '🇫🇷' },
66    DE: { lat: 51.2,  lon: 10.4,   name: 'Germany',         flag: '🇩🇪' },
67    ES: { lat: 40.5,  lon: -3.7,   name: 'Spain',           flag: '🇪🇸' },
68    IT: { lat: 41.9,  lon: 12.6,   name: 'Italy',           flag: '🇮🇹' },
69    PT: { lat: 39.4,  lon: -8.2,   name: 'Portugal',        flag: '🇵🇹' },
70    NL: { lat: 52.1,  lon: 5.3,    name: 'Netherlands',     flag: '🇳🇱' },
71    BE: { lat: 50.5,  lon: 4.5,    name: 'Belgium',         flag: '🇧🇪' },
72    LU: { lat: 49.8,  lon: 6.1,    name: 'Luxembourg',      flag: '🇱🇺' },
73    AT: { lat: 47.5,  lon: 14.6,   name: 'Austria',         flag: '🇦🇹' },
74    CH: { lat: 46.8,  lon: 8.2,    name: 'Switzerland',     flag: '🇨🇭' },
75    DK: { lat: 56.3,  lon: 9.5,    name: 'Denmark',         flag: '🇩🇰' },
76    SE: { lat: 60.1,  lon: 18.6,   name: 'Sweden',          flag: '🇸🇪' },
77    NO: { lat: 60.5,  lon: 8.5,    name: 'Norway',          flag: '🇳🇴' },
78    FI: { lat: 61.9,  lon: 25.7,   name: 'Finland',         flag: '🇫🇮' },
79    PL: { lat: 51.9,  lon: 19.1,   name: 'Poland',          flag: '🇵🇱' },
80    CZ: { lat: 49.8,  lon: 15.5,   name: 'Czechia',         flag: '🇨🇿' },
81    GR: { lat: 39.1,  lon: 21.8,   name: 'Greece',          flag: '🇬🇷' },
82    HU: { lat: 47.2,  lon: 19.5,   name: 'Hungary',         flag: '🇭🇺' },
83    RO: { lat: 45.9,  lon: 24.9,   name: 'Romania',         flag: '🇷🇴' },
84    IS: { lat: 64.9,  lon: -19.0,  name: 'Iceland',         flag: '🇮🇸' },
85    CA: { lat: 56.1,  lon: -106.3, name: 'Canada',          flag: '🇨🇦' },
86    MX: { lat: 23.6,  lon: -102.5, name: 'Mexico',          flag: '🇲🇽' },
87    BR: { lat: -14.2, lon: -51.9,  name: 'Brazil',          flag: '🇧🇷' },
88    AR: { lat: -38.4, lon: -63.6,  name: 'Argentina',       flag: '🇦🇷' },
89    CL: { lat: -35.7, lon: -71.5,  name: 'Chile',           flag: '🇨🇱' },
90    CO: { lat: 4.6,   lon: -74.1,  name: 'Colombia',        flag: '🇨🇴' },
91    PE: { lat: -9.2,  lon: -75.0,  name: 'Peru',            flag: '🇵🇪' },
92    AU: { lat: -25.3, lon: 133.8,  name: 'Australia',       flag: '🇦🇺' },
93    NZ: { lat: -40.9, lon: 174.9,  name: 'New Zealand',     flag: '🇳🇿' },
94    JP: { lat: 36.2,  lon: 138.3,  name: 'Japan',           flag: '🇯🇵' },
95    KR: { lat: 35.9,  lon: 127.8,  name: 'South Korea',     flag: '🇰🇷' },
96    CN: { lat: 35.9,  lon: 104.2,  name: 'China',           flag: '🇨🇳' },
97    HK: { lat: 22.3,  lon: 114.2,  name: 'Hong Kong',       flag: '🇭🇰' },
98    TW: { lat: 23.7,  lon: 121.0,  name: 'Taiwan',          flag: '🇹🇼' },
99    SG: { lat: 1.4,   lon: 103.8,  name: 'Singapore',       flag: '🇸🇬' },
100    MY: { lat: 4.2,   lon: 101.9,  name: 'Malaysia',        flag: '🇲🇾' },
101    TH: { lat: 15.9,  lon: 100.9,  name: 'Thailand',        flag: '🇹🇭' },
102    ID: { lat: -0.8,  lon: 113.9,  name: 'Indonesia',       flag: '🇮🇩' },
103    VN: { lat: 14.1,  lon: 108.3,  name: 'Vietnam',         flag: '🇻🇳' },
104    PH: { lat: 12.9,  lon: 121.8,  name: 'Philippines',     flag: '🇵🇭' },
105    IN: { lat: 20.6,  lon: 78.9,   name: 'India',           flag: '🇮🇳' },
106    PK: { lat: 30.4,  lon: 69.3,   name: 'Pakistan',        flag: '🇵🇰' },
107    AE: { lat: 23.4,  lon: 53.8,   name: 'UAE',             flag: '🇦🇪' },
108    SA: { lat: 23.9,  lon: 45.1,   name: 'Saudi Arabia',    flag: '🇸🇦' },
109    IL: { lat: 31.0,  lon: 34.9,   name: 'Israel',          flag: '🇮🇱' },
110    TR: { lat: 38.9,  lon: 35.2,   name: 'Türkiye',         flag: '🇹🇷' },
111    EG: { lat: 26.8,  lon: 30.8,   name: 'Egypt',           flag: '🇪🇬' },
112    ZA: { lat: -30.6, lon: 22.9,   name: 'South Africa',    flag: '🇿🇦' },
113    KE: { lat: -0.0,  lon: 37.9,   name: 'Kenya',           flag: '🇰🇪' },
114    NG: { lat: 9.1,   lon: 8.7,    name: 'Nigeria',         flag: '🇳🇬' },
115    MA: { lat: 31.8,  lon: -7.1,   name: 'Morocco',         flag: '🇲🇦' },
116    RU: { lat: 61.5,  lon: 105.3,  name: 'Russia',          flag: '🇷🇺' },
117    UA: { lat: 48.4,  lon: 31.2,   name: 'Ukraine',         flag: '🇺🇦' },
118  };
119
120  // Country → preferred currency for visitors landing from that c
120ountry.
121  // We err on the conservative side: if we don't know, USD.
122  const COUNTRY_TO_CCY = {
123    US: 'USD',
124    GB: 'GBP', UK: 'GBP', JE: 'GBP', GG: 'GBP', IM: 'GBP',
125    CA: 'CAD',
126    AU: 'AUD',
127    NZ: 'NZD',
128    JP: 'JPY',
129    IN: 'INR',
130    BR: 'BRL',
131    MX: 'MXN',
132    CH: 'CHF', LI: 'CHF',
133    SE: 'SEK',
134    NO: 'NOK',
135    DK: 'DKK',
136    SG: 'SGD',
137    ZA: 'ZAR',
138    // Eurozone — everyone gets EUR
139    AT: 'EUR', BE: 'EUR', CY: 'EUR', DE: 'EUR', EE: 'EUR', ES: 'EUR',
140    FI: 'EUR', FR: 'EUR', GR: 'EUR', HR: 'EUR', IE: 'EUR', IT: 'EUR',
141    LT: 'EUR', LU: 'EUR', LV: 'EUR', MT: 'EUR', NL: 'EUR', PT: 'EUR',
142    SI: 'EUR', SK: 'EUR',
143  };
144
145  // ---- Language dictionary ----------------------------------------------
146  // Keys are dotted (nav.watch, cta.donate). Anything missing falls back
147  // to the English default which equals the textContent already on the
148  // element, so untranslated keys are graceful.
149  const STRINGS = {
150    en: {
151      // Nav
152      'nav.mission':        'Mission',
153      'nav.how_we_help':    'How we help',
154      'nav.projects':       'Projects',
155      'nav.watch':          'Watch',
156      'nav.news':           'News',
157      'nav.about':          'About',
158      'nav.give':           'Change',
159      'nav.business':       'Business',
160      // Header CTAs
161      'cta.channel':        'Channel',
162      'cta.support':        'Support our work',
163      'cta.donate':         'Donate',
164      'cta.donate_now':     'Donate now',
165      'cta.learn_more':     'Learn more',
166      'cta.read_more':      'Read more',
167      'cta.watch_now':      'Watch now',
168      'cta.find_out_more':  'Find out more',
169      'cta.book_call':      'Book a call',
170      'cta.continue':       'Continue to checkout',
171      // Footer headings
172      'footer.foundation':  'Foundation',
173      'footer.give':        'Give',
174      'footer.watch':       'Watch',
175      'footer.business':    'Business',
176      // Donate page
177      'donate.h1':          'Give where the film stops.',
178      'donate.amount_pick': 'Pick an amount',
179      'donate.once':        'Once',
180      'donate.monthly':     'Monthly',
181      'donate.quick':       'Quick donate',
182      'donate.quick_sub':   'One tap. No forms. Your wallet fills in the rest.',
183      'donate.read_trust':  'Read full security details',
184      // Toggle
185      'toggle.label':       'Showing in',
186      'toggle.switch_to':   'Switch to',
187      'toggle.dismiss':     'Dismiss',
188      'currency.note':      'Donations are charged in US dollars; your bank converts at the day\'s rate.',
189    },
190    fr: {
191      'nav.mission':        'Mission',
192      'nav.how_we_help':    'Comment nous aidons',
193      'nav.projects':       'Projets',
194      'nav.watch':          'Regarder',
195      'nav.news':           'Actualités',
196      'nav.about':          'À propos',
197      'nav.give':           'Faire un don',
198      'nav.business':       'Entreprise',
199      'cta.channel':        'La chaîne',
200      'cta.support':        'Soutenez-nous',
201      'cta.donate':         'Faire un don',
202      'cta.donate_now':     'Faire un don',
203      'cta.learn_more':     'En savoir plus',
204      'cta.read_more':      'Lire la suite',
205      'cta.watch_now':      'Regarder',
206      'cta.find_out_more':  'En savoir plus',
207      'cta.book_call':      'Réserver un appel',
208      'cta.continue':       'Passer à la caisse',
209      'footer.foundation':  'Fondation',
210      'footer.give':        'Donner',
211      'footer.watch':       'Regarder',
212      'footer.business':    'Entreprise',
213      'donate.h1':          'Donnez là où le film s\'arrête.',
214      'donate.amount_pick': 'Choisissez un montant',
215      'donate.once':        'Une fois',
216      'donate.monthly':     'Mensuel',
217      'donate.quick':       'Don rapide',
218      'donate.quick_sub':   'Un seul geste. Sans formulaire. Votre portefeuille fait le reste.',
219      'donate.read_trust':  'Voir tous les détails de sécurité',
220      'toggle.label':       'Affichage en',
221      'toggle.switch_to':   'Passer à',
222      'toggle.dismiss':     'Fermer',
223      'currency.note':      'Les dons sont facturés en dollars américains ; votre banque applique le taux du jour.',
224    },
225    es: {
226      'nav.mission':        'Misión',
227      'nav.how_we_help':    'Cómo ayudamos',
228      'nav.projects':       'Proyectos',
229      'nav.watch':          'Ver',
230      'nav.news':           'Noticias',
231      'nav.about':          'Quiénes somos',
232      'nav.give':           'Donar',
233      'nav.business':       'Empresa',
234      'cta.channel':        'El canal',
235      'cta.support':        'Apoya nuestro trabajo',
236      'cta.donate':         'Donar',
237      'cta.donate_now':     'Donar ahora',
238      'cta.learn_more':     'Más información',
239      'cta.read_more':      'Leer más',
240      'cta.watch_now':      'Ver ahora',
241      'cta.find_out_more':  'Saber más',
242      'cta.book_call':      'Reservar una llamada',
243      'cta.continue':       'Continuar al pago',
244      'footer.foundation':  'Fundación',
245      'footer.give':        'Donar',
246      'footer.watch':       'Ver',
247      'footer.business':    'Empresa',
248      'donate.h1':          'Dona donde acaba la película.',
249      'donate.amount_pick': 'Elige un importe',
250      'donate.once':        'Una vez',
251      'donate.monthly':     'Mensual',
252      'donate.quick':       'Donación rápida',
253      'donate.quick_sub':   'Un toque. Sin formularios. Tu cartera rellena el resto.',
254      'donate.read_trust':  'Ver los detalles de seguridad',
255      'toggle.label':       'Mostrando en',
256      'toggle.switch_to':   'Cambiar a',
257      'toggle.dismiss':     'Cerrar',
258      'currency.note':      'Las donaciones se cobran en dólares estadounidenses; tu banco aplica la tasa del día.',
259    },
260    de: {
261      'nav.mission':        'Mission',
262      'nav.how_we_help':    'So helfen wir',
263      'nav.projects':       'Projekte',
264      'nav.watch':          'Ansehen',
265      'nav.news':           'Aktuelles',
266      'nav.about':          'Über uns',
267      'nav.give':           'Spenden',
268      'nav.business':       'Geschäft',
269      'cta.channel':        'Der Kanal',
270      'cta.support':        'Unterstützen Sie uns',
271      'cta.donate':         'Spenden',
272      'cta.donate_now':     'Jetzt spenden',
273      'cta.learn_more':     'Mehr erfahren',
274      'cta.read_more':      'Weiterlesen',
275      'cta.watch_now':      'Jetzt ansehen',
276      'cta.find_out_more':  'Mehr erfahren',
277      'cta.book_call':      'Gespräch buchen',
278      'cta.continue':       'Weiter zur Kasse',
279      'footer.foundation':  'Stiftung',
280      'footer.give':        'Spenden',
281      'footer.watch':       'Ansehen',
282      'footer.business':    'Geschäft',
283      'donate.h1':          'Spenden Sie, wo der Film endet.',
284      'donate.amount_pick': 'Betrag wählen',
285      'donate.once':        'Einmalig',
286      'donate.monthly':     'Monatlich',
287      'donate.quick':       'Schnellspende',
288      'donate.quick_sub':   'Ein Tipp. Keine Formulare. Ihre Wallet erledigt den Rest.',
289      'donate.read_trust':  'Alle Sicherheitsdetails ansehen',
290      'toggle.label':       'Anzeige in',
291      'toggle.switch_to':   'Wechseln zu',
292      'toggle.dismiss':     'Schließen',
293      'currency.note':      'Spenden werden in US-Dollar abgerechnet; Ihre Bank rechnet zum Tageskurs um.',
294    },
295    pt: {
296      'nav.mission':        'Missão',
297      'nav.how_we_help':    'Como ajudamos',
298      'nav.projects':       'Projetos',
299      'nav.watch':          'Assistir',
300      'nav.news':           'Notícias',
301      'nav.about':          'Sobre',
302      'nav.give':           'Doar',
303      'nav.business':       'Empresa',
304      'cta.channel':        'O canal',
305      'cta.support':        'Apoie o nosso trabalho',
306      'cta.donate':         'Doar',
307      'cta.donate_now':     'Doar agora',
308      'cta.learn_more':     'Saiba mais',
309      'cta.read_more':      'Ler mais',
310      'cta.watch_now':      'Assistir agora',
311      'cta.find_out_more':  'Saiba mais',
312      'cta.book_call':      'Agendar uma chamada',
313      'cta.continue':       'Continuar para o pagamento',
314      'footer.foundation':  'Fundação',
315      'footer.give':        'Doar',
316      'footer.watch':       'Assistir',
317      'footer.business':    'Empresa',
318      'donate.h1':          'Doe onde o filme termina.',
319      'donate.amount_pick': 'Escolha um valor',
320      'donate.once':        'Única vez',
321      'donate.monthly':     'Mensal',
322      'donate.quick':       'Doação rápida',
323      'donate.quick_sub':   'Um toque. Sem formulários. A sua carteira faz o resto.',
324      'donate.read_trust':  'Ver todos os detalhes de segurança',
325      'toggle.label':       'A ver em',
326      'toggle.switch_to':   'Mudar para',
327      'toggle.dismiss':     'Fechar',
328      'currency.note':      'As doações são cobradas em dólares dos EUA; o seu banco converte à taxa do dia.',
329    },
330    it: {
331      'nav.mission':        'Missione',
332      'nav.how_we_help':    'Come aiutiamo',
333      'nav.projects':       'Progetti',
334      'nav.watch':          'Guarda',
335      'nav.news':           'Notizie',
336      'nav.about':          'Chi siamo',
337      'nav.give':           'Dona',
338      'nav.business':       'Aziende',
339      'cta.channel':        'Il canale',
340      'cta.support':        'Sostieni il nostro lavoro',
341      'cta.donate':         'Dona',
342      'cta.donate_now':     'Dona ora',
343      'cta.learn_more':     'Scopri di più',
344      'cta.read_more':      'Leggi di più',
345      'cta.watch_now':      'Guarda ora',
346      'cta.find_out_more':  'Scopri di più',
347      'cta.book_call':      'Prenota una chiamata',
348      'cta.continue':       'Vai al pagamento',
349      'footer.foundation':  'Fondazione',
350      'footer.give':        'Dona',
351      'footer.watch':       'Guarda',
352      'footer.business':    'Aziende',
353      'donate.h1':          'Dona dove finisce il film.',
354      'donate.amount_pick': 'Scegli un importo',
355      'donate.once':        'Una volta',
356      'donate.monthly':     'Mensile',
357      'donate.quick':       'Donazione rapida',
358      'donate.quick_sub':   'Un tocco. Niente moduli. Il portafoglio fa il resto.',
359      'donate.read_trust':  'Vedi tutti i dettagli di sicurezza',
360      'toggle.label':       'Visualizzato in',
361      'toggle.switch_to':   'Passa a',
362      'toggle.dismiss':     'Chiudi',
363      'currency.note':      'Le donazioni vengono addebitate in dollari statunitensi; la tua banca applica il tasso del giorno.',
364    },
365  };
366
367  const LANG_NAMES = {
368    en: 'English', fr: 'Français', es: 'Español', de: 'Deutsch',
369    pt: 'Português', it: 'Italiano',
370  };
371
372  const DEFAULT_LOCALE = { lang: 'en', currency: 'USD' };
373  const STORAGE_KEY = 'ecoflix:locale';
374  const DISMISS_KEY = 'ecoflix:i18n-dismiss';
375
376  // ---- Storage helpers ---------------------------------------------------
377  function readStored() {
378    try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); }
379    catch (_) { return null; }
380  }
381  function writeStored(loc) {
382    try { localStorage.setItem(STORAGE_KEY, JSON.stringify(loc)); } catch (_) {}
383  }
384  function readDismissed() {
385    try { return sessionStorage.getItem(DISMISS_KEY) === '1'; } catch (_) { return false; }
386  }
387  function writeDismissed() {
388    try { sessionStorage.setItem(DISMISS_KEY, '1'); } catch (_) {}
389  }
390
391  // ---- Detection ---------------------------------------------------------
392  function detect() {
393    // 1. User has explicitly chosen → that wins forever (until they clear)
394    const stored = readStored();
395    if (stored && stored.lang) {
396      return Object.assign({ source: 'stored' }, stored);
397    }
398
399    // 2. Server-injected geo (Vercel edge headers) — optional, if present
400    const geo = window.__ECOFLIX_GEO__ || null;
401
402    // 3. Browser-reported language
403    const navLang = (navigator.language || navigator.userLanguage || 'en-US').toString();
404    const parts = navLang.split('-');
405    const langCode = (parts[0] || 'en').toLowerCase();
406    const browserCountry = (parts[1] || '').toUpperCase();
407
408    const country = (geo && geo.country) || browserCountry || 'US';
409    const lang = STRINGS[langCode] ? langCode : 'en';
410    const currency = COUNTRY_TO_CCY[country] || 'USD';
411
412    return { lang, currency, country, source: 'auto' };
413  }
414
415  // ---- Translation pass --------------------------------------------------
416  function getString(loc, key) {
417    const dict = STRINGS[loc.lang] || STRINGS.en;
418    return dict[key] || STRINGS.en[key] || null;
419  }
420
421  function applyTranslations(loc, root) {
422    root = root || document;
423    root.querySelectorAll('[data-i18n]').forEach(el => {
424      const k = el.getAttribute('data-i18n');
425      const t = getString(loc, k);
426      if (t == null) return;
427      // If the element has a child .arrow / <span> we want preserved (button
428      // arrows etc.), only replace the first text node. Otherwise just set.
429      const firstText = Array.from(el.childNodes).find(n => n.nodeType === 3);
430      if (firstText && el.children.length) {
431        firstText.nodeValue = t + ' ';
432      } else {
433        el.textContent = t;
434      }
435    });
436    // aria-label translation: data-i18n-aria="key"
437    root.querySelectorAll('[data-i18n-aria]').forEach(el => {
438      const k = el.getAttribute('data-i18n-aria');
439      const t = getString(loc, k);
440      if (t != null) el.setAttribute('aria-label', t);
441    });
442  }
443
444  // ---- Currency pass -----------------------------------------------------
445  // Round UP to a "nice" donation-tier number so converted amounts read
446  // like real donate buttons instead of FX-noise (e.g. €45.93 → €50,
447  // ¥7,816 → ¥8,000). Steps grow with magnitude so a $25 USD button
448  // becomes €25 in EUR — not €30 — while $1,000 becomes the cleanest
449  // round-thousand the locale supports.
450  function niceRoundUp(n) {
451    if (!isFinite(n) || n <= 0) return 0;
452    let step;
453    if      (n < 10)     step = 1;
454    else if (n < 50)     step = 5;
455    else if (n < 200)    step = 10;
456    else if (n < 1000)   step = 50;
457    else if (n < 10000)  step = 100;
458    else if (n < 100000) step = 1000;
459    else                 step = 10000;
460    return Math.ceil(n / step) * step;
461  }
462
463  function formatLocal(amountUsd, currency) {
464    const rate = RATES[currency] || 1;
465    const sym  = SYMBOLS[currency] || '$';
466    const raw  = amountUsd * rate;
467
468    // For USD we display the source amount verbatim — no rounding noise.
469    // For everything else we round UP to the nearest "nice" tier so the
470    // donate buttons feel as deliberate as they do in English/USD.
471    const local = (currency === 'USD') ? raw : niceRoundUp(raw);
472
473    let display;
474    if (currency === 'JPY' || currency === 'INR' || currency === 'BRL' || currency === 'MXN' || currency === 'ZAR') {
475      display = Math.round(local).toLocaleString('en-US');
476    } else if (currency === 'USD' && Math.abs(local - Math.round(local)) >= 0.005) {
477      display = local.toFixed(2);
478    } else {
479      display = Math.round(local).toLocaleString('en-US');
480    }
481
482    // CHF wants "CHF 45", kr wants "45 kr" suffix, everything else
483    // sticks a symbol on the front.
484    if (currency === 'SEK' || currency === 'NOK' || currency === 'DKK') {
485      return display + ' ' + sym;
486    }
487    return sym + display;
488  }
489
490  const CURRENCY_NOUN = { USD:'dollar', CAD:'dollar', AUD:'dollar', NZD:'dollar', SGD:'dollar', GBP:'pound', EUR:'euro', JPY:'yen', INR:'rupee', BRL:'real', MXN:'peso', CHF:'franc', SEK:'krona', NOK:'krone', DKK:'krone', ZAR:'rand', UAE:'dirham' };
491  function applyCurrency(loc, root) {
492    root = root || document;
493    root.querySelectorAll('[data-money-word]').forEach(function(el){ el.textContent = CURRENCY_NOUN[loc.currency] || 'dollar'; });
494    root.querySelectorAll('[data-money]').forEach(el => {
495      const usd = parseFloat(el.getAttribute('data-money'));
496      if (!isFinite(usd)) return;
497      el.textContent = formatLocal(usd, loc.currency);
498      el.setAttribute('data-money-currency', loc.currency);
499    });
500  }
501
502  // ---- Globe widget ------------------------------------------------------
503  // Returns the inline SVG markup for a stylized mini-globe with a leaf
504  // pin dropped on the visitor's country. The projection is a simple
505  // orthographic onto a 56×56 viewBox; longitude/latitude lines are
506  // dashed leaf-tinted graticule arcs matching the site's cartograph
507  // aesthetic. If the country is on the far hemisphere from the centre,
508  // the pin is hidden (it would land off the visible disc).
509  function buildGlobeSvg(country) {
510    const info = COUNTRIES[country] || null;
511    // Centre the projection so that most populated countries are visible.
512    const CENTRE_LON = 10;   // ~central Europe / North Africa
513    const CENTRE_LAT = 20;   // tilt north a touch
514    const R = 22;            // disc radius inside a 56 viewBox
515    const CX = 28, CY = 28;
516
517    // Pin position (orthographic projection of country centroid)
518    let pin = '';
519    if (info) {
520      const toRad = (d) => d * Math.PI / 180;
521      const lon = toRad(info.lon - CENTRE_LON);
522      const lat = toRad(info.lat);
523      const cLat = toRad(CENTRE_LAT);
524      // Standard orthographic projection from sphere to plane
525      const cosc = Math.sin(cLat) * Math.sin(lat) + Math.cos(cLat) * Math.cos(lat) * Math.cos(lon);
526      // cosc > 0 means the point is on the visible hemisphere
527      if (cosc > 0) {
528        const x = R * Math.cos(lat) * Math.sin(lon);
529        const y = -R * (Math.cos(cLat) * Math.sin(lat) - Math.sin(cLat) * Math.cos(lat) * Math.cos(lon));
530        pin =
531          '<circle class="i18n-globe-pin-glow" cx="' + (CX + x).toFixed(1) + '" cy="' + (CY + y).toFixed(1) + '" r="5"/>' +
532          '<circle class="i18n-globe-pin"      cx="' + (CX + x).toFixed(1) + '" cy="' + (CY + y).toFixed(1) + '" r="2.6"/>';
533      }
534    }
535
536    return [
537      '<svg viewBox="0 0 56 56" class="i18n-globe-svg" aria-hidden="true">',
538      // Disc
539      '  <circle class="i18n-globe-disc" cx="', CX, '" cy="', CY, '" r="', R, '"/>',
540      // Graticule — latitudes
541      '  <ellipse class="i18n-globe-lat" cx="', CX, '" cy="', CY, '" rx="', R, '" ry="6"/>',
542      '  <ellipse class="i18n-globe-lat" cx="', CX, '" cy="', CY, '" rx="', R, '" ry="13"/>',
543      '  <ellipse class="i18n-globe-lat" cx="', CX, '" cy="', CY, '" rx="', R, '" ry="18.5"/>',
544      // Graticule — meridians
545      '  <ellipse class="i18n-globe-lon" cx="', CX, '" cy="', CY, '" rx="6"    ry="', R, '"/>',
546      '  <ellipse class="i18n-globe-lon" cx="', CX, '" cy="', CY, '" rx="13"   ry="', R, '"/>',
547      '  <ellipse class="i18n-globe-lon" cx="', CX, '" cy="', CY, '" rx="18.5" ry="', R, '"/>',
548      // Outer ring
549      '  <circle class="i18n-globe-ring" cx="', CX, '" cy="', CY, '" r="', R, '"/>',
550      pin,
551      '</svg>',
552    ].join('');
553  }
554
555  // ---- Toggle bar --------------------------------------------------------
556  function buildToggle(loc) {
557    if (readDismissed()) return null;
558
559    const langName = LANG_NAMES[loc.lang] || 'English';
560    const showingLabel = getString(loc, 'toggle.label') || 'Showing in';
561    const switchLabel  = getString(loc, 'toggle.switch_to') || 'Switch to';
562    const dismissLabel = getString(loc, 'toggle.dismiss') || 'Dismiss';
563    const country      = COUNTRIES[loc.country] || null;
564    const countryLabel = country ? (country.flag + ' ' + country.name) : (loc.country || 'Detected');
565
566    const bar = document.createElement('div');
567    bar.className = 'i18n-bar';
568    bar.setAttribute('role', 'region');
569    bar.setAttribute('aria-label', 'Language, currency and location');
570    bar.innerHTML = [
571      '<div class="i18n-bar-inner">',
572      '  <span class="i18n-globe-wrap" title="' + escapeHtml(countryLabel) + '">',
573           buildGlobeSvg(loc.country),
574      '  </span>',
575      '  <span class="i18n-label">',
576      '    <span class="i18n-prefix">' + escapeHtml(showingLabel) + '</span>',
577      '    <b>' + escapeHtml(langName) + '</b>',
578      '    <span class="i18n-dot" aria-hidden="true">·</span>',
579      '    <b>' + escapeHtml(loc.currency) + '</b>',
580      '    <span class="i18n-where" aria-hidden="true">' + escapeHtml(countryLabel) + '</span>',
581      '  </span>',
582      '  <button class="i18n-switch" type="button" aria-label="Switch to English and US dollars">',
583      '    ' + escapeHtml(switchLabel) + ' <b>English · USD</b>',
584      '    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7"/><path d="M3 4v5h5"/></svg>',
585      '  </button>',
586      '  <button class="i18n-close" type="button" aria-label="' + escapeHtml(dismissLabel) + '">',
587      '    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>',
588      '  </button>',
589      '</div>',
590    ].join('');
591
592    bar.querySelector('.i18n-switch').addEventListener('click', () => {
593      writeStored({ lang: 'en', currency: 'USD', userChose: true });
594      // Optional dismiss so the bar doesn't reappear immediately after reload
595      writeDismissed();
596      // Re-apply in place — also reload to refresh anything that boots from locale
597      window.location.reload();
598    });
599    bar.querySelector('.i18n-close').addEventListener('click', () => {
600      writeDismissed();
601      bar.classList.add('is-out');
602      if (bar._cleanupHeaderOffset) bar._cleanupHeaderOffset();
603      setTimeout(() => bar.remove(), 280);
604    });
605    return bar;
606  }
607
608  // Keep the fixed site header below the locale bar instead of overlapping it.
609  // The bar is in normal flow (it scrolls away), so we glue the fixed header to
610  // the bar's bottom edge and let it pin to the top once the bar has scrolled
611  // out of view. Without this the 75px fixed header sat on top of the 61px bar,
612  // jumbling the navigation (badly so on mobile, where the bar text wraps).
613  function syncHeaderOffset(bar) {
614    const header = document.querySelector('header.header');
615    if (!header) return;
616    let raf = 0;
617    const update = () => {
618      raf = 0;
619      if (!bar.isConnected) { header.style.top = ''; return; }
620      header.style.top = Math.max(0, Math.round(bar.getBoundingClientRect().bottom)) + 'px';
621    };
622    const onScroll = () => { if (!raf) raf = requestAnimationFrame(update); };
623    window.addEventListener('scroll', onScroll, { passive: true });
624    window.addEventListener('resize', onScroll, { passive: true });
625    bar._cleanupHeaderOffset = () => {
626      window.removeEventListener('scroll', onScroll);
627      window.removeEventListener('resize', onScroll);
628      header.style.top = '';
629    };
630    update();
631  }
632
633  function escapeHtml(s) {
634    return String(s).replace(/[&<>"']/g, c =>
635      ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
636  }
637
638  function shouldShowToggle(loc) {
639    // Per-page opt-out: any page can suppress the locale bar by adding
640    // a `data-i18n-no-bar` attribute to <html> or <body>. The donate
641    // page does this because it renders a richer contextual locale
642    // card inline next to the amount picker.
643    if (document.documentElement.hasAttribute('data-i18n-no-bar') ||
644        document.body.hasAttribute('data-i18n-no-bar')) {
645      return false;
646    }
647    // Only auto-detected non-default locales show the toggle. If the user
648    // has explicitly chosen English+USD, don't show it. If they've chosen
649    // anything else explicitly, still show it so they can flip back.
650    if (loc.source === 'stored' && loc.lang === 'en' && loc.currency === 'USD') return false;
651    if (loc.lang === 'en' && loc.currency === 'USD') return false;
652    return true;
653  }
654
655  // ---- Boot --------------------------------------------------------------
656  function boot() {
657    const loc = detect();
658
659    // Expose globally so other scripts can react
660    window.__ECOFLIX_LOCALE__ = loc;
661    window.__ECOFLIX_CONVERT__ = (usd) => {
662      const rate = RATES[loc.currency] || 1;
663      return (parseFloat(usd) || 0) * rate;
664    };
665    window.__ECOFLIX_FORMAT__ = (usd) => formatLocal(parseFloat(usd) || 0, loc.currency);
666    window.__ECOFLIX_T__ = (key) => getString(loc, key);
667    // Helpers for other scripts that want to render their own globe widget
668    window.__ECOFLIX_GLOBE__   = (country) => buildGlobeSvg(country || loc.country);
669    window.__ECOFLIX_COUNTRY__ = () => COUNTRIES[loc.country] || null;
670
671    // <html lang="…"> — accessibility + SEO
672    document.documentElement.setAttribute('lang', loc.lang);
673
674    applyTranslations(loc);
675    applyCurrency(loc);
676
677    // Switch-currency/language banner disabled by request — currency and
678    // language are geolocated automatically, so nothing pops down at the top
679    // of the page. (buildToggle/shouldShowToggle are retained above should it
680    // ever need reinstating.)
681
682    // Re-apply after the site.js nav rewriter has run. site.js runs
683    // synchronously on DOMContentLoaded; we run after that. But to be
684    // safe with any deferred mutation, run one more pass on a
685    // microtask + a frame.
686    Promise.resolve().then(() => {
687      applyTranslations(loc);
688      applyCurrency(loc);
689    });
690    requestAnimationFrame(() => {
691      applyTranslations(loc);
692      applyCurrency(loc);
693    });
694
695    // Broadcast that locale is ready (other scripts can listen)
696    window.dispatchEvent(new CustomEvent('ecoflix:locale-ready', { detail: loc }));
697  }
698
699  // ---- Live FX: refresh USD-base rates daily (free API, no key), cached locally ----
700  (function(){
701    try { var c = JSON.parse(localStorage.getItem('ecoflix_fx') || 'null');
702      if (c && c.rates && Date.now() - c.t < 86400000) { Object.assign(RATES, c.rates); return; } } catch(e){}
703    fetch('https://open.er-api.com/v6/latest/USD').then(function(r){return r.json();}).then(function(d){
704      if (!d || !d.rates) return;
705      var upd = {};
706      Object.keys(RATES).forEach(function(cur){ if (d.rates[cur]) { RATES[cur] = d.rates[cur]; upd[cur] = d.rates[cur]; } });
707      try { localStorage.setItem('ecoflix_fx', JSON.stringify({ t: Date.now(), rates: upd })); } catch(e){}
708      var loc = window.__ECOFLIX_LOCALE__;
709      if (loc) applyCurrency(loc, document);
710    }).catch(function(){});
711  })();
712
713  if (document.readyState === 'loading') {
714    document.addEventListener('DOMContentLoaded', boot, { once: true });
715  } else {
716    boot();
717  }
718})();

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.