@saasicat/ui-vue 0.24.2 → 0.26.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 (90) hide show
  1. package/README.md +32 -8
  2. package/dist/{catalog-DJcfm5N2.d.cts → catalog-Dch1Ryw0.d.cts} +353 -69
  3. package/dist/{catalog-DJcfm5N2.d.ts → catalog-Dch1Ryw0.d.ts} +353 -69
  4. package/dist/{chunk-F7NRM6KI.js → chunk-BPF2BMCQ.js} +748 -24
  5. package/dist/{chunk-D74FFCCP.js → chunk-NEXTZZRQ.js} +95 -177
  6. package/dist/{chunk-TQQMKWN6.js → chunk-O3J3ITF2.js} +119 -7
  7. package/dist/client/index.cjs +758 -83
  8. package/dist/client/index.d.cts +414 -9
  9. package/dist/client/index.d.ts +414 -9
  10. package/dist/client/index.js +84 -12
  11. package/dist/index.cjs +1325 -294
  12. package/dist/index.d.cts +205 -15
  13. package/dist/index.d.ts +205 -15
  14. package/dist/index.js +566 -240
  15. package/dist/quasar/index.cjs +574 -62
  16. package/dist/quasar/index.d.cts +61 -6
  17. package/dist/quasar/index.d.ts +61 -6
  18. package/dist/quasar/index.js +83 -19
  19. package/dist/{use-sa-theme-OUBlaqgl.d.ts → resource-registry-D7Xjs-5f.d.cts} +157 -7
  20. package/dist/{use-sa-theme-B5t7oXph.d.cts → resource-registry-TaR7rtq0.d.ts} +157 -7
  21. package/package.json +4 -3
  22. package/src/client/admin-error.ts +552 -0
  23. package/src/client/admin-resource-client.ts +9 -12
  24. package/src/client/batch-column-fetcher.ts +2 -2
  25. package/src/client/boot-loader.ts +12 -1
  26. package/src/client/http/axios-http-client.ts +383 -0
  27. package/src/client/http/fetch-http-client.ts +89 -0
  28. package/src/client/http/index.ts +6 -0
  29. package/src/client/http-json.ts +82 -16
  30. package/src/client/i18n/messages/discovery.ts +0 -6
  31. package/src/client/i18n/messages/errors.ts +44 -0
  32. package/src/client/i18n/messages/marketing.ts +0 -6
  33. package/src/client/i18n/messages/promos.ts +0 -2
  34. package/src/client/i18n/messages/shell.ts +12 -0
  35. package/src/client/i18n/messages.ts +3 -0
  36. package/src/client/index.ts +6 -0
  37. package/src/client/manifest-loader.ts +65 -15
  38. package/src/client/resources/audit.resource.ts +25 -0
  39. package/src/client/resources/define-resource.ts +166 -0
  40. package/src/client/resources/index.ts +42 -0
  41. package/src/client/resources/list-resource.ts +176 -0
  42. package/src/client/resources/plans.resource.ts +169 -0
  43. package/src/client/resources/resource-request.ts +117 -0
  44. package/src/client/resources/tenants.resource.ts +27 -0
  45. package/src/client/types.ts +10 -3
  46. package/src/components/BundleVersionPublishDialog.vue +1 -1
  47. package/src/components/MarketingPromotionsTab.vue +263 -241
  48. package/src/components/ThemeSwitcher.vue +85 -0
  49. package/src/components/admin-page/AdminAccordion.vue +125 -0
  50. package/src/components/bundle-editor/BundleCreatePanel.vue +1 -1
  51. package/src/components/dialogs/PromoCodeDialogFields.vue +20 -20
  52. package/src/components/plan/PlanCycleToggle.vue +1 -1
  53. package/src/components/plan-detail/PlanVersionsPanel.vue +5 -2
  54. package/src/index.ts +5 -0
  55. package/src/pages-standard/AdminLayout.vue +57 -0
  56. package/src/pages-standard/AdminManifestErrorPage.vue +4 -3
  57. package/src/pages-standard/BundlesPage.vue +5 -35
  58. package/src/pages-standard/DiscoveryPage.vue +8 -1
  59. package/src/pages-standard/MarketingCatalogPage.vue +14 -3
  60. package/src/pages-standard/SuperAdminLoginPage.vue +23 -2
  61. package/src/pages-standard/SuperAdminSetupWizard.vue +37 -3
  62. package/src/pages-standard/bundles-page/BundleAccordionList.vue +30 -32
  63. package/src/pages-standard/discovery-page/DiscoveryFeatureCard.vue +80 -88
  64. package/src/pages-standard/discovery-page/DiscoveryQuotaCard.vue +73 -79
  65. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +44 -6
  66. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +8 -2
  67. package/src/pages-tenant/PackageSnapshotPanel.vue +31 -2
  68. package/src/quasar/confirm.ts +83 -0
  69. package/src/quasar/create-super-admin-app.ts +84 -10
  70. package/src/quasar/dark-bridge.ts +47 -10
  71. package/src/quasar/index.ts +1 -0
  72. package/src/ui/theme/components/accordion.css +118 -0
  73. package/src/ui/theme/index.css +1 -0
  74. package/src/vue/create-admin-routes.ts +9 -1
  75. package/src/vue/platform-loaders.ts +12 -2
  76. package/src/vue/resource-registry.ts +211 -0
  77. package/src/vue/super-admin-context.ts +20 -5
  78. package/src/vue/ui-confirm.ts +76 -0
  79. package/src/vue/use-api-list.ts +50 -45
  80. package/src/vue/use-async-action.ts +170 -0
  81. package/src/vue/use-async-data.ts +83 -0
  82. package/src/vue/use-bundles.ts +34 -7
  83. package/src/vue/use-catalog-entries.ts +37 -12
  84. package/src/vue/use-discovery.ts +27 -6
  85. package/src/vue/use-marketing-projections.ts +29 -7
  86. package/src/vue/use-plans.ts +36 -8
  87. package/src/vue/use-promotions.ts +27 -7
  88. package/src/vue/use-resource-list.ts +272 -0
  89. package/src/vue/use-sa-theme.ts +34 -5
  90. package/src/vue/use-tenant-subscription-bundles.ts +22 -3
@@ -0,0 +1,85 @@
1
+ <template>
2
+ <q-btn
3
+ v-if="visible"
4
+ class="sa-theme-switcher"
5
+ flat
6
+ dense
7
+ no-caps
8
+ icon="contrast"
9
+ :label="activeLabel"
10
+ :aria-label="msg.header.theme?.label"
11
+ >
12
+ <q-menu auto-close anchor="bottom right" self="top right">
13
+ <q-list dense class="sa-theme-switcher__list">
14
+ <q-item
15
+ v-for="option in options"
16
+ :key="option.scheme"
17
+ clickable
18
+ :active="option.scheme === scheme"
19
+ @click="select(option.scheme)"
20
+ >
21
+ <q-item-section>{{ option.label }}</q-item-section>
22
+ <q-item-section v-if="option.scheme === scheme" 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 { useSaMessages } from '../vue/use-super-admin-i18n.js';
35
+ import { SA_COLOR_SCHEMES, useSaTheme, type SaColorScheme } from '../vue/use-sa-theme.js';
36
+
37
+ // Colour-scheme switcher for the shell chrome, the sibling of
38
+ // `LocaleSwitcher` — same button, same menu, same fail-open rule, and the two
39
+ // sit next to each other in every header that carries one.
40
+ //
41
+ // It writes the PICKED scheme, never the resolved one. That distinction is the
42
+ // whole reason the control has three entries instead of a toggle: 'system' is
43
+ // a standing instruction to follow the operating system, and collapsing it to
44
+ // whatever the machine says right now silently turns it into a fourth state
45
+ // nobody chose.
46
+ //
47
+ // The button inherits its color from the surrounding chrome (`currentColor`),
48
+ // so it needs no variant prop — each host sets the color it wants. The header
49
+ // is permanently dark in both themes and its foreground is an
50
+ // `--sa-color-inverse-*` role; inheriting is what keeps this button out of that
51
+ // decision.
52
+
53
+ const { scheme, switcherEnabled } = useSaTheme();
54
+ const msg = useSaMessages('shell');
55
+
56
+ // Only an explicit `false` hides the switcher. The injection key is a
57
+ // `Symbol.for`, so a context created by an older copy of this package resolves
58
+ // here just as well — and that one has no `switcherEnabled` at all. Treating
59
+ // the missing field as "off" would make every switcher vanish after such an
60
+ // upgrade, without a warning anywhere. Chrome fails open.
61
+ const visible = switcherEnabled !== false;
62
+
63
+ // Same reason as above, on the i18n side: a catalog from before these keys
64
+ // existed has no `header.theme` at all, and reading through it unguarded turns
65
+ // an upgrade skew into a render-time exception instead of a missing word.
66
+ const options = computed(() =>
67
+ SA_COLOR_SCHEMES.map((value) => ({
68
+ scheme: value,
69
+ label: msg.value.header.theme?.[value] ?? value,
70
+ })),
71
+ );
72
+ const activeLabel = computed(
73
+ () => options.value.find((option) => option.scheme === scheme.value)?.label ?? scheme.value,
74
+ );
75
+
76
+ function select(next: SaColorScheme): void {
77
+ scheme.value = next;
78
+ }
79
+ </script>
80
+
81
+ <style scoped>
82
+ .sa-theme-switcher {
83
+ color: inherit;
84
+ }
85
+ </style>
@@ -0,0 +1,125 @@
1
+ <template>
2
+ <div class="sa-accordion" :class="{ 'sa-accordion--open': open }">
3
+ <div class="sa-accordion__head">
4
+ <button
5
+ :id="triggerId"
6
+ type="button"
7
+ class="sa-accordion__trigger"
8
+ :aria-expanded="open"
9
+ :aria-controls="bodyId"
10
+ :disabled="disabled"
11
+ @click="emit('update:open', !open)"
12
+ >
13
+ <span
14
+ v-if="$slots.mark"
15
+ class="sa-accordion__mark"
16
+ :class="`sa-accordion__mark--${markTone}`"
17
+ >
18
+ <slot name="mark" />
19
+ </span>
20
+ <span class="sa-accordion__label"><slot name="header" /></span>
21
+ <q-icon name="chevron_right" size="18px" class="sa-accordion__chev" />
22
+ </button>
23
+ <div v-if="$slots['header-actions']" class="sa-accordion__actions">
24
+ <slot name="header-actions" />
25
+ </div>
26
+ </div>
27
+ <div
28
+ v-if="open"
29
+ :id="bodyId"
30
+ role="region"
31
+ :aria-labelledby="triggerId"
32
+ class="sa-accordion__body"
33
+ >
34
+ <slot />
35
+ </div>
36
+ </div>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+ import { useId } from 'vue';
41
+
42
+ // One disclosure: a header that opens a body. Eight surfaces in this package
43
+ // built their own, in three incompatible idioms, and agreed on nothing —
44
+ // three header paddings, three body paddings, two radii, two colours for the
45
+ // open border, two transition durations, and hover feedback on exactly one of
46
+ // six despite five setting `cursor: pointer`.
47
+ //
48
+ // The reason is not that eight decisions were taken. It is that none was: there
49
+ // is an `AdminTable` and an `AdminSection`, and for "open this" there was
50
+ // nothing, so every page wrote it again. `admin-page-shell.test.ts` rule 14
51
+ // records the same story for tables — "nine pages used QTable directly and
52
+ // agreed on nothing".
53
+ //
54
+ // **The trigger is a `<button>`, and that is the point of the component.**
55
+ // Four of the eight were a `<div>` with a click handler: no `tabindex`, no
56
+ // keyboard, and a screen reader announcing text rather than a control. A grep
57
+ // for `aria-expanded` across the whole package returned nothing at all. A
58
+ // shared component has that argument once; eight surfaces have to win it eight
59
+ // times, and the score was 4:4.
60
+ //
61
+ // `header-actions` is a SIBLING of the button rather than a slot inside it, and
62
+ // both halves of that matter. A control inside the trigger would fire the
63
+ // toggle on its way past — the discovery cards put a live status control in
64
+ // their clickable header — and interactive content nested in a `<button>` is
65
+ // not valid HTML, so the browser's own behaviour there is not something to
66
+ // rely on.
67
+ //
68
+ // The state is NOT owned here. Five of the eight take it from the page
69
+ // (`openKey === row.id`), and in the bundle list opening one card while closing
70
+ // another is tied to loading that bundle's versions — a component that owned a
71
+ // boolean would break that. The other four simply hold the id, and lose nothing
72
+ // by the arrangement. `v-model:open` keeps the page in charge either way.
73
+ //
74
+ // The body is `v-if`, which is what all seven JS implementations already did.
75
+ // Uniform by accident until now; uniform on purpose from here.
76
+ const {
77
+ open,
78
+ disabled = false,
79
+ markTone = 'accent',
80
+ } = defineProps<{
81
+ /** Whether the body is shown. The page owns this — see above. */
82
+ open: boolean;
83
+ /** A locked row: the trigger is inert and announces itself as such. */
84
+ disabled?: boolean;
85
+ /**
86
+ * The `mark` badge's tone. `accent` unless the row is in a state the badge
87
+ * itself should report — the discovery quota with no usage provider is the
88
+ * one case, and it swaps its glyph as well.
89
+ *
90
+ * Two values because two exist. The frame, size, radius and position do not
91
+ * change with it: that is what makes the badge the same badge everywhere.
92
+ */
93
+ markTone?: 'accent' | 'negative';
94
+ }>();
95
+
96
+ const emit = defineEmits<{ 'update:open': [value: boolean] }>();
97
+
98
+ defineSlots<{
99
+ /**
100
+ * The icon that identifies the row, in a badge this component draws.
101
+ *
102
+ * A slot rather than a prop, because the glyph is sometimes conditional and
103
+ * sometimes carries its own attributes. The BADGE is not the page's
104
+ * business: three surfaces had three answers — a 34px accent-tinted square,
105
+ * a bare 22px glyph, and a bare 20px glyph coloured by a Quasar palette
106
+ * prop — so the same row on two pages did not look like the same row.
107
+ */
108
+ mark?(): unknown;
109
+ /** The header's content — a title, keys, chips, whatever the row needs. */
110
+ header(): unknown;
111
+ /**
112
+ * Controls on the right of the header that must NOT toggle it.
113
+ * Rendered outside the trigger, so a click here is only a click here.
114
+ */
115
+ 'header-actions'?(): unknown;
116
+ /** The body. Only rendered while open. */
117
+ default(): unknown;
118
+ }>();
119
+
120
+ // `useId`, as in `AdminSection`: `aria-controls` and `aria-labelledby` have to
121
+ // point at THIS instance, and an accordion is by nature rendered many times on
122
+ // one page.
123
+ const triggerId = useId();
124
+ const bodyId = useId();
125
+ </script>
@@ -157,7 +157,7 @@
157
157
  <div>
158
158
  <div class="bcp-section-title">
159
159
  {{ msg.fields.quotas }}
160
- <span class="bcp-field-hint" style="margin-left: 6px">
160
+ <span class="bcp-field-hint" style="margin-left: var(--sa-gap-inline)">
161
161
  {{ msg.create.quotasOptional }}
162
162
  </span>
163
163
  </div>
@@ -122,7 +122,7 @@
122
122
  class="pc-input"
123
123
  type="number"
124
124
  min="1"
125
- style="margin-top: 8px; max-width: 120px"
125
+ style="margin-top: var(--sa-space-3); max-width: 120px"
126
126
  :placeholder="
127
127
  form.durationType === 'MONTHS'
128
128
  ? msg.form.durationMonthsPlaceholder
@@ -205,13 +205,20 @@
205
205
  </div>
206
206
  </div>
207
207
 
208
- <!-- Section: Advanced (backend-only fields, collapsed) -->
209
- <div class="pc-section">
210
- <button type="button" class="pc-section__toggle" @click="advancedOpen = !advancedOpen">
211
- <q-icon :name="advancedOpen ? 'expand_more' : 'chevron_right'" size="16px" />
212
- {{ msg.form.advancedToggle }}
213
- </button>
214
- <div v-if="advancedOpen" class="pc-advanced">
208
+ <!-- Section: Advanced (backend-only fields, collapsed).
209
+
210
+ An `AdminAccordion` rather than a fourth `.pc-section`: this is the one
211
+ section of the four that opens, and the package has one way of saying
212
+ that. It reads as `bg-surface` where its three siblings are
213
+ `bg-surface-raised` — the section that behaves differently is the one
214
+ that looks different, and the radius is the same rung either way. -->
215
+ <AdminAccordion v-model:open="advancedOpen">
216
+ <template #header>
217
+ <span class="pc-section__title pc-section__title--inline">
218
+ {{ msg.form.advancedToggle }}
219
+ </span>
220
+ </template>
221
+ <div class="pc-advanced">
215
222
  <div class="pc-grid pc-grid--2">
216
223
  <div class="pc-field">
217
224
  <div class="pc-field__label">{{ msg.form.billingCycleLabel }}</div>
@@ -253,7 +260,7 @@
253
260
  />
254
261
  </div>
255
262
  </div>
256
- </div>
263
+ </AdminAccordion>
257
264
 
258
265
  <!-- Live preview -->
259
266
  <div class="pc-preview">
@@ -268,6 +275,7 @@
268
275
 
269
276
  <script setup lang="ts">
270
277
  import { computed } from 'vue';
278
+ import AdminAccordion from '../admin-page/AdminAccordion.vue';
271
279
  import { IDENTITY_NEUTRAL, identityChipStyle } from '../../client/identity-accents.js';
272
280
  import { formatMessage } from '../../client/i18n/format.js';
273
281
  import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
@@ -427,23 +435,15 @@ const advancedOpen = defineModel<boolean>('advancedOpen', { default: false });
427
435
  letter-spacing: -0.005em;
428
436
  }
429
437
 
430
- .pc-section__toggle {
431
- border: 0;
432
- background: transparent;
433
- cursor: pointer;
434
- font: 600 var(--sa-text-md) var(--sa-font-body, system-ui, sans-serif);
435
- color: var(--sa-color-fg-secondary);
436
- display: inline-flex;
437
- align-items: center;
438
- gap: 4px;
439
- padding: 0;
438
+ /* The same title, in a header that already spaces itself. */
439
+ .pc-section__title--inline {
440
+ margin-bottom: 0;
440
441
  }
441
442
 
442
443
  .pc-advanced {
443
444
  display: flex;
444
445
  flex-direction: column;
445
446
  gap: 12px;
446
- margin-top: 12px;
447
447
  }
448
448
 
449
449
  .pc-grid {
@@ -33,7 +33,7 @@ interface I18n {
33
33
  ariaLabel?: string;
34
34
  monthly?: string;
35
35
  yearly?: string;
36
- /** Optional: Spar-Pill neben "Jährlich" (z. B. "−2 Mt"). */
36
+ /** Optional savings pill next to the yearly option (e.g. "−2 mo"). */
37
37
  savePill?: string;
38
38
  }
39
39
 
@@ -90,7 +90,10 @@
90
90
  <div>
91
91
  <div class="pd-vcol">
92
92
  <span class="pd-v-num">v{{ v.version }}</span>
93
- <span :class="['chip dot', statusChip(v)]" style="font-size: 10px">
93
+ <span
94
+ :class="['chip dot', statusChip(v)]"
95
+ style="font-size: var(--sa-text-2xs)"
96
+ >
94
97
  {{ statusOf(v) }}
95
98
  </span>
96
99
  </div>
@@ -105,7 +108,7 @@
105
108
  <span v-if="v.validUntil" class="pd-validity-date">
106
109
  {{ v.validUntil.slice(0, 10) }}
107
110
  </span>
108
- <span v-else class="pd-arrow-inf" style="font-size: 14px">∞</span>
111
+ <span v-else class="pd-arrow-inf">∞</span>
109
112
  </div>
110
113
  <span class="pd-validity-sub">{{ validityLabel(v) }}</span>
111
114
  </div>
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ export * from './client/index.js';
31
31
  // ---------------------------------------------------------------------------
32
32
  export * from './vue/super-admin-context.js';
33
33
  export * from './vue/ui-notify.js';
34
+ export * from './vue/ui-confirm.js';
34
35
  export * from './vue/use-super-admin-context.js';
35
36
  export * from './vue/use-super-admin-i18n.js';
36
37
  export * from './vue/use-sa-theme.js';
@@ -41,6 +42,10 @@ export * from './vue/use-sa-theme.js';
41
42
  export * from './vue/create-admin-routes.js';
42
43
  export * from './vue/use-api-list.js';
43
44
  export * from './vue/use-pagination.js';
45
+ export * from './vue/use-async-action.js';
46
+ export * from './vue/use-async-data.js';
47
+ export * from './vue/resource-registry.js';
48
+ export * from './vue/use-resource-list.js';
44
49
  export * from './vue/use-tenants.js';
45
50
  export * from './vue/use-audit-entries.js';
46
51
  export * from './vue/use-entitlement.js';
@@ -15,6 +15,7 @@
15
15
  <q-space />
16
16
  <slot name="header-actions" />
17
17
  <LocaleSwitcher class="q-mr-sm" />
18
+ <ThemeSwitcher class="q-mr-sm" />
18
19
  <q-badge class="sa-admin-badge q-mr-sm">{{ msg.header.roleBadge }}</q-badge>
19
20
  <div class="sa-admin-user">
20
21
  <q-avatar size="32px" class="sa-admin-user__avatar">
@@ -101,6 +102,7 @@ import { useRoute } from 'vue-router';
101
102
  import type { AdminManifest, StandardPageKey } from '@saasicat/types';
102
103
  import { buildRoutes, buildSidebar, defaultSectionOrder } from '../client/nav-builder.js';
103
104
  import LocaleSwitcher from '../components/LocaleSwitcher.vue';
105
+ import ThemeSwitcher from '../components/ThemeSwitcher.vue';
104
106
  import { SUPER_ADMIN_BRAND_KEY, SUPER_ADMIN_MANIFEST_KEY } from '../vue/super-admin-context.js';
105
107
  import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
106
108
  import { useSignOut } from '../vue/use-sign-out.js';
@@ -352,18 +354,73 @@ const currentPageTitle = computed(() => {
352
354
  display: flex;
353
355
  align-items: center;
354
356
  gap: 8px;
357
+ /* The row's flexible member. Without this the identity block below sits at
358
+ * its content width, and a long name pushes the sign-out button off the
359
+ * end of a `nowrap` toolbar instead of being shortened. */
360
+ min-width: 0;
355
361
  }
356
362
  .sa-admin-user__avatar {
357
363
  background: var(--sa-admin-user-avatar-bg, var(--sa-color-inverse-accent));
358
364
  color: var(--sa-admin-user-avatar-fg, var(--sa-color-inverse-accent-fg));
359
365
  }
366
+ .sa-admin-user__avatar,
367
+ .sa-admin-user .q-btn {
368
+ /* The two things in this group that are controls or fixed squares. A flex
369
+ * item shrinks from its own basis, so without this the avatar and the
370
+ * sign-out button donate width to a name they should be donating none to —
371
+ * the same shrink that squeezed the login mark to 0px at 320. */
372
+ flex: none;
373
+ }
360
374
  .sa-admin-user__name {
361
375
  line-height: 1.05;
376
+ min-width: 0;
377
+ }
378
+ /* Text is what shrinking is for; a control is not. Both lines therefore
379
+ * shorten rather than widen the group. */
380
+ .sa-admin-user__name > div {
381
+ max-width: 100%;
382
+ overflow: hidden;
383
+ text-overflow: ellipsis;
384
+ white-space: nowrap;
362
385
  }
363
386
  .sa-admin-user__email {
364
387
  color: var(--sa-admin-user-email, var(--sa-color-inverse-accent));
365
388
  }
366
389
 
390
+ /* Below `sm` the header row cannot hold everything it holds on a desktop, and
391
+ * the switcher LABELS are the cheapest thing in it: the icon still names the
392
+ * control, the menu still spells every option out, and — unlike hiding the
393
+ * button — the operator can still reach both. The role badge goes with them
394
+ * because the subtitle two lines up already carries the same word: it is the
395
+ * platform's own `header.subtitle`, "SuperAdmin · …", and it stays on screen at
396
+ * every width. The drawer's brand tag repeats it a third time by default.
397
+ *
398
+ * `599.98px` is Quasar's `xs` upper bound from `theme/_breakpoints.scss`; a
399
+ * `<style>` block cannot read the SCSS, and a custom property is not
400
+ * substituted inside a media condition. */
401
+ @media (max-width: 599.98px) {
402
+ /* `!important` because the thing being hidden is Quasar's own `.block`
403
+ * utility, which declares `display: block !important` — specificity alone
404
+ * loses to it however long the selector gets. Same reason the active drawer
405
+ * item above carries one. */
406
+ .sa-admin-header :deep(.sa-locale-switcher .q-btn__content > .block),
407
+ .sa-admin-header :deep(.sa-theme-switcher .q-btn__content > .block) {
408
+ display: none !important;
409
+ }
410
+ .sa-admin-badge {
411
+ display: none;
412
+ }
413
+ /* And the operator's own name and address, which at this width would be
414
+ * bought from the page title: measured at 360 with both filled in, the
415
+ * title got 24px — one letter — while the row spelled out an email nobody
416
+ * came to the page to read. The avatar keeps the identity present and the
417
+ * full text is one breakpoint away. The truncation above is what covers
418
+ * the band where the block is shown and space is still tight. */
419
+ .sa-admin-user__name {
420
+ display: none;
421
+ }
422
+ }
423
+
367
424
  .sa-admin-drawer :deep(.q-drawer),
368
425
  .sa-admin-drawer :deep(.q-drawer__content),
369
426
  .sa-admin-drawer__stack {
@@ -82,9 +82,10 @@ function retry(): void | Promise<void> {
82
82
  * backend has recovered.
83
83
  *
84
84
  * And the cache must go first. The loader keeps an ETag in storage, which a
85
- * full document load does not touch. Its own documented failure "server
86
- * returned 304 but the cache body is missing" would otherwise repeat exactly:
87
- * same `If-None-Match`, same 304, same missing body, straight back here.
85
+ * full document load does not touch, so a plain retry revalidates: a 304 hands
86
+ * back the manifest the app already had, and an operator retrying after a
87
+ * deployment would keep being shown the old one. Clearing first is what makes
88
+ * the retry ask an open question.
88
89
  *
89
90
  * `router.resolve` rather than a bare path so an app served under a base href
90
91
  * lands inside its own app and not at the domain root.
@@ -687,32 +687,14 @@ const classifyDiff = computed(() => props.classifyDiff);
687
687
  flex-direction: column;
688
688
  gap: 8px;
689
689
  }
690
- .sa-bd-card {
691
- background: var(--sa-color-bg-surface);
692
- border: 1px solid var(--sa-color-border);
693
- border-radius: 10px;
694
- overflow: hidden;
695
- }
696
- .sa-bd-card.open {
697
- border-color: var(--sa-color-accent);
698
- }
690
+ /* The card's surface, open border, head padding, chevron and body now come
691
+ * from `AdminAccordion` — twelve declarations that were this page's fourth
692
+ * opinion on what an accordion looks like. What is left below is what the
693
+ * BUNDLE row puts inside that header. */
699
694
  .sa-bd-card__head {
700
695
  display: flex;
701
- gap: 12px;
696
+ gap: var(--sa-space-4);
702
697
  align-items: center;
703
- padding: 12px 14px;
704
- cursor: pointer;
705
- }
706
- .sa-bd-card__mark {
707
- width: 34px;
708
- height: 34px;
709
- border-radius: 8px;
710
- background: var(--sa-color-accent-surface-strong);
711
- color: var(--sa-color-accent-strong);
712
- display: flex;
713
- align-items: center;
714
- justify-content: center;
715
- flex-shrink: 0;
716
698
  }
717
699
  .sa-bd-card__titlewrap {
718
700
  flex: 1;
@@ -737,18 +719,6 @@ const classifyDiff = computed(() => props.classifyDiff);
737
719
  font-size: var(--sa-text-sm);
738
720
  color: var(--sa-color-fg-muted);
739
721
  }
740
- .sa-bd-card__chev {
741
- transition: transform 0.15s;
742
- color: var(--sa-color-fg-subtle);
743
- }
744
- .sa-bd-card__chev.open {
745
- transform: rotate(90deg);
746
- }
747
- .sa-bd-card__body {
748
- border-top: 1px solid var(--sa-color-border);
749
- padding: 14px;
750
- background: var(--sa-color-bg-sunken);
751
- }
752
722
  .sa-bd-grid {
753
723
  display: grid;
754
724
  grid-template-columns: minmax(280px, 360px) 1fr;
@@ -18,7 +18,7 @@
18
18
  />
19
19
  <q-banner v-if="error" class="sa-discovery__error" rounded>
20
20
  <template #avatar><q-icon name="warning" color="negative" /></template>
21
- {{ common.error }}: {{ error.message }}
21
+ {{ common.error }}: {{ errorText }}
22
22
  </q-banner>
23
23
 
24
24
  <AdminSection class="q-mt-lg">
@@ -165,6 +165,7 @@ import AdminPage from '../components/admin-page/AdminPage.vue';
165
165
  import DiscoveryKpis from './discovery-page/DiscoveryKpis.vue';
166
166
  import DiscoveryMetaBanner from './discovery-page/DiscoveryMetaBanner.vue';
167
167
  import DiscoveryQuotaCard from './discovery-page/DiscoveryQuotaCard.vue';
168
+ import { adminErrorMessage } from '../client/admin-error.js';
168
169
  import { formatMessage } from '../client/i18n/format.js';
169
170
  import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
170
171
  import { statusLabel } from './discovery-page/discovery-ui.js';
@@ -216,8 +217,14 @@ const expandedQuota = ref<string | null>(null);
216
217
 
217
218
  const msg = useSaMessages('discovery');
218
219
  const common = useSaMessages('common');
220
+ const errors = useSaMessages('errors');
219
221
  const { intlLocale } = useSuperAdminI18n();
220
222
 
223
+ // The banner shows what the failing side said, or the catalog's sentence for
224
+ // what happened — never the error's own `message`, which is an English
225
+ // diagnostic for the log and was reaching this screen verbatim.
226
+ const errorText = computed(() => (props.error ? adminErrorMessage(props.error, errors.value) : ''));
227
+
221
228
  const statusFilterOptions = computed<Array<{ label: string; value: DiscoveryStatus | 'all' }>>(
222
229
  () => [
223
230
  { label: msg.value.statusFilterAll, value: 'all' },
@@ -148,6 +148,7 @@ import { usePlans } from '../vue/use-plans.js';
148
148
  import { useMarketingProjections } from '../vue/use-marketing-projections.js';
149
149
  import { usePromotions } from '../vue/use-promotions.js';
150
150
  import { useCatalogEntries } from '../vue/use-catalog-entries.js';
151
+ import { adminErrorMessage } from '../client/admin-error.js';
151
152
  import { formatCurrency } from '../client/i18n/currency.js';
152
153
  import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
153
154
  import { formatMessage } from '../client/i18n/format.js';
@@ -185,8 +186,18 @@ const props = defineProps<{
185
186
 
186
187
  const msg = useSaMessages('marketing');
187
188
  const common = useSaMessages('common');
189
+ const errors = useSaMessages('errors');
188
190
  const { locale, intlLocale } = useSuperAdminI18n();
189
191
 
192
+ /**
193
+ * What to put in the page banner for a caught failure: what the failing side
194
+ * said, or the catalog's sentence for what happened. Never `err.message` — for
195
+ * this page's own composables that is an English diagnostic for the log.
196
+ */
197
+ function bannerText(err: unknown): string {
198
+ return adminErrorMessage(err, errors.value);
199
+ }
200
+
190
201
  const availableLocales = computed(() =>
191
202
  props.availableLocales && props.availableLocales.length > 0 ? props.availableLocales : ['de'],
192
203
  );
@@ -398,7 +409,7 @@ async function reloadAll(): Promise<void> {
398
409
  ]);
399
410
  await reloadVersions();
400
411
  } catch (err) {
401
- pageError.value = err instanceof Error ? err.message : String(err);
412
+ pageError.value = bannerText(err);
402
413
  } finally {
403
414
  busy.value = false;
404
415
  }
@@ -744,7 +755,7 @@ async function patch(row: MarketingRow, partial: Partial<ResolvedMarketing>): Pr
744
755
  });
745
756
  }
746
757
  } catch (err) {
747
- pageError.value = err instanceof Error ? err.message : String(err);
758
+ pageError.value = bannerText(err);
748
759
  await projectionsApi.load();
749
760
  } finally {
750
761
  busy.value = false;
@@ -831,7 +842,7 @@ async function onLocaleChange(loc: string): Promise<void> {
831
842
  locale: loc,
832
843
  });
833
844
  } catch (err) {
834
- pageError.value = err instanceof Error ? err.message : String(err);
845
+ pageError.value = bannerText(err);
835
846
  } finally {
836
847
  busy.value = false;
837
848
  }
@@ -19,6 +19,7 @@
19
19
  <div class="sa-login-brand__tag">{{ tagText }}</div>
20
20
  </div>
21
21
  <LocaleSwitcher class="sa-login-locale" />
22
+ <ThemeSwitcher class="sa-login-theme" />
22
23
  </div>
23
24
 
24
25
  <h1 class="sa-login-title">{{ msg.login.signIn }}</h1>
@@ -101,6 +102,7 @@ import { getJson } from '../client/http-json.js';
101
102
  import { isProductionBoot, resolveLoginBranding } from '../client/login-branding.js';
102
103
  import { useSaMessages } from '../vue/use-super-admin-i18n.js';
103
104
  import LocaleSwitcher from '../components/LocaleSwitcher.vue';
105
+ import ThemeSwitcher from '../components/ThemeSwitcher.vue';
104
106
  import SuperAdminSetupWizard from './SuperAdminSetupWizard.vue';
105
107
 
106
108
  interface Props {
@@ -194,13 +196,18 @@ async function handleSubmit(): Promise<void> {
194
196
  </script>
195
197
 
196
198
  <style scoped>
197
- .sa-login-locale {
199
+ .sa-login-locale,
200
+ .sa-login-theme {
198
201
  /* In the card's flow rather than floating over it: a fixed overlay lands
199
202
  on top of the centered card on short viewports. */
200
- margin-left: auto;
201
203
  align-self: flex-start;
202
204
  color: var(--sa-login-tag-color, var(--sa-color-fg-secondary));
203
205
  }
206
+ /* One `auto` on the first of the pair moves both — a second would put the gap
207
+ between them at the mercy of the card width instead of the row's own. */
208
+ .sa-login-locale {
209
+ margin-left: auto;
210
+ }
204
211
  .sa-login-wrap {
205
212
  min-height: 100vh;
206
213
  display: flex;
@@ -231,6 +238,13 @@ async function handleSubmit(): Promise<void> {
231
238
  .sa-login-logo {
232
239
  width: 44px;
233
240
  height: 44px;
241
+ /* The row has to give width up somewhere once the switchers joined it, and
242
+ the mark is the wrong donor: a flex item shrinks from its own basis, so
243
+ the logo went to 32.73px at 390 and to nothing at all at 320. Pinning it
244
+ moves the deficit onto the brand text beside it, which has somewhere to
245
+ put it — it wraps. `min-width: 0` on that text is the other half; without
246
+ it the text refuses the deficit too and the row leaves the viewport. */
247
+ flex: none;
234
248
  border-radius: 10px;
235
249
  display: flex;
236
250
  align-items: center;
@@ -259,6 +273,13 @@ async function handleSubmit(): Promise<void> {
259
273
  height: 100%;
260
274
  object-fit: contain;
261
275
  }
276
+ /* `min-width: auto` is a flex item's default and it refuses to shrink below the
277
+ longest word — which is why the row's deficit landed on the mark instead, and
278
+ why pinning the mark on its own is not the whole fix. Zero lets this block
279
+ take the deficit and wrap. */
280
+ .sa-login-brand__text {
281
+ min-width: 0;
282
+ }
262
283
  .sa-login-brand__name {
263
284
  font-weight: 800;
264
285
  font-size: var(--sa-text-lg);