@saasicat/ui-vue 0.16.0 → 0.18.0

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.
Files changed (46) hide show
  1. package/README.md +12 -11
  2. package/dist/{messages-DetB75lX.d.ts → catalog-JXdKdIqG.d.cts} +104 -40
  3. package/dist/{messages-DetB75lX.d.cts → catalog-JXdKdIqG.d.ts} +104 -40
  4. package/dist/{chunk-TZ2NGQXK.js → chunk-CC4WK3DC.js} +40 -8
  5. package/dist/{chunk-CRUUZMMN.js → chunk-VRCVJTY4.js} +30 -4
  6. package/dist/{chunk-MGKJCVDU.js → chunk-Y5MWMWMD.js} +18 -18
  7. package/dist/client/index.cjs +70 -10
  8. package/dist/client/index.d.cts +51 -5
  9. package/dist/client/index.d.ts +51 -5
  10. package/dist/client/index.js +10 -4
  11. package/dist/index.cjs +85 -22
  12. package/dist/index.d.cts +5 -5
  13. package/dist/index.d.ts +5 -5
  14. package/dist/index.js +11 -5
  15. package/dist/quasar/index.cjs +54 -20
  16. package/dist/quasar/index.d.cts +2 -2
  17. package/dist/quasar/index.d.ts +2 -2
  18. package/dist/quasar/index.js +2 -2
  19. package/dist/{use-super-admin-i18n-DokHhkS_.d.cts → use-super-admin-i18n-1nCOkvgi.d.cts} +29 -9
  20. package/dist/{use-super-admin-i18n-BPC-H9jz.d.ts → use-super-admin-i18n-BlqcD0N7.d.ts} +29 -9
  21. package/package.json +8 -3
  22. package/src/client/i18n/catalog.ts +120 -0
  23. package/src/client/i18n/currency.ts +7 -2
  24. package/src/client/i18n/define.ts +2 -2
  25. package/src/client/i18n/index.ts +1 -0
  26. package/src/client/i18n/locale.ts +23 -11
  27. package/src/client/i18n/messages/bundles.ts +2 -3
  28. package/src/client/i18n/messages/marketing.ts +2 -2
  29. package/src/client/i18n/messages/nav.ts +3 -3
  30. package/src/client/i18n/messages.ts +7 -4
  31. package/src/client/index.ts +1 -0
  32. package/src/client/login-branding.ts +61 -0
  33. package/src/client/nav-builder.ts +33 -6
  34. package/src/components/LocaleSwitcher.vue +21 -12
  35. package/src/components/bundle-editor/BundleVersionStrip.vue +1 -1
  36. package/src/components/bundle-editor/bundle-version-status.ts +18 -11
  37. package/src/pages-standard/AdminLayout.vue +7 -3
  38. package/src/pages-standard/DiscoveryPage.vue +1 -1
  39. package/src/pages-standard/SuperAdminLoginPage.vue +8 -9
  40. package/src/pages-standard/bundles-page/BundleAccordionList.vue +2 -3
  41. package/src/pages-standard/discovery-page/CatalogEntryTransPanel.vue +4 -4
  42. package/src/pages-standard/discovery-page/DiscoveryStatusControl.vue +7 -7
  43. package/src/pages-standard/discovery-page/discovery-ui.ts +24 -15
  44. package/src/pages-standard/plan-versions/PlanVersionsTimeline.vue +2 -3
  45. package/src/pages-standard/plan-versions/format.ts +7 -8
  46. package/src/vue/use-super-admin-i18n.ts +59 -28
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saasicat/ui-vue",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Vue 3 components, resource clients and composables for the SuperAdmin UI shell. Provides boot and manifest loaders, navigation, actions and standard pages.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -53,7 +53,7 @@
53
53
  "src"
54
54
  ],
55
55
  "dependencies": {
56
- "@saasicat/types": "^0.16.0"
56
+ "@saasicat/types": "^0.18.0"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "pinia": "^2.0.0 || ^3.0.0",
@@ -75,10 +75,14 @@
75
75
  "devDependencies": {
76
76
  "@playwright/test": "^1.59.1",
77
77
  "@types/node": "^25.6.0",
78
+ "@vitejs/plugin-vue": "^6.0.8",
79
+ "@vue/test-utils": "^2.4.11",
80
+ "jsdom": "^30.0.1",
78
81
  "pinia": "^3.0.0",
79
- "quasar": "^2.22.0",
82
+ "quasar": "^2.24.0",
80
83
  "tsup": "^8.0.0",
81
84
  "typescript": "^6.0.0",
85
+ "vitest": "^4.1.10",
82
86
  "vue": "^3.5.0",
83
87
  "vue-router": "^5.0.0",
84
88
  "vue-tsc": "^3.3.3"
@@ -102,6 +106,7 @@
102
106
  "typecheck": "vue-tsc --noEmit -p tsconfig.json",
103
107
  "pretest": "pnpm run build",
104
108
  "test": "node --test tests/**/*.test.js",
109
+ "test:component": "vitest run --config vitest.config.ts",
105
110
  "test:e2e": "playwright test"
106
111
  }
107
112
  }
@@ -0,0 +1,120 @@
1
+ // Resolves a locale to the three things the shell needs from it: the catalog to
2
+ // render, the tag `Intl` formats with, and the name the switcher shows.
3
+ //
4
+ // The platform ships German and English and stops there. Which of them an app
5
+ // offers, which languages it adds, and what wording it prefers is the app's
6
+ // decision — this module is the single place all three are applied.
7
+
8
+ import { mergeMessages } from './define.js';
9
+ import {
10
+ DEFAULT_SA_LOCALE,
11
+ SA_INTL_LOCALES,
12
+ SA_LOCALES,
13
+ SA_LOCALE_LABELS,
14
+ isSaBuiltinLocale,
15
+ type SaBuiltinLocale,
16
+ type SaLocale,
17
+ } from './locale.js';
18
+ import { SA_MESSAGES, type SaMessages, type SaMessagesOverrides } from './messages.js';
19
+
20
+ /** A language an app brings along, on top of the two the platform ships. */
21
+ export interface SaLocaleDefinition {
22
+ /**
23
+ * Name for the switcher, written in the language itself (`'Français'`) —
24
+ * that way the entry stays readable whatever locale is currently active.
25
+ */
26
+ label: string;
27
+ /** BCP-47 tag for `Intl`/`toLocaleString`, e.g. `'fr-FR'`. */
28
+ intlLocale: string;
29
+ /**
30
+ * Built-in catalog that fills whatever `messages` leaves out, default
31
+ * `'en'`. A partial translation therefore renders — untranslated keys fall
32
+ * back instead of showing blanks.
33
+ */
34
+ basedOn?: SaBuiltinLocale;
35
+ /** The translation. Every key is optional; see `basedOn`. */
36
+ messages: SaMessagesOverrides;
37
+ }
38
+
39
+ export interface SaCatalogOptions {
40
+ /**
41
+ * Locales offered in the switcher, in the order given. Default: the
42
+ * built-ins plus everything in `additionalLocales`. Narrow it to ship a
43
+ * single language; entries without a catalog are ignored.
44
+ */
45
+ locales?: readonly SaLocale[];
46
+ /** Languages the app adds, keyed by locale code (`'fr'`, `'pt-BR'`, …). */
47
+ additionalLocales?: Readonly<Record<string, SaLocaleDefinition>>;
48
+ /** Per-locale wording changes layered on top of whatever catalog applies. */
49
+ overrides?: Readonly<Partial<Record<SaLocale, SaMessagesOverrides>>>;
50
+ }
51
+
52
+ /** One entry of the switcher. */
53
+ export interface SaLocaleOption {
54
+ code: SaLocale;
55
+ label: string;
56
+ }
57
+
58
+ export interface SaCatalog {
59
+ /** Locales to offer, in switcher order. Never empty. */
60
+ readonly locales: readonly SaLocaleOption[];
61
+ /**
62
+ * Whether the app offers this locale. Deliberately not "can it be
63
+ * rendered": an app that narrowed to English must not be pushed back into
64
+ * German by a stored pick from before, even though German is still shipped.
65
+ */
66
+ has(locale: SaLocale): boolean;
67
+ /** Locale to start from when nothing else is known. */
68
+ readonly defaultLocale: SaLocale;
69
+ messagesFor(locale: SaLocale): SaMessages;
70
+ intlLocaleFor(locale: SaLocale): string;
71
+ labelFor(locale: SaLocale): string;
72
+ }
73
+
74
+ /**
75
+ * Builds the catalog an app runs on. Resolution is cached per locale, so
76
+ * switching back and forth does not re-merge the message tree.
77
+ */
78
+ export function createSaCatalog(options: SaCatalogOptions = {}): SaCatalog {
79
+ const additional = options.additionalLocales ?? {};
80
+ const known = new Set<string>([...SA_LOCALES, ...Object.keys(additional)]);
81
+
82
+ // An app that lists an unknown code would otherwise get a switcher entry
83
+ // that renders nothing; dropping it here keeps the failure visible in one
84
+ // place instead of at every call site.
85
+ const requested = options.locales?.filter((code) => known.has(code));
86
+ const offered: readonly SaLocale[] =
87
+ requested && requested.length > 0 ? requested : [...SA_LOCALES, ...Object.keys(additional)];
88
+
89
+ const labelFor = (locale: SaLocale): string =>
90
+ additional[locale]?.label ??
91
+ (isSaBuiltinLocale(locale) ? SA_LOCALE_LABELS[locale] : locale);
92
+
93
+ const cache = new Map<SaLocale, SaMessages>();
94
+ const messagesFor = (locale: SaLocale): SaMessages => {
95
+ const hit = cache.get(locale);
96
+ if (hit) return hit;
97
+ const definition = additional[locale];
98
+ const base =
99
+ SA_MESSAGES[definition?.basedOn ?? (isSaBuiltinLocale(locale) ? locale : 'en')];
100
+ const translated = definition ? mergeMessages(base, definition.messages) : base;
101
+ const resolved = mergeMessages(translated, options.overrides?.[locale]);
102
+ cache.set(locale, resolved);
103
+ return resolved;
104
+ };
105
+
106
+ const offeredSet = new Set<string>(offered);
107
+
108
+ return {
109
+ locales: offered.map((code) => ({ code, label: labelFor(code) })),
110
+ has: (locale) => offeredSet.has(locale),
111
+ // The app's first offered locale beats the platform default: an app
112
+ // shipping English only must not start in German.
113
+ defaultLocale: offered.includes(DEFAULT_SA_LOCALE) ? DEFAULT_SA_LOCALE : offered[0],
114
+ messagesFor,
115
+ intlLocaleFor: (locale) =>
116
+ additional[locale]?.intlLocale ??
117
+ (isSaBuiltinLocale(locale) ? SA_INTL_LOCALES[locale] : locale),
118
+ labelFor,
119
+ };
120
+ }
@@ -2,7 +2,12 @@
2
2
  // grouping and decimal separator follow the active UI locale
3
3
  // (de → `12.345,60 €`, en → `€12,345.60`).
4
4
 
5
- import { DEFAULT_SA_LOCALE, SA_INTL_LOCALES, type SaLocale } from './locale.js';
5
+ import {
6
+ DEFAULT_SA_LOCALE,
7
+ SA_INTL_LOCALES,
8
+ type SaBuiltinLocale,
9
+ type SaLocale,
10
+ } from './locale.js';
6
11
 
7
12
  const DEFAULT_CURRENCY = 'EUR';
8
13
 
@@ -12,7 +17,7 @@ function formatterFor(locale: SaLocale, currency: string, decimals: number): Int
12
17
  const key = `${locale}:${currency}:${decimals}`;
13
18
  let formatter = formatters.get(key);
14
19
  if (!formatter) {
15
- formatter = new Intl.NumberFormat(SA_INTL_LOCALES[locale], {
20
+ formatter = new Intl.NumberFormat(SA_INTL_LOCALES[locale as SaBuiltinLocale] ?? locale, {
16
21
  style: 'currency',
17
22
  currency,
18
23
  minimumFractionDigits: decimals,
@@ -3,7 +3,7 @@
3
3
  // must mirror that structure exactly, enforced at compile time via
4
4
  // `TranslationOf`.
5
5
 
6
- import type { SaLocale } from './locale.js';
6
+ import type { SaBuiltinLocale } from './locale.js';
7
7
 
8
8
  /** Nested string map — the shape of every message namespace. */
9
9
  export interface MessageTree {
@@ -27,7 +27,7 @@ export type PartialMessages<T> = {
27
27
  export function defineMessages<T extends MessageTree>(
28
28
  de: T,
29
29
  en: TranslationOf<T>,
30
- ): Record<SaLocale, T> {
30
+ ): Record<SaBuiltinLocale, T> {
31
31
  // `TranslationOf<T>` and `T` are structurally identical for string-valued
32
32
  // trees; the cast only erases the mapped-type wrapper.
33
33
  return { de, en: en as T };
@@ -12,3 +12,4 @@ export {
12
12
  export * from './format.js';
13
13
  export * from './currency.js';
14
14
  export * from './messages.js';
15
+ export * from './catalog.js';
@@ -1,15 +1,27 @@
1
- // Supported display locales of the SuperAdmin UI. The platform ships a German
2
- // (reference) and an English catalog; consumers pick the locale per app or per
3
- // user via `createSuperAdminApp({ i18n })`.
1
+ // Display locales of the SuperAdmin UI.
2
+ //
3
+ // The platform ships two complete catalogs — German (the reference that fixes
4
+ // the key structure) and English — and nothing beyond that. Which of them an
5
+ // app offers, whether it adds languages of its own, and what vocabulary it
6
+ // prefers is the app's call, made through `createSuperAdminApp({ i18n })`.
4
7
 
5
- export type SaLocale = 'de' | 'en';
8
+ /** Locales the platform itself ships a catalog for. */
9
+ export type SaBuiltinLocale = 'de' | 'en';
6
10
 
7
- export const SA_LOCALES: readonly SaLocale[] = ['de', 'en'];
11
+ /**
12
+ * Any locale the shell can run in: the built-ins plus whatever an app adds via
13
+ * `i18n.additionalLocales`. The `string & {}` arm keeps autocompletion for the
14
+ * built-ins while leaving the set open.
15
+ */
16
+ export type SaLocale = SaBuiltinLocale | (string & {});
17
+
18
+ /** The locales the platform ships. Apps narrow or extend this, see `i18n`. */
19
+ export const SA_LOCALES: readonly SaBuiltinLocale[] = ['de', 'en'];
8
20
 
9
- export const DEFAULT_SA_LOCALE: SaLocale = 'de';
21
+ export const DEFAULT_SA_LOCALE: SaBuiltinLocale = 'de';
10
22
 
11
- /** BCP-47 tags used for `Intl`/`toLocaleString` formatting per UI locale. */
12
- export const SA_INTL_LOCALES: Record<SaLocale, string> = {
23
+ /** BCP-47 tags used for `Intl`/`toLocaleString` formatting per built-in locale. */
24
+ export const SA_INTL_LOCALES: Record<SaBuiltinLocale, string> = {
13
25
  de: 'de-DE',
14
26
  en: 'en-US',
15
27
  };
@@ -19,12 +31,12 @@ export const SA_INTL_LOCALES: Record<SaLocale, string> = {
19
31
  * language, so the entries stay readable no matter which locale is active and
20
32
  * never need translating.
21
33
  */
22
- export const SA_LOCALE_LABELS: Record<SaLocale, string> = {
34
+ export const SA_LOCALE_LABELS: Record<SaBuiltinLocale, string> = {
23
35
  de: 'Deutsch',
24
36
  en: 'English',
25
37
  };
26
38
 
27
- /** Narrows untrusted input (persisted preference, query parameter) to a locale. */
28
- export function isSaLocale(value: unknown): value is SaLocale {
39
+ /** Narrows untrusted input to a locale the platform ships a catalog for. */
40
+ export function isSaBuiltinLocale(value: unknown): value is SaBuiltinLocale {
29
41
  return typeof value === 'string' && (SA_LOCALES as readonly string[]).includes(value);
30
42
  }
@@ -113,8 +113,7 @@ export const bundlesMessages = defineMessages(
113
113
  bundleKeyHint: 'API-stabil · wird aus dem Label erzeugt',
114
114
  errorKeyFormat: 'Nur A-Z, 0-9, Underscore; muss mit Buchstabe beginnen.',
115
115
  errorKeyExists: 'Dieser Bundle-Key existiert bereits.',
116
- descriptionPlaceholder:
117
- 'z. B. Kampagnen, WhatsApp und Korrespondenz für aktive Vereine.',
116
+ descriptionPlaceholder: 'z. B. Kampagnen, WhatsApp und Korrespondenz.',
118
117
  sectionPricing: 'v1 · Pricing & Gültigkeit',
119
118
  pricingHint: 'Monats- & Jahrespreis sowie Datum, ab dem das Bundle verkaufbar wird.',
120
119
  validFromImmediate: '✓ Bundle ist nach Anlage sofort live und verkaufbar.',
@@ -341,7 +340,7 @@ export const bundlesMessages = defineMessages(
341
340
  bundleKeyHint: 'API-stable · derived from the label',
342
341
  errorKeyFormat: 'Only A-Z, 0-9, underscore; must start with a letter.',
343
342
  errorKeyExists: 'This bundle key already exists.',
344
- descriptionPlaceholder: 'e.g. Campaigns, WhatsApp and correspondence for active clubs.',
343
+ descriptionPlaceholder: 'e.g. Campaigns, WhatsApp and correspondence.',
345
344
  sectionPricing: 'v1 · pricing & validity',
346
345
  pricingHint:
347
346
  'Monthly & yearly price plus the date from which the bundle becomes sellable.',
@@ -76,7 +76,7 @@ export const marketingMessages = defineMessages(
76
76
  ctaOverrideLabel: 'CTA-Text überschreiben (optional)',
77
77
  ctaOverrideHint: 'leer lassen für Auto-Text',
78
78
  topFeatureCount: '{count} Einträge',
79
- featureLabelPlaceholder: 'z. B. Beitragsverwaltung',
79
+ featureLabelPlaceholder: 'z. B. Erweiterte Berichte',
80
80
  featureStrongPlaceholder: 'Highlight, z. B. bis 100',
81
81
  moveUp: 'Nach oben',
82
82
  moveDown: 'Nach unten',
@@ -209,7 +209,7 @@ export const marketingMessages = defineMessages(
209
209
  ctaOverrideLabel: 'Override CTA text (optional)',
210
210
  ctaOverrideHint: 'leave empty for the auto text',
211
211
  topFeatureCount: '{count} entries',
212
- featureLabelPlaceholder: 'e.g. Membership management',
212
+ featureLabelPlaceholder: 'e.g. Advanced reporting',
213
213
  featureStrongPlaceholder: 'Highlight, e.g. up to 100',
214
214
  moveUp: 'Move up',
215
215
  moveDown: 'Move down',
@@ -6,11 +6,11 @@
6
6
  import type { StandardPageKey } from '@saasicat/types';
7
7
 
8
8
  import { defineMessages } from '../define.js';
9
- import type { SaLocale } from '../locale.js';
9
+ import type { SaBuiltinLocale } from '../locale.js';
10
10
 
11
11
  // Type alias (not interface) so the shape keeps an implicit index signature
12
12
  // and stays assignable to `MessageTree`.
13
- type NavNamespace = {
13
+ export type SaNavMessages = {
14
14
  readonly pages: Record<StandardPageKey, string>;
15
15
  readonly sections: {
16
16
  readonly overview: string;
@@ -20,7 +20,7 @@ type NavNamespace = {
20
20
  };
21
21
  };
22
22
 
23
- export const navMessages: Record<SaLocale, NavNamespace> = defineMessages(
23
+ export const navMessages: Record<SaBuiltinLocale, SaNavMessages> = defineMessages(
24
24
  {
25
25
  pages: {
26
26
  dashboard: 'Dashboard',
@@ -3,7 +3,7 @@
3
3
  // namespace files via `defineMessages`.
4
4
 
5
5
  import { mergeMessages, type PartialMessages } from './define.js';
6
- import type { SaLocale } from './locale.js';
6
+ import type { SaBuiltinLocale } from './locale.js';
7
7
  import { auditMessages } from './messages/audit.js';
8
8
  import { bundlesMessages } from './messages/bundles.js';
9
9
  import { commonMessages } from './messages/common.js';
@@ -46,7 +46,7 @@ export type SaMessages = {
46
46
  /** Shape of app-side string overrides (deep partial of the catalog). */
47
47
  export type SaMessagesOverrides = PartialMessages<SaMessages>;
48
48
 
49
- function catalogFor(locale: SaLocale): SaMessages {
49
+ function catalogFor(locale: SaBuiltinLocale): SaMessages {
50
50
  return {
51
51
  common: commonMessages[locale],
52
52
  nav: navMessages[locale],
@@ -69,7 +69,7 @@ function catalogFor(locale: SaLocale): SaMessages {
69
69
  }
70
70
 
71
71
  /** Complete, immutable message catalogs per locale. */
72
- export const SA_MESSAGES: Record<SaLocale, SaMessages> = {
72
+ export const SA_MESSAGES: Record<SaBuiltinLocale, SaMessages> = {
73
73
  de: catalogFor('de'),
74
74
  en: catalogFor('en'),
75
75
  };
@@ -78,7 +78,10 @@ export const SA_MESSAGES: Record<SaLocale, SaMessages> = {
78
78
  * Returns the platform catalog for `locale`, optionally overlaid with
79
79
  * app-side overrides.
80
80
  */
81
- export function resolveMessages(locale: SaLocale, overrides?: SaMessagesOverrides): SaMessages {
81
+ export function resolveMessages(
82
+ locale: SaBuiltinLocale,
83
+ overrides?: SaMessagesOverrides,
84
+ ): SaMessages {
82
85
  if (!overrides) return SA_MESSAGES[locale];
83
86
  return mergeMessages(SA_MESSAGES[locale], overrides);
84
87
  }
@@ -24,3 +24,4 @@ export * from './action-registry.js';
24
24
  export * from './batch-column-fetcher.js';
25
25
  export * from './admin-resource-client.js';
26
26
  export * from './i18n/index.js';
27
+ export * from './login-branding.js';
@@ -0,0 +1,61 @@
1
+ // Brand presentation of the login and first-run cards: what the public boot
2
+ // endpoint reports, falling back to the branding the app configured.
3
+ //
4
+ // Pure projections, kept out of the SFC so they can be tested without a DOM —
5
+ // and so the guards below cannot quietly go missing again. A boot response
6
+ // whose `project` is absent used to throw here and take the whole card with it
7
+ // on the next render.
8
+
9
+ /** The slice of the public boot response these cards read. */
10
+ export interface LoginBootProject {
11
+ displayName?: string | null;
12
+ label?: string | null;
13
+ icon?: string | null;
14
+ logoUrl?: string | null;
15
+ environment?: string | null;
16
+ }
17
+
18
+ /** App-configured branding, used wherever boot says nothing. */
19
+ export interface LoginBrandFallback {
20
+ name?: string;
21
+ tag?: string;
22
+ logoText?: string;
23
+ }
24
+
25
+ export interface LoginBranding {
26
+ name: string;
27
+ tag: string;
28
+ icon: string;
29
+ logoUrl: string | null;
30
+ /** Environment worth showing, i.e. anything but production. `null` hides it. */
31
+ environment: string | null;
32
+ }
33
+
34
+ const DEFAULT_TAG = 'SuperAdmin';
35
+
36
+ /**
37
+ * `boot` is whatever the endpoint returned — possibly `null`, possibly an
38
+ * object without `project`. Both are treated as "boot said nothing" rather
39
+ * than as a reason to fail.
40
+ */
41
+ export function resolveLoginBranding(
42
+ boot: { project?: LoginBootProject | null } | null | undefined,
43
+ fallback: LoginBrandFallback = {},
44
+ ): LoginBranding {
45
+ const project = boot?.project ?? null;
46
+ const environment = project?.environment ?? null;
47
+ return {
48
+ name: project?.displayName || fallback.name || '',
49
+ tag: project?.label || fallback.tag || DEFAULT_TAG,
50
+ icon: project?.icon || fallback.logoText || '',
51
+ logoUrl: project?.logoUrl || null,
52
+ environment: environment && environment !== 'production' ? environment : null,
53
+ };
54
+ }
55
+
56
+ /** Test credentials must never be printed in production, whatever boot omits. */
57
+ export function isProductionBoot(
58
+ boot: { project?: LoginBootProject | null } | null | undefined,
59
+ ): boolean {
60
+ return boot?.project?.environment === 'production';
61
+ }
@@ -11,8 +11,8 @@
11
11
 
12
12
  import type { AdminManifest, StandardPageDef, StandardPageKey } from '@saasicat/types';
13
13
 
14
- import { DEFAULT_SA_LOCALE, type SaLocale } from './i18n/locale.js';
15
- import { navMessages } from './i18n/messages/nav.js';
14
+ import { DEFAULT_SA_LOCALE, isSaBuiltinLocale, type SaLocale } from './i18n/locale.js';
15
+ import { navMessages, type SaNavMessages } from './i18n/messages/nav.js';
16
16
 
17
17
  /**
18
18
  * Default routes for the platform standard pages. Consumers may override this
@@ -64,8 +64,17 @@ export interface NavBuilderOptions {
64
64
  * UI locale for the default labels and section names, default `'de'`.
65
65
  * Pass the same locale to `buildSidebar()`'s `sectionOrder` (via
66
66
  * `defaultSectionOrder(locale)`) — section names are compared as strings.
67
+ *
68
+ * Only resolves the two catalogs the platform ships. Prefer `nav`, which
69
+ * carries app overrides and app-supplied languages as well.
67
70
  */
68
71
  locale?: SaLocale;
72
+ /**
73
+ * Resolved `nav` namespace, as `useSuperAdminI18n().messages.value.nav`
74
+ * hands it out. Wins over `locale`, and is the only way the sidebar sees
75
+ * `i18n.overrides` or a language the app added itself.
76
+ */
77
+ nav?: SaNavMessages;
69
78
  /**
70
79
  * Optional: overrides the default routes for certain standard pages.
71
80
  * Consumers set this if they want an alternative URL structure.
@@ -123,13 +132,31 @@ const PAGE_SECTIONS: Partial<Record<StandardPageKey, SectionKey>> = {
123
132
  platformEmailHistory: 'system',
124
133
  };
125
134
 
135
+ /**
136
+ * The catalog to draw labels from. `nav` wins because it already carries app
137
+ * overrides and app-supplied languages; `locale` only ever reaches the two
138
+ * catalogs the platform ships.
139
+ */
140
+ function resolveNav(options: NavBuilderOptions): SaNavMessages {
141
+ return options.nav ?? builtinNav(options.locale);
142
+ }
143
+
144
+ /** An unknown locale falls back rather than crashing the whole sidebar. */
145
+ function builtinNav(locale: SaLocale | undefined): SaNavMessages {
146
+ return navMessages[isSaBuiltinLocale(locale) ? locale : DEFAULT_SA_LOCALE];
147
+ }
148
+
126
149
  /**
127
150
  * Localized default section names in drawer order (Übersicht → Produktkatalog
128
151
  * → Kunden → System). Pass the result to `buildSidebar()` when building routes
129
- * with a non-default locale.
152
+ * with a non-default locale, or hand it the resolved `nav` catalog directly —
153
+ * section names are compared as strings, so both sides must come from the same
154
+ * source.
130
155
  */
131
- export function defaultSectionOrder(locale: SaLocale = DEFAULT_SA_LOCALE): readonly string[] {
132
- const sections = navMessages[locale].sections;
156
+ export function defaultSectionOrder(
157
+ locale: SaLocale | SaNavMessages = DEFAULT_SA_LOCALE,
158
+ ): readonly string[] {
159
+ const sections = typeof locale === 'string' ? builtinNav(locale).sections : locale.sections;
133
160
  return [sections.overview, sections.catalog, sections.customers, sections.system];
134
161
  }
135
162
 
@@ -149,7 +176,7 @@ export function buildRoutes(
149
176
 
150
177
  // StandardPages
151
178
  const standard = manifest.navigation?.standardPages ?? {};
152
- const nav = navMessages[options.locale ?? DEFAULT_SA_LOCALE];
179
+ const nav = resolveNav(options);
153
180
  const defaultNavSections: Partial<Record<StandardPageKey, string>> = Object.fromEntries(
154
181
  Object.entries(PAGE_SECTIONS).map(([page, section]) => [page, nav.sections[section]]),
155
182
  );
@@ -6,20 +6,20 @@
6
6
  dense
7
7
  no-caps
8
8
  icon="language"
9
- :label="SA_LOCALE_LABELS[locale]"
9
+ :label="activeLabel"
10
10
  :aria-label="msg.header.language"
11
11
  >
12
12
  <q-menu auto-close anchor="bottom right" self="top right">
13
13
  <q-list dense class="sa-locale-switcher__list">
14
14
  <q-item
15
- v-for="option in SA_LOCALES"
16
- :key="option"
15
+ v-for="option in availableLocales"
16
+ :key="option.code"
17
17
  clickable
18
- :active="option === locale"
19
- @click="select(option)"
18
+ :active="option.code === locale"
19
+ @click="select(option.code)"
20
20
  >
21
- <q-item-section>{{ SA_LOCALE_LABELS[option] }}</q-item-section>
22
- <q-item-section v-if="option === locale" side>
21
+ <q-item-section>{{ option.label }}</q-item-section>
22
+ <q-item-section v-if="option.code === locale" side>
23
23
  <q-icon name="check" size="16px" />
24
24
  </q-item-section>
25
25
  </q-item>
@@ -29,7 +29,9 @@
29
29
  </template>
30
30
 
31
31
  <script setup lang="ts">
32
- import { SA_LOCALES, SA_LOCALE_LABELS, type SaLocale } from '../client/i18n/index.js';
32
+ import { computed } from 'vue';
33
+
34
+ import type { SaLocale, SaLocaleOption } from '../client/i18n/index.js';
33
35
  import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
34
36
 
35
37
  // Language switcher for the shell chrome. Writes straight to the shared i18n
@@ -39,11 +41,11 @@ import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js
39
41
  // The button inherits its color from the surrounding chrome (`currentColor`),
40
42
  // so it needs no variant prop — each host sets the color it wants.
41
43
  //
42
- // It renders nothing when the app opted out of the switcher or handed over a
43
- // locale it does not allow writing to; hosts therefore embed it unconditionally
44
- // and never have to repeat that check.
44
+ // It renders nothing when the app opted out, offers a single language, or
45
+ // handed over a locale it does not allow writing to; hosts therefore embed it
46
+ // unconditionally and never have to repeat that check.
45
47
 
46
- const { locale, switcherEnabled } = useSuperAdminI18n();
48
+ const { locale, switcherEnabled, availableLocales } = useSuperAdminI18n();
47
49
 
48
50
  // Only an explicit `false` hides the switcher. The injection key is a
49
51
  // `Symbol.for`, so a context created by an older copy of this package resolves
@@ -53,6 +55,13 @@ const { locale, switcherEnabled } = useSuperAdminI18n();
53
55
  const visible = switcherEnabled !== false;
54
56
  const msg = useSaMessages('shell');
55
57
 
58
+ // Same reason as above: such a context carries no `availableLocales` either, so
59
+ // the list has to tolerate its absence instead of throwing during render.
60
+ const options = computed<readonly SaLocaleOption[]>(() => availableLocales ?? []);
61
+ const activeLabel = computed(
62
+ () => options.value.find((option) => option.code === locale.value)?.label ?? locale.value,
63
+ );
64
+
56
65
  function select(next: SaLocale): void {
57
66
  locale.value = next;
58
67
  }
@@ -99,7 +99,7 @@ function statusOf(v: BundleVersionRow) {
99
99
  }
100
100
 
101
101
  function statusMetaOf(v: BundleVersionRow): BundleStatusMeta {
102
- return bundleStatusMeta(statusOf(v), locale.value);
102
+ return bundleStatusMeta(statusOf(v), msg.value);
103
103
  }
104
104
  </script>
105
105
 
@@ -6,8 +6,8 @@
6
6
 
7
7
  import type { BundleVersionRow, PlanVersionRow } from '@saasicat/types';
8
8
 
9
- import { SA_INTL_LOCALES, type SaLocale } from '../../client/i18n/locale.js';
10
- import { bundlesMessages } from '../../client/i18n/messages/bundles.js';
9
+ import { SA_INTL_LOCALES, type SaBuiltinLocale, type SaLocale } from '../../client/i18n/locale.js';
10
+ import type { SaMessages } from '../../client/i18n/messages.js';
11
11
 
12
12
  /** UI lifecycle status of a single BundleVersion. */
13
13
  export type BundleVersionUiStatus = 'draft' | 'live' | 'scheduled' | 'superseded';
@@ -62,12 +62,16 @@ const BUNDLE_STATUS_CLASS: Record<BundleAggregateStatus, BundleStatusMeta['cls']
62
62
  retired: 'supersed',
63
63
  };
64
64
 
65
- /** Label, chip class and tooltip for a status in the given UI locale. */
65
+ /**
66
+ * Label, chip class and tooltip for a status. Takes the resolved slice rather
67
+ * than a locale code — that is what lets an app-supplied language and
68
+ * `i18n.overrides` reach these labels.
69
+ */
66
70
  export function bundleStatusMeta(
67
71
  status: BundleAggregateStatus,
68
- locale: SaLocale,
72
+ bundles: SaMessages['bundles'],
69
73
  ): BundleStatusMeta {
70
- const texts = bundlesMessages[locale].status[status];
74
+ const texts = bundles.status[status];
71
75
  return { label: texts.label, cls: BUNDLE_STATUS_CLASS[status], tooltip: texts.tooltip };
72
76
  }
73
77
 
@@ -155,10 +159,13 @@ export function formatDate(iso: string | null | undefined, locale: SaLocale): st
155
159
  const day = iso.slice(0, 10);
156
160
  const [y, m, d] = day.split('-');
157
161
  if (!y || !m || !d) return iso;
158
- return new Date(`${day}T00:00:00Z`).toLocaleDateString(SA_INTL_LOCALES[locale], {
159
- day: '2-digit',
160
- month: '2-digit',
161
- year: 'numeric',
162
- timeZone: 'UTC',
163
- });
162
+ return new Date(`${day}T00:00:00Z`).toLocaleDateString(
163
+ SA_INTL_LOCALES[locale as SaBuiltinLocale] ?? locale,
164
+ {
165
+ day: '2-digit',
166
+ month: '2-digit',
167
+ year: 'numeric',
168
+ timeZone: 'UTC',
169
+ },
170
+ );
164
171
  }