diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Services/LocalizationService.cs b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Services/LocalizationService.cs
new file mode 100644
index 00000000..58a7fed7
--- /dev/null
+++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Services/LocalizationService.cs
@@ -0,0 +1,119 @@
+using EnvelopeGenerator.ReceiverUI.Web.Client.Api;
+
+namespace EnvelopeGenerator.ReceiverUI.Web.Client.Services;
+
+///
+/// Pulls all localized strings from the API once and exposes them
+/// via an indexer mimicking IStringLocalizer<Resource>["Key"]
+/// in the legacy MVC Web project.
+///
+/// Missing keys fall back to the key itself (matching the previous behavior
+/// where _localizer["X"].Value returned "X" if not found).
+///
+/// Components consume this service as a scoped dependency and may call
+/// in OnInitializedAsync.
+///
+public class LocalizationService
+{
+ private readonly ReceiverApiClient _api;
+ private readonly ILogger _logger;
+ private Dictionary _strings = new();
+ private Task? _loadTask;
+ private readonly object _gate = new();
+
+ public LocalizationService(ReceiverApiClient api, ILogger logger)
+ {
+ _api = api;
+ _logger = logger;
+ }
+
+ ///
+ /// Languages exposed in the footer's language switcher. Kept as a
+ /// small in-memory list so the receiver UI does not need an extra
+ /// API roundtrip just to populate a dropdown. Mirrors the cultures
+ /// configured server-side (de-DE, en-US, tr-TR).
+ ///
+ public static readonly IReadOnlyList<(string Code, string Native, string Flag)> SupportedLanguages =
+ new[]
+ {
+ ("de", "Deutsch", "fi-de"),
+ ("en", "English", "fi-gb"),
+ ("tr", "Türkçe", "fi-tr"),
+ };
+
+ /// Currently active language code (best effort, set after a switch).
+ public string? CurrentLanguage { get; private set; }
+
+ /// Fires whenever the language changes and strings are reloaded.
+ public event Action? Changed;
+
+ ///
+ /// Get a localized string by key. Returns the key itself if not found
+ /// (compatible with the legacy _localizer["..."].Value behavior).
+ ///
+ public string this[string key]
+ {
+ get => _strings.TryGetValue(key, out var v) ? v : key;
+ }
+
+ ///
+ /// Format a localized template with positional arguments (e.g. "{0}", "{1}").
+ ///
+ public string Format(string key, params object?[] args)
+ {
+ var template = this[key];
+ try
+ {
+ return string.Format(template, args);
+ }
+ catch (FormatException)
+ {
+ return template;
+ }
+ }
+
+ public IReadOnlyDictionary All => _strings;
+
+ ///
+ /// Loads localization strings from the API. Safe to call multiple times;
+ /// concurrent callers share the same in-flight request.
+ ///
+ public Task EnsureLoadedAsync(CancellationToken ct = default)
+ {
+ lock (_gate)
+ {
+ return _loadTask ??= LoadCoreAsync(ct);
+ }
+ }
+
+ /// Forces a reload (e.g. after a language change).
+ public Task ReloadAsync(CancellationToken ct = default)
+ {
+ lock (_gate)
+ {
+ _loadTask = LoadCoreAsync(ct);
+ return _loadTask;
+ }
+ }
+
+ private async Task LoadCoreAsync(CancellationToken ct)
+ {
+ // Refresh the current language alongside the strings so the
+ // footer dropdown reflects the cookie value picked up by the API.
+ var langTask = _api.GetLanguageAsync(ct);
+ var dict = await _api.GetLocalizationStringsAsync(ct);
+ if (dict is not null)
+ _strings = new Dictionary(dict, StringComparer.OrdinalIgnoreCase);
+ else
+ _logger.LogWarning("Localization strings could not be loaded; falling back to keys.");
+ CurrentLanguage = await langTask ?? CurrentLanguage;
+ Changed?.Invoke();
+ }
+
+ public async Task ChangeLanguageAsync(string language, CancellationToken ct = default)
+ {
+ await _api.SetLanguageAsync(language, ct);
+ CurrentLanguage = language;
+ await ReloadAsync(ct);
+ }
+}