@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.
@@ -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;AAElC,SAAS,WAAW;AACzB,6BAA2B,aAAa;AACxC,6BAA2B,gBAAgB;AAC3C,6BAA2B,cAAc;AACzC,6BAA2B,eAAe;AAC1C,mCAAiC,0BAA0B,wBAAwB;AACrF;",
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 { locales } from "@open-mercato/shared/lib/i18n/config";
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 { locales } from '@open-mercato/shared/lib/i18n/config'\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 const matchedCountryCodes = matchCountryCodes(term, { locales })\n if (matchedCountryCodes.length > 0) {\n orFilters.push({ country: { $in: matchedCountryCodes } })\n }\n return orFilters\n}\n"],
5
- "mappings": "AAAA,SAAS,yBAAyB;AAClC,SAAS,eAAe;AACxB,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;AACA,QAAM,sBAAsB,kBAAkB,MAAM,EAAE,QAAQ,CAAC;AAC/D,MAAI,oBAAoB,SAAS,GAAG;AAClC,cAAU,KAAK,EAAE,SAAS,EAAE,KAAK,oBAAoB,EAAE,CAAC;AAAA,EAC1D;AACA,SAAO;AACT;",
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.7181.1.702cedc42c",
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.7181.1.702cedc42c",
256
- "@open-mercato/shared": "0.7.1-develop.7181.1.702cedc42c",
257
- "@open-mercato/ui": "0.7.1-develop.7181.1.702cedc42c",
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.7181.1.702cedc42c",
263
- "@open-mercato/shared": "0.7.1-develop.7181.1.702cedc42c",
264
- "@open-mercato/ui": "0.7.1-develop.7181.1.702cedc42c",
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 { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
5
- import { resolveForcedLocale } from '@open-mercato/shared/lib/i18n/locale'
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
- const supportedLocales = new Set<Locale>(locales)
11
- const localeSchema = z.object({ locale: z.enum(locales as [Locale, ...Locale[]]) })
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
- if (typeof locale !== 'string' || !supportedLocales.has(locale as Locale)) {
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', locale as Locale, { path: '/', maxAge: 60 * 60 * 24 * 365 })
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 locale = url.searchParams.get('locale')
48
- if (!locale || !supportedLocales.has(locale as Locale)) {
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', locale as Locale, { path: '/', maxAge: 60 * 60 * 24 * 365 })
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
- return NextResponse.json({ locales: Array.isArray(locales) ? locales : [...defaultLocales] })
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<string[]>({
60
+ return useQuery<TranslationLocales>({
53
61
  queryKey: ['translation-locales'],
54
62
  queryFn: async () => {
55
- const res = await apiCall<{ locales: string[] }>('/api/translations/locales')
56
- if (!res.ok) return [...defaultLocales]
57
- return Array.isArray(res.result?.locales) && res.result.locales.length > 0
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: locales = [...defaultLocales] } = useTranslationLocales()
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: locales = [], isLoading } = useTranslationLocales()
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
- queryClient.setQueryData(['translation-locales'], result)
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: `${entry.code.toUpperCase()} — ${entry.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', 'Configure which locales are available for translations. Add ISO language codes (e.g. fr, it, ja, zh).')}
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
- {locales.map((locale) => (
649
- <span
650
- key={locale}
651
- className="inline-flex items-center gap-1.5 rounded-full border bg-muted/50 px-3 py-1 text-sm font-medium"
652
- title={getIso639Label(locale) ?? locale}
653
- >
654
- {locale.toUpperCase()}{getIso639Label(locale) ? ` — ${getIso639Label(locale)}` : ''}
655
- {locales.length > 1 && (
656
- <IconButton
657
- variant="ghost"
658
- size="xs"
659
- fullRadius
660
- aria-label={t('translations.locales.remove', 'Remove {{locale}}', { locale: getIso639Label(locale) ?? locale.toUpperCase() })}
661
- title={t('translations.locales.remove', 'Remove {{locale}}', { locale: getIso639Label(locale) ?? locale.toUpperCase() })}
662
- onClick={() => removeLocale(locale)}
663
- disabled={mutation.isPending}
664
- >
665
- <X className="h-3 w-3" />
666
- </IconButton>
667
- )}
668
- </span>
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', 'Search language...')}
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
- return label ? `${value.toUpperCase()} — ${label}` : value.toUpperCase()
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.description": "Konfiguriere die verfügbaren Sprachen für Übersetzungen. Füge ISO-Sprachcodes hinzu (z.B. fr, it, ja, zh).",
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.description": "Configure which locales are available for translations. Add ISO language codes (e.g. fr, it, ja, zh).",
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.description": "Configura los idiomas disponibles para las traducciones. Añade códigos ISO de idioma (p. ej. fr, it, ja, zh).",
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.description": "번역에 사용할 로케일을 설정하세요. ISO 언어 코드를 추가하세요 (예: fr, it, ja, zh).",
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.description": "Skonfiguruj dostępne języki dla tłumaczeń. Dodaj kody ISO języków (np. fr, it, ja, zh).",
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
+ }