@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.
- package/README.md +13 -10
- package/dist/{messages-BIYw32u1.d.ts → catalog-C4Gv2_KM.d.cts} +126 -39
- package/dist/{messages-BIYw32u1.d.cts → catalog-C4Gv2_KM.d.ts} +126 -39
- package/dist/{chunk-TY2WYKME.js → chunk-247RJPU4.js} +38 -11
- package/dist/{chunk-TBLXKSTR.js → chunk-IZGFS5RS.js} +90 -21
- package/dist/{chunk-66BUSVC5.js → chunk-XPSD5HKG.js} +30 -4
- package/dist/client/index.cjs +122 -22
- package/dist/client/index.d.cts +51 -5
- package/dist/client/index.d.ts +51 -5
- package/dist/client/index.js +14 -2
- package/dist/index.cjs +152 -30
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +17 -3
- package/dist/quasar/index.cjs +110 -14
- package/dist/quasar/index.d.cts +2 -2
- package/dist/quasar/index.d.ts +2 -2
- package/dist/quasar/index.js +2 -2
- package/dist/{use-super-admin-i18n-DL_qI1BY.d.cts → use-super-admin-i18n-BdPqeTHl.d.cts} +51 -5
- package/dist/{use-super-admin-i18n-CWNu_W3u.d.ts → use-super-admin-i18n-uteRCL9q.d.ts} +51 -5
- package/package.json +8 -3
- package/src/client/i18n/catalog.ts +120 -0
- package/src/client/i18n/currency.ts +7 -2
- package/src/client/i18n/define.ts +2 -2
- package/src/client/i18n/index.ts +1 -0
- package/src/client/i18n/locale.ts +52 -8
- package/src/client/i18n/messages/bundles.ts +2 -3
- package/src/client/i18n/messages/marketing.ts +2 -2
- package/src/client/i18n/messages/nav.ts +3 -3
- package/src/client/i18n/messages/shell.ts +2 -0
- package/src/client/i18n/messages.ts +7 -4
- package/src/client/index.ts +1 -0
- package/src/client/login-branding.ts +61 -0
- package/src/client/nav-builder.ts +33 -6
- package/src/client/types.ts +51 -14
- package/src/components/LocaleSwitcher.vue +77 -0
- package/src/components/bundle-editor/bundle-version-status.ts +16 -8
- package/src/pages-standard/AdminLayout.vue +9 -3
- package/src/pages-standard/SuperAdminLoginPage.vue +17 -9
- package/src/pages-standard/SuperAdminSetupWizard.vue +7 -0
- package/src/pages-standard/discovery-page/discovery-ui.ts +8 -8
- package/src/pages-standard/plan-versions/format.ts +2 -2
- 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 {
|
|
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<
|
|
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 };
|
package/src/client/i18n/index.ts
CHANGED
|
@@ -1,15 +1,59 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
8
|
+
/** Locales the platform itself ships a catalog for. */
|
|
9
|
+
export type SaBuiltinLocale = 'de' | 'en';
|
|
6
10
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
|
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<
|
|
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 {
|
|
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:
|
|
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<
|
|
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(
|
|
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
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -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(
|
|
132
|
-
|
|
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 =
|
|
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
|
);
|
package/src/client/types.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
37
|
-
|
|
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) =>
|
|
47
|
-
|
|
48
|
-
|
|
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 {
|
|
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(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
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
|
|
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;
|