@open-mercato/core 0.7.1-develop.7181.1.702cedc42c → 0.7.1-develop.7183.1.db9678eeb8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/locale/route.js +18 -9
- package/dist/modules/auth/api/locale/route.js.map +2 -2
- package/dist/modules/translations/api/get/locales.js +7 -2
- package/dist/modules/translations/api/get/locales.js.map +2 -2
- package/dist/modules/translations/components/TranslationManager.js +70 -35
- package/dist/modules/translations/components/TranslationManager.js.map +2 -2
- package/dist/modules/translations/di.js +3 -0
- package/dist/modules/translations/di.js.map +2 -2
- package/dist/modules/translations/lib/supported-locales.js +31 -0
- package/dist/modules/translations/lib/supported-locales.js.map +7 -0
- package/dist/modules/wms/api/warehouseSearch.js +2 -2
- package/dist/modules/wms/api/warehouseSearch.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/locale/route.ts +41 -9
- package/src/modules/translations/api/get/locales.ts +11 -1
- package/src/modules/translations/components/TranslationManager.tsx +103 -34
- package/src/modules/translations/di.ts +10 -0
- package/src/modules/translations/i18n/de.json +4 -1
- package/src/modules/translations/i18n/en.json +4 -1
- package/src/modules/translations/i18n/es.json +4 -1
- package/src/modules/translations/i18n/ko.json +4 -1
- package/src/modules/translations/i18n/pl.json +4 -1
- package/src/modules/translations/lib/supported-locales.ts +44 -0
- package/src/modules/wms/api/warehouseSearch.ts +6 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/translations/di.ts"],
|
|
4
|
-
"sourcesContent": ["import { registerTranslatableFields } from '@open-mercato/shared/lib/localization/translatable-fields'\nimport { registerTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'\nimport { translatableFields as catalogFields } from '../catalog/translations'\nimport { translatableFields as dictionaryFields } from '../dictionaries/translations'\nimport { translatableFields as entitiesFields } from '../entities/translations'\nimport { translatableFields as resourcesFields } from '../resources/translations'\nimport { applyTranslationOverlays } from './lib/apply'\nimport { resolveLocaleFromRequest } from './lib/locale'\n\nexport function register() {\n registerTranslatableFields(catalogFields)\n registerTranslatableFields(dictionaryFields)\n registerTranslatableFields(entitiesFields)\n registerTranslatableFields(resourcesFields)\n registerTranslationOverlayPlugin(applyTranslationOverlays, resolveLocaleFromRequest)\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,kCAAkC;AAC3C,SAAS,wCAAwC;AACjD,SAAS,sBAAsB,qBAAqB;AACpD,SAAS,sBAAsB,wBAAwB;AACvD,SAAS,sBAAsB,sBAAsB;AACrD,SAAS,sBAAsB,uBAAuB;AACtD,SAAS,gCAAgC;AACzC,SAAS,gCAAgC;
|
|
4
|
+
"sourcesContent": ["import { registerTranslatableFields } from '@open-mercato/shared/lib/localization/translatable-fields'\nimport { registerTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'\nimport { registerSupportedLocalesResolver } from '@open-mercato/shared/lib/i18n/locale-registry'\nimport { translatableFields as catalogFields } from '../catalog/translations'\nimport { translatableFields as dictionaryFields } from '../dictionaries/translations'\nimport { translatableFields as entitiesFields } from '../entities/translations'\nimport { translatableFields as resourcesFields } from '../resources/translations'\nimport { applyTranslationOverlays } from './lib/apply'\nimport { resolveLocaleFromRequest } from './lib/locale'\nimport { resolveTenantSupportedLocales } from './lib/supported-locales'\n\n// Registered at module scope, not inside `register()`: module DI registrars only\n// run when the first request container is built, and the root layout resolves the\n// served locale set without ever building one. Inside `register()` the very first\n// render of a fresh process would find an empty resolver slot and serve the\n// un-narrowed set. `di.generated.ts` imports this module statically from the app\n// bootstrap, so the slot is filled at import time instead.\nregisterSupportedLocalesResolver(resolveTenantSupportedLocales)\n\nexport function register() {\n registerTranslatableFields(catalogFields)\n registerTranslatableFields(dictionaryFields)\n registerTranslatableFields(entitiesFields)\n registerTranslatableFields(resourcesFields)\n registerTranslationOverlayPlugin(applyTranslationOverlays, resolveLocaleFromRequest)\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,kCAAkC;AAC3C,SAAS,wCAAwC;AACjD,SAAS,wCAAwC;AACjD,SAAS,sBAAsB,qBAAqB;AACpD,SAAS,sBAAsB,wBAAwB;AACvD,SAAS,sBAAsB,sBAAsB;AACrD,SAAS,sBAAsB,uBAAuB;AACtD,SAAS,gCAAgC;AACzC,SAAS,gCAAgC;AACzC,SAAS,qCAAqC;AAQ9C,iCAAiC,6BAA6B;AAEvD,SAAS,WAAW;AACzB,6BAA2B,aAAa;AACxC,6BAA2B,gBAAgB;AAC3C,6BAA2B,cAAc;AACzC,6BAA2B,eAAe;AAC1C,mCAAiC,0BAA0B,wBAAwB;AACrF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
2
|
+
import { getAuthFromCookies } from "@open-mercato/shared/lib/auth/server";
|
|
3
|
+
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
4
|
+
const logger = createLogger("translations").child({ component: "supported-locales" });
|
|
5
|
+
const SUPPORTED_LOCALES_CONFIG_MODULE = "translations";
|
|
6
|
+
const SUPPORTED_LOCALES_CONFIG_NAME = "supported_locales";
|
|
7
|
+
async function resolveTenantSupportedLocales() {
|
|
8
|
+
try {
|
|
9
|
+
const auth = await getAuthFromCookies();
|
|
10
|
+
if (!auth?.tenantId) return null;
|
|
11
|
+
const container = await createRequestContainer();
|
|
12
|
+
const configService = container.resolve("moduleConfigService");
|
|
13
|
+
const configured = await configService.getValue(
|
|
14
|
+
SUPPORTED_LOCALES_CONFIG_MODULE,
|
|
15
|
+
SUPPORTED_LOCALES_CONFIG_NAME,
|
|
16
|
+
{ defaultValue: null, scope: { tenantId: auth.tenantId } }
|
|
17
|
+
);
|
|
18
|
+
if (!Array.isArray(configured)) return null;
|
|
19
|
+
const codes = configured.filter((code) => typeof code === "string" && code.length > 0);
|
|
20
|
+
return codes.length > 0 ? codes : null;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
logger.debug("Could not resolve tenant supported locales", { err });
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
SUPPORTED_LOCALES_CONFIG_MODULE,
|
|
28
|
+
SUPPORTED_LOCALES_CONFIG_NAME,
|
|
29
|
+
resolveTenantSupportedLocales
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=supported-locales.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/translations/lib/supported-locales.ts"],
|
|
4
|
+
"sourcesContent": ["import { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromCookies } from '@open-mercato/shared/lib/auth/server'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'\n\nconst logger = createLogger('translations').child({ component: 'supported-locales' })\n\nexport const SUPPORTED_LOCALES_CONFIG_MODULE = 'translations'\nexport const SUPPORTED_LOCALES_CONFIG_NAME = 'supported_locales'\n\n/**\n * The locale codes the signed-in user's tenant has opted into, as managed on\n * Settings \u2192 Module Configs \u2192 Translations, or `null` when there is no tenant\n * context or the tenant has never saved a selection.\n *\n * `null` means \"no opinion\" and leaves the served set untouched \u2014 it is not the\n * same as an empty selection. Reads go through `ModuleConfigService`, which\n * caches for 60s and invalidates on write, so this stays cheap enough to call\n * once per page render.\n */\nexport async function resolveTenantSupportedLocales(): Promise<readonly string[] | null> {\n try {\n const auth = await getAuthFromCookies()\n if (!auth?.tenantId) return null\n\n const container = await createRequestContainer()\n const configService = container.resolve('moduleConfigService') as ModuleConfigService\n const configured = await configService.getValue<string[]>(\n SUPPORTED_LOCALES_CONFIG_MODULE,\n SUPPORTED_LOCALES_CONFIG_NAME,\n { defaultValue: null, scope: { tenantId: auth.tenantId } },\n )\n\n if (!Array.isArray(configured)) return null\n const codes = configured.filter((code): code is string => typeof code === 'string' && code.length > 0)\n return codes.length > 0 ? codes : null\n } catch (err) {\n // Anonymous requests, a container that cannot be built yet during boot, or a\n // database that is briefly unavailable must not break locale detection \u2014\n // the caller falls back to the full supported set.\n logger.debug('Could not resolve tenant supported locales', { err })\n return null\n }\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,oBAAoB;AAG7B,MAAM,SAAS,aAAa,cAAc,EAAE,MAAM,EAAE,WAAW,oBAAoB,CAAC;AAE7E,MAAM,kCAAkC;AACxC,MAAM,gCAAgC;AAY7C,eAAsB,gCAAmE;AACvF,MAAI;AACF,UAAM,OAAO,MAAM,mBAAmB;AACtC,QAAI,CAAC,MAAM,SAAU,QAAO;AAE5B,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,gBAAgB,UAAU,QAAQ,qBAAqB;AAC7D,UAAM,aAAa,MAAM,cAAc;AAAA,MACrC;AAAA,MACA;AAAA,MACA,EAAE,cAAc,MAAM,OAAO,EAAE,UAAU,KAAK,SAAS,EAAE;AAAA,IAC3D;AAEA,QAAI,CAAC,MAAM,QAAQ,UAAU,EAAG,QAAO;AACvC,UAAM,QAAQ,WAAW,OAAO,CAAC,SAAyB,OAAO,SAAS,YAAY,KAAK,SAAS,CAAC;AACrG,WAAO,MAAM,SAAS,IAAI,QAAQ;AAAA,EACpC,SAAS,KAAK;AAIZ,WAAO,MAAM,8CAA8C,EAAE,IAAI,CAAC;AAClE,WAAO;AAAA,EACT;AACF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
2
|
-
import {
|
|
2
|
+
import { getSupportedLocales } from "@open-mercato/shared/lib/i18n/locale-set";
|
|
3
3
|
import { matchCountryCodes } from "@open-mercato/shared/lib/location/countries";
|
|
4
4
|
function buildWarehouseListSearchOr(term) {
|
|
5
5
|
const like = `%${escapeLikePattern(term)}%`;
|
|
@@ -9,7 +9,7 @@ function buildWarehouseListSearchOr(term) {
|
|
|
9
9
|
{ city: { $ilike: like } },
|
|
10
10
|
{ country: { $ilike: like } }
|
|
11
11
|
];
|
|
12
|
-
const matchedCountryCodes = matchCountryCodes(term, { locales });
|
|
12
|
+
const matchedCountryCodes = matchCountryCodes(term, { locales: [...getSupportedLocales()] });
|
|
13
13
|
if (matchedCountryCodes.length > 0) {
|
|
14
14
|
orFilters.push({ country: { $in: matchedCountryCodes } });
|
|
15
15
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/wms/api/warehouseSearch.ts"],
|
|
4
|
-
"sourcesContent": ["import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport {
|
|
5
|
-
"mappings": "AAAA,SAAS,yBAAyB;AAClC,SAAS,
|
|
4
|
+
"sourcesContent": ["import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { getSupportedLocales } from '@open-mercato/shared/lib/i18n/locale-set'\nimport { matchCountryCodes } from '@open-mercato/shared/lib/location/countries'\n\n/**\n * Warehouse list search matches name/code/city plus both stored country values:\n * ISO codes (`PL`) and legacy free-text (`Poland`). Localized labels shown in\n * the table (`Poland` / `Polska`) must resolve back to the stored ISO code.\n */\nexport function buildWarehouseListSearchOr(term: string): Array<Record<string, unknown>> {\n const like = `%${escapeLikePattern(term)}%`\n const orFilters: Array<Record<string, unknown>> = [\n { name: { $ilike: like } },\n { code: { $ilike: like } },\n { city: { $ilike: like } },\n { country: { $ilike: like } },\n ]\n // The served set, not the shipped baseline: an operator searching in a locale\n // their app registered should match country names in that locale too.\n // `resolveCountryName` goes through `Intl.DisplayNames`, so any code the\n // runtime has region data for resolves without shipping a table.\n const matchedCountryCodes = matchCountryCodes(term, { locales: [...getSupportedLocales()] })\n if (matchedCountryCodes.length > 0) {\n orFilters.push({ country: { $in: matchedCountryCodes } })\n }\n return orFilters\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,yBAAyB;AAClC,SAAS,2BAA2B;AACpC,SAAS,yBAAyB;AAO3B,SAAS,2BAA2B,MAA8C;AACvF,QAAM,OAAO,IAAI,kBAAkB,IAAI,CAAC;AACxC,QAAM,YAA4C;AAAA,IAChD,EAAE,MAAM,EAAE,QAAQ,KAAK,EAAE;AAAA,IACzB,EAAE,MAAM,EAAE,QAAQ,KAAK,EAAE;AAAA,IACzB,EAAE,MAAM,EAAE,QAAQ,KAAK,EAAE;AAAA,IACzB,EAAE,SAAS,EAAE,QAAQ,KAAK,EAAE;AAAA,EAC9B;AAKA,QAAM,sBAAsB,kBAAkB,MAAM,EAAE,SAAS,CAAC,GAAG,oBAAoB,CAAC,EAAE,CAAC;AAC3F,MAAI,oBAAoB,SAAS,GAAG;AAClC,cAAU,KAAK,EAAE,SAAS,EAAE,KAAK,oBAAoB,EAAE,CAAC;AAAA,EAC1D;AACA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7183.1.db9678eeb8",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -252,16 +252,16 @@
|
|
|
252
252
|
"zod": "^4.4.3"
|
|
253
253
|
},
|
|
254
254
|
"peerDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
256
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
257
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7183.1.db9678eeb8",
|
|
256
|
+
"@open-mercato/shared": "0.7.1-develop.7183.1.db9678eeb8",
|
|
257
|
+
"@open-mercato/ui": "0.7.1-develop.7183.1.db9678eeb8",
|
|
258
258
|
"react": "^19.0.0",
|
|
259
259
|
"react-dom": "^19.0.0"
|
|
260
260
|
},
|
|
261
261
|
"devDependencies": {
|
|
262
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
263
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
264
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
262
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7183.1.db9678eeb8",
|
|
263
|
+
"@open-mercato/shared": "0.7.1-develop.7183.1.db9678eeb8",
|
|
264
|
+
"@open-mercato/ui": "0.7.1-develop.7183.1.db9678eeb8",
|
|
265
265
|
"@testing-library/dom": "^10.4.1",
|
|
266
266
|
"@testing-library/jest-dom": "^7.0.1",
|
|
267
267
|
"@testing-library/react": "^16.3.3",
|
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
isSupportedLocale,
|
|
6
|
+
resolveSupportedLocalesForRequest,
|
|
7
|
+
} from '@open-mercato/shared/lib/i18n/locale-registry'
|
|
8
|
+
import { resolveForcedLocale, resolveSupportedLocale } from '@open-mercato/shared/lib/i18n/locale'
|
|
6
9
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
10
|
import { sanitizeRedirectPath } from '@open-mercato/core/modules/auth/lib/safeRedirect'
|
|
8
11
|
import { getAppBaseUrl } from '@open-mercato/shared/lib/url'
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
// Resolved per request, not at module scope: an app or tenant may register a
|
|
14
|
+
// locale after this module is first imported, and a snapshot taken at import
|
|
15
|
+
// time would reject it for the lifetime of the process.
|
|
16
|
+
//
|
|
17
|
+
// This costs the generated OpenAPI its `enum` of valid values, which a closed
|
|
18
|
+
// `z.enum(locales)` used to give for free. That is the honest documentation now
|
|
19
|
+
// rather than a regression: the accepted set is per-tenant (see
|
|
20
|
+
// `resolveLocaleForRequest` below), so any static list published in a spec
|
|
21
|
+
// shared by every tenant would be wrong for most of them. The description points
|
|
22
|
+
// at the endpoint that answers the question for the caller's own tenant.
|
|
23
|
+
const localeSchema = z.object({
|
|
24
|
+
locale: z
|
|
25
|
+
.string()
|
|
26
|
+
.refine(isSupportedLocale, { message: 'Unsupported locale' })
|
|
27
|
+
.describe('A locale code this tenant serves — one of the `servable` entries returned by `GET /api/translations/locales`. Codes are canonicalized (`de-AT` → `de`).'),
|
|
28
|
+
})
|
|
12
29
|
const localeQuerySchema = localeSchema.extend({
|
|
13
30
|
redirect: z.string().optional(),
|
|
14
31
|
})
|
|
@@ -20,6 +37,20 @@ export const metadata = {
|
|
|
20
37
|
POST: { requireAuth: false },
|
|
21
38
|
}
|
|
22
39
|
|
|
40
|
+
// Both handlers write the `locale` cookie, and `detectLocale` later reads it back
|
|
41
|
+
// against the *request's* served set — the tenant's selection, not the
|
|
42
|
+
// process-wide registry. Validating against the wider set would make a locale the
|
|
43
|
+
// tenant has not selected return 200 (or 302) and set a year-long cookie that
|
|
44
|
+
// every subsequent render silently discards, so the caller is told the change
|
|
45
|
+
// took effect and nothing ever changes.
|
|
46
|
+
async function resolveLocaleForRequest(value: unknown) {
|
|
47
|
+
if (typeof value !== 'string') return null
|
|
48
|
+
// Resolve rather than merely validate: the cookie must hold the canonical
|
|
49
|
+
// code the registry stores (`pt-BR` → `pt-br`, `cs-CZ` → `cs`), because
|
|
50
|
+
// `detectLocale` compares it against the served set verbatim.
|
|
51
|
+
return resolveSupportedLocale(value, await resolveSupportedLocalesForRequest())
|
|
52
|
+
}
|
|
53
|
+
|
|
23
54
|
export async function POST(req: Request) {
|
|
24
55
|
const { t } = await resolveTranslations()
|
|
25
56
|
if (resolveForcedLocale(process.env)) {
|
|
@@ -27,11 +58,12 @@ export async function POST(req: Request) {
|
|
|
27
58
|
}
|
|
28
59
|
try {
|
|
29
60
|
const { locale } = await req.json()
|
|
30
|
-
|
|
61
|
+
const resolved = await resolveLocaleForRequest(locale)
|
|
62
|
+
if (!resolved) {
|
|
31
63
|
return NextResponse.json({ error: t('api.errors.invalidLocale', 'Invalid locale') }, { status: 400 })
|
|
32
64
|
}
|
|
33
65
|
const res = NextResponse.json({ ok: true })
|
|
34
|
-
res.cookies.set('locale',
|
|
66
|
+
res.cookies.set('locale', resolved, { path: '/', maxAge: 60 * 60 * 24 * 365 })
|
|
35
67
|
return res
|
|
36
68
|
} catch {
|
|
37
69
|
return NextResponse.json({ error: t('api.errors.badRequest', 'Bad request') }, { status: 400 })
|
|
@@ -44,14 +76,14 @@ export async function GET(req: Request) {
|
|
|
44
76
|
return NextResponse.json({ error: t('api.errors.localeForced', 'Locale is fixed by configuration') }, { status: 409 })
|
|
45
77
|
}
|
|
46
78
|
const url = new URL(req.url)
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
79
|
+
const resolved = await resolveLocaleForRequest(url.searchParams.get('locale'))
|
|
80
|
+
if (!resolved) {
|
|
49
81
|
return NextResponse.json({ error: t('api.errors.invalidLocale', 'Invalid locale') }, { status: 400 })
|
|
50
82
|
}
|
|
51
83
|
const baseUrl = getAppBaseUrl(req)
|
|
52
84
|
const safePath = sanitizeRedirectPath(url.searchParams.get('redirect'), baseUrl, '/')
|
|
53
85
|
const res = NextResponse.redirect(new URL(safePath, url.origin))
|
|
54
|
-
res.cookies.set('locale',
|
|
86
|
+
res.cookies.set('locale', resolved, { path: '/', maxAge: 60 * 60 * 24 * 365 })
|
|
55
87
|
return res
|
|
56
88
|
}
|
|
57
89
|
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import { resolveTranslationsRouteContext } from '@open-mercato/core/modules/translations/api/context'
|
|
4
4
|
import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
5
5
|
import { locales as defaultLocales } from '@open-mercato/shared/lib/i18n/config'
|
|
6
|
+
import { getSupportedLocales } from '@open-mercato/shared/lib/i18n/locale-set'
|
|
6
7
|
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
7
8
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
8
9
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
@@ -24,7 +25,15 @@ async function GET(req: Request) {
|
|
|
24
25
|
scope: { tenantId: context.tenantId },
|
|
25
26
|
})
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
// `servable` is what the application can actually render its own UI in
|
|
29
|
+
// (platform baseline plus app-registered locales). The stored selection also
|
|
30
|
+
// drives the content-translation editor, which accepts any ISO 639-1 code, so
|
|
31
|
+
// the two sets differ and the settings screen has to be able to tell them
|
|
32
|
+
// apart before it claims a locale was added to the UI language set.
|
|
33
|
+
return NextResponse.json({
|
|
34
|
+
locales: Array.isArray(locales) ? locales : [...defaultLocales],
|
|
35
|
+
servable: [...getSupportedLocales()],
|
|
36
|
+
})
|
|
28
37
|
} catch (err) {
|
|
29
38
|
if (isCrudHttpError(err)) {
|
|
30
39
|
return NextResponse.json(err.body, { status: err.status })
|
|
@@ -36,6 +45,7 @@ async function GET(req: Request) {
|
|
|
36
45
|
|
|
37
46
|
const responseSchema = z.object({
|
|
38
47
|
locales: z.array(z.string()),
|
|
48
|
+
servable: z.array(z.string()),
|
|
39
49
|
})
|
|
40
50
|
|
|
41
51
|
const getDoc: OpenApiMethodDoc = {
|
|
@@ -4,6 +4,7 @@ import * as React from 'react'
|
|
|
4
4
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
5
5
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
6
6
|
import { IconButton } from '@open-mercato/ui/primitives/icon-button'
|
|
7
|
+
import { Badge } from '@open-mercato/ui/primitives/badge'
|
|
7
8
|
import { Tabs, TabsList, TabsTrigger } from '@open-mercato/ui/primitives/tabs'
|
|
8
9
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
9
10
|
import { ComboboxInput } from '@open-mercato/ui/backend/inputs'
|
|
@@ -17,7 +18,7 @@ import { useCustomFieldDefs } from '@open-mercato/ui/backend/utils/customFieldDe
|
|
|
17
18
|
import { Save, Plus, X } from 'lucide-react'
|
|
18
19
|
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
19
20
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
20
|
-
import { locales as defaultLocales } from '@open-mercato/shared/lib/i18n/config'
|
|
21
|
+
import { defaultLocale, locales as defaultLocales } from '@open-mercato/shared/lib/i18n/config'
|
|
21
22
|
import { ISO_639_1, isValidIso639, getIso639Label } from '@open-mercato/shared/lib/i18n/iso639'
|
|
22
23
|
import { formatEntityLabel, buildEntityListUrl, getRecordLabel, resolveBaseValue } from '../lib/helpers'
|
|
23
24
|
import { resolveFieldList } from '../lib/resolve-field-list'
|
|
@@ -48,15 +49,27 @@ type TranslationsResponse = {
|
|
|
48
49
|
updatedAt?: string
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
type TranslationLocales = {
|
|
53
|
+
/** The tenant's stored selection: which locales content can be translated into. */
|
|
54
|
+
locales: string[]
|
|
55
|
+
/** Which of those the admin UI itself can be rendered in. Resolved on the server. */
|
|
56
|
+
servable: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
51
59
|
function useTranslationLocales() {
|
|
52
|
-
return useQuery<
|
|
60
|
+
return useQuery<TranslationLocales>({
|
|
53
61
|
queryKey: ['translation-locales'],
|
|
54
62
|
queryFn: async () => {
|
|
55
|
-
const res = await apiCall<
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
const res = await apiCall<TranslationLocales>('/api/translations/locales')
|
|
64
|
+
const fallback = { locales: [...defaultLocales], servable: [...defaultLocales] }
|
|
65
|
+
if (!res.ok) return fallback
|
|
66
|
+
const locales = Array.isArray(res.result?.locales) && res.result.locales.length > 0
|
|
58
67
|
? res.result.locales
|
|
59
68
|
: [...defaultLocales]
|
|
69
|
+
const servable = Array.isArray(res.result?.servable) && res.result.servable.length > 0
|
|
70
|
+
? res.result.servable
|
|
71
|
+
: [...defaultLocales]
|
|
72
|
+
return { locales, servable }
|
|
60
73
|
},
|
|
61
74
|
staleTime: 60_000,
|
|
62
75
|
})
|
|
@@ -85,7 +98,10 @@ export function TranslationManager({
|
|
|
85
98
|
const entityType = isEmbedded ? (propEntityType ?? '') : selectedEntityType
|
|
86
99
|
const recordId = isEmbedded ? (propRecordId ?? '') : selectedRecordId
|
|
87
100
|
|
|
88
|
-
const { data:
|
|
101
|
+
const { data: localeData } = useTranslationLocales()
|
|
102
|
+
// Memoized: `locales` feeds effect dependency lists below, and a fresh array
|
|
103
|
+
// on every render while the query is still loading would re-fire them.
|
|
104
|
+
const locales = React.useMemo(() => localeData?.locales ?? [...defaultLocales], [localeData])
|
|
89
105
|
|
|
90
106
|
React.useEffect(() => {
|
|
91
107
|
if (locales.length > 0 && (!activeLocale || !locales.includes(activeLocale))) {
|
|
@@ -572,7 +588,9 @@ export function TranslationManager({
|
|
|
572
588
|
export function LocaleManager() {
|
|
573
589
|
const t = useT()
|
|
574
590
|
const queryClient = useQueryClient()
|
|
575
|
-
const { data:
|
|
591
|
+
const { data: localeData, isLoading } = useTranslationLocales()
|
|
592
|
+
const locales = React.useMemo(() => localeData?.locales ?? [], [localeData])
|
|
593
|
+
const servable = React.useMemo(() => localeData?.servable ?? [], [localeData])
|
|
576
594
|
const [newLocale, setNewLocale] = React.useState('')
|
|
577
595
|
|
|
578
596
|
const { runMutation, retryLastMutation } = useGuardedMutation<{
|
|
@@ -603,7 +621,16 @@ export function LocaleManager() {
|
|
|
603
621
|
})
|
|
604
622
|
},
|
|
605
623
|
onSuccess: (result) => {
|
|
606
|
-
|
|
624
|
+
// The PUT response carries the stored selection only, so `servable` has to
|
|
625
|
+
// come from the cached entry. With no entry to read, defaulting it to `[]`
|
|
626
|
+
// would mark every chip "Content only" — including the shipped locales —
|
|
627
|
+
// which is the one answer that is definitely wrong. Refetch instead.
|
|
628
|
+
const previous = queryClient.getQueryData<TranslationLocales>(['translation-locales'])
|
|
629
|
+
if (previous) {
|
|
630
|
+
queryClient.setQueryData<TranslationLocales>(['translation-locales'], { ...previous, locales: result })
|
|
631
|
+
} else {
|
|
632
|
+
void queryClient.invalidateQueries({ queryKey: ['translation-locales'] })
|
|
633
|
+
}
|
|
607
634
|
flash(t('translations.locales.flash.saved', 'Locales updated'), 'success')
|
|
608
635
|
},
|
|
609
636
|
onError: () => {
|
|
@@ -611,11 +638,32 @@ export function LocaleManager() {
|
|
|
611
638
|
},
|
|
612
639
|
})
|
|
613
640
|
|
|
641
|
+
// A locale the app has no dictionary for can be translated into, but the admin
|
|
642
|
+
// UI can never be shown in it — `resolveSupportedLocalesForRequest` intersects
|
|
643
|
+
// the selection with what the app serves. Saying so at the point of action is
|
|
644
|
+
// what keeps the successful-looking add honest.
|
|
645
|
+
const contentOnlyLabel = t('translations.locales.contentOnly', 'Content only')
|
|
646
|
+
const isServable = React.useCallback(
|
|
647
|
+
(code: string) => servable.includes(code.toLowerCase()),
|
|
648
|
+
[servable],
|
|
649
|
+
)
|
|
650
|
+
|
|
614
651
|
const availableLocales = React.useMemo(
|
|
615
652
|
() => ISO_639_1.filter((entry) => !locales.includes(entry.code)).map((entry) => ({
|
|
616
653
|
value: entry.code,
|
|
617
|
-
label:
|
|
654
|
+
label: isServable(entry.code)
|
|
655
|
+
? `${entry.code.toUpperCase()} — ${entry.label}`
|
|
656
|
+
: `${entry.code.toUpperCase()} — ${entry.label} (${contentOnlyLabel})`,
|
|
618
657
|
})),
|
|
658
|
+
[locales, isServable, contentOnlyLabel],
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
// `resolveSupportedLocalesForRequest` keeps `defaultLocale` in the served set
|
|
662
|
+
// whatever the stored selection says, so a tenant whose saved list omits it
|
|
663
|
+
// still gets it in the language switcher. Rendering the raw selection here
|
|
664
|
+
// would leave this screen and the switcher disagreeing about what is served.
|
|
665
|
+
const chips = React.useMemo(
|
|
666
|
+
() => (locales.includes(defaultLocale) ? locales : [defaultLocale, ...locales]),
|
|
619
667
|
[locales],
|
|
620
668
|
)
|
|
621
669
|
|
|
@@ -628,6 +676,10 @@ export function LocaleManager() {
|
|
|
628
676
|
|
|
629
677
|
const removeLocale = (locale: string) => {
|
|
630
678
|
if (locales.length <= 1) return
|
|
679
|
+
// The default locale stays servable whatever the selection says
|
|
680
|
+
// (`resolveSupportedLocalesForRequest` re-adds it), so letting it be removed
|
|
681
|
+
// here would leave the chip list claiming something untrue.
|
|
682
|
+
if (locale === defaultLocale) return
|
|
631
683
|
mutation.mutate(locales.filter((l) => l !== locale))
|
|
632
684
|
}
|
|
633
685
|
|
|
@@ -640,33 +692,49 @@ export function LocaleManager() {
|
|
|
640
692
|
<div className="space-y-1">
|
|
641
693
|
<h3 className="text-lg font-semibold">{t('translations.locales.title', 'Supported locales')}</h3>
|
|
642
694
|
<p className="text-sm text-muted-foreground">
|
|
643
|
-
{t('translations.locales.description', '
|
|
695
|
+
{t('translations.locales.description', 'Which languages content can be translated into. A language the application ships an interface for is also offered in the admin language switcher; the rest are available for content only.')}
|
|
644
696
|
</p>
|
|
645
697
|
</div>
|
|
646
698
|
|
|
647
699
|
<div className="flex flex-wrap gap-2">
|
|
648
|
-
{
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
{
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
700
|
+
{chips.map((locale) => {
|
|
701
|
+
const localeLabel = getIso639Label(locale) ?? locale.toUpperCase()
|
|
702
|
+
const isDefault = locale === defaultLocale
|
|
703
|
+
const isStored = locales.includes(locale)
|
|
704
|
+
const removeLabel = t('translations.locales.remove', 'Remove {{locale}}', { locale: localeLabel })
|
|
705
|
+
const defaultLabel = t(
|
|
706
|
+
'translations.locales.alwaysServed',
|
|
707
|
+
'{{locale}} is the default language and is always served, so it cannot be removed.',
|
|
708
|
+
{ locale: localeLabel },
|
|
709
|
+
)
|
|
710
|
+
return (
|
|
711
|
+
<span
|
|
712
|
+
key={locale}
|
|
713
|
+
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/50 px-3 py-1 text-sm font-medium"
|
|
714
|
+
title={isStored ? (getIso639Label(locale) ?? locale) : defaultLabel}
|
|
715
|
+
>
|
|
716
|
+
{locale.toUpperCase()}{getIso639Label(locale) ? ` — ${getIso639Label(locale)}` : ''}
|
|
717
|
+
{!isServable(locale) && (
|
|
718
|
+
<Badge variant="outline" size="sm" title={t('translations.locales.contentOnlyHint', 'The application ships no interface for this language, so it is available for content translations only.')}>
|
|
719
|
+
{contentOnlyLabel}
|
|
720
|
+
</Badge>
|
|
721
|
+
)}
|
|
722
|
+
{isStored && locales.length > 1 && (
|
|
723
|
+
<IconButton
|
|
724
|
+
variant="ghost"
|
|
725
|
+
size="xs"
|
|
726
|
+
fullRadius
|
|
727
|
+
aria-label={isDefault ? defaultLabel : removeLabel}
|
|
728
|
+
title={isDefault ? defaultLabel : removeLabel}
|
|
729
|
+
onClick={() => removeLocale(locale)}
|
|
730
|
+
disabled={mutation.isPending || isDefault}
|
|
731
|
+
>
|
|
732
|
+
<X className="h-3 w-3" />
|
|
733
|
+
</IconButton>
|
|
734
|
+
)}
|
|
735
|
+
</span>
|
|
736
|
+
)
|
|
737
|
+
})}
|
|
670
738
|
</div>
|
|
671
739
|
|
|
672
740
|
<div className="flex gap-2 items-center">
|
|
@@ -674,11 +742,12 @@ export function LocaleManager() {
|
|
|
674
742
|
<ComboboxInput
|
|
675
743
|
value={newLocale}
|
|
676
744
|
onChange={setNewLocale}
|
|
677
|
-
placeholder={t('translations.locales.addPlaceholder', '
|
|
745
|
+
placeholder={t('translations.locales.addPlaceholder', 'e.g. fr, it, ja...')}
|
|
678
746
|
suggestions={availableLocales}
|
|
679
747
|
resolveLabel={(value) => {
|
|
680
748
|
const label = getIso639Label(value)
|
|
681
|
-
|
|
749
|
+
const base = label ? `${value.toUpperCase()} — ${label}` : value.toUpperCase()
|
|
750
|
+
return isServable(value) ? base : `${base} (${contentOnlyLabel})`
|
|
682
751
|
}}
|
|
683
752
|
/>
|
|
684
753
|
</div>
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { registerTranslatableFields } from '@open-mercato/shared/lib/localization/translatable-fields'
|
|
2
2
|
import { registerTranslationOverlayPlugin } from '@open-mercato/shared/lib/localization/overlay-plugin'
|
|
3
|
+
import { registerSupportedLocalesResolver } from '@open-mercato/shared/lib/i18n/locale-registry'
|
|
3
4
|
import { translatableFields as catalogFields } from '../catalog/translations'
|
|
4
5
|
import { translatableFields as dictionaryFields } from '../dictionaries/translations'
|
|
5
6
|
import { translatableFields as entitiesFields } from '../entities/translations'
|
|
6
7
|
import { translatableFields as resourcesFields } from '../resources/translations'
|
|
7
8
|
import { applyTranslationOverlays } from './lib/apply'
|
|
8
9
|
import { resolveLocaleFromRequest } from './lib/locale'
|
|
10
|
+
import { resolveTenantSupportedLocales } from './lib/supported-locales'
|
|
11
|
+
|
|
12
|
+
// Registered at module scope, not inside `register()`: module DI registrars only
|
|
13
|
+
// run when the first request container is built, and the root layout resolves the
|
|
14
|
+
// served locale set without ever building one. Inside `register()` the very first
|
|
15
|
+
// render of a fresh process would find an empty resolver slot and serve the
|
|
16
|
+
// un-narrowed set. `di.generated.ts` imports this module statically from the app
|
|
17
|
+
// bootstrap, so the slot is filled at import time instead.
|
|
18
|
+
registerSupportedLocalesResolver(resolveTenantSupportedLocales)
|
|
9
19
|
|
|
10
20
|
export function register() {
|
|
11
21
|
registerTranslatableFields(catalogFields)
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
"translations.config.nav.title": "Übersetzungen",
|
|
3
3
|
"translations.locales.add": "Hinzufügen",
|
|
4
4
|
"translations.locales.addPlaceholder": "z.B. fr, it, ja...",
|
|
5
|
-
"translations.locales.
|
|
5
|
+
"translations.locales.alwaysServed": "{{locale}} ist die Standardsprache und wird immer ausgeliefert, daher kann sie nicht entfernt werden.",
|
|
6
|
+
"translations.locales.contentOnly": "Nur Inhalte",
|
|
7
|
+
"translations.locales.contentOnlyHint": "Die Anwendung liefert keine Oberfläche in dieser Sprache, sie steht daher nur für Inhaltsübersetzungen zur Verfügung.",
|
|
8
|
+
"translations.locales.description": "Sprachen, in die Inhalte übersetzt werden können. Eine Sprache, für die die Anwendung eine Oberfläche mitliefert, erscheint auch in der Sprachauswahl des Backends; die übrigen stehen nur für Inhalte zur Verfügung.",
|
|
6
9
|
"translations.locales.flash.error": "Fehler beim Aktualisieren der Sprachen",
|
|
7
10
|
"translations.locales.flash.saved": "Sprachen aktualisiert",
|
|
8
11
|
"translations.locales.loading": "Sprachen werden geladen...",
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
"translations.config.nav.title": "Translations",
|
|
3
3
|
"translations.locales.add": "Add",
|
|
4
4
|
"translations.locales.addPlaceholder": "e.g. fr, it, ja...",
|
|
5
|
-
"translations.locales.
|
|
5
|
+
"translations.locales.alwaysServed": "{{locale}} is the default language and is always served, so it cannot be removed.",
|
|
6
|
+
"translations.locales.contentOnly": "Content only",
|
|
7
|
+
"translations.locales.contentOnlyHint": "The application ships no interface for this language, so it is available for content translations only.",
|
|
8
|
+
"translations.locales.description": "Which languages content can be translated into. A language the application ships an interface for is also offered in the admin language switcher; the rest are available for content only.",
|
|
6
9
|
"translations.locales.flash.error": "Failed to update locales",
|
|
7
10
|
"translations.locales.flash.saved": "Locales updated",
|
|
8
11
|
"translations.locales.loading": "Loading locales...",
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
"translations.config.nav.title": "Traducciones",
|
|
3
3
|
"translations.locales.add": "Añadir",
|
|
4
4
|
"translations.locales.addPlaceholder": "p. ej. fr, it, ja...",
|
|
5
|
-
"translations.locales.
|
|
5
|
+
"translations.locales.alwaysServed": "{{locale}} es el idioma predeterminado y siempre se ofrece, por lo que no se puede eliminar.",
|
|
6
|
+
"translations.locales.contentOnly": "Solo contenido",
|
|
7
|
+
"translations.locales.contentOnlyHint": "La aplicación no incluye interfaz en este idioma, por lo que solo está disponible para traducciones de contenido.",
|
|
8
|
+
"translations.locales.description": "Idiomas a los que se puede traducir el contenido. Un idioma para el que la aplicación incluye interfaz también aparece en el selector de idioma del panel; el resto solo está disponible para contenido.",
|
|
6
9
|
"translations.locales.flash.error": "Error al actualizar los idiomas",
|
|
7
10
|
"translations.locales.flash.saved": "Idiomas actualizados",
|
|
8
11
|
"translations.locales.loading": "Cargando idiomas...",
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
"translations.config.nav.title": "번역",
|
|
3
3
|
"translations.locales.add": "추가",
|
|
4
4
|
"translations.locales.addPlaceholder": "예: fr, it, ja...",
|
|
5
|
-
"translations.locales.
|
|
5
|
+
"translations.locales.alwaysServed": "{{locale}}은(는) 기본 언어로 항상 제공되므로 제거할 수 없습니다.",
|
|
6
|
+
"translations.locales.contentOnly": "콘텐츠 전용",
|
|
7
|
+
"translations.locales.contentOnlyHint": "애플리케이션이 이 언어의 인터페이스를 제공하지 않으므로 콘텐츠 번역에만 사용할 수 있습니다.",
|
|
8
|
+
"translations.locales.description": "콘텐츠를 번역할 수 있는 언어입니다. 애플리케이션이 인터페이스를 제공하는 언어는 관리자 언어 전환기에도 표시되며, 나머지는 콘텐츠 번역에만 사용됩니다.",
|
|
6
9
|
"translations.locales.flash.error": "로케일 업데이트에 실패했습니다",
|
|
7
10
|
"translations.locales.flash.saved": "로케일이 업데이트되었습니다",
|
|
8
11
|
"translations.locales.loading": "로케일을 불러오는 중...",
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
"translations.config.nav.title": "Tłumaczenia",
|
|
3
3
|
"translations.locales.add": "Dodaj",
|
|
4
4
|
"translations.locales.addPlaceholder": "np. fr, it, ja...",
|
|
5
|
-
"translations.locales.
|
|
5
|
+
"translations.locales.alwaysServed": "{{locale}} jest językiem domyślnym i zawsze pozostaje dostępny, dlatego nie można go usunąć.",
|
|
6
|
+
"translations.locales.contentOnly": "Tylko treści",
|
|
7
|
+
"translations.locales.contentOnlyHint": "Aplikacja nie ma interfejsu w tym języku, więc służy on wyłącznie do tłumaczenia treści.",
|
|
8
|
+
"translations.locales.description": "Języki, na które można tłumaczyć treści. Język, dla którego aplikacja ma interfejs, jest też dostępny w przełączniku języka panelu; pozostałe służą wyłącznie do tłumaczenia treści.",
|
|
6
9
|
"translations.locales.flash.error": "Nie udało się zaktualizować języków",
|
|
7
10
|
"translations.locales.flash.saved": "Języki zaktualizowane",
|
|
8
11
|
"translations.locales.loading": "Ładowanie języków...",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
2
|
+
import { getAuthFromCookies } from '@open-mercato/shared/lib/auth/server'
|
|
3
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
4
|
+
import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
|
|
5
|
+
|
|
6
|
+
const logger = createLogger('translations').child({ component: 'supported-locales' })
|
|
7
|
+
|
|
8
|
+
export const SUPPORTED_LOCALES_CONFIG_MODULE = 'translations'
|
|
9
|
+
export const SUPPORTED_LOCALES_CONFIG_NAME = 'supported_locales'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The locale codes the signed-in user's tenant has opted into, as managed on
|
|
13
|
+
* Settings → Module Configs → Translations, or `null` when there is no tenant
|
|
14
|
+
* context or the tenant has never saved a selection.
|
|
15
|
+
*
|
|
16
|
+
* `null` means "no opinion" and leaves the served set untouched — it is not the
|
|
17
|
+
* same as an empty selection. Reads go through `ModuleConfigService`, which
|
|
18
|
+
* caches for 60s and invalidates on write, so this stays cheap enough to call
|
|
19
|
+
* once per page render.
|
|
20
|
+
*/
|
|
21
|
+
export async function resolveTenantSupportedLocales(): Promise<readonly string[] | null> {
|
|
22
|
+
try {
|
|
23
|
+
const auth = await getAuthFromCookies()
|
|
24
|
+
if (!auth?.tenantId) return null
|
|
25
|
+
|
|
26
|
+
const container = await createRequestContainer()
|
|
27
|
+
const configService = container.resolve('moduleConfigService') as ModuleConfigService
|
|
28
|
+
const configured = await configService.getValue<string[]>(
|
|
29
|
+
SUPPORTED_LOCALES_CONFIG_MODULE,
|
|
30
|
+
SUPPORTED_LOCALES_CONFIG_NAME,
|
|
31
|
+
{ defaultValue: null, scope: { tenantId: auth.tenantId } },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if (!Array.isArray(configured)) return null
|
|
35
|
+
const codes = configured.filter((code): code is string => typeof code === 'string' && code.length > 0)
|
|
36
|
+
return codes.length > 0 ? codes : null
|
|
37
|
+
} catch (err) {
|
|
38
|
+
// Anonymous requests, a container that cannot be built yet during boot, or a
|
|
39
|
+
// database that is briefly unavailable must not break locale detection —
|
|
40
|
+
// the caller falls back to the full supported set.
|
|
41
|
+
logger.debug('Could not resolve tenant supported locales', { err })
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|