feat(map,i18n): single-copy world clamp + regional flags + pt-PT

- Map: min zoom = smallest integer zoom whose 256*2^z world covers the
  viewport (no grey bands, no duplicated continents). Horizontal wrap is
  restored (worldCopyJump, no longitude maxBounds) so Hawaii/Pacific are
  reachable by dragging; vertical panning stays clamped.
- Flags: real SVGs for Catalonia, Basque Country, Galicia and England
  (no Unicode region flags exist; English is not the UK). Portuguese now has
  Brasil (pt) and Portugal (pt-PT).
- pt-PT shares the pt dictionary with a European drift for the visible chrome
  (Guardar, Palavra-passe, Autocarro, Utilizador, ...).
- check-i18n covers pt-PT: 24 codes at 100%; tsc/tests/build green.
This commit is contained in:
SkateSpots 2026-10-26 16:09:21 +07:00
parent d4c6261b81
commit 7e48884eb9
15 changed files with 243 additions and 23 deletions

View file

@ -2697,14 +2697,27 @@ similar are internal endpoints/hook names, not credits — leave them.)
stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr, stays English (spot, park, bowl, DIY, vert, crew, line, Stash, The Lab, Nostr,
Lightning, Zap, Relay, sats) because Thai skaters use the English terms. Lightning, Zap, Relay, sats) because Thai skaters use the English terms.
### Catalan, Basque, Galician — 23 locales, all 100% (2026-10-26) ### Catalan, Basque, Galician + pt-PT — 23 dictionaries, 24 codes, all 100% (2026-10-26)
- Added **ca (Català), eu (Euskara), gl (Galego)** → **23 locales**. - Added **ca (Català), eu (Euskara), gl (Galego)** → **23 dictionaries**;
**pt-PT** is a variant that shares the `pt` dictionary (24 selectable codes).
- Registered in `Language` + `LANGUAGE_LOADERS`, `detectLanguage` (ca/eu/gl - Registered in `Language` + `LANGUAGE_LOADERS`, `detectLanguage` (ca/eu/gl
checked before `es`), the `LanguageToggle` list, and `dateFnsI18n` checked before `es`; `pt-BR`→`pt`, `pt-PT`→`pt-PT`), the `LanguageToggle`
(date-fns `ca`/`eu`/`gl`, codes `ca-ES`/`eu-ES`/`gl-ES`). list, and `dateFnsI18n` (date-fns `ca`/`eu`/`gl`/`pt`, codes
- `LanguageToggle` uses **text badges `CA` / `EU` / `GL`**: there are no Unicode `ca-ES`/`eu-ES`/`gl-ES`/`pt-BR`/`pt-PT`).
region flags for these languages, and a country flag would be wrong. The - **Flags are real SVGs** in `public/flags/` for `ca`, `eu`, `gl` and `gb-eng`
`CreateTourPage` language list already had ca/eu/gl for tour tags. (there are no Unicode region flags for Catalonia/Basque/Galicia, and England
is not the UK flag). `LanguageToggle` renders a path as `<img>` and an emoji
as text. **English now uses the England flag** (not the UK), and Portuguese
has **Brasil 🇧🇷** (`pt`) + **Portugal 🇵🇹** (`pt-PT`).
- **`pt-PT`** shares the `pt` base and applies a European drift
(`src/i18n/pt-PT/drift.ts`: Salvar→Guardar, Senha→Palavra-passe,
Ônibus→Autocarro, etc.). A full European-Portuguese review is a follow-up.
- **Map world clamp:** `SpotMap` uses `worldCopyJump: true` and
`applyWorldMinZoom()` (smallest integer zoom whose 256·2^z world covers the
viewport). That stops a second full copy of the continents and keeps the map
covering the surface (no grey bands), while still letting you drag across the
antimeridian to Hawaii/Pacific. On a wide desktop the whole globe cannot also
fit without distortion; the clamp prioritises coverage.
- Full dictionaries in `src/i18n/{ca,eu,gl}/` — 7 namespace batches each - Full dictionaries in `src/i18n/{ca,eu,gl}/` — 7 namespace batches each
(8,703 keys), composed in `index.ts`; placeholders verified, only 2–4% (8,703 keys), composed in `index.ts`; placeholders verified, only 2–4%
identical strings (brand/protocol names). `check-i18n.mjs` includes them in identical strings (brand/protocol names). `check-i18n.mjs` includes them in

9
public/flags/ca.svg Normal file
View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 36" preserveAspectRatio="none">
<rect width="60" height="36" fill="#fcdd09"/>
<g fill="#da121a">
<rect y="4" width="60" height="4"/>
<rect y="12" width="60" height="4"/>
<rect y="20" width="60" height="4"/>
<rect y="28" width="60" height="4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 334 B

5
public/flags/eu.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 36" preserveAspectRatio="none">
<rect width="60" height="36" fill="#d52b1e"/>
<path d="M0 0 L60 36 M60 0 L0 36" stroke="#009b48" stroke-width="7"/>
<path d="M30 0 V36 M0 18 H60" stroke="#fff" stroke-width="8"/>
</svg>

After

Width:  |  Height:  |  Size: 280 B

5
public/flags/gb-eng.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 36" preserveAspectRatio="none">
<rect width="60" height="36" fill="#fff"/>
<rect x="25" y="0" width="10" height="36" fill="#cf142b"/>
<rect x="0" y="13" width="60" height="10" fill="#cf142b"/>
</svg>

After

Width:  |  Height:  |  Size: 262 B

4
public/flags/gl.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 36" preserveAspectRatio="none">
<rect width="60" height="36" fill="#fff"/>
<path d="M0 0 L60 36" stroke="#0055a4" stroke-width="12"/>
</svg>

After

Width:  |  Height:  |  Size: 201 B

View file

@ -130,7 +130,7 @@ async function main() {
const jaCoverage = en.size ? Math.round(((en.size - missingJa) / en.size) * 100) : 100; const jaCoverage = en.size ? Math.round(((en.size - missingJa) / en.size) * 100) : 100;
// 5. Additional partial locales (same rules as th/ja). // 5. Additional partial locales (same rules as th/ja).
const extraLocales = ['pt', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko', 'zh-CN', 'zh-TW', 'uk', 'tr', 'vi', 'id', 'sv', 'cs', 'ro', 'el', 'ca', 'eu', 'gl']; const extraLocales = ['pt', 'pt-PT', 'fr', 'de', 'it', 'nl', 'pl', 'ru', 'ko', 'zh-CN', 'zh-TW', 'uk', 'tr', 'vi', 'id', 'sv', 'cs', 'ro', 'el', 'ca', 'eu', 'gl'];
const extraStats = []; const extraStats = [];
for (const lang of extraLocales) { for (const lang of extraLocales) {
const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`))); const dict = flatten(await loadTranslations(join(SRC, `i18n/${lang}/index.ts`)));

View file

@ -18,12 +18,13 @@ interface LanguageOption {
} }
const languages: LanguageOption[] = [ const languages: LanguageOption[] = [
{ code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧' }, { code: 'en', name: 'English', nativeName: 'English', flag: '/flags/gb-eng.svg' },
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' }, { code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸' },
{ code: 'ca', name: 'Catalan', nativeName: 'Català', flag: 'CA' }, { code: 'ca', name: 'Catalan', nativeName: 'Català', flag: '/flags/ca.svg' },
{ code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: 'EU' }, { code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: '/flags/eu.svg' },
{ code: 'gl', name: 'Galician', nativeName: 'Galego', flag: 'GL' }, { code: 'gl', name: 'Galician', nativeName: 'Galego', flag: '/flags/gl.svg' },
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷' }, { code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)', flag: '🇧🇷' },
{ code: 'pt-PT', name: 'Portuguese (Portugal)', nativeName: 'Português (Portugal)', flag: '🇵🇹' },
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' }, { code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪' }, { code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪' },
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹' }, { code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹' },
@ -62,7 +63,26 @@ interface LanguageToggleProps {
} }
/** /**
* Language toggle dropdown for switching between English and Spanish * Flags are an emoji or a local SVG. Catalonia, the Basque Country, Galicia and
* England have no Unicode emoji, so they ship as real SVG files and render as
* an image instead of a text badge.
*/
function Flag({ flag, className }: { flag: string; className?: string }) {
if (flag.startsWith('/')) {
return (
<img
src={flag}
alt=""
aria-hidden="true"
className={cn('h-4 w-6 shrink-0 rounded-[2px] object-cover shadow-sm', className)}
/>
);
}
return <span className={cn('text-lg leading-none', className)}>{flag}</span>;
}
/**
* Language toggle dropdown for switching between the available locales
*/ */
export function LanguageToggle({ compact = false, inline = false, className, variant = 'ghost' }: LanguageToggleProps) { export function LanguageToggle({ compact = false, inline = false, className, variant = 'ghost' }: LanguageToggleProps) {
const { language, setLanguage, t } = useTranslation(); const { language, setLanguage, t } = useTranslation();
@ -103,7 +123,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var
: 'text-muted-foreground hover:bg-muted hover:text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'
)} )}
> >
<span className="text-lg">{lang.flag}</span> <Flag flag={lang.flag} />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{lang.nativeName}</span> <span className="font-medium">{lang.nativeName}</span>
{lang.nativeName !== lang.name && ( {lang.nativeName !== lang.name && (
@ -136,7 +156,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var
className className
)} )}
> >
<span className="text-lg">{currentLanguage.flag}</span> <Flag flag={currentLanguage.flag} />
{!compact && ( {!compact && (
<> <>
<span className="hidden sm:inline">{currentLanguage.nativeName}</span> <span className="hidden sm:inline">{currentLanguage.nativeName}</span>
@ -168,7 +188,7 @@ export function LanguageToggle({ compact = false, inline = false, className, var
language === lang.code && 'bg-primary/10 text-primary' language === lang.code && 'bg-primary/10 text-primary'
)} )}
> >
<span className="text-lg">{lang.flag}</span> <Flag flag={lang.flag} />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{lang.nativeName}</span> <span className="font-medium">{lang.nativeName}</span>
{lang.nativeName !== lang.name && ( {lang.nativeName !== lang.name && (

View file

@ -602,6 +602,7 @@ export function RouteDialog({
attribution: '', attribution: '',
subdomains: 'abc', subdomains: 'abc',
maxZoom: 19, maxZoom: 19,
noWrap: true,
className: 'skatespots-dark-tiles', className: 'skatespots-dark-tiles',
}).addTo(map); }).addTo(map);

View file

@ -58,6 +58,7 @@ export function SpotLocationPicker({ value, onChange, className }: SpotLocationP
subdomains: 'abc', subdomains: 'abc',
minZoom: 2, minZoom: 2,
maxZoom: 19, maxZoom: 19,
noWrap: true,
className: 'skatespots-dark-tiles', className: 'skatespots-dark-tiles',
}).addTo(map); }).addTo(map);

View file

@ -21,6 +21,27 @@ import { useTranslation } from '@/contexts/language';
/** Translation function shape, so module-level popup builders can receive `t`. */ /** Translation function shape, so module-level popup builders can receive `t`. */
type Translate = (key: string, params?: Record<string, string | number>) => string; type Translate = (key: string, params?: Record<string, string | number>) => string;
/**
* The map never shows a *second full copy* of the world. `applyWorldMinZoom`
* clamps the minimum zoom so the 256·2^z world is at least as large as the
* viewport. The map can still wrap across the antimeridian (so Hawaii and the
* Pacific are reachable by dragging), but at that zoom you only ever see a
* sliver of the adjacent copy — never duplicated continents.
*/
function applyWorldMinZoom(map: L.Map): void {
// The world is a 256·2^z square. To cover the viewport (no grey bands, no
// repeated continents) pick the smallest integer zoom whose world is at
// least as large as the larger viewport dimension. Using the container's
// client size directly avoids the 0×0 size Leaflet reports before layout.
const el = map.getContainer();
const width = el.clientWidth || map.getSize().x;
const height = el.clientHeight || map.getSize().y;
if (!width || !height) return;
const min = Math.max(1, Math.ceil(Math.log2(Math.max(width, height) / 256)));
map.setMinZoom(min);
if (map.getZoom() < min) map.setZoom(min, { animate: false });
}
/** /**
* SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping. * SECURITY: Leaflet popups are built as raw DOM, outside React's auto-escaping.
* Every value that originates from a Nostr event (spot/tour/crew/event fields) * Every value that originates from a Nostr event (spot/tour/crew/event fields)
@ -921,13 +942,19 @@ export function SpotMap({
zoom, zoom,
scrollWheelZoom: true, scrollWheelZoom: true,
attributionControl: false, attributionControl: false,
// Prevent zooming out past a single world view (no grey space, no tile duplication) // Prevent zooming out past a single world view (no grey space, no tile
minZoom: 2, // duplication). The real limit is computed after mount by
// Clamp panning so the world fills the viewport — no grey bands above/below // `applyWorldMinZoom()` because it depends on the viewport size.
maxBounds: [[-90, -Infinity], [90, Infinity]], minZoom: 1,
// Clamp vertically (no grey above/below the poles) but let the map wrap
// horizontally at the antimeridian, so the Pacific is reachable.
maxBounds: [[-85, -Infinity], [85, Infinity]],
maxBoundsViscosity: 1.0, maxBoundsViscosity: 1.0,
worldCopyJump: true,
}); });
applyWorldMinZoom(map);
// Free, no-API-key basemap: OpenStreetMap standard tiles, darkened via a CSS // Free, no-API-key basemap: OpenStreetMap standard tiles, darkened via a CSS
// filter (see the `.skatespots-dark-tiles` rule in index.css) so it matches // filter (see the `.skatespots-dark-tiles` rule in index.css) so it matches
// the app's dark theme without depending on a keyed provider like CARTO. // the app's dark theme without depending on a keyed provider like CARTO.
@ -935,7 +962,7 @@ export function SpotMap({
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '', attribution: '',
subdomains: 'abc', subdomains: 'abc',
minZoom: 2, minZoom: 1,
maxZoom: 19, maxZoom: 19,
className: 'skatespots-dark-tiles', className: 'skatespots-dark-tiles',
}).addTo(map); }).addTo(map);
@ -997,6 +1024,7 @@ export function SpotMap({
// Fix map size after initialization and ensure tiles are loaded // Fix map size after initialization and ensure tiles are loaded
setTimeout(() => { setTimeout(() => {
map.invalidateSize(); map.invalidateSize();
applyWorldMinZoom(map);
// Force a redraw to ensure everything is ready // Force a redraw to ensure everything is ready
map.whenReady(() => { map.whenReady(() => {
// Trigger initial map move event // Trigger initial map move event
@ -1021,6 +1049,7 @@ export function SpotMap({
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
map.invalidateSize(); map.invalidateSize();
applyWorldMinZoom(map);
}); });
resizeObserver.observe(mapRef.current); resizeObserver.observe(mapRef.current);

View file

@ -28,6 +28,8 @@ function detectLanguage(): Language {
if (tags.some((t) => t.startsWith('th'))) return 'th'; if (tags.some((t) => t.startsWith('th'))) return 'th';
if (tags.some((t) => t.startsWith('ja'))) return 'ja'; if (tags.some((t) => t.startsWith('ja'))) return 'ja';
if (tags.some((t) => t.startsWith('ko'))) return 'ko'; if (tags.some((t) => t.startsWith('ko'))) return 'ko';
if (tags.some((t) => t.startsWith('pt-br'))) return 'pt';
if (tags.some((t) => t.startsWith('pt-pt'))) return 'pt-PT';
if (tags.some((t) => t.startsWith('pt'))) return 'pt'; if (tags.some((t) => t.startsWith('pt'))) return 'pt';
if (tags.some((t) => t.startsWith('fr'))) return 'fr'; if (tags.some((t) => t.startsWith('fr'))) return 'fr';
if (tags.some((t) => t.startsWith('de'))) return 'de'; if (tags.some((t) => t.startsWith('de'))) return 'de';

View file

@ -7,7 +7,7 @@ import en from '@/i18n/en';
* `react-refresh/only-export-components` rule. * `react-refresh/only-export-components` rule.
*/ */
export type Language = 'en' | 'es' | 'ca' | 'eu' | 'gl' | 'th' | 'ja' | 'pt' | 'fr' | 'de' | 'it' | 'nl' | 'pl' | 'ru' | 'ko' | 'ko-KP' | 'zh-CN' | 'zh-TW' | 'uk' | 'tr' | 'vi' | 'id' | 'sv' | 'cs' | 'ro' | 'el'; export type Language = 'en' | 'es' | 'ca' | 'eu' | 'gl' | 'th' | 'ja' | 'pt' | 'pt-PT' | 'fr' | 'de' | 'it' | 'nl' | 'pl' | 'ru' | 'ko' | 'ko-KP' | 'zh-CN' | 'zh-TW' | 'uk' | 'tr' | 'vi' | 'id' | 'sv' | 'cs' | 'ro' | 'el';
type TranslationKey = string; type TranslationKey = string;
type TranslationValue = string | { [key: string]: TranslationValue }; type TranslationValue = string | { [key: string]: TranslationValue };
@ -45,6 +45,9 @@ export const LANGUAGE_LOADERS: Record<Language, () => Promise<Translations>> = {
th: () => import('@/i18n/th').then((m) => m.default), th: () => import('@/i18n/th').then((m) => m.default),
ja: () => import('@/i18n/ja').then((m) => m.default), ja: () => import('@/i18n/ja').then((m) => m.default),
pt: () => import('@/i18n/pt').then((m) => m.default), pt: () => import('@/i18n/pt').then((m) => m.default),
// European Portuguese reuses the Brazilian dictionary as a base and applies
// a drift of European terms (see src/i18n/pt-PT/drift.ts).
'pt-PT': () => import('@/i18n/pt-PT').then((m) => m.default),
fr: () => import('@/i18n/fr').then((m) => m.default), fr: () => import('@/i18n/fr').then((m) => m.default),
de: () => import('@/i18n/de').then((m) => m.default), de: () => import('@/i18n/de').then((m) => m.default),
it: () => import('@/i18n/it').then((m) => m.default), it: () => import('@/i18n/it').then((m) => m.default),

111
src/i18n/pt-PT/drift.ts Normal file
View file

@ -0,0 +1,111 @@
/**
* European Portuguese overrides for the shared `pt` (Brazilian) dictionary.
* Keys must also exist in `en`.
*/
const drift = {
common: {
save: "Guardar",
},
chromeUi: {
logIn: "Iniciar sessão",
logOut: "Terminar sessão",
},
navSheet: {
login: "Iniciar sessão",
},
mapUi: {
saveChanges: "Guardar alterações",
youAreHere: "Está aqui",
youreOffline: "Está offline.",
relayOnlineNoAnswer: "Está online, mas o relay da comunidade não está a responder agora.",
mapFooter: {
links: "Ligações",
},
},
spotUi: {
saveChanges: "Guardar alterações",
targetType: {
user: "Utilizador",
},
loginToEditSpot: "Tem de iniciar sessão para editar este spot.",
logInToReport: "Tem de iniciar sessão para denunciar conteúdo.",
accessDeniedDesc: "Só pode editar spots que criou.",
areYouShopOwner: "É o dono da loja?",
claimBullet1: "Torna-se o dono e pode editar o spot",
claimIntro: "Está prestes a reivindicar a propriedade deste spot. Isto criará a sua própria versão do spot que pode editar e manter.",
claim: {
submittedDesc: "Um moderador vai rever a sua reivindicação. Assumirá o spot assim que for aprovada.",
},
spotClaimedDesc: "Agora é o dono deste spot. Pode editá-lo a partir do seu perfil.",
},
cardUi: {
save: "Guardar cartão no Nostr",
saveFrontPng: "Guardar frente (PNG)",
studio: {
saveChanges: "Guardar alterações",
},
verify: {
invalidLink: "Ligação inválida",
},
},
routeUi: {
saveSpotOne: "Guardar {{count}} spot numa Coleção",
saveSpots: "Guardar {{count}} spots numa Coleção",
group: {
bus: "Autocarro",
train: "Comboio",
},
arrived: "Chegou. 🛹",
voiceArrived: "Chegou",
},
pwa: {
iosStep2: "Role e toque em \"Adicionar ao Ecrã Principal\"",
},
sasquatchUi: {
mustBeLoggedIn: "Tem de iniciar sessão.",
proSupporter: "É um apoiador Pro ⚡",
},
settingsUi: {
loginArea: {
logIn: "Iniciar sessão",
},
accountSwitcher: {
logout: "Terminar sessão",
},
loginDialog: {
login: "Iniciar sessão",
loginWithAmber: "Iniciar sessão com Amber (Android)",
loginWithExtension: "Iniciar sessão com extensão",
password: "Palavra-passe",
},
signupDialog: {
download: "Transferir",
stepSaveKey: "Guardar chave",
saveAndStart: "Guardar e começar a andar",
saveEncBackup: "Guardar uma cópia de segurança encriptada",
saveEncBackupCta: "Guardar cópia de segurança encriptada",
encBackupBefore: "Uma cópia protegida por palavra-passe — segura para guardar na nuvem ou num gestor de palavras-passe. Vai precisar do ficheiro",
fileTooLargeTitle: "Ficheiro demasiado grande",
nameRequiredDesc: "Escolha um nome de apresentação para os outros skaters saberem quem é. Pode alterá-lo quando quiser.",
changeAnytime: "Pode alterá-lo quando quiser. Bio e avatar são opcionais.",
youreInPickName: "Entrou! 🛹 Escolha um nome de apresentação para os skaters saberem quem é",
youreIn: "Entrou! 🛹",
reserveFailedDesc: "{{error}} Pode reivindicar um mais tarde nas Definições.",
profileFailedDesc: "A sua conta foi criada, mas a configuração do perfil falhou. Pode atualizá-lo mais tarde.",
},
editProfile: {
saveProfile: "Guardar perfil",
invalidCordnTitle: "Ligação do Cordn inválida",
mustBeLoggedIn: "Tem de iniciar sessão para atualizar o seu perfil.",
},
mnemonic: {
download: "Transferir",
twelveWordDesc: "A sua frase de recuperação é como uma palavra-passe mestra. Anote no papel e guarde-a bem. Vai precisar dela para aceder à sua conta noutros dispositivos ou se limpar os dados do navegador.",
},
security: {
recoveryCodeDesc: "Não guardou quando se registou? Pode obtê-lo aqui quando quiser.",
},
},
};
export default drift;

13
src/i18n/pt-PT/index.ts Normal file
View file

@ -0,0 +1,13 @@
import pt from '../pt';
import drift from './drift';
import { deepMerge } from '../deepMerge';
/**
* European Portuguese. The full dictionary is shared with `pt` (Brazilian);
* `drift` rewrites the visible Brazilian vocabulary (Salvar→Guardar,
* Celular→Telemóvel, Ônibus→Autocarro, Senha→Palavra-passe, etc.) to European
* Portuguese. Missing/unchanged keys fall back to the shared base.
*/
const ptPT = deepMerge(pt, drift);
export default ptPT;

View file

@ -28,6 +28,7 @@ import {
th as thLocale, th as thLocale,
ja as jaLocale, ja as jaLocale,
ptBR as ptLocale, ptBR as ptLocale,
pt as ptPTLocale,
fr as frLocale, fr as frLocale,
de as deLocale, de as deLocale,
it as itLocale, it as itLocale,
@ -75,6 +76,9 @@ export function setDateLocale(language: Language): void {
} else if (language === 'pt') { } else if (language === 'pt') {
activeLocale = ptLocale; activeLocale = ptLocale;
activeCode = 'pt-BR'; activeCode = 'pt-BR';
} else if (language === 'pt-PT') {
activeLocale = ptPTLocale;
activeCode = 'pt-PT';
} else if (language === 'fr') { } else if (language === 'fr') {
activeLocale = frLocale; activeLocale = frLocale;
activeCode = 'fr-FR'; activeCode = 'fr-FR';