diff --git a/npm/ng-packs/apps/dev-app/src/app/localization-test/localization-test.component.ts b/npm/ng-packs/apps/dev-app/src/app/localization-test/localization-test.component.ts
new file mode 100644
index 0000000000..187f6d1e37
--- /dev/null
+++ b/npm/ng-packs/apps/dev-app/src/app/localization-test/localization-test.component.ts
@@ -0,0 +1,58 @@
+import { Component, inject, OnInit } from '@angular/core';
+import { LocalizationPipe, UILocalizationService, SessionStateService } from '@abp/ng.core';
+import { CommonModule } from '@angular/common';
+import { CardComponent, CardBodyComponent } from '@abp/ng.theme.shared';
+import { AsyncPipe } from '@angular/common';
+
+@Component({
+ selector: 'app-localization-test',
+ standalone: true,
+ imports: [CommonModule, LocalizationPipe, CardComponent, CardBodyComponent, AsyncPipe],
+ template: `
+
+
Hybrid Localization Test
+
+
+
+ Backend Localization (if available)
+ MyProjectName::Welcome: {{ 'MyProjectName::Welcome' | abpLocalization }}
+ AbpAccount::Login: {{ 'AbpAccount::Login' | abpLocalization }}
+
+
+
+
+
+ UI Localization (from /assets/localization/{{ currentLanguage$ | async }}.json)
+ MyProjectName::CustomKey: {{ 'MyProjectName::CustomKey' | abpLocalization }}
+ MyProjectName::TestMessage: {{ 'MyProjectName::TestMessage' | abpLocalization }}
+
+
+
+
+
+ UI Override (UI > Backend Priority)
+ AbpAccount::Login: {{ 'AbpAccount::Login' | abpLocalization }}
+ If backend has "Login", UI version should override it
+
+
+
+
+
+ Loaded UI Localizations
+ {{ loadedLocalizations | json }}
+
+
+
+ `,
+})
+export class LocalizationTestComponent implements OnInit {
+ private uiLocalizationService = inject(UILocalizationService);
+ private sessionState = inject(SessionStateService);
+
+ loadedLocalizations: any = {};
+ currentLanguage$ = this.sessionState.getLanguage$();
+
+ ngOnInit() {
+ this.loadedLocalizations = this.uiLocalizationService.getLoadedLocalizations();
+ }
+}
diff --git a/npm/ng-packs/apps/dev-app/src/assets/localization/en.json b/npm/ng-packs/apps/dev-app/src/assets/localization/en.json
new file mode 100644
index 0000000000..579709ebd0
--- /dev/null
+++ b/npm/ng-packs/apps/dev-app/src/assets/localization/en.json
@@ -0,0 +1,10 @@
+{
+ "MyProjectName": {
+ "Welcome": "Welcome from UI (en.json)",
+ "CustomKey": "This is a UI-only localization",
+ "TestMessage": "UI localization is working!"
+ },
+ "AbpAccount": {
+ "Login": "Sign In (UI Override)"
+ }
+}
diff --git a/npm/ng-packs/apps/dev-app/src/assets/localization/tr.json b/npm/ng-packs/apps/dev-app/src/assets/localization/tr.json
new file mode 100644
index 0000000000..1d565a14d2
--- /dev/null
+++ b/npm/ng-packs/apps/dev-app/src/assets/localization/tr.json
@@ -0,0 +1,10 @@
+{
+ "MyProjectName": {
+ "Welcome": "UI'dan Hoş Geldiniz (tr.json)",
+ "CustomKey": "Bu sadece UI'da olan bir localization",
+ "TestMessage": "UI localization çalışıyor!"
+ },
+ "AbpAccount": {
+ "Login": "Giriş Yap (UI Override)"
+ }
+}
diff --git a/npm/ng-packs/packages/core/src/lib/models/common.ts b/npm/ng-packs/packages/core/src/lib/models/common.ts
index 704fde958a..1506dc3a8b 100644
--- a/npm/ng-packs/packages/core/src/lib/models/common.ts
+++ b/npm/ng-packs/packages/core/src/lib/models/common.ts
@@ -16,6 +16,26 @@ export namespace ABP {
othersGroup?: string;
dynamicLayouts?: Map
;
disableProjectNameInTitle?: boolean;
+ uiLocalization?: UILocalizationOptions;
+ }
+
+ export interface UILocalizationOptions {
+ /**
+ * Enable UI localization feature
+ * When enabled, localization files are automatically loaded based on selected language
+ * Files should be located at: {basePath}/{culture}.json
+ * Example: /assets/localization/en.json
+ * JSON format: { "ResourceName": { "Key": "Value" } }
+ * Merges with backend localizations (UI > Backend priority)
+ */
+ enabled?: boolean;
+ /**
+ * Base path for localization JSON files
+ * Default: '/assets/localization'
+ * Files should be located at: {basePath}/{culture}.json
+ * Example: /assets/localization/en.json
+ */
+ basePath?: string;
}
export interface Child {
diff --git a/npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts b/npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts
index 6d46caedeb..08f1beb706 100644
--- a/npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts
+++ b/npm/ng-packs/packages/core/src/lib/providers/core-module-config.provider.ts
@@ -23,7 +23,12 @@ import { RoutesHandler } from '../handlers';
import { ABP, SortableItem } from '../models';
import { AuthErrorFilterService } from '../abstracts';
import { DEFAULT_DYNAMIC_LAYOUTS } from '../constants';
-import { LocalizationService, LocalStorageListenerService, AbpTitleStrategy } from '../services';
+import {
+ LocalizationService,
+ LocalStorageListenerService,
+ AbpTitleStrategy,
+ UILocalizationService,
+} from '../services';
import { DefaultQueueManager, getInitialData } from '../utils';
import { CookieLanguageProvider, IncludeLocalizationResourcesProvider, LocaleProvider } from './';
import { timezoneInterceptor, transferStateInterceptor } from '../interceptors';
@@ -113,6 +118,11 @@ export function provideAbpCore(...features: CoreFeature[]) {
inject(LocalizationService);
inject(LocalStorageListenerService);
inject(RoutesHandler);
+ // Initialize UILocalizationService if UI-only mode is enabled
+ const options = inject(CORE_OPTIONS);
+ if (options?.uiLocalization?.enabled) {
+ inject(UILocalizationService);
+ }
await getInitialData();
}),
LocaleProvider,
diff --git a/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/abp-application-configuration.service.ts b/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/abp-application-configuration.service.ts
index 9dc35fa7b6..4e1b115b7c 100644
--- a/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/abp-application-configuration.service.ts
+++ b/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/abp-application-configuration.service.ts
@@ -6,8 +6,8 @@ import { Injectable, inject } from '@angular/core';
@Injectable({
providedIn: 'root',
})
-export class AbpApplicationConfigurationService {
- private restService = inject(RestService);
+export class AbpApplicationConfigurationService {
+ private restService = inject(RestService);
apiName = 'abp';
diff --git a/npm/ng-packs/packages/core/src/lib/services/index.ts b/npm/ng-packs/packages/core/src/lib/services/index.ts
index 61fd1ef480..205fd2f0dc 100644
--- a/npm/ng-packs/packages/core/src/lib/services/index.ts
+++ b/npm/ng-packs/packages/core/src/lib/services/index.ts
@@ -8,6 +8,7 @@ export * from './http-wait.service';
export * from './lazy-load.service';
export * from './list.service';
export * from './localization.service';
+export * from './ui-localization.service';
export * from './multi-tenancy.service';
export * from './permission.service';
export * from './replaceable-components.service';
diff --git a/npm/ng-packs/packages/core/src/lib/services/localization.service.ts b/npm/ng-packs/packages/core/src/lib/services/localization.service.ts
index d91679effb..0e27973a90 100644
--- a/npm/ng-packs/packages/core/src/lib/services/localization.service.ts
+++ b/npm/ng-packs/packages/core/src/lib/services/localization.service.ts
@@ -60,6 +60,8 @@ export class LocalizationService {
private initLocalizationValues() {
localizations$.subscribe(val => this.addLocalization(val));
+ // Backend-based localization loading (always enabled)
+ // UI localizations are merged via addLocalization() (UI > Backend priority)
const legacyResources$ = this.configState.getDeep$('localization.values') as Observable<
Record>
>;
@@ -90,7 +92,8 @@ export class LocalizationService {
const resourceName = entry[0];
const remoteTexts = entry[1];
let resource = local?.get(resourceName) || {};
- resource = { ...resource, ...remoteTexts };
+ // UI > Backend priority: local texts override remote texts
+ resource = { ...remoteTexts, ...resource };
local?.set(resourceName, resource);
});
diff --git a/npm/ng-packs/packages/core/src/lib/services/ui-localization.service.ts b/npm/ng-packs/packages/core/src/lib/services/ui-localization.service.ts
new file mode 100644
index 0000000000..3bbbfa71aa
--- /dev/null
+++ b/npm/ng-packs/packages/core/src/lib/services/ui-localization.service.ts
@@ -0,0 +1,119 @@
+import { Injectable, inject } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { BehaviorSubject, distinctUntilChanged, switchMap, of } from 'rxjs';
+import { catchError, shareReplay, tap } from 'rxjs/operators';
+import { ABP } from '../models/common';
+import { LocalizationService } from './localization.service';
+import { SessionStateService } from './session-state.service';
+import { CORE_OPTIONS } from '../tokens/options.token';
+
+export interface UILocalizationResource {
+ [resourceName: string]: Record;
+}
+
+/**
+ * Service for managing UI localizations in ABP Angular applications.
+ * Automatically loads localization files based on selected language
+ * Merges with backend localizations (UI > Backend priority)
+ */
+@Injectable({ providedIn: 'root' })
+export class UILocalizationService {
+ private http = inject(HttpClient);
+ private localizationService = inject(LocalizationService);
+ private sessionState = inject(SessionStateService);
+ private options = inject(CORE_OPTIONS);
+
+ private loadedLocalizations$ = new BehaviorSubject>({});
+
+ private currentLanguage$ = this.sessionState.getLanguage$();
+
+ constructor() {
+ const uiLocalization = this.options.uiLocalization;
+ if (uiLocalization?.enabled) {
+ this.subscribeToLanguageChanges();
+ }
+ }
+
+ private subscribeToLanguageChanges() {
+ this.currentLanguage$
+ .pipe(
+ distinctUntilChanged(),
+ switchMap(culture => this.loadLocalizationFile(culture))
+ )
+ .subscribe();
+ }
+
+ private loadLocalizationFile(culture: string) {
+ const config = this.options.uiLocalization;
+ if (!config?.enabled) return of(null);
+
+ const basePath = config.basePath || '/assets/localization';
+ const url = `${basePath}/${culture}.json`;
+
+ return this.http.get(url).pipe(
+ catchError(() => {
+ // If file not found or error occurs, return null
+ return of(null);
+ }),
+ tap(data => {
+ if (data) {
+ this.processLocalizationData(culture, data);
+ }
+ }),
+ );
+ }
+
+ private processLocalizationData(culture: string, data: UILocalizationResource) {
+ const abpFormat: ABP.Localization[] = [
+ {
+ culture,
+ resources: Object.entries(data).map(([resourceName, texts]) => ({
+ resourceName,
+ texts,
+ })),
+ },
+ ];
+ this.localizationService.addLocalization(abpFormat);
+
+ const current = this.loadedLocalizations$.value;
+ current[culture] = data;
+ this.loadedLocalizations$.next(current);
+ }
+
+ addAngularLocalizeLocalization(
+ culture: string,
+ resourceName: string,
+ translations: Record,
+ ): void {
+ const abpFormat: ABP.Localization[] = [
+ {
+ culture,
+ resources: [
+ {
+ resourceName,
+ texts: translations,
+ },
+ ],
+ },
+ ];
+ this.localizationService.addLocalization(abpFormat);
+
+ const current = this.loadedLocalizations$.value;
+ if (!current[culture]) {
+ current[culture] = {};
+ }
+ if (!current[culture][resourceName]) {
+ current[culture][resourceName] = {};
+ }
+ current[culture][resourceName] = {
+ ...current[culture][resourceName],
+ ...translations,
+ };
+ this.loadedLocalizations$.next(current);
+ }
+
+ getLoadedLocalizations(culture?: string): UILocalizationResource {
+ const lang = culture || this.sessionState.getLanguage();
+ return this.loadedLocalizations$.value[lang] || {};
+ }
+}
diff --git a/npm/ng-packs/packages/core/src/lib/tests/ui-localization.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/ui-localization.service.spec.ts
new file mode 100644
index 0000000000..abb15217c1
--- /dev/null
+++ b/npm/ng-packs/packages/core/src/lib/tests/ui-localization.service.spec.ts
@@ -0,0 +1,160 @@
+import { createServiceFactory, SpectatorService } from '@ngneat/spectator/vitest';
+import { of, Subject, throwError } from 'rxjs';
+import { HttpClient } from '@angular/common/http';
+import { UILocalizationService } from '../services/ui-localization.service';
+import { LocalizationService } from '../services/localization.service';
+import { SessionStateService } from '../services/session-state.service';
+import { CORE_OPTIONS } from '../tokens/options.token';
+
+describe('UILocalizationService', () => {
+ let spectator: SpectatorService;
+ let service: UILocalizationService;
+ let language$: Subject;
+ let httpGet: ReturnType;
+ let addLocalizationSpy: ReturnType;
+
+ const createService = createServiceFactory({
+ service: UILocalizationService,
+ mocks: [HttpClient, LocalizationService],
+ providers: [
+ {
+ provide: SessionStateService,
+ useFactory: () => {
+ let currentLanguage = 'en';
+ language$ = new Subject();
+ language$.subscribe(lang => {
+ currentLanguage = lang;
+ });
+ return {
+ getLanguage: vi.fn(() => currentLanguage),
+ getLanguage$: vi.fn(() => language$.asObservable()),
+ };
+ },
+ },
+ {
+ provide: CORE_OPTIONS,
+ useValue: {
+ uiLocalization: {
+ enabled: true,
+ basePath: '/assets/localization',
+ },
+ },
+ },
+ ],
+ });
+
+ beforeEach(() => {
+ spectator = createService();
+ service = spectator.service;
+ const http = spectator.inject(HttpClient);
+ const localizationService = spectator.inject(LocalizationService);
+ httpGet = vi.fn();
+ (http as any).get = httpGet;
+ addLocalizationSpy = vi.fn();
+ (localizationService as any).addLocalization = addLocalizationSpy;
+ });
+
+ describe('when uiLocalization is enabled', () => {
+ it('should load localization file when language changes', () => {
+ const uiData = { MyApp: { Welcome: 'Welcome from UI' } };
+ httpGet.mockReturnValue(of(uiData));
+
+ language$.next('en');
+
+ expect(httpGet).toHaveBeenCalledWith('/assets/localization/en.json');
+ expect(addLocalizationSpy).toHaveBeenCalledWith([
+ {
+ culture: 'en',
+ resources: [{ resourceName: 'MyApp', texts: { Welcome: 'Welcome from UI' } }],
+ },
+ ]);
+ });
+
+ it('should use default basePath when not provided', () => {
+ expect(httpGet).not.toHaveBeenCalled();
+ httpGet.mockReturnValue(of({}));
+ language$.next('en');
+ expect(httpGet).toHaveBeenCalledWith('/assets/localization/en.json');
+ });
+
+ it('should not call addLocalization when file is missing (HTTP error)', () => {
+ httpGet.mockReturnValue(throwError(() => new Error('404')));
+
+ language$.next('fr');
+
+ expect(httpGet).toHaveBeenCalledWith('/assets/localization/fr.json');
+ expect(addLocalizationSpy).not.toHaveBeenCalled();
+ });
+
+ it('should cache loaded data in getLoadedLocalizations', () => {
+ const uiData = { AbpAccount: { Login: 'Sign In (UI)' } };
+ httpGet.mockReturnValue(of(uiData));
+
+ language$.next('en');
+
+ const loaded = service.getLoadedLocalizations('en');
+ expect(loaded).toEqual(uiData);
+ });
+
+ it('should load again when language changes to another culture', () => {
+ httpGet.mockReturnValue(of({}));
+ language$.next('en');
+ expect(httpGet).toHaveBeenCalledTimes(1);
+
+ httpGet.mockClear();
+ httpGet.mockReturnValue(of({ MyApp: { Title: 'Titre' } }));
+ language$.next('fr');
+
+ expect(httpGet).toHaveBeenCalledWith('/assets/localization/fr.json');
+ expect(addLocalizationSpy).toHaveBeenCalledWith([
+ {
+ culture: 'fr',
+ resources: [{ resourceName: 'MyApp', texts: { Title: 'Titre' } }],
+ },
+ ]);
+ });
+ });
+
+ describe('addAngularLocalizeLocalization', () => {
+ it('should add localization via LocalizationService (UI data in merge pipeline)', () => {
+ service.addAngularLocalizeLocalization('en', 'MyApp', {
+ CustomKey: 'UI Override',
+ });
+
+ expect(addLocalizationSpy).toHaveBeenCalledWith([
+ {
+ culture: 'en',
+ resources: [
+ {
+ resourceName: 'MyApp',
+ texts: { CustomKey: 'UI Override' },
+ },
+ ],
+ },
+ ]);
+ });
+
+ it('should merge into getLoadedLocalizations cache', () => {
+ service.addAngularLocalizeLocalization('en', 'MyApp', { Key1: 'Val1' });
+ service.addAngularLocalizeLocalization('en', 'MyApp', { Key2: 'Val2' });
+
+ const loaded = service.getLoadedLocalizations('en');
+ expect(loaded.MyApp).toEqual({ Key1: 'Val1', Key2: 'Val2' });
+ });
+ });
+
+ describe('getLoadedLocalizations', () => {
+ it('should return empty object when no culture loaded', () => {
+ expect(service.getLoadedLocalizations('de')).toEqual({});
+ });
+
+ it('should return current language when culture not passed', () => {
+ const uiData = { R: { K: 'V' } };
+ httpGet.mockReturnValue(of(uiData));
+ language$.next('tr');
+
+ const loaded = service.getLoadedLocalizations();
+ expect(loaded).toEqual(uiData);
+ });
+ });
+});