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 ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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.