@saasicat/ui-vue 0.15.1 → 0.17.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 (43) hide show
  1. package/README.md +13 -10
  2. package/dist/{messages-BIYw32u1.d.ts → catalog-C4Gv2_KM.d.cts} +126 -39
  3. package/dist/{messages-BIYw32u1.d.cts → catalog-C4Gv2_KM.d.ts} +126 -39
  4. package/dist/{chunk-TY2WYKME.js → chunk-247RJPU4.js} +38 -11
  5. package/dist/{chunk-TBLXKSTR.js → chunk-IZGFS5RS.js} +90 -21
  6. package/dist/{chunk-66BUSVC5.js → chunk-XPSD5HKG.js} +30 -4
  7. package/dist/client/index.cjs +122 -22
  8. package/dist/client/index.d.cts +51 -5
  9. package/dist/client/index.d.ts +51 -5
  10. package/dist/client/index.js +14 -2
  11. package/dist/index.cjs +152 -30
  12. package/dist/index.d.cts +5 -5
  13. package/dist/index.d.ts +5 -5
  14. package/dist/index.js +17 -3
  15. package/dist/quasar/index.cjs +110 -14
  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-DL_qI1BY.d.cts → use-super-admin-i18n-BdPqeTHl.d.cts} +51 -5
  20. package/dist/{use-super-admin-i18n-CWNu_W3u.d.ts → use-super-admin-i18n-uteRCL9q.d.ts} +51 -5
  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 +52 -8
  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/shell.ts +2 -0
  31. package/src/client/i18n/messages.ts +7 -4
  32. package/src/client/index.ts +1 -0
  33. package/src/client/login-branding.ts +61 -0
  34. package/src/client/nav-builder.ts +33 -6
  35. package/src/client/types.ts +51 -14
  36. package/src/components/LocaleSwitcher.vue +77 -0
  37. package/src/components/bundle-editor/bundle-version-status.ts +16 -8
  38. package/src/pages-standard/AdminLayout.vue +9 -3
  39. package/src/pages-standard/SuperAdminLoginPage.vue +17 -9
  40. package/src/pages-standard/SuperAdminSetupWizard.vue +7 -0
  41. package/src/pages-standard/discovery-page/discovery-ui.ts +8 -8
  42. package/src/pages-standard/plan-versions/format.ts +2 -2
  43. package/src/vue/use-super-admin-i18n.ts +116 -19
@@ -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,59 @@
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 & {});
8
17
 
9
- export const DEFAULT_SA_LOCALE: SaLocale = 'de';
18
+ /** The locales the platform ships. Apps narrow or extend this, see `i18n`. */
19
+ export const SA_LOCALES: readonly SaBuiltinLocale[] = ['de', 'en'];
10
20
 
11
- /** BCP-47 tags used for `Intl`/`toLocaleString` formatting per UI locale. */
12
- export const SA_INTL_LOCALES: Record<SaLocale, string> = {
21
+ export const DEFAULT_SA_LOCALE: SaBuiltinLocale = 'de';
22
+
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
  };
28
+
29
+ /**
30
+ * Names for the language switcher. Endonyms — a language is listed in its own
31
+ * language, so the entries stay readable no matter which locale is active and
32
+ * never need translating.
33
+ */
34
+ export const SA_LOCALE_LABELS: Record<SaBuiltinLocale, string> = {
35
+ de: 'Deutsch',
36
+ en: 'English',
37
+ };
38
+
39
+ /** Narrows untrusted input to a locale the platform ships a catalog for. */
40
+ export function isSaBuiltinLocale(value: unknown): value is SaBuiltinLocale {
41
+ return typeof value === 'string' && (SA_LOCALES as readonly string[]).includes(value);
42
+ }
43
+
44
+ /**
45
+ * The built-in catalog a locale reads from. Helper modules that index the raw
46
+ * catalogs (rather than the resolved one from `useSaMessages`) need this: a
47
+ * language an app added has no entry there, and indexing it would yield
48
+ * `undefined` and take the page down.
49
+ *
50
+ * Strings resolved this way stay in the fallback language for an app-supplied
51
+ * locale — migrating those helpers to the resolved catalog is tracked
52
+ * separately.
53
+ */
54
+ export function builtinLocaleOf(
55
+ locale: SaLocale | undefined,
56
+ fallback: SaBuiltinLocale = DEFAULT_SA_LOCALE,
57
+ ): SaBuiltinLocale {
58
+ return isSaBuiltinLocale(locale) ? locale : fallback;
59
+ }
@@ -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',
@@ -10,6 +10,7 @@ export const shellMessages = defineMessages(
10
10
  subtitle: 'SuperAdmin · Plattform-Verwaltung',
11
11
  roleBadge: 'SUPER ADMIN',
12
12
  logout: 'Abmelden',
13
+ language: 'Sprache',
13
14
  },
14
15
  drawer: {
15
16
  docs: 'Doku öffnen',
@@ -81,6 +82,7 @@ export const shellMessages = defineMessages(
81
82
  subtitle: 'SuperAdmin · Platform administration',
82
83
  roleBadge: 'SUPER ADMIN',
83
84
  logout: 'Sign out',
85
+ language: 'Language',
84
86
  },
85
87
  drawer: {
86
88
  docs: 'Open documentation',
@@ -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
  );
@@ -28,27 +28,64 @@ export interface KvStore {
28
28
  remove(key: string): void;
29
29
  }
30
30
 
31
+ /** Stand-in for when there is no usable `localStorage`. */
32
+ const NOOP_KV_STORE: KvStore = {
33
+ get: () => null,
34
+ set: () => {},
35
+ remove: () => {},
36
+ };
37
+
31
38
  /**
32
- * Returns a `KvStore` wrapper around `localStorage` (or `null` under SSR).
33
- * Tests may pass an in-memory stub.
39
+ * Returns a `KvStore` wrapper around `localStorage`, or a no-op store when the
40
+ * environment has none or refuses access. Tests may pass an in-memory stub.
41
+ *
42
+ * Every persistence built on this is a convenience — losing it must never take
43
+ * the app down, so no failure mode is allowed to escape.
34
44
  */
35
45
  export function defaultKvStore(): KvStore {
36
- if (typeof globalThis.localStorage === 'undefined') {
37
- // SSR / non-browser → no-op stub.
38
- return {
39
- get: () => null,
40
- set: () => {},
41
- remove: () => {},
42
- };
43
- }
44
- const ls = globalThis.localStorage;
46
+ const ls = resolveLocalStorage();
47
+ if (!ls) return NOOP_KV_STORE;
45
48
  return {
46
- get: (k) => ls.getItem(k),
47
- set: (k, v) => ls.setItem(k, v),
48
- remove: (k) => ls.removeItem(k),
49
+ get: (k) => {
50
+ try {
51
+ return ls.getItem(k);
52
+ } catch {
53
+ // See resolveLocalStorage: access can be revoked per call too.
54
+ return null;
55
+ }
56
+ },
57
+ set: (k, v) => {
58
+ try {
59
+ ls.setItem(k, v);
60
+ } catch {
61
+ // Full quota or Safari's private mode — the value is simply
62
+ // not remembered.
63
+ }
64
+ },
65
+ remove: (k) => {
66
+ try {
67
+ ls.removeItem(k);
68
+ } catch {
69
+ // Nothing to clean up if the store is unreachable.
70
+ }
71
+ },
49
72
  };
50
73
  }
51
74
 
75
+ /**
76
+ * `localStorage` is a throwing getter, not a plain property: a browser that
77
+ * blocks site data (cookie blocking, a sandboxed iframe) raises `SecurityError`
78
+ * on read, and `typeof` evaluates the getter rather than guarding it. The
79
+ * access therefore has to happen inside `try`.
80
+ */
81
+ function resolveLocalStorage(): Storage | null {
82
+ try {
83
+ return globalThis.localStorage ?? null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
52
89
  /**
53
90
  * Default `HttpClient` over `fetch`. Consumers pass their own variant
54
91
  * through when they need auth headers / tenant headers / retry logic.
@@ -0,0 +1,77 @@
1
+ <template>
2
+ <q-btn
3
+ v-if="visible"
4
+ class="sa-locale-switcher"
5
+ flat
6
+ dense
7
+ no-caps
8
+ icon="language"
9
+ :label="activeLabel"
10
+ :aria-label="msg.header.language"
11
+ >
12
+ <q-menu auto-close anchor="bottom right" self="top right">
13
+ <q-list dense class="sa-locale-switcher__list">
14
+ <q-item
15
+ v-for="option in availableLocales"
16
+ :key="option.code"
17
+ clickable
18
+ :active="option.code === locale"
19
+ @click="select(option.code)"
20
+ >
21
+ <q-item-section>{{ option.label }}</q-item-section>
22
+ <q-item-section v-if="option.code === locale" side>
23
+ <q-icon name="check" size="16px" />
24
+ </q-item-section>
25
+ </q-item>
26
+ </q-list>
27
+ </q-menu>
28
+ </q-btn>
29
+ </template>
30
+
31
+ <script setup lang="ts">
32
+ import { computed } from 'vue';
33
+
34
+ import type { SaLocale, SaLocaleOption } from '../client/i18n/index.js';
35
+ import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
36
+
37
+ // Language switcher for the shell chrome. Writes straight to the shared i18n
38
+ // locale, so every catalog text, the sidebar labels and `Intl` formatting
39
+ // follow on the next render.
40
+ //
41
+ // The button inherits its color from the surrounding chrome (`currentColor`),
42
+ // so it needs no variant prop — each host sets the color it wants.
43
+ //
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.
47
+
48
+ const { locale, switcherEnabled, availableLocales } = useSuperAdminI18n();
49
+
50
+ // Only an explicit `false` hides the switcher. The injection key is a
51
+ // `Symbol.for`, so a context created by an older copy of this package resolves
52
+ // here just as well — and that one has no `switcherEnabled` at all. Treating
53
+ // the missing field as "off" would make every switcher vanish after such an
54
+ // upgrade, without a warning anywhere. Chrome fails open.
55
+ const visible = switcherEnabled !== false;
56
+ const msg = useSaMessages('shell');
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
+
65
+ function select(next: SaLocale): void {
66
+ locale.value = next;
67
+ }
68
+ </script>
69
+
70
+ <style scoped>
71
+ .sa-locale-switcher {
72
+ color: inherit;
73
+ }
74
+ .sa-locale-switcher__list {
75
+ min-width: 160px;
76
+ }
77
+ </style>
@@ -6,7 +6,12 @@
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';
9
+ import {
10
+ SA_INTL_LOCALES,
11
+ builtinLocaleOf,
12
+ type SaBuiltinLocale,
13
+ type SaLocale,
14
+ } from '../../client/i18n/locale.js';
10
15
  import { bundlesMessages } from '../../client/i18n/messages/bundles.js';
11
16
 
12
17
  /** UI lifecycle status of a single BundleVersion. */
@@ -67,7 +72,7 @@ export function bundleStatusMeta(
67
72
  status: BundleAggregateStatus,
68
73
  locale: SaLocale,
69
74
  ): BundleStatusMeta {
70
- const texts = bundlesMessages[locale].status[status];
75
+ const texts = bundlesMessages[builtinLocaleOf(locale)].status[status];
71
76
  return { label: texts.label, cls: BUNDLE_STATUS_CLASS[status], tooltip: texts.tooltip };
72
77
  }
73
78
 
@@ -155,10 +160,13 @@ export function formatDate(iso: string | null | undefined, locale: SaLocale): st
155
160
  const day = iso.slice(0, 10);
156
161
  const [y, m, d] = day.split('-');
157
162
  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
- });
163
+ return new Date(`${day}T00:00:00Z`).toLocaleDateString(
164
+ SA_INTL_LOCALES[locale as SaBuiltinLocale] ?? locale,
165
+ {
166
+ day: '2-digit',
167
+ month: '2-digit',
168
+ year: 'numeric',
169
+ timeZone: 'UTC',
170
+ },
171
+ );
164
172
  }
@@ -14,6 +14,7 @@
14
14
  </q-toolbar-title>
15
15
  <q-space />
16
16
  <slot name="header-actions" />
17
+ <LocaleSwitcher class="q-mr-sm" />
17
18
  <q-badge class="sa-admin-badge q-mr-sm">{{ msg.header.roleBadge }}</q-badge>
18
19
  <div class="sa-admin-user">
19
20
  <q-avatar size="32px" class="sa-admin-user__avatar">
@@ -97,6 +98,7 @@ import { computed, inject, ref } from 'vue';
97
98
  import { useRoute } from 'vue-router';
98
99
  import type { AdminManifest, StandardPageKey } from '@saasicat/types';
99
100
  import { buildRoutes, buildSidebar, defaultSectionOrder } from '../client/nav-builder.js';
101
+ import LocaleSwitcher from '../components/LocaleSwitcher.vue';
100
102
  import { SUPER_ADMIN_BRAND_KEY, SUPER_ADMIN_MANIFEST_KEY } from '../vue/super-admin-context.js';
101
103
  import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
102
104
  import { ADMIN_UI_VERSION } from '../client/version.js';
@@ -187,7 +189,7 @@ const emit = defineEmits<{
187
189
 
188
190
  const route = useRoute();
189
191
  const msg = useSaMessages('shell');
190
- const { locale } = useSuperAdminI18n();
192
+ const { messages } = useSuperAdminI18n();
191
193
  // Provided by createSuperAdminApp(); null when the layout is mounted
192
194
  // stand-alone. Grouped into one object so the names cannot shadow the
193
195
  // same-named props inside the template.
@@ -225,13 +227,17 @@ const navSections = computed<NavSection[]>(() => {
225
227
  // section, so that the UI has no flicker before manifest load.
226
228
  return [{ title: null, items: [...props.staticNavFallback, ...props.localItems] }];
227
229
  }
230
+ // The resolved catalog rather than the bare locale: it carries
231
+ // `i18n.overrides` and languages the app added itself, neither of which the
232
+ // builder can look up from a locale code.
233
+ const nav = messages.value.nav;
228
234
  const routes = buildRoutes(m, {
229
- locale: locale.value,
235
+ nav,
230
236
  standardPageRoutes: props.standardPageRoutes,
231
237
  standardPageNavSection: props.standardPageNavSection,
232
238
  availableExtensions: props.availableExtensions,
233
239
  });
234
- const sections = buildSidebar(routes, props.sectionOrder ?? defaultSectionOrder(locale.value));
240
+ const sections = buildSidebar(routes, props.sectionOrder ?? defaultSectionOrder(nav));
235
241
  const result: NavSection[] = sections.map((s) => ({
236
242
  title: s.section,
237
243
  items: s.items.map((item) => ({
@@ -18,6 +18,7 @@
18
18
  <div class="sa-login-brand__name">{{ brandName }}</div>
19
19
  <div class="sa-login-brand__tag">{{ tagText }}</div>
20
20
  </div>
21
+ <LocaleSwitcher class="sa-login-locale" />
21
22
  </div>
22
23
 
23
24
  <h1 class="sa-login-title">{{ msg.login.signIn }}</h1>
@@ -97,7 +98,9 @@ import {
97
98
  useSuperAdminLoginAdapter,
98
99
  } from '../vue/use-super-admin-context.js';
99
100
  import { getJson } from '../client/http-json.js';
101
+ import { isProductionBoot, resolveLoginBranding } from '../client/login-branding.js';
100
102
  import { useSaMessages } from '../vue/use-super-admin-i18n.js';
103
+ import LocaleSwitcher from '../components/LocaleSwitcher.vue';
101
104
  import SuperAdminSetupWizard from './SuperAdminSetupWizard.vue';
102
105
 
103
106
  interface Props {
@@ -126,18 +129,16 @@ const errorMessage = ref<string | null>(null);
126
129
  // wizard instead of the login. Apps without a SetupModule return 404 → stays false.
127
130
  const needsSetup = ref(false);
128
131
 
129
- const brandName = computed(() => boot.boot.value?.project.displayName ?? brand.name);
130
- const tagText = computed(() => boot.boot.value?.project.label ?? brand.tag ?? 'SuperAdmin');
131
- const iconText = computed(() => boot.boot.value?.project.icon ?? brand.logoText);
132
- const logoUrl = computed(() => boot.boot.value?.project.logoUrl ?? null);
133
- const bootEnvironment = computed(() => {
134
- const env = boot.boot.value?.project.environment;
135
- return env && env !== 'production' ? env : null;
136
- });
132
+ const branding = computed(() => resolveLoginBranding(boot.boot.value, brand));
133
+ const brandName = computed(() => branding.value.name);
134
+ const tagText = computed(() => branding.value.tag);
135
+ const iconText = computed(() => branding.value.icon);
136
+ const logoUrl = computed(() => branding.value.logoUrl);
137
+ const bootEnvironment = computed(() => branding.value.environment);
137
138
 
138
139
  const devHint = computed(() => {
139
140
  if (!adapter.devHint) return null;
140
- if (boot.boot.value?.project.environment === 'production') return null;
141
+ if (isProductionBoot(boot.boot.value)) return null;
141
142
  return adapter.devHint;
142
143
  });
143
144
 
@@ -193,6 +194,13 @@ async function handleSubmit(): Promise<void> {
193
194
  </script>
194
195
 
195
196
  <style scoped>
197
+ .sa-login-locale {
198
+ /* In the card's flow rather than floating over it: a fixed overlay lands
199
+ on top of the centered card on short viewports. */
200
+ margin-left: auto;
201
+ align-self: flex-start;
202
+ color: var(--sa-login-tag-color, #475569);
203
+ }
196
204
  .sa-login-wrap {
197
205
  min-height: 100vh;
198
206
  display: flex;