@saasicat/ui-vue 0.23.0 → 0.24.1
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/dist/{chunk-XMMFN2WJ.js → chunk-D74FFCCP.js} +52 -1
- package/dist/client/index.cjs +58 -0
- package/dist/client/index.d.cts +70 -1
- package/dist/client/index.d.ts +70 -1
- package/dist/client/index.js +15 -1
- package/dist/index.cjs +109 -0
- package/dist/index.d.cts +65 -2
- package/dist/index.d.ts +65 -2
- package/dist/index.js +65 -1
- package/package.json +2 -2
- package/src/client/identity-accents.ts +167 -0
- package/src/client/index.ts +1 -0
- package/src/components/MarketingPromotionsTab.vue +6 -2
- package/src/components/bundle-editor/BundleVersionInlineEditor.vue +1 -1
- package/src/components/dialogs/PromoCodeDialogFields.vue +38 -16
- package/src/components/plan/PromoCodeInput.vue +1 -1
- package/src/components/plan-create-dialog/PlanCreateDialog.vue +1 -1
- package/src/components/plan-detail/PlanDetail.vue +8 -3
- package/src/components/plan-list/PlanList.vue +18 -23
- package/src/components/plan-matrix/PlanMatrix.vue +9 -26
- package/src/components/plan-review/PlanReview.vue +1 -1
- package/src/components/plan-version-editor/PlanVersionEditor.vue +22 -3
- package/src/pages-standard/AdminManifestErrorPage.vue +4 -1
- package/src/pages-standard/BundlesPage.vue +1 -1
- package/src/pages-standard/DashboardPage.vue +8 -5
- package/src/pages-standard/DiscoveryPage.vue +42 -8
- package/src/pages-standard/MarketingCatalogPage.vue +33 -9
- package/src/pages-standard/PromoCodesPage.vue +3 -1
- package/src/pages-standard/SuperAdminLoginPage.vue +6 -1
- package/src/pages-standard/SuperAdminSetupWizard.vue +1 -1
- package/src/pages-standard/TenantsPage.vue +5 -4
- package/src/pages-standard/discovery-page/discovery-ui.ts +10 -7
- package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +2 -5
- package/src/pages-standard/plan-versions/PlanDiffCard.vue +11 -4
- package/src/pages-standard/plan-versions/PlanVersionsDiff.vue +3 -1
- package/src/pages-standard/tenants/StatusPill.vue +1 -1
- package/src/pages-standard/tenants/format.ts +11 -12
- package/src/pages-tenant/MySubscriptionBundlesPage.vue +9 -0
- package/src/pages-tenant/OnboardingConfigurator.vue +2 -2
- package/src/pages-tenant/PlanChangeWizard.vue +2 -49
- package/src/pages-tenant/TenantPlanSection.vue +6 -48
- package/src/pages-tenant/default-i18n.ts +113 -0
- package/src/ui/theme/_breakpoints.scss +8 -2
- package/src/ui/theme/base.css +13 -1
- package/src/ui/theme/components/card.css +26 -0
- package/src/ui/theme/components/field.css +44 -0
- package/src/ui/theme/components/statistics.css +1 -1
- package/src/ui/theme/tokens.primitive.css +4 -0
- package/src/ui/theme/tokens.semantic.dark.css +12 -0
- package/src/ui/theme/tokens.semantic.light.css +30 -0
|
@@ -45,11 +45,7 @@
|
|
|
45
45
|
<div class="sa-plan-list-plan-name">
|
|
46
46
|
<div
|
|
47
47
|
class="sa-plan-list-plan-mark"
|
|
48
|
-
:style="
|
|
49
|
-
background: planAccent(p.planKey) + '15',
|
|
50
|
-
color: planAccent(p.planKey),
|
|
51
|
-
borderColor: planAccent(p.planKey) + '33',
|
|
52
|
-
}"
|
|
48
|
+
:style="identityChipStyle(planAccent(p.planKey))"
|
|
53
49
|
>
|
|
54
50
|
{{ p.planKey.slice(0, 3) }}
|
|
55
51
|
</div>
|
|
@@ -415,6 +411,7 @@
|
|
|
415
411
|
import { resolvePlans, type ResolvedPlan } from '../../client/resolve-plans.js';
|
|
416
412
|
import { computed, ref } from 'vue';
|
|
417
413
|
import type { PlanRow, PlanVersionRow } from '@saasicat/types';
|
|
414
|
+
import { identityAccentFor, identityChipStyle } from '../../client/identity-accents.js';
|
|
418
415
|
import { formatMessage } from '../../client/i18n/format.js';
|
|
419
416
|
import { formatCurrency } from '../../client/i18n/currency.js';
|
|
420
417
|
import { useSaMessages, useSuperAdminI18n } from '../../vue/use-super-admin-i18n.js';
|
|
@@ -456,24 +453,12 @@ const common = useSaMessages('common');
|
|
|
456
453
|
// `clearable` emits null, not '' — see Quasar's use-field clearValue().
|
|
457
454
|
const search = ref<string | null>('');
|
|
458
455
|
|
|
459
|
-
const DEFAULT_ACCENTS: Record<string, string> = {
|
|
460
|
-
STARTER: '#64748b',
|
|
461
|
-
STANDARD: '#2563eb',
|
|
462
|
-
PRO: '#7c3aed',
|
|
463
|
-
PROFESSIONAL: '#7c3aed',
|
|
464
|
-
BUSINESS: '#0ea5e9',
|
|
465
|
-
ENTERPRISE: '#0f766e',
|
|
466
|
-
BASIC: '#475569',
|
|
467
|
-
};
|
|
468
|
-
const FALLBACK_ACCENTS = ['#2563eb', '#7c3aed', '#0f766e', '#f59e0b', '#0ea5e9', '#ef4444'];
|
|
469
|
-
|
|
470
456
|
function planAccent(planKey: string): string {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
return FALLBACK_ACCENTS[idx % FALLBACK_ACCENTS.length] ?? FALLBACK_ACCENTS[0]!;
|
|
457
|
+
return identityAccentFor(
|
|
458
|
+
planKey,
|
|
459
|
+
props.planAccents,
|
|
460
|
+
props.plans.findIndex((p) => p.planKey === planKey),
|
|
461
|
+
);
|
|
477
462
|
}
|
|
478
463
|
|
|
479
464
|
const resolvedPlans = computed(() =>
|
|
@@ -566,7 +551,13 @@ function hasAnyPublished(row: ResolvedPlan<PlanRow, PlanVersionRow>): boolean {
|
|
|
566
551
|
background: var(--sa-color-bg-surface);
|
|
567
552
|
border: 1px solid var(--sa-color-border);
|
|
568
553
|
border-radius: 10px;
|
|
569
|
-
overflow: hidden
|
|
554
|
+
/* Scrolls rather than clips. `overflow: hidden` was here to keep the corner
|
|
555
|
+
* radius, and it also cut the six-column grid off below ~790px — six data
|
|
556
|
+
* columns squeezed into a phone are not readable anyway, so the honest
|
|
557
|
+
* answer is the same as for the tab bar: let it scroll. `hidden` on the
|
|
558
|
+
* cross axis keeps the radius doing its job. */
|
|
559
|
+
overflow-x: auto;
|
|
560
|
+
overflow-y: hidden;
|
|
570
561
|
}
|
|
571
562
|
.sa-plan-list-toolbar {
|
|
572
563
|
display: flex;
|
|
@@ -590,6 +581,10 @@ function hasAnyPublished(row: ResolvedPlan<PlanRow, PlanVersionRow>): boolean {
|
|
|
590
581
|
.sa-plan-list-list {
|
|
591
582
|
display: grid;
|
|
592
583
|
grid-template-columns: 1.6fr 0.9fr 0.7fr 0.9fr 1.4fr 160px;
|
|
584
|
+
/* So the scroll above has something to scroll: without a content floor the
|
|
585
|
+
* tracks shrink to the container and the cells overflow individually,
|
|
586
|
+
* which is the clipped state rather than a scrollable one. */
|
|
587
|
+
min-width: max-content;
|
|
593
588
|
/* Stretch, not centre. Every row is `display: contents`, so its cells are
|
|
594
589
|
* grid items in their own right — and a centred item is only as tall as its
|
|
595
590
|
* own content. The row's hover background is painted per cell, so the band
|
|
@@ -272,10 +272,7 @@
|
|
|
272
272
|
<span
|
|
273
273
|
v-if="hasFeature(p, fKey)"
|
|
274
274
|
class="pm-check"
|
|
275
|
-
:style="
|
|
276
|
-
background: planAccent(p.planKey) + '15',
|
|
277
|
-
color: planAccent(p.planKey),
|
|
278
|
-
}"
|
|
275
|
+
:style="identityChipStyle(planAccent(p.planKey))"
|
|
279
276
|
>
|
|
280
277
|
<svg
|
|
281
278
|
width="12"
|
|
@@ -323,10 +320,7 @@
|
|
|
323
320
|
<span
|
|
324
321
|
v-if="hasBundle(p, bKey)"
|
|
325
322
|
class="pm-check"
|
|
326
|
-
:style="
|
|
327
|
-
background: planAccent(p.planKey) + '15',
|
|
328
|
-
color: planAccent(p.planKey),
|
|
329
|
-
}"
|
|
323
|
+
:style="identityChipStyle(planAccent(p.planKey))"
|
|
330
324
|
>
|
|
331
325
|
<svg
|
|
332
326
|
width="12"
|
|
@@ -384,6 +378,7 @@ import { computed } from 'vue';
|
|
|
384
378
|
*/
|
|
385
379
|
const NBSP = '\u00A0';
|
|
386
380
|
import type { PlanRow, PlanVersionRow } from '@saasicat/types';
|
|
381
|
+
import { identityAccentFor, identityChipStyle } from '../../client/identity-accents.js';
|
|
387
382
|
import { formatMessage } from '../../client/i18n/format.js';
|
|
388
383
|
import { formatCurrency } from '../../client/i18n/currency.js';
|
|
389
384
|
import { useSaMessages, useSuperAdminI18n } from '../../vue/use-super-admin-i18n.js';
|
|
@@ -477,24 +472,12 @@ function validFromLabel(validFrom: string): string {
|
|
|
477
472
|
return formatMessage(msg.value.matrix.validFrom, { date: validFrom.slice(0, 10) });
|
|
478
473
|
}
|
|
479
474
|
|
|
480
|
-
const DEFAULT_ACCENTS: Record<string, string> = {
|
|
481
|
-
STARTER: '#64748b',
|
|
482
|
-
STANDARD: '#2563eb',
|
|
483
|
-
PRO: '#7c3aed',
|
|
484
|
-
PROFESSIONAL: '#7c3aed',
|
|
485
|
-
BUSINESS: '#0ea5e9',
|
|
486
|
-
ENTERPRISE: '#0f766e',
|
|
487
|
-
BASIC: '#475569',
|
|
488
|
-
};
|
|
489
|
-
const FALLBACK_ACCENTS = ['#2563eb', '#7c3aed', '#0f766e', '#f59e0b', '#0ea5e9', '#ef4444'];
|
|
490
|
-
|
|
491
475
|
function planAccent(planKey: string): string {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
return FALLBACK_ACCENTS[idx % FALLBACK_ACCENTS.length] ?? FALLBACK_ACCENTS[0]!;
|
|
476
|
+
return identityAccentFor(
|
|
477
|
+
planKey,
|
|
478
|
+
props.planAccents,
|
|
479
|
+
props.plans.findIndex((p) => p.planKey === planKey),
|
|
480
|
+
);
|
|
498
481
|
}
|
|
499
482
|
|
|
500
483
|
const resolvedPlans = computed<ResolvedPlan[]>(() =>
|
|
@@ -828,7 +811,7 @@ function formatQuota(v: number | undefined): string {
|
|
|
828
811
|
height: 30px;
|
|
829
812
|
border-radius: 50%;
|
|
830
813
|
background: var(--sa-color-accent-surface-strong);
|
|
831
|
-
color: var(--sa-color-accent);
|
|
814
|
+
color: var(--sa-color-accent-strong);
|
|
832
815
|
margin: 0 auto 6px;
|
|
833
816
|
}
|
|
834
817
|
.pm-add-title {
|
|
@@ -760,6 +760,10 @@ function emitSave(): void {
|
|
|
760
760
|
display: flex;
|
|
761
761
|
flex-direction: column;
|
|
762
762
|
box-sizing: border-box;
|
|
763
|
+
/* The editor reflows on ITS OWN width, not the window's — see the
|
|
764
|
+
`@container` rules at the end of this block. */
|
|
765
|
+
container-type: inline-size;
|
|
766
|
+
container-name: pve;
|
|
763
767
|
/* A full view rather than a modal: the columns stretch to the content area
|
|
764
768
|
AdminPage already sizes. */
|
|
765
769
|
height: 100%;
|
|
@@ -1546,13 +1550,28 @@ function emitSave(): void {
|
|
|
1546
1550
|
display: inline-flex;
|
|
1547
1551
|
}
|
|
1548
1552
|
|
|
1549
|
-
/* ── Responsive
|
|
1550
|
-
|
|
1553
|
+
/* ── Responsive ────────────────────────────────────────────────────
|
|
1554
|
+
*
|
|
1555
|
+
* On the CONTAINER's width, not the viewport's, because the viewport is not
|
|
1556
|
+
* what this grid has to fit into. The admin drawer is 240px and collapsible, so
|
|
1557
|
+
* a viewport threshold is wrong in one of its two states by construction — and
|
|
1558
|
+
* with the drawer open at 1024–1100px the old viewport rule left 784–860px for
|
|
1559
|
+
* `320px 1fr 360px`, squeezing the middle column to 104–180px and clipping its
|
|
1560
|
+
* form controls behind `overflow: hidden`.
|
|
1561
|
+
*
|
|
1562
|
+
* The thresholds are the widths the columns actually need:
|
|
1563
|
+
* 1060px → 320 + 360 fixed leaves ≥ 380px for the basket
|
|
1564
|
+
* 780px → below this the three-column form is not usable at all
|
|
1565
|
+
*
|
|
1566
|
+
* These are container queries, so they are not viewport breakpoints and the
|
|
1567
|
+
* Quasar-band rule does not reach them; `@container` needs no newer browser
|
|
1568
|
+
* than the theme already requires. */
|
|
1569
|
+
@container pve (max-width: 1060px) {
|
|
1551
1570
|
.pve-body {
|
|
1552
1571
|
grid-template-columns: 320px 1fr 360px;
|
|
1553
1572
|
}
|
|
1554
1573
|
}
|
|
1555
|
-
@
|
|
1574
|
+
@container pve (max-width: 780px) {
|
|
1556
1575
|
.pve-body {
|
|
1557
1576
|
grid-template-columns: 1fr;
|
|
1558
1577
|
overflow-y: auto;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="sa-manifest-error">
|
|
2
|
+
<div class="sa-page sa-manifest-error">
|
|
3
3
|
<q-card class="sa-manifest-error__card">
|
|
4
4
|
<q-card-section class="sa-manifest-error__head">
|
|
5
5
|
<q-icon name="cloud_off" size="32px" color="negative" />
|
|
@@ -112,6 +112,9 @@ function logout(): void | Promise<void> {
|
|
|
112
112
|
padding: 32px;
|
|
113
113
|
display: flex;
|
|
114
114
|
justify-content: center;
|
|
115
|
+
/* The page frame is a full viewport tall, and a stretched flex item would
|
|
116
|
+
* make this card as tall as the screen for four lines of text. */
|
|
117
|
+
align-items: flex-start;
|
|
115
118
|
}
|
|
116
119
|
.sa-manifest-error__card {
|
|
117
120
|
max-width: 640px;
|
|
@@ -754,7 +754,7 @@ const classifyDiff = computed(() => props.classifyDiff);
|
|
|
754
754
|
grid-template-columns: minmax(280px, 360px) 1fr;
|
|
755
755
|
gap: 18px;
|
|
756
756
|
}
|
|
757
|
-
@media (max-width:
|
|
757
|
+
@media (max-width: 1023.98px) {
|
|
758
758
|
.sa-bd-grid {
|
|
759
759
|
grid-template-columns: 1fr;
|
|
760
760
|
}
|
|
@@ -215,7 +215,10 @@ const cards = reactive<KpiCardState[]>([]);
|
|
|
215
215
|
const loading = ref(false);
|
|
216
216
|
const error = ref<Error | null>(null);
|
|
217
217
|
|
|
218
|
-
|
|
218
|
+
// Two stops of the brand accent rather than two fixed blues: a host that sets
|
|
219
|
+
// its own `$primary` had a brand-coloured admin with platform-blue KPI bars.
|
|
220
|
+
const defaultBarColor =
|
|
221
|
+
'linear-gradient(90deg, var(--sa-color-accent), var(--sa-color-accent-strong))';
|
|
219
222
|
|
|
220
223
|
watch(
|
|
221
224
|
() => props.manifest,
|
|
@@ -402,7 +405,7 @@ function barWidth(value: number, max?: number): number {
|
|
|
402
405
|
/* Header look comes from the global .sa-page-head — only a margin tweak here. */
|
|
403
406
|
|
|
404
407
|
.sa-kpi__icon :deep(.q-icon) {
|
|
405
|
-
color: var(--sa-color-accent);
|
|
408
|
+
color: var(--sa-color-accent-strong);
|
|
406
409
|
}
|
|
407
410
|
|
|
408
411
|
.sa-dashboard__rows {
|
|
@@ -412,7 +415,7 @@ function barWidth(value: number, max?: number): number {
|
|
|
412
415
|
margin-bottom: 14px;
|
|
413
416
|
align-items: start;
|
|
414
417
|
}
|
|
415
|
-
@media (max-width:
|
|
418
|
+
@media (max-width: 1439.98px) {
|
|
416
419
|
.sa-dashboard__rows {
|
|
417
420
|
grid-template-columns: 1fr;
|
|
418
421
|
}
|
|
@@ -420,7 +423,7 @@ function barWidth(value: number, max?: number): number {
|
|
|
420
423
|
|
|
421
424
|
.sa-dashboard__count {
|
|
422
425
|
background: var(--sa-color-accent-surface);
|
|
423
|
-
color: var(--sa-color-accent);
|
|
426
|
+
color: var(--sa-color-accent-strong);
|
|
424
427
|
font-size: var(--sa-text-xs);
|
|
425
428
|
font-weight: 700;
|
|
426
429
|
padding: 2px 7px;
|
|
@@ -489,7 +492,7 @@ function barWidth(value: number, max?: number): number {
|
|
|
489
492
|
border-color: var(--sa-color-accent-border);
|
|
490
493
|
}
|
|
491
494
|
.sa-dashboard__shortcut :deep(.q-icon) {
|
|
492
|
-
color: var(--sa-color-accent);
|
|
495
|
+
color: var(--sa-color-accent-strong);
|
|
493
496
|
flex-shrink: 0;
|
|
494
497
|
}
|
|
495
498
|
.sa-dashboard__shortcut-title {
|
|
@@ -228,25 +228,59 @@ const statusFilterOptions = computed<Array<{ label: string; value: DiscoveryStat
|
|
|
228
228
|
],
|
|
229
229
|
);
|
|
230
230
|
|
|
231
|
-
|
|
231
|
+
// `?.app?.` rather than `?.app.` — the optional chain has to survive the whole
|
|
232
|
+
// path, not just its first step. `useDiscovery` assigns the response body with
|
|
233
|
+
// an unchecked `as DiscoverySnapshot`, so a 200 whose body is not a snapshot
|
|
234
|
+
// (an older backend, a proxy's JSON error page, a partial response) reaches
|
|
235
|
+
// here as a non-null value without `app`. The guard then stopped one step short
|
|
236
|
+
// and the page threw during a computed — which white-screens the route rather
|
|
237
|
+
// than showing the dash these fallbacks exist for.
|
|
238
|
+
// Narrowed to a string, not merely to "present". The type says `key` is a
|
|
239
|
+
// string; the runtime value is whatever the server sent, because `useDiscovery`
|
|
240
|
+
// assigns the body with an unchecked `as DiscoverySnapshot`. A truthy non-string
|
|
241
|
+
// — `{"app":{"key":1}}` — passes an existence check and then throws on
|
|
242
|
+
// `.charAt`, which is the same white-screen one step further in. Guarding
|
|
243
|
+
// presence without guarding type only moves the crash.
|
|
244
|
+
const appKeyText = computed(() => {
|
|
245
|
+
const key = props.snapshot?.app?.key;
|
|
246
|
+
return typeof key === 'string' ? key : '';
|
|
247
|
+
});
|
|
248
|
+
const appKey = computed(() => appKeyText.value || '—');
|
|
232
249
|
const appLabel = computed(() => {
|
|
233
|
-
const k =
|
|
250
|
+
const k = appKeyText.value;
|
|
234
251
|
return k ? k.charAt(0).toUpperCase() + k.slice(1) : 'Discovery';
|
|
235
252
|
});
|
|
236
|
-
const appVersion = computed(() =>
|
|
253
|
+
const appVersion = computed(() => {
|
|
254
|
+
const version = props.snapshot?.app?.version;
|
|
255
|
+
return typeof version === 'string' && version ? version : '0.0.0';
|
|
256
|
+
});
|
|
237
257
|
const scanLabel = computed(() => {
|
|
238
|
-
|
|
258
|
+
const scannedAt = props.snapshot?.scannedAt;
|
|
259
|
+
// Same reasoning: the catch below covers a bad DATE, not a bad type — the
|
|
260
|
+
// fallback `return scannedAt` would hand the template a non-string.
|
|
261
|
+
if (typeof scannedAt !== 'string' || !scannedAt) return msg.value.notScannedYet;
|
|
239
262
|
try {
|
|
240
|
-
return new Date(
|
|
263
|
+
return new Date(scannedAt).toLocaleString(intlLocale.value);
|
|
241
264
|
} catch {
|
|
242
|
-
return
|
|
265
|
+
return scannedAt;
|
|
243
266
|
}
|
|
244
267
|
});
|
|
245
268
|
|
|
246
269
|
const declaredAtByKey = computed<Record<string, string>>(() => {
|
|
247
270
|
const map: Record<string, string> = {};
|
|
248
|
-
|
|
249
|
-
|
|
271
|
+
// `Array.isArray`, not `?? []`. The nullish fallback only covers `null` and
|
|
272
|
+
// `undefined`; anything else present goes straight into `for…of`, and a
|
|
273
|
+
// JSON object or a number is not iterable — `for (const c of {})` throws,
|
|
274
|
+
// which takes the route down exactly like the string methods above did.
|
|
275
|
+
//
|
|
276
|
+
// A string would have slipped through both: it IS iterable, so the loop
|
|
277
|
+
// walks its characters and silently builds nonsense. That is why the case
|
|
278
|
+
// covering this in the test suite had to be an object rather than a string —
|
|
279
|
+
// the first version used a string and passed without exercising anything.
|
|
280
|
+
const capabilities = props.snapshot?.capabilities;
|
|
281
|
+
if (!Array.isArray(capabilities)) return map;
|
|
282
|
+
for (const c of capabilities) {
|
|
283
|
+
if (c && typeof c.capabilityKey === 'string') map[c.capabilityKey] = c.declaredAt;
|
|
250
284
|
}
|
|
251
285
|
return map;
|
|
252
286
|
});
|
|
@@ -164,7 +164,9 @@ import type {
|
|
|
164
164
|
ResolvedMarketing,
|
|
165
165
|
} from './marketing-catalog/types.js';
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
import { IDENTITY_NEUTRAL, identityAccentAt } from '../client/identity-accents.js';
|
|
168
|
+
|
|
169
|
+
const DEFAULT_ACCENT = IDENTITY_NEUTRAL;
|
|
168
170
|
|
|
169
171
|
const props = defineProps<{
|
|
170
172
|
adminEndpoint: string;
|
|
@@ -650,7 +652,7 @@ function promoFineprintOf(row: MarketingRow): string {
|
|
|
650
652
|
return promo.i18n?.[activeLocale.value]?.fineprint || promo.i18n?.de?.fineprint || '';
|
|
651
653
|
}
|
|
652
654
|
function promoColorOf(row: MarketingRow): string {
|
|
653
|
-
return promoOf(row)?.color ??
|
|
655
|
+
return promoOf(row)?.color ?? identityAccentAt(0);
|
|
654
656
|
}
|
|
655
657
|
|
|
656
658
|
// ─── CTA ───
|
|
@@ -999,12 +1001,22 @@ async function onLocaleChange(loc: string): Promise<void> {
|
|
|
999
1001
|
|
|
1000
1002
|
.sa-marketing-toolbar {
|
|
1001
1003
|
display: flex;
|
|
1004
|
+
/* The tab bar and the meta line do not fit side by side below ~672px, and
|
|
1005
|
+
* without this they simply pushed the page off its own right edge — at every
|
|
1006
|
+
* viewport narrower than that, not just the smallest. Nothing measured it
|
|
1007
|
+
* until the overflow guard started asking at each breakpoint band. */
|
|
1008
|
+
flex-wrap: wrap;
|
|
1002
1009
|
gap: 12px;
|
|
1003
1010
|
align-items: center;
|
|
1004
1011
|
margin: 0px;
|
|
1005
1012
|
}
|
|
1006
1013
|
.sa-marketing-tabbar {
|
|
1007
1014
|
display: inline-flex;
|
|
1015
|
+
/* Scrolls rather than wraps: a row of tabs broken across two lines reads as
|
|
1016
|
+
* two groups. It fits every band except the narrowest phone, where the four
|
|
1017
|
+
* tabs need 389px. */
|
|
1018
|
+
overflow-x: auto;
|
|
1019
|
+
max-width: 100%;
|
|
1008
1020
|
gap: 2px;
|
|
1009
1021
|
background: var(--sa-color-bg-surface);
|
|
1010
1022
|
border: 1px solid var(--sa-color-border);
|
|
@@ -1043,6 +1055,10 @@ async function onLocaleChange(loc: string): Promise<void> {
|
|
|
1043
1055
|
}
|
|
1044
1056
|
.sa-marketing-meta {
|
|
1045
1057
|
margin-left: auto;
|
|
1058
|
+
/* Its own contents wrap too: `catalogVersion <code> · locale <code>` is
|
|
1059
|
+
* wider than a phone on its own, so wrapping only the toolbar would move
|
|
1060
|
+
* the overflow one level down instead of removing it. */
|
|
1061
|
+
flex-wrap: wrap;
|
|
1046
1062
|
font-size: var(--sa-text-sm);
|
|
1047
1063
|
color: var(--sa-color-fg-secondary);
|
|
1048
1064
|
display: flex;
|
|
@@ -1513,14 +1529,22 @@ async function onLocaleChange(loc: string): Promise<void> {
|
|
|
1513
1529
|
transform: rotate(90deg);
|
|
1514
1530
|
}
|
|
1515
1531
|
|
|
1532
|
+
/* The open editor is a WELL under its row, not another row.
|
|
1533
|
+
*
|
|
1534
|
+
* It used to end on a gradient whose last stop was `--sa-color-bg-surface` —
|
|
1535
|
+
* exactly the colour of the next row — closed by the same soft hairline that
|
|
1536
|
+
* separates any two rows. Nothing then said where the editor stopped, so the
|
|
1537
|
+
* following plan read as part of the plan being edited.
|
|
1538
|
+
*
|
|
1539
|
+
* Three things say it now, and each carries a different half of the message:
|
|
1540
|
+
* the surface is recessed (`bg-sunken`, which is what a well is for), an accent
|
|
1541
|
+
* edge on the left ties the panel to the row it belongs to, and a real border
|
|
1542
|
+
* closes it at the bottom instead of a hairline that means "next row". */
|
|
1516
1543
|
.sa-marketing-admin-expand {
|
|
1517
1544
|
grid-column: 1 / -1;
|
|
1518
|
-
background:
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
var(--sa-color-bg-surface) 100%
|
|
1522
|
-
);
|
|
1523
|
-
border-bottom: 1px solid var(--sa-color-border-soft);
|
|
1545
|
+
background: var(--sa-color-bg-sunken);
|
|
1546
|
+
border-left: 2px solid var(--sa-color-accent);
|
|
1547
|
+
border-bottom: 2px solid var(--sa-color-border-strong);
|
|
1524
1548
|
padding: 18px 20px 22px;
|
|
1525
1549
|
}
|
|
1526
1550
|
.sa-marketing-expand-grid {
|
|
@@ -1715,7 +1739,7 @@ async function onLocaleChange(loc: string): Promise<void> {
|
|
|
1715
1739
|
cursor: not-allowed;
|
|
1716
1740
|
}
|
|
1717
1741
|
|
|
1718
|
-
@media (max-width:
|
|
1742
|
+
@media (max-width: 1023.98px) {
|
|
1719
1743
|
.sa-marketing-expand-grid {
|
|
1720
1744
|
grid-template-columns: 1fr;
|
|
1721
1745
|
}
|
|
@@ -110,7 +110,9 @@
|
|
|
110
110
|
import AdminTable from '../components/admin-page/AdminTable.vue';
|
|
111
111
|
import type { PromoCodePlanOption } from '../components/dialogs/types.js';
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
import { IDENTITY_ACCENTS } from '../client/identity-accents.js';
|
|
114
|
+
|
|
115
|
+
const PLAN_COLOR_PALETTE = IDENTITY_ACCENTS;
|
|
114
116
|
|
|
115
117
|
/**
|
|
116
118
|
* Heuristic from a consumer wrapper: assigns each plan a stable color so plan
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
:icon="iconText"
|
|
6
6
|
@done="needsSetup = false"
|
|
7
7
|
/>
|
|
8
|
-
<div v-else class="sa-login-wrap">
|
|
8
|
+
<div v-else class="sa-page sa-login-wrap">
|
|
9
9
|
<div class="sa-login-card">
|
|
10
10
|
<div class="sa-login-brand">
|
|
11
11
|
<div v-if="logoUrl" class="sa-login-logo sa-login-logo--img">
|
|
@@ -285,7 +285,12 @@ async function handleSubmit(): Promise<void> {
|
|
|
285
285
|
line-height: 1.5;
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
+
/* The heading block is one unit, and the form sits the same distance below it
|
|
289
|
+
* whether or not the optional subtitle is there. Without this the gap was the
|
|
290
|
+
* title's 6px on a login that passes no subtitle — which nobody saw, because
|
|
291
|
+
* Quasar's element-level `h1` line-height was padding it out to 96px. */
|
|
288
292
|
.sa-login-form {
|
|
293
|
+
margin-top: var(--sa-space-6);
|
|
289
294
|
display: flex;
|
|
290
295
|
flex-direction: column;
|
|
291
296
|
}
|
|
@@ -176,7 +176,8 @@ import {
|
|
|
176
176
|
import MfaPromptDialog from '../components/MfaPromptDialog.vue';
|
|
177
177
|
import TenantActionConfirmDialog from '../components/TenantActionConfirmDialog.vue';
|
|
178
178
|
import StatusPill, { type PillTone } from './tenants/StatusPill.vue';
|
|
179
|
-
import {
|
|
179
|
+
import { identityChipStyle } from '../client/identity-accents.js';
|
|
180
|
+
import { formatDate, planAccent, tenantInitials } from './tenants/format.js';
|
|
180
181
|
|
|
181
182
|
// Platform standard page: tenant list.
|
|
182
183
|
//
|
|
@@ -323,7 +324,7 @@ const props = withDefaults(
|
|
|
323
324
|
pageSize: 25,
|
|
324
325
|
showPlanColumn: true,
|
|
325
326
|
planLabelField: 'plan',
|
|
326
|
-
planAccents: () =>
|
|
327
|
+
planAccents: () => ({}),
|
|
327
328
|
usageFields: () => [],
|
|
328
329
|
manifest: null,
|
|
329
330
|
},
|
|
@@ -484,8 +485,8 @@ const combinedActions = computed<TenantRowAction[]>(() => {
|
|
|
484
485
|
const hasActions = computed(() => combinedActions.value.length > 0);
|
|
485
486
|
|
|
486
487
|
function avatarStyle(row: TenantRow): Record<string, string> {
|
|
487
|
-
const
|
|
488
|
-
return { background
|
|
488
|
+
const { background, color } = identityChipStyle(planAccentFor(row));
|
|
489
|
+
return { background, color };
|
|
489
490
|
}
|
|
490
491
|
|
|
491
492
|
function planAccentFor(row: TenantRow): string {
|
|
@@ -122,16 +122,19 @@ export function reviewMenuActions(
|
|
|
122
122
|
|
|
123
123
|
// ─── Capability kind styling (read-only code facts, #20) ─────────────────────
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
// A capability kind is a category with no ranking, so it takes the identity
|
|
126
|
+
// ramp rather than a status colour: an endpoint is not "better" than a job.
|
|
127
|
+
import { IDENTITY_NEUTRAL, identityChipStyle } from '../../client/identity-accents.js';
|
|
128
|
+
|
|
129
|
+
const KIND_ACCENTS: Record<string, string> = {
|
|
130
|
+
endpoint: 'var(--sa-color-identity-1)',
|
|
131
|
+
service: 'var(--sa-color-identity-2)',
|
|
132
|
+
job: 'var(--sa-color-identity-5)',
|
|
133
|
+
event: 'var(--sa-color-identity-4)',
|
|
130
134
|
};
|
|
131
135
|
|
|
132
136
|
export function kindStyle(kind: string): Record<string, string> {
|
|
133
|
-
|
|
134
|
-
return { background: `${c}1a`, color: c, borderColor: `${c}33` };
|
|
137
|
+
return identityChipStyle(KIND_ACCENTS[kind] ?? IDENTITY_NEUTRAL);
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
/**
|
|
@@ -26,11 +26,7 @@
|
|
|
26
26
|
<div class="sa-marketing-plan-cell">
|
|
27
27
|
<div
|
|
28
28
|
class="sa-marketing-plan-mark"
|
|
29
|
-
:style="
|
|
30
|
-
background: row.accent + '15',
|
|
31
|
-
color: row.accent,
|
|
32
|
-
borderColor: row.accent + '33',
|
|
33
|
-
}"
|
|
29
|
+
:style="identityChipStyle(row.accent)"
|
|
34
30
|
>
|
|
35
31
|
{{ row.plan.planKey.slice(0, 3) }}
|
|
36
32
|
</div>
|
|
@@ -462,6 +458,7 @@
|
|
|
462
458
|
|
|
463
459
|
<script setup lang="ts">
|
|
464
460
|
import type { MarketingTopFeature, PlanRow, PlanVersionRow } from '@saasicat/types';
|
|
461
|
+
import { identityChipStyle } from '../../client/identity-accents.js';
|
|
465
462
|
import { formatMessage } from '../../client/i18n/format.js';
|
|
466
463
|
import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
|
|
467
464
|
import type { FeatureSuggestion, MarketingRow, ResolvedMarketing } from './types.js';
|
|
@@ -11,10 +11,7 @@
|
|
|
11
11
|
</div>
|
|
12
12
|
<div v-else class="sa-pv-diff-card">
|
|
13
13
|
<header class="sa-pv-diff-card__head">
|
|
14
|
-
<span
|
|
15
|
-
class="sa-pv-diff-card__accent"
|
|
16
|
-
:style="{ background: `${accent}14`, borderColor: `${accent}40` }"
|
|
17
|
-
>
|
|
14
|
+
<span class="sa-pv-diff-card__accent" :style="accentWashStyle">
|
|
18
15
|
<q-icon name="inventory_2" size="14px" :style="{ color: accent }" />
|
|
19
16
|
</span>
|
|
20
17
|
<span class="sa-pv-diff-card__name">{{ title }}</span>
|
|
@@ -38,6 +35,7 @@
|
|
|
38
35
|
<script setup lang="ts">
|
|
39
36
|
import { computed } from 'vue';
|
|
40
37
|
import type { VersionChange } from '@saasicat/types';
|
|
38
|
+
import { identityChipStyle } from '../../client/identity-accents.js';
|
|
41
39
|
import { formatMessage } from '../../client/i18n/format.js';
|
|
42
40
|
import { useSaMessages } from '../../vue/use-super-admin-i18n.js';
|
|
43
41
|
import VersionDiffPreview from '../../components/VersionDiffPreview.vue';
|
|
@@ -52,6 +50,15 @@ const props = defineProps<{
|
|
|
52
50
|
}>();
|
|
53
51
|
|
|
54
52
|
const msg = useSaMessages('planVersions');
|
|
53
|
+
|
|
54
|
+
// The wash and the edge this badge always had — deliberately not the chip
|
|
55
|
+
// helper's third property. `identityChipStyle` also sets a text colour, and
|
|
56
|
+
// the icon inside already sets its own; no fixture renders this card, so a
|
|
57
|
+
// change nobody can observe is a change nobody should make.
|
|
58
|
+
const accentWashStyle = computed(() => {
|
|
59
|
+
const { background, borderColor } = identityChipStyle(props.accent);
|
|
60
|
+
return { background, borderColor };
|
|
61
|
+
});
|
|
55
62
|
const common = useSaMessages('common');
|
|
56
63
|
|
|
57
64
|
const changeCountLabel = computed(() => {
|
|
@@ -66,8 +66,10 @@ const props = defineProps<{
|
|
|
66
66
|
|
|
67
67
|
const msg = useSaMessages('planVersions');
|
|
68
68
|
|
|
69
|
+
import { identityAccentFor } from '../../client/identity-accents.js';
|
|
70
|
+
|
|
69
71
|
function planAccent(planId: string): string {
|
|
70
|
-
return props.planAccents
|
|
72
|
+
return identityAccentFor(planId, props.planAccents ?? {});
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
const planDiffs = computed<SnapshotEntityDiff[]>(() => {
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
// Formatting helpers for the tenants page.
|
|
2
2
|
//
|
|
3
3
|
// Shared between TenantsPage.vue and subcomponents (Avatar, Pill).
|
|
4
|
-
// Apps with their own plan accents pass them through via the `planAccents` prop
|
|
5
|
-
//
|
|
4
|
+
// Apps with their own plan accents pass them through via the `planAccents` prop.
|
|
5
|
+
//
|
|
6
|
+
// The default map used to live here as five hex literals, and it disagreed with
|
|
7
|
+
// the two in `PlanList`/`PlanMatrix`: `STANDARD` was the brand blue on this page
|
|
8
|
+
// and a plain blue on the plans page, `ENTERPRISE` near-black here and teal
|
|
9
|
+
// there. Same plan, three colours, depending on which screen you were looking
|
|
10
|
+
// at. It now comes from the shared ramp, so the answer is one.
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
BASIC: '#94a3b8',
|
|
9
|
-
STANDARD: '#3f6bff',
|
|
10
|
-
PROFESSIONAL: '#7c3aed',
|
|
11
|
-
BUSINESS: '#0ea5e9',
|
|
12
|
-
ENTERPRISE: '#0f172a',
|
|
13
|
-
};
|
|
12
|
+
import { IDENTITY_NEUTRAL, identityAccentFor } from '../../client/identity-accents.js';
|
|
14
13
|
|
|
15
14
|
export function tenantInitials(name: string | null | undefined): string {
|
|
16
15
|
if (!name) return '?';
|
|
@@ -22,10 +21,10 @@ export function tenantInitials(name: string | null | undefined): string {
|
|
|
22
21
|
|
|
23
22
|
export function planAccent(
|
|
24
23
|
planId: string | null | undefined,
|
|
25
|
-
accents: Record<string, string> =
|
|
24
|
+
accents: Record<string, string> = {},
|
|
26
25
|
): string {
|
|
27
|
-
if (!planId) return
|
|
28
|
-
return
|
|
26
|
+
if (!planId) return IDENTITY_NEUTRAL;
|
|
27
|
+
return identityAccentFor(planId, accents);
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
/** `locale` is a BCP-47 tag — pages pass `intlLocale.value` of the active UI locale. */
|