File size: 24,598 Bytes
18fd433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ccbe961
18fd433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
/* ─────────────────────────────────────────────────────────────────────────────
 * i18n.js — PawMap lightweight runtime localizer (EN ⇄ PT-BR)
 *
 * How it works
 *  - English is the canonical language. A few strings are hardcoded in
 *    Portuguese in the source; the dictionary stores every pair as
 *    [English, Portuguese] so BOTH directions work:
 *      · PT mode: an English node → its Portuguese; a node already in
 *        Portuguese isn't an English key, so it's left untouched (stays PT).
 *      · EN mode: a Portuguese node → its English (via the reverse map);
 *        English nodes are native and left alone.
 *  - A set of regex PATTERNS (PT mode only) handles interpolated strings
 *    like "5 sightings" or "3d ago".
 *  - A MutationObserver re-applies translation to anything the app renders at
 *    runtime, so JS-generated text is covered.
 *  - Switching language persists the choice and reloads (the app re-fetches
 *    its data anyway), giving a clean re-render.
 *  - The AI free-text sentence is NOT machine-translated; in PT mode app.js
 *    rebuilds a description from the structured AI fields using the maps
 *    exposed here on window.PAWMAP_I18N. The AI prompt itself stays English.
 * ──────────────────────────────────────────────────────────────────────────── */
(function () {
  'use strict';

  var LANG_KEY = 'pawmap_lang';

  function detectLang() {
    var saved = localStorage.getItem(LANG_KEY);
    if (saved === 'pt' || saved === 'en') return saved;
    return (navigator.language || '').toLowerCase().indexOf('pt') === 0 ? 'pt' : 'en';
  }

  var LANG = detectLang();
  window.PAWMAP_LANG = LANG;
  document.documentElement.lang = LANG === 'pt' ? 'pt-BR' : 'en';

  /* ── Word maps (reused in dictionary + exposed for AI-description rebuild) ── */
  var SPECIES = { dog: 'Cachorro', cat: 'Gato' };
  var COLORS = {
    black: 'Preto', white: 'Branco', gray: 'Cinza', grey: 'Cinza', brown: 'Marrom',
    chocolate: 'Chocolate', caramel: 'Caramelo', tan: 'Bege', golden: 'Dourado',
    cream: 'Creme', fawn: 'Fulvo', orange: 'Laranja', ginger: 'Ruivo', red: 'Vermelho',
    sable: 'Zibelina', cinnamon: 'Canela', blue: 'Azul', lilac: 'Lilás', tabby: 'Tigrado',
    tuxedo: 'Smoking', calico: 'Calico', tortoiseshell: 'Escaminha', tricolor: 'Tricolor',
    bicolor: 'Bicolor', brindle: 'Rajado', merle: 'Merle', spotted: 'Malhado', mixed: 'Misto'
  };
  var SIZES = {
    tiny: 'Mini', small: 'Pequeno', medium: 'Médio', large: 'Grande',
    'extra large': 'Extra Grande', giant: 'Gigante'
  };
  var CONDITIONS = {
    healthy: 'saudável', thin: 'magro', injured: 'ferido',
    'looks healthy': 'parece saudável', 'has collar': 'tem coleira'
  };
  var MARKS = {
    collar: 'coleira', scar: 'cicatriz', patch: 'mancha', 'missing ear': 'orelha faltando',
    'floppy ears': 'orelhas caídas', wound: 'ferimento', spot: 'pinta', limp: 'mancando'
  };

  function ptColor(c) {
    if (!c) return '';
    var k = String(c).toLowerCase();
    return COLORS[k] || (c.charAt(0).toUpperCase() + c.slice(1));
  }
  function ptSize(s) {
    if (!s) return '';
    return SIZES[String(s).toLowerCase()] || s;
  }
  function ptSpecies(sp) { return SPECIES[String(sp).toLowerCase()] || sp; }
  function ptCondition(c) {
    if (!c) return '';
    var k = String(c).toLowerCase();
    return CONDITIONS[k] || c;
  }
  function ptMark(m) {
    if (!m) return '';
    return MARKS[String(m).toLowerCase()] || m;
  }

  window.PAWMAP_I18N = {
    get lang() { return window.PAWMAP_LANG; },
    color: ptColor, size: ptSize, species: ptSpecies, condition: ptCondition, mark: ptMark
  };

  /* ── Dictionary: [English, Portuguese] (always this order) ── */
  var PAIRS = [
    // Onboarding / desktop sidebars
    ['Collaborative stray animal mapping, powered by AI.', 'Mapeamento colaborativo de animais de rua, com IA.'],
    ['The problem', 'O problema'],
    ['Independent rescuers in Brasília, DF keep track of entire stray animal colonies in their heads — which ones were neutered, which one got hurt last winter, which one hasn\'t shown up this week. That knowledge lives in WhatsApp groups and fades when people move on.',
     'Protetores independentes em Brasília, DF guardam de cabeça colônias inteiras de animais de rua — quais foram castrados, qual se machucou no último inverno, qual não apareceu esta semana. Esse conhecimento vive em grupos de WhatsApp e se perde quando as pessoas seguem em frente.'],
    ['PawMap turns those scattered sightings into a shared, persistent map — one photo at a time.',
     'O PawMap transforma esses avistamentos dispersos em um mapa compartilhado e permanente — uma foto de cada vez.'],
    ['How it works', 'Como funciona'],
    ['Snap a photo', 'Tire uma foto'],
    ['Point your camera at any stray dog or cat.', 'Aponte a câmera para qualquer cão ou gato de rua.'],
    ['AI identifies', 'A IA identifica'],
    ['Species, breed, color and condition — automatically.', 'Espécie, raça, cor e estado — automaticamente.'],
    ['Smart matching', 'Correspondência inteligente'],
    ['Checks if this animal has been seen before.', 'Verifica se este animal já foi visto antes.'],
    ['Map updates', 'O mapa atualiza'],
    ['Sighting linked to the animal\'s profile — or a new one is created.', 'Avistamento ligado ao perfil do animal — ou um novo é criado.'],
    ['Better on mobile', 'Melhor no celular'],
    ['Skip', 'Pular'],
    ['Step 1 of 4', 'Passo 1 de 4'],
    ['Step 2 of 4', 'Passo 2 de 4'],
    ['Step 3 of 4', 'Passo 3 de 4'],
    ['Step 4 of 4', 'Passo 4 de 4'],
    ['Help a stray find its way', 'Ajude um animal de rua a encontrar seu caminho'],
    ['Every street dog and cat deserves to be seen. PawMap lets your community map them together — one photo at a time.',
     'Todo cão e gato de rua merece ser visto. O PawMap deixa sua comunidade mapeá-los juntos — uma foto de cada vez.'],
    ['Snap a photo — AI does the rest', 'Tire uma foto — a IA faz o resto'],
    ['Point your camera and tap. Our AI instantly identifies species, breed, color, and condition. No forms to fill.',
     'Aponte a câmera e toque. Nossa IA identifica na hora espécie, raça, cor e estado. Sem formulários para preencher.'],
    ['AI matches the same animal', 'A IA reconhece o mesmo animal'],
    ['Seen this dog before? The AI compares your photo with previous sightings and links them automatically — building each animal\'s story.',
     'Já viu este cão antes? A IA compara sua foto com avistamentos anteriores e os conecta automaticamente — construindo a história de cada animal.'],
    ['Every map pin is a chance', 'Cada ponto no mapa é uma chance'],
    ['Mapping a dog brings more chances to find a forever family — or get help from the community when it\'s needed most.',
     'Mapear um cão traz mais chances de encontrar uma família para sempre — ou de receber ajuda da comunidade na hora mais necessária.'],
    ['Next →', 'Próximo →'],
    ['Get Started', 'Começar'],
    // Filter row
    ['All', 'Todos'],
    ['Dogs', 'Cachorros'],
    ['Cats', 'Gatos'],
    ['Today', 'Hoje'],
    ['This Week', 'Esta semana'],
    // Map / card
    ['No sightings yet', 'Nenhum avistamento ainda'],
    ['Dog', 'Cachorro'],
    ['Cat', 'Gato'],
    ['Dog spotted', 'Cachorro avistado'],
    ['Cat spotted', 'Gato avistado'],
    ['Yesterday', 'Ontem'],
    ['View profile', 'Ver perfil'],
    // Register
    ['Add a photo to detect location', 'Adicione uma foto para detectar a localização'],
    // reset() in app.js emits this exact PT sentence; pair it (PT→EN) so EN mode restores English.
    // The duplicate English key is harmless: enToPt keeps the first, ptToEn gets this PT key.
    ['Add a photo to detect location', 'Adicione uma foto para definir a localização'],
    ['Tap the button to take a photo', 'Toque no botão para tirar uma foto'],
    ['Add a note (optional)', 'Adicione uma nota (opcional)'],
    ['Use current location', 'Usar localização atual'],
    ['Select on map', 'Selecionar no mapa'],
    ['Report Sighting', 'Registrar avistamento'],
    ['Reading photo location…', 'Lendo a localização da foto…'],
    ['No location in the photo — set it below', 'Sem localização na foto — defina abaixo'],
    ['GPS not available', 'GPS indisponível'],
    ['Detecting...', 'Detectando...'],
    ['Permission denied', 'Permissão negada'],
    ['Location unavailable', 'Localização indisponível'],
    ['Timed out', 'Tempo esgotado'],
    ['GPS error', 'Erro de GPS'],
    // Analysis
    ['Analyzing Sighting', 'Analisando avistamento'],
    ['AI Analyzing...', 'IA analisando...'],
    ['AI Done', 'IA concluída'],
    ['Analysis error', 'Erro na análise'],
    ['No animal found', 'Nenhum animal encontrado'],
    ['Identified Details', 'Detalhes identificados'],
    ['AI filled in these details. Correct if needed.', 'A IA preencheu estes detalhes. Corrija se precisar.'],
    ['Species', 'Espécie'],
    ['Breed (Estimated)', 'Raça (estimada)'],
    ['Primary Color', 'Cor principal'],
    ['Size', 'Porte'],
    ['Condition (Optional)', 'Estado (opcional)'],
    ['Injured', 'Ferido'],
    ['Looks Healthy', 'Parece saudável'],
    ['Has Collar', 'Tem coleira'],
    ['Similar animals nearby', 'Animais parecidos por perto'],
    ['Found by AI for context — recognition is automatic.', 'Encontrados pela IA para contexto — o reconhecimento é automático.'],
    ['Name', 'Nome'],
    ['optional', 'opcional'],
    ['Registered', 'Registrado'],
    ['Confirm & Report →', 'Confirmar e registrar →'],
    ['Confirm without AI →', 'Confirmar sem IA →'],
    ['Discard Photo', 'Descartar foto'],
    ['← Take another photo', '← Tirar outra foto'],
    ['Analyzing...', 'Analisando...'],
    ['Saving...', 'Salvando...'],
    ['Saving…', 'Salvando…'],
    ['Error saving. Please try again.', 'Erro ao salvar. Tente novamente.'],
    // help-proof.js emits this exact PT sentence; pair it (PT→EN) for EN mode.
    ['Error saving. Please try again.', 'Erro ao registrar. Tente novamente.'],
    // Color options
    ['Black', 'Preto'], ['White', 'Branco'], ['Gray', 'Cinza'], ['Brown', 'Marrom'],
    ['Chocolate', 'Chocolate'], ['Caramel', 'Caramelo'], ['Tan', 'Bege'], ['Golden', 'Dourado'],
    ['Cream', 'Creme'], ['Fawn', 'Fulvo'], ['Orange', 'Laranja'], ['Ginger', 'Ruivo'],
    ['Red', 'Vermelho'], ['Sable', 'Zibelina'], ['Cinnamon', 'Canela'], ['Blue (gray)', 'Azul (cinza)'],
    ['Lilac', 'Lilás'], ['Tabby', 'Tigrado'], ['Tuxedo', 'Smoking'], ['Calico', 'Calico'],
    ['Tortoiseshell', 'Escaminha'], ['Tricolor', 'Tricolor'], ['Bicolor', 'Bicolor'],
    ['Brindle', 'Rajado'], ['Merle', 'Merle'], ['Spotted', 'Malhado'], ['Mixed', 'Misto'],
    // Size options + labels
    ['Tiny', 'Mini'], ['Small', 'Pequeno'], ['Medium', 'Médio'], ['Large', 'Grande'],
    ['Extra Large', 'Extra Grande'], ['Giant', 'Gigante'],
    ['↕ Tiny', '↕ Mini'], ['↕ Small', '↕ Pequeno'], ['↕ Medium', '↕ Médio'],
    ['↕ Large', '↕ Grande'], ['↕ Extra Large', '↕ Extra Grande'], ['↕ Giant', '↕ Gigante'],
    // Confirm screen
    ['Sighting', 'Avistamento'],
    ['Reported!', 'registrado!'],
    ['Animal:', 'Animal:'],
    ['Location:', 'Local:'],
    ['Time:', 'Hora:'],
    ['AI Identification', 'Identificação da IA'],
    ['Breed', 'Raça'],
    ['Color', 'Cor'],
    ['Condition', 'Estado'],
    ['Report Another', 'Registrar outro'],
    ['View on Map', 'Ver no mapa'],
    // Sightings list
    ['Loading...', 'Carregando...'],
    ['Failed to load. Try again.', 'Falha ao carregar. Tente novamente.'],
    ['No animals on record yet.', 'Nenhum animal registrado ainda.'],
    ['Go to the Report tab to get started!', 'Vá para a aba Registrar para começar!'],
    ['Unknown breed', 'Raça desconhecida'],
    ['Unknown', 'Desconhecida'],
    ['today', 'hoje'],
    ['yesterday', 'ontem'],
    // Profile
    ['This animal has been helped by the community', 'Este animal foi ajudado pela comunidade'],
    ['Identification', 'Identificação'],
    ['Sightings Gallery', 'Galeria de avistamentos'],
    ['See all', 'Ver todos'],
    ['Community Help', 'Ajuda da comunidade'],
    ['Recent Path', 'Trajeto recente'],
    ['I Want to Adopt / Help', 'Quero adotar / ajudar'],
    ['Seen today', 'Visto hoje'],
    ['Seen yesterday', 'Visto ontem'],
    ['No photos recorded.', 'Nenhuma foto registrada.'],
    ['Location not recorded', 'Local não registrado'],
    ['No locations recorded', 'Nenhum local registrado'],
    ['Failed to load', 'Falha ao carregar'],
    // Help types
    ['Fed', 'Alimentou'],
    ['Took to vet', 'Levou ao veterinário'],
    ['Adopted', 'Adotou'],
    ['Rescued', 'Resgatou'],
    ['Helped', 'Ajudou'],
    ['Other', 'Outro'],
    // Help sheet
    ['Share this animal', 'Compartilhar este animal'],
    ['Send via WhatsApp or copy link', 'Enviar pelo WhatsApp ou copiar link'],
    ['Get directions', 'Ver rota'],
    ['Navigate to last known location', 'Navegar até o último local conhecido'],
    ['I helped this animal', 'Eu ajudei este animal'],
    ['Log that you fed, treated, or fostered them', 'Registre que você alimentou, tratou ou acolheu'],
    ['Cancel', 'Cancelar'],
    ['No location recorded for this animal yet.', 'Nenhum local registrado para este animal ainda.'],
    // Helped confirm
    ['Thank you', 'Obrigada'],
    ['Your help has been recorded.', 'Sua ajuda foi registrada.'],
    ['Every act of care makes a difference.', 'Cada gesto de cuidado faz diferença.'],
    ['Done', 'Concluído'],
    // Source chooser (hardcoded PT in source)
    ['Take photo', 'Tirar foto'],
    ['Open the camera', 'Abrir a câmera'],
    ['Choose from gallery', 'Escolher da galeria'],
    ['Select an existing photo', 'Selecionar uma foto existente'],
    // Map picker (hardcoded PT in source)
    ['Where did you see the animal?', 'Onde você viu o animal?'],
    ['Search address or neighborhood…', 'Buscar endereço ou bairro…'],
    ['Use my location', 'Usar minha localização'],
    ['Drag the map to position the pin', 'Arraste o mapa para posicionar o pin'],
    ['Confirm location', 'Confirmar localização'],
    // Help proof
    ['I Helped This Animal', 'Eu ajudei este animal'],
    ['What did you do?', 'O que você fez?'],
    ['Add a photo', 'Adicione uma foto'],
    ['Tap to add photo proof', 'Toque para adicionar uma foto de prova'],
    ['Verifying with AI…', 'Verificando com IA…'],
    ['Same animal confirmed', 'Mesmo animal confirmado'],
    ['Likely the same animal', 'Provavelmente o mesmo animal'],
    ['Photo added — animal not matched', 'Foto adicionada — animal não correspondido'],
    ['No animal detected in photo', 'Nenhum animal detectado na foto'],
    ['Tell the story', 'Conte a história'],
    ['What happened? Where did you take them?', 'O que aconteceu? Para onde você o levou?'],
    ['Register Help', 'Registrar ajuda'],
    // Photo menu
    ['Download photo', 'Baixar foto'],
    // Bottom nav
    ['Map', 'Mapa'],
    ['Report', 'Registrar'],
    ['Sightings', 'Avistamentos'],
    // Right sidebar
    ['Built for', 'Feito para'],
    ['Backyard AI track · Hugging Face', 'Trilha Backyard AI · Hugging Face'],
    ['Tech stack', 'Tecnologias'],
    ['Vision AI', 'IA de visão'],
    ['Reasoning brain', 'Cérebro de raciocínio'],
    ['Matching', 'Correspondência'],
    ['Frontend', 'Frontend'],
    ['Storage', 'Armazenamento'],
    ['Help an animal today', 'Ajude um animal hoje'],
    ['Live Space ↗', 'Space ao vivo ↗'],
    ['Dataset ↗', 'Dataset ↗'],
    // aria / titles
    ['Menu', 'Menu'],
    ['Options', 'Opções'],
    ['Close', 'Fechar'],
    ['Refresh', 'Atualizar']
  ];

  /* ── Build lookup maps ── */
  var enToPt = Object.create(null);
  var ptToEn = Object.create(null);
  PAIRS.forEach(function (p) {
    var en = p[0], pt = p[1];
    if (!(en in enToPt)) enToPt[en] = pt;
    if (!(pt in ptToEn)) ptToEn[pt] = en;
  });

  /* ── Helpers for interpolated strings (EN → PT, PT mode only) ── */
  function plural(n, sing, plur) { return n === 1 ? sing : plur; }
  function dayPhrasePt(s) {
    if (s === 'today') return 'hoje';
    if (s === 'yesterday') return 'ontem';
    var m = s.match(/^(\d+) days ago$/);
    if (m) return 'há ' + m[1] + ' dias';
    return s;
  }

  var PATTERNS = [
    [/^(\d+)d ago$/, function (m) { return 'há ' + m[1] + 'd'; }],
    [/^(\d+) days ago$/, function (m) { return 'há ' + m[1] + ' dias'; }],
    [/^Seen (\d+) days ago$/, function (m) { return 'Visto há ' + m[1] + ' dias'; }],
    [/^Not seen for (\d+)d$/, function (m) { return 'Sem ser visto há ' + m[1] + 'd'; }],
    [/^(\d+) animals? on record$/, function (m) {
      var n = +m[1]; return n + ' ' + plural(n, 'animal registrado', 'animais registrados');
    }],
    [/^(\d+) sightings?$/, function (m) {
      var n = +m[1]; return n + ' ' + plural(n, 'avistamento', 'avistamentos');
    }],
    [/^(\d+) sightings? · last: (.+)$/, function (m) {
      var n = +m[1]; return n + ' ' + plural(n, 'avistamento', 'avistamentos') + ' · último: ' + m[2];
    }],
    [/^Seen (.+) · (\d+)x spotted$/, function (m) {
      return 'Visto ' + dayPhrasePt(m[1]) + ' · ' + m[2] + 'x avistado';
    }],
    [/^Animal #(\d+)$/, function (m) { return 'Animal nº' + m[1]; }],
    [/^Dog #(\d+)$/, function (m) { return 'Cachorro nº' + m[1]; }],
    [/^Cat #(\d+)$/, function (m) { return 'Gato nº' + m[1]; }],
    // "Dog Caramel" / "Cat White" — species + known color
    [/^(Dog|Cat) (.+)$/, function (m) {
      var col = m[2].toLowerCase();
      if (!COLORS[col]) return null;
      return ptSpecies(m[1].toLowerCase()) + ' ' + COLORS[col];
    }],
    // GPS coords with an English source label appended
    [/^(.+) · from photo$/, function (m) { return m[1] + ' · da foto'; }],
    // share text
    [/^Help this stray animal find a home! Track their location on PawMap: (.+)$/, function (m) {
      return 'Ajude este animal de rua a encontrar um lar! Acompanhe a localização no PawMap: ' + m[1];
    }]
  ];

  /* ── Core translation for a string ── */
  function translateString(text) {
    var key = text.trim();
    if (!key) return null;
    if (LANG === 'pt') {
      if (enToPt[key] !== undefined && enToPt[key] !== key) return rewrap(text, key, enToPt[key]);
      for (var i = 0; i < PATTERNS.length; i++) {
        var m = key.match(PATTERNS[i][0]);
        if (m) {
          var out = PATTERNS[i][1](m);
          if (out != null && out !== key) return rewrap(text, key, out);
        }
      }
      return null;
    } else { // EN: only fix hardcoded-PT source strings
      if (ptToEn[key] !== undefined && ptToEn[key] !== key) return rewrap(text, key, ptToEn[key]);
      return null;
    }
  }
  // preserve any leading/trailing whitespace of the original node text
  function rewrap(orig, trimmed, replacement) {
    var idx = orig.indexOf(trimmed);
    if (idx < 0) return replacement;
    return orig.slice(0, idx) + replacement + orig.slice(idx + trimmed.length);
  }

  /* ── DOM walking ── */
  var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1 };
  function shouldSkip(node) {
    var p = node.parentNode;
    while (p && p.nodeType === 1) {
      if (SKIP_TAGS[p.tagName]) return true;
      if (p.hasAttribute && p.hasAttribute('data-noi18n')) return true;
      if (p.classList && p.classList.contains('leaflet-container')) return true;
      p = p.parentNode;
    }
    return false;
  }

  var applying = false;
  function localizeTextNode(node) {
    if (!node.nodeValue) return;
    if (shouldSkip(node)) return;
    var out = translateString(node.nodeValue);
    if (out != null && out !== node.nodeValue) node.nodeValue = out;
  }

  var ATTRS = ['placeholder', 'title', 'aria-label'];
  function localizeAttrs(el) {
    if (!el || !el.getAttribute) return;
    if (el.hasAttribute('data-noi18n')) return;
    for (var i = 0; i < ATTRS.length; i++) {
      var v = el.getAttribute(ATTRS[i]);
      if (!v) continue;
      var out = translateString(v);
      if (out != null && out !== v) el.setAttribute(ATTRS[i], out);
    }
  }

  function walk(root) {
    if (root.nodeType === 3) { localizeTextNode(root); return; }
    if (root.nodeType !== 1) return;
    if (SKIP_TAGS[root.tagName]) return;
    if (root.classList && root.classList.contains('leaflet-container')) return;
    if (root.hasAttribute && root.hasAttribute('data-noi18n')) return;
    localizeAttrs(root);
    var tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
    var batch = [];
    var n;
    while ((n = tw.nextNode())) batch.push(n);
    batch.forEach(localizeTextNode);
    var els = root.querySelectorAll('[placeholder],[title],[aria-label]');
    for (var i = 0; i < els.length; i++) localizeAttrs(els[i]);
  }

  function run(fn) {
    if (applying) return fn();
    applying = true;
    try { fn(); } finally { applying = false; }
  }

  /* ── Initial sweep + observer ── */
  function initialSweep() {
    run(function () { walk(document.body); });
  }

  function startObserver() {
    var appEl = document.getElementById('app');
    if (!appEl) return;
    var obs = new MutationObserver(function (mutations) {
      if (applying) return;
      run(function () {
        for (var i = 0; i < mutations.length; i++) {
          var mu = mutations[i];
          if (mu.type === 'characterData') {
            localizeTextNode(mu.target);
          } else if (mu.type === 'attributes') {
            localizeAttrs(mu.target);
          } else {
            for (var j = 0; j < mu.addedNodes.length; j++) walk(mu.addedNodes[j]);
          }
        }
      });
    });
    obs.observe(appEl, {
      childList: true, subtree: true, characterData: true,
      attributes: true, attributeFilter: ATTRS
    });
  }

  /* ── Language toggle UI (the top-right three-dots button) ── */
  function buildToggle() {
    var header = document.getElementById('header');
    if (!header) return;
    var btns = header.querySelectorAll('.icon-btn');
    var optBtn = btns.length ? btns[btns.length - 1] : null; // "Options" (three-dots) button
    if (!optBtn) return;

    var menu = document.createElement('div');
    menu.id = 'lang-menu';
    menu.setAttribute('data-noi18n', '');
    menu.style.cssText =
      'position:absolute;top:52px;right:10px;background:#fff;border-radius:12px;' +
      'box-shadow:0 8px 28px rgba(0,0,0,.18);overflow:hidden;z-index:1200;display:none;' +
      'min-width:150px;font-family:inherit;border:1px solid rgba(0,0,0,.06);';
    function row(code, label) {
      var active = LANG === code;
      return '<button data-lang="' + code + '" style="display:flex;align-items:center;gap:8px;width:100%;' +
        'border:none;background:' + (active ? '#F1F8F3' : '#fff') + ';padding:11px 14px;font-size:14px;' +
        'color:#222;cursor:pointer;text-align:left;font-weight:' + (active ? '600' : '400') + ';">' +
        '<span style="width:16px;display:inline-block;color:#388C59;">' + (active ? '✓' : '') + '</span>' +
        label + '</button>';
    }
    menu.innerHTML = row('pt', 'Português') + row('en', 'English');
    var app = document.getElementById('app');
    (app || document.body).appendChild(menu);

    function close() { menu.style.display = 'none'; }
    optBtn.addEventListener('click', function (e) {
      e.stopPropagation();
      menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
    });
    document.addEventListener('click', function (e) {
      if (e.target.closest && e.target.closest('#lang-menu')) return;
      close();
    });
    menu.addEventListener('click', function (e) {
      var b = e.target.closest('[data-lang]');
      if (!b) return;
      var code = b.getAttribute('data-lang');
      if (code === LANG) { close(); return; }
      localStorage.setItem(LANG_KEY, code);
      location.reload();
    });
  }

  /* ── Boot ── */
  function boot() {
    buildToggle();
    initialSweep();
    startObserver();
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot);
  } else {
    boot();
  }
})();