@saasicat/ui-vue 0.19.0 → 0.21.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 (66) hide show
  1. package/dist/{catalog-JXdKdIqG.d.cts → catalog-BY_t9prv.d.cts} +1 -0
  2. package/dist/{catalog-JXdKdIqG.d.ts → catalog-BY_t9prv.d.ts} +1 -0
  3. package/dist/{chunk-RVG6LQHL.js → chunk-7IFKVGZV.js} +1 -1
  4. package/dist/{chunk-DPTIOQXX.js → chunk-JN6HZGLV.js} +2 -0
  5. package/dist/{chunk-ORX3ZG47.js → chunk-OTP3OSSW.js} +1 -1
  6. package/dist/client/index.cjs +2 -0
  7. package/dist/client/index.d.cts +2 -2
  8. package/dist/client/index.d.ts +2 -2
  9. package/dist/client/index.js +2 -2
  10. package/dist/index.cjs +2 -0
  11. package/dist/index.d.cts +4 -4
  12. package/dist/index.d.ts +4 -4
  13. package/dist/index.js +3 -3
  14. package/dist/quasar/index.cjs +2 -0
  15. package/dist/quasar/index.d.cts +2 -2
  16. package/dist/quasar/index.d.ts +2 -2
  17. package/dist/quasar/index.js +2 -2
  18. package/dist/{use-super-admin-i18n-1nCOkvgi.d.cts → use-super-admin-i18n-DlJRGIVq.d.cts} +1 -1
  19. package/dist/{use-super-admin-i18n-BlqcD0N7.d.ts → use-super-admin-i18n-c_Eqsu9u.d.ts} +1 -1
  20. package/package.json +2 -2
  21. package/src/client/i18n/messages/common.ts +2 -0
  22. package/src/components/admin-page/AdminBody.vue +40 -0
  23. package/src/components/admin-page/AdminHero.vue +32 -0
  24. package/src/components/admin-page/AdminPage.vue +16 -0
  25. package/src/components/admin-page/AdminSection.vue +38 -0
  26. package/src/components/plan-cockpit/PlanCockpit.vue +15 -0
  27. package/src/components/plan-cockpit/PlanCockpitAuditLog.vue +8 -8
  28. package/src/components/plan-cockpit/PlanCockpitDiffPanel.vue +10 -11
  29. package/src/components/plan-cockpit/PlanCockpitImpactPanel.vue +8 -8
  30. package/src/components/plan-cockpit/PlanCockpitVersions.vue +9 -11
  31. package/src/components/plan-detail/PlanAuditLog.vue +7 -8
  32. package/src/components/plan-detail/PlanDetail.vue +14 -1
  33. package/src/components/plan-detail/PlanVersionDiffPanel.vue +5 -8
  34. package/src/components/plan-detail/PlanVersionsPanel.vue +9 -11
  35. package/src/components/plan-list/PlanList.vue +224 -257
  36. package/src/pages-standard/AdminLayout.vue +4 -2
  37. package/src/pages-standard/AuditPage.vue +18 -43
  38. package/src/pages-standard/BundlesPage.vue +17 -34
  39. package/src/pages-standard/DashboardPage.vue +20 -51
  40. package/src/pages-standard/DiscoveryPage.vue +38 -51
  41. package/src/pages-standard/EmailHistoryPage.vue +20 -71
  42. package/src/pages-standard/MarketingCatalogPage.vue +304 -315
  43. package/src/pages-standard/PilotsPage.vue +24 -56
  44. package/src/pages-standard/PlansPage.vue +25 -22
  45. package/src/pages-standard/PlatformEmailPage.vue +19 -51
  46. package/src/pages-standard/PromoCodeDetailPage.vue +39 -50
  47. package/src/pages-standard/PromoCodesPage.vue +8 -15
  48. package/src/pages-standard/SubscriptionsPage.vue +9 -35
  49. package/src/pages-standard/SuperAdminSetupWizard.vue +2 -1
  50. package/src/pages-standard/TenantDetailPage.vue +79 -174
  51. package/src/pages-standard/TenantsPage.vue +8 -33
  52. package/src/pages-standard/UsersPage.vue +21 -54
  53. package/src/pages-standard/bundles-page/BundlesToolbar.vue +67 -0
  54. package/src/pages-standard/marketing-catalog/MarketingCatalogAdmin.vue +111 -73
  55. package/src/pages-standard/marketing-catalog/MarketingCatalogHeader.vue +54 -62
  56. package/src/pages-standard/marketing-catalog/MarketingCatalogPreview.vue +53 -35
  57. package/src/pages-standard/marketing-catalog/MarketingCatalogToolbar.vue +7 -7
  58. package/src/pages-standard/plans-page/PlansPageToast.vue +5 -5
  59. package/src/pages-standard/sa-theme.css +85 -29
  60. package/src/pages-standard/tenant-detail/TenantMasterData.vue +28 -0
  61. package/src/pages-standard/tenant-detail/TenantUsage.vue +31 -0
  62. package/src/pages-standard/tenant-detail/TenantUsers.vue +21 -0
  63. package/src/pages-standard/tenant-detail/types.ts +38 -0
  64. package/src/vue/use-mfa-prompt.ts +72 -0
  65. package/src/pages-standard/bundles-page/BundlesHeader.vue +0 -68
  66. package/src/pages-standard/discovery-page/DiscoveryHeader.vue +0 -34
@@ -1,13 +1,10 @@
1
1
  <template>
2
- <div class="sa-pilots">
3
- <header class="sa-page-head">
4
- <div>
5
- <h1 class="sa-page-head__title">{{ msg.title }}</h1>
6
- <p class="sa-page-head__sub">
7
- {{ formatMessage(msg.subtitleCount, { count: rows.length }) }}
8
- </p>
9
- </div>
10
- <div class="sa-page-head__actions">
2
+ <AdminPage class="sa-pilots">
3
+ <AdminHero
4
+ :title="msg.title"
5
+ :subtitle="formatMessage(msg.subtitleCount, { count: rows.length })"
6
+ >
7
+ <template #actions>
11
8
  <slot name="head-actions">
12
9
  <q-btn
13
10
  v-if="enableCreate"
@@ -18,8 +15,8 @@
18
15
  @click="showCreate = true"
19
16
  />
20
17
  </slot>
21
- </div>
22
- </header>
18
+ </template>
19
+ </AdminHero>
23
20
 
24
21
  <div class="sa-stats">
25
22
  <button
@@ -98,22 +95,25 @@
98
95
 
99
96
  <MfaPromptDialog
100
97
  v-if="needsMfaDialog"
101
- :model-value="showMfa"
102
- :description="mfaDescription"
103
- :error="mfaError"
98
+ :model-value="mfa.show.value"
99
+ :description="mfa.description.value"
100
+ :error="mfa.error.value"
104
101
  :setup-hint="mfaSetupHint"
105
- @update:model-value="onMfaDialogVisibility"
106
- @confirm="onMfaConfirm"
102
+ @update:model-value="mfa.onVisibility"
103
+ @confirm="mfa.onConfirm"
107
104
  />
108
- </div>
105
+ </AdminPage>
109
106
  </template>
110
107
 
111
108
  <script setup lang="ts">
112
109
  import { computed, onMounted, ref } from 'vue';
110
+ import { useMfaPrompt } from '../vue/use-mfa-prompt.js';
113
111
  import { useQuasar } from 'quasar';
114
112
  import { formatMessage } from '../client/i18n/format.js';
115
113
  import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
116
114
  import { useSuperAdminNotify } from '../quasar/notify.js';
115
+ import AdminHero from '../components/admin-page/AdminHero.vue';
116
+ import AdminPage from '../components/admin-page/AdminPage.vue';
117
117
  import PilotCreateDialog from '../components/dialogs/PilotCreateDialog.vue';
118
118
  import PilotEditDialog from '../components/dialogs/PilotEditDialog.vue';
119
119
  import MfaPromptDialog from '../components/MfaPromptDialog.vue';
@@ -219,38 +219,11 @@ const showEdit = ref(false);
219
219
  const editRow = ref<PilotRow | null>(null);
220
220
  const bakedPlanOptions = ref<PilotPlanOption[]>([]);
221
221
 
222
- // MFA dialog state for per-flow MFA (extend/revoke). Promise-resolver pattern
223
- // analogous to use-platform-tenant-actions.ts: `onExtendClick`/`onRevokeClick`
224
- // calls `runWithMfa(...)`, which opens the dialog and waits for `onMfaConfirm`
225
- // (or cancellation via `update:modelValue=false`).
226
- const showMfa = ref(false);
227
- const mfaError = ref('');
228
- const mfaDescription = ref('');
229
- let pendingMfaResolve: ((code: string | null) => void) | null = null;
222
+ // Per-flow MFA for extend/revoke: `runWithMfa` awaits `mfa.prompt`, which
223
+ // resolves with the code or with null when the user closes the dialog.
224
+ const mfa = useMfaPrompt();
230
225
  const needsMfaDialog = computed(() => props.requireMfaForExtend || props.requireMfaForRevoke);
231
226
 
232
- function promptMfa(description: string): Promise<string | null> {
233
- return new Promise((resolve) => {
234
- mfaDescription.value = description;
235
- mfaError.value = '';
236
- showMfa.value = true;
237
- pendingMfaResolve = (code) => {
238
- pendingMfaResolve = null;
239
- resolve(code);
240
- };
241
- });
242
- }
243
-
244
- function onMfaConfirm(code: string): void {
245
- pendingMfaResolve?.(code);
246
- }
247
-
248
- function onMfaDialogVisibility(open: boolean): void {
249
- showMfa.value = open;
250
- if (!open && pendingMfaResolve) {
251
- pendingMfaResolve(null);
252
- }
253
- }
254
227
  // Consumers can set `createPlanOptions` instead of `loadPlanOptions`
255
228
  // (e.g. apps with a hard-coded plan list).
256
229
  const effectiveCreatePlanOptions = computed<readonly (string | PilotPlanOption)[]>(() => {
@@ -498,21 +471,21 @@ async function runAction(
498
471
  // MFA loop: as long as the server returns 401, keep the dialog open and
499
472
  // wait again for a code. Cancelling (resolver === null) ends it.
500
473
  while (true) {
501
- const code = await promptMfa(actionLabel);
474
+ const code = await mfa.prompt(actionLabel);
502
475
  if (code === null) return;
503
476
  try {
504
477
  await invoke(code);
505
- showMfa.value = false;
478
+ mfa.show.value = false;
506
479
  notify('positive', successMessage);
507
480
  await reload();
508
481
  return;
509
482
  } catch (err) {
510
483
  const status = (err as { response?: { status?: number } })?.response?.status;
511
484
  if (status === 401) {
512
- mfaError.value = msg.value.mfa.invalidOrNotSetUp;
485
+ mfa.error.value = msg.value.mfa.invalidOrNotSetUp;
513
486
  continue;
514
487
  }
515
- showMfa.value = false;
488
+ mfa.show.value = false;
516
489
  notify('negative', errMsg(err));
517
490
  return;
518
491
  }
@@ -575,11 +548,6 @@ function formatDate(iso: string | null | undefined): string | null {
575
548
  </script>
576
549
 
577
550
  <style scoped>
578
- .sa-pilots {
579
- min-height: calc(100vh - 56px);
580
- background: var(--sa-bg-app, #f1f5f9);
581
- padding: 20px 28px 28px;
582
- }
583
551
  .sa-pilots__card {
584
552
  background: #fff;
585
553
  border: 1px solid var(--sa-border, #e2e8f0);
@@ -1,5 +1,23 @@
1
1
  <template>
2
- <q-page class="sa-plans">
2
+ <AdminPage class="sa-plans">
3
+ <AdminHero :title="msg.list.title">
4
+ <template v-if="mode === 'list'" #actions>
5
+ <q-btn
6
+ flat
7
+ icon="grid_view"
8
+ :label="msg.list.matrixView"
9
+ @click="mode = 'matrix'"
10
+ />
11
+ <q-btn
12
+ unelevated
13
+ color="primary"
14
+ icon="add"
15
+ :label="msg.list.newPlan"
16
+ @click="openCreate"
17
+ />
18
+ </template>
19
+ </AdminHero>
20
+
3
21
  <q-banner v-if="error" class="bg-negative text-white q-ma-md" rounded>
4
22
  <template #avatar>
5
23
  <q-icon name="error" />
@@ -10,12 +28,7 @@
10
28
  </template>
11
29
  </q-banner>
12
30
 
13
- <div v-if="loading && plans.length === 0" class="sa-plans__loading">
14
- <q-spinner size="32px" />
15
- <span>{{ msg.page.loading }}</span>
16
- </div>
17
-
18
- <template v-else>
31
+ <AdminBody :loading="loading && plans.length === 0" :loading-text="msg.page.loading">
19
32
  <!-- Default: list view (plan simulation) + Bundle overview -->
20
33
  <template v-if="mode === 'list'">
21
34
  <PlanList
@@ -25,13 +38,11 @@
25
38
  :plan-accents="props.planAccents"
26
39
  :highlight-plan-key="highlightPlanKey"
27
40
  @open-plan="onOpenPlan"
28
- @create-plan="openCreate"
29
41
  @clone-plan="onClonePlan"
30
42
  @new-version="onNewVersionFromList"
31
43
  @edit-draft="onEditDraftFromList"
32
44
  @discard-draft="onDiscardDraftFromList"
33
45
  @archive-plan="onArchivePlanFromList"
34
- @show-matrix="mode = 'matrix'"
35
46
  />
36
47
  <PlanBundleOverview
37
48
  :bundles="availableBundles"
@@ -113,7 +124,7 @@
113
124
  @save-and-exit="onReviewSaveExit"
114
125
  @publish="onReviewPublish"
115
126
  />
116
- </template>
127
+ </AdminBody>
117
128
 
118
129
  <PlansPageToast :message="toastMessage" />
119
130
 
@@ -157,11 +168,14 @@
157
168
  :format-change-value="formatChangeValue"
158
169
  @execute="executePublish"
159
170
  />
160
- </q-page>
171
+ </AdminPage>
161
172
  </template>
162
173
 
163
174
  <script setup lang="ts">
164
175
  import { computed, onMounted, ref, shallowRef, watch } from 'vue';
176
+ import AdminBody from '../components/admin-page/AdminBody.vue';
177
+ import AdminHero from '../components/admin-page/AdminHero.vue';
178
+ import AdminPage from '../components/admin-page/AdminPage.vue';
165
179
  import type {
166
180
  FeatureCatalogEntryRow,
167
181
  PlanRow,
@@ -1238,17 +1252,6 @@ onMounted(async () => {
1238
1252
 
1239
1253
  <style scoped>
1240
1254
  .sa-plans {
1241
- min-height: calc(100vh - 56px);
1242
- background: #f6f7f9;
1243
1255
  position: relative;
1244
1256
  }
1245
- .sa-plans__loading {
1246
- display: flex;
1247
- align-items: center;
1248
- justify-content: center;
1249
- gap: 12px;
1250
- padding: 48px;
1251
- color: #64748b;
1252
- font-size: 14px;
1253
- }
1254
1257
  </style>
@@ -1,11 +1,7 @@
1
1
  <template>
2
- <div class="sa-pemail">
3
- <header class="sa-page-head">
4
- <div>
5
- <h1 class="sa-page-head__title">{{ resolvedTitle }}</h1>
6
- <p class="sa-page-head__sub">{{ msg.provider.subtitle }}</p>
7
- </div>
8
- <div class="sa-page-head__actions">
2
+ <AdminPage class="sa-pemail">
3
+ <AdminHero :title="resolvedTitle" :subtitle="msg.provider.subtitle">
4
+ <template #actions>
9
5
  <q-btn
10
6
  v-if="rows.length === 0"
11
7
  unelevated
@@ -15,8 +11,8 @@
15
11
  @click="openCreate"
16
12
  />
17
13
  <q-btn flat icon="refresh" :label="common.reload" @click="reload" />
18
- </div>
19
- </header>
14
+ </template>
15
+ </AdminHero>
20
16
 
21
17
  <div class="sa-pemail__card">
22
18
  <q-table
@@ -185,19 +181,22 @@
185
181
 
186
182
  <MfaPromptDialog
187
183
  v-if="requireMfaForWrite"
188
- :model-value="showMfa"
189
- :description="mfaDescription"
190
- :error="mfaError"
184
+ :model-value="mfa.show.value"
185
+ :description="mfa.description.value"
186
+ :error="mfa.error.value"
191
187
  :setup-hint="mfaSetupHint"
192
- @update:model-value="onMfaDialogVisibility"
193
- @confirm="onMfaConfirm"
188
+ @update:model-value="mfa.onVisibility"
189
+ @confirm="mfa.onConfirm"
194
190
  />
195
- </div>
191
+ </AdminPage>
196
192
  </template>
197
193
 
198
194
  <script setup lang="ts">
199
195
  import { computed, reactive, ref } from 'vue';
196
+ import { useMfaPrompt } from '../vue/use-mfa-prompt.js';
200
197
  import { useQuasar } from 'quasar';
198
+ import AdminHero from '../components/admin-page/AdminHero.vue';
199
+ import AdminPage from '../components/admin-page/AdminPage.vue';
201
200
  import { useSuperAdminNotify } from '../quasar/notify.js';
202
201
  import MfaPromptDialog from '../components/MfaPromptDialog.vue';
203
202
  import { formatMessage } from '../client/i18n/format.js';
@@ -279,10 +278,7 @@ const testResult = ref<PlatformEmailTestResult | null>(null);
279
278
  const testing = ref(false);
280
279
 
281
280
  // MFA loop analogous to UsersPage (promise-resolver pattern).
282
- const showMfa = ref(false);
283
- const mfaError = ref('');
284
- const mfaDescription = ref('');
285
- let pendingMfaResolve: ((code: string | null) => void) | null = null;
281
+ const mfa = useMfaPrompt();
286
282
 
287
283
  function emptyForm(): PlatformEmailWriteInput {
288
284
  return {
@@ -298,29 +294,6 @@ function emptyForm(): PlatformEmailWriteInput {
298
294
  };
299
295
  }
300
296
 
301
- function promptMfa(description: string): Promise<string | null> {
302
- return new Promise((resolve) => {
303
- mfaDescription.value = description;
304
- mfaError.value = '';
305
- showMfa.value = true;
306
- pendingMfaResolve = (code) => {
307
- pendingMfaResolve = null;
308
- resolve(code);
309
- };
310
- });
311
- }
312
-
313
- function onMfaConfirm(code: string): void {
314
- pendingMfaResolve?.(code);
315
- }
316
-
317
- function onMfaDialogVisibility(open: boolean): void {
318
- showMfa.value = open;
319
- if (!open && pendingMfaResolve) {
320
- pendingMfaResolve(null);
321
- }
322
- }
323
-
324
297
  function errMsg(err: unknown): string {
325
298
  return (
326
299
  (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
@@ -381,19 +354,19 @@ async function runWrite(
381
354
  }
382
355
  }
383
356
  for (;;) {
384
- const code = await promptMfa(label);
357
+ const code = await mfa.prompt(label);
385
358
  if (code === null) return false;
386
359
  try {
387
360
  await invoke(code);
388
- showMfa.value = false;
361
+ mfa.show.value = false;
389
362
  return true;
390
363
  } catch (err) {
391
364
  const status = (err as { response?: { status?: number } })?.response?.status;
392
365
  if (status === 401) {
393
- mfaError.value = shell.value.mfa.invalidCode;
366
+ mfa.error.value = shell.value.mfa.invalidCode;
394
367
  continue;
395
368
  }
396
- showMfa.value = false;
369
+ mfa.show.value = false;
397
370
  notify('negative', errMsg(err));
398
371
  return false;
399
372
  }
@@ -484,11 +457,6 @@ async function onTest(): Promise<void> {
484
457
  </script>
485
458
 
486
459
  <style scoped>
487
- .sa-pemail {
488
- min-height: calc(100vh - 56px);
489
- background: var(--sa-bg-app, #f1f5f9);
490
- padding: 20px 28px 28px;
491
- }
492
460
  .sa-pemail__card {
493
461
  background: #fff;
494
462
  border: 1px solid var(--sa-border, #e2e8f0);
@@ -1,7 +1,7 @@
1
1
  <template>
2
- <div class="sa-promo-detail">
3
- <header class="sa-page-head">
4
- <div>
2
+ <AdminPage class="sa-promo-detail">
3
+ <AdminHero :title="data?.promo.code ?? labels.promoCode">
4
+ <template #before-title>
5
5
  <q-btn
6
6
  flat
7
7
  dense
@@ -10,19 +10,18 @@
10
10
  :to="backRoute"
11
11
  class="sa-promo-detail__back"
12
12
  />
13
- <h1 class="sa-page-head__title">{{ data?.promo.code ?? labels.promoCode }}</h1>
14
- <p class="sa-page-head__sub">
15
- <template v-if="data">
16
- {{ data.promo.valueType }} · {{ data.promo.value }} ·
17
- <q-badge
18
- :color="resolveStatusColor(String(data.promo.status))"
19
- :label="String(data.promo.status)"
20
- />
21
- </template>
22
- <template v-else>—</template>
23
- </p>
24
- </div>
25
- <div class="sa-promo-detail__head-actions">
13
+ </template>
14
+ <template #subtitle>
15
+ <template v-if="data">
16
+ {{ data.promo.valueType }} · {{ data.promo.value }} ·
17
+ <q-badge
18
+ :color="resolveStatusColor(String(data.promo.status))"
19
+ :label="String(data.promo.status)"
20
+ />
21
+ </template>
22
+ <template v-else>—</template>
23
+ </template>
24
+ <template #actions>
26
25
  <q-btn
27
26
  v-if="editSubmit && data"
28
27
  color="primary"
@@ -32,36 +31,34 @@
32
31
  @click="openEdit"
33
32
  />
34
33
  <slot name="header-actions" />
35
- </div>
36
- </header>
37
-
38
- <div class="sa-promo-detail__body">
39
- <div v-if="loading" class="sa-promo-detail__state">
40
- <q-spinner size="32px" /> {{ msg.detail.loading }}
41
- </div>
42
- <div v-else-if="!data" class="sa-promo-detail__state">{{ labels.empty }}</div>
34
+ </template>
35
+ </AdminHero>
43
36
 
44
- <template v-else>
45
- <div class="sa-card sa-promo-detail__section">
46
- <div class="sa-promo-detail__section-head">{{ labels.config }}</div>
37
+ <AdminBody
38
+ :loading="loading"
39
+ :empty="!data"
40
+ :loading-text="msg.detail.loading"
41
+ :empty-text="labels.empty"
42
+ >
43
+ <template v-if="data">
44
+ <AdminSection :title="labels.config" class="sa-card sa-promo-detail__section">
47
45
  <slot name="config" :promo="data.promo">
48
46
  <pre class="sa-promo-detail__kv">{{ resolveFormatPromo(data.promo) }}</pre>
49
47
  </slot>
50
- </div>
48
+ </AdminSection>
51
49
 
52
- <div class="sa-card sa-promo-detail__section">
53
- <div class="sa-promo-detail__section-head">{{ labels.stats }}</div>
50
+ <AdminSection :title="labels.stats" class="sa-card sa-promo-detail__section">
54
51
  <slot name="stats" :stats="data.stats">
55
52
  <pre class="sa-promo-detail__kv">{{
56
53
  JSON.stringify(data.stats, null, 2)
57
54
  }}</pre>
58
55
  </slot>
59
- </div>
56
+ </AdminSection>
60
57
 
61
- <div class="sa-card sa-promo-detail__section">
62
- <div class="sa-promo-detail__section-head">
63
- {{ labels.redemptions }} ({{ data.redemptions.length }})
64
- </div>
58
+ <AdminSection
59
+ :title="`${labels.redemptions} (${data.redemptions.length})`"
60
+ class="sa-card sa-promo-detail__section"
61
+ >
65
62
  <slot name="redemptions" :redemptions="data.redemptions">
66
63
  <q-table
67
64
  flat
@@ -72,11 +69,11 @@
72
69
  hide-pagination
73
70
  />
74
71
  </slot>
75
- </div>
72
+ </AdminSection>
76
73
 
77
74
  <slot name="extra-sections" :data="data" />
78
75
  </template>
79
- </div>
76
+ </AdminBody>
80
77
 
81
78
  <PromoCodeEditDialog
82
79
  v-if="editSubmit"
@@ -86,11 +83,15 @@
86
83
  :submit="editSubmit"
87
84
  @updated="onEditUpdated"
88
85
  />
89
- </div>
86
+ </AdminPage>
90
87
  </template>
91
88
 
92
89
  <script setup lang="ts">
93
90
  import { computed, onMounted, ref } from 'vue';
91
+ import AdminBody from '../components/admin-page/AdminBody.vue';
92
+ import AdminHero from '../components/admin-page/AdminHero.vue';
93
+ import AdminSection from '../components/admin-page/AdminSection.vue';
94
+ import AdminPage from '../components/admin-page/AdminPage.vue';
94
95
  import type { RouteLocationRaw } from 'vue-router';
95
96
  import type { QTableColumn } from 'quasar';
96
97
  import PromoCodeEditDialog, {
@@ -283,10 +284,6 @@ const defaultColumns = computed<QTableColumn[]>(() => [
283
284
  </script>
284
285
 
285
286
  <style scoped>
286
- .sa-promo-detail {
287
- min-height: calc(100vh - 56px);
288
- background: var(--sa-bg-app);
289
- }
290
287
  .sa-promo-detail__back {
291
288
  margin-bottom: 6px;
292
289
  }
@@ -297,7 +294,6 @@ const defaultColumns = computed<QTableColumn[]>(() => [
297
294
  margin-left: auto;
298
295
  }
299
296
  .sa-promo-detail__body {
300
- padding: 12px 28px 28px;
301
297
  display: flex;
302
298
  flex-direction: column;
303
299
  gap: 14px;
@@ -324,11 +320,4 @@ const defaultColumns = computed<QTableColumn[]>(() => [
324
320
  overflow-x: auto;
325
321
  font-family: var(--sa-font-mono);
326
322
  }
327
- .sa-promo-detail__state {
328
- padding: 24px;
329
- display: flex;
330
- align-items: center;
331
- gap: 12px;
332
- color: var(--sa-muted);
333
- }
334
323
  </style>
@@ -1,11 +1,7 @@
1
1
  <template>
2
- <div class="sa-promo-codes">
3
- <header class="sa-page-head">
4
- <div>
5
- <h1 class="sa-page-head__title">{{ msg.title }}</h1>
6
- <p v-if="subtitle" class="sa-page-head__sub">{{ subtitle }}</p>
7
- </div>
8
- <div class="sa-page-head__actions">
2
+ <AdminPage class="sa-promo-codes">
3
+ <AdminHero :title="msg.title" :subtitle="subtitle">
4
+ <template #actions>
9
5
  <slot name="head-actions">
10
6
  <q-btn
11
7
  v-if="enableCreate"
@@ -16,8 +12,8 @@
16
12
  @click="showCreate = true"
17
13
  />
18
14
  </slot>
19
- </div>
20
- </header>
15
+ </template>
16
+ </AdminHero>
21
17
 
22
18
  <div class="sa-stats">
23
19
  <button
@@ -108,7 +104,7 @@
108
104
  :submit="submitEdit"
109
105
  @updated="onUpdated"
110
106
  />
111
- </div>
107
+ </AdminPage>
112
108
  </template>
113
109
 
114
110
  <script lang="ts">
@@ -137,6 +133,8 @@ export function computePlanColors(
137
133
 
138
134
  <script setup lang="ts">
139
135
  import { computed, onMounted, reactive, ref } from 'vue';
136
+ import AdminHero from '../components/admin-page/AdminHero.vue';
137
+ import AdminPage from '../components/admin-page/AdminPage.vue';
140
138
  import { useQuasar } from 'quasar';
141
139
  import { formatMessage } from '../client/i18n/format.js';
142
140
  import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
@@ -562,11 +560,6 @@ function formatDate(iso: string | Date | null | undefined): string | null {
562
560
  </script>
563
561
 
564
562
  <style scoped>
565
- .sa-promo-codes {
566
- min-height: calc(100vh - 56px);
567
- background: var(--sa-bg-app, #f1f5f9);
568
- padding: 20px 28px 28px;
569
- }
570
563
  .sa-promo-codes__filter {
571
564
  display: flex;
572
565
  gap: 10px;
@@ -1,12 +1,10 @@
1
1
  <template>
2
- <div class="sa-subscriptions">
3
- <header class="sa-subscriptions__head">
4
- <div>
5
- <h1 class="sa-subscriptions__title">{{ msg.subscriptions.title }}</h1>
6
- <p v-if="subtitle" class="sa-subscriptions__sub">{{ subtitle }}</p>
7
- </div>
8
- <slot name="head-actions" />
9
- </header>
2
+ <AdminPage class="sa-subscriptions">
3
+ <AdminHero :title="msg.subscriptions.title" :subtitle="subtitle">
4
+ <template #actions>
5
+ <slot name="head-actions" />
6
+ </template>
7
+ </AdminHero>
10
8
 
11
9
  <div class="sa-subscriptions__body">
12
10
  <div class="sa-subscriptions__card">
@@ -21,11 +19,13 @@
21
19
  />
22
20
  </div>
23
21
  </div>
24
- </div>
22
+ </AdminPage>
25
23
  </template>
26
24
 
27
25
  <script setup lang="ts">
28
26
  import { computed, onMounted, ref } from 'vue';
27
+ import AdminHero from '../components/admin-page/AdminHero.vue';
28
+ import AdminPage from '../components/admin-page/AdminPage.vue';
29
29
  import { useSaMessages, useSuperAdminI18n } from '../vue/use-super-admin-i18n.js';
30
30
 
31
31
  // Platform standard page: subscriptions (minimal). Apps pass
@@ -129,32 +129,6 @@ function formatDate(iso: string | null | undefined): string | null {
129
129
  </script>
130
130
 
131
131
  <style scoped>
132
- .sa-subscriptions {
133
- min-height: calc(100vh - 56px);
134
- background: var(--sa-bg-app, #f1f5f9);
135
- }
136
- .sa-subscriptions__head {
137
- padding: 20px 28px 8px;
138
- display: flex;
139
- align-items: flex-start;
140
- justify-content: space-between;
141
- gap: 16px;
142
- }
143
- .sa-subscriptions__title {
144
- margin: 0;
145
- font-family: var(--sa-font-head, system-ui, sans-serif);
146
- font-weight: 700;
147
- font-size: 22px;
148
- color: var(--sa-heading, #0f172a);
149
- }
150
- .sa-subscriptions__sub {
151
- margin: 4px 0 0;
152
- color: var(--sa-muted-dark, #475569);
153
- font-size: 13.5px;
154
- }
155
- .sa-subscriptions__body {
156
- padding: 12px 28px 28px;
157
- }
158
132
  .sa-subscriptions__card {
159
133
  background: #fff;
160
134
  border: 1px solid var(--sa-border, #e2e8f0);
@@ -4,7 +4,7 @@
4
4
  <div class="sa-setup-head">
5
5
  <div class="sa-setup-badge">{{ iconText }}</div>
6
6
  <div>
7
- <div class="sa-setup-title">{{ msg.setup.title }}</div>
7
+ <h1 class="sa-setup-title">{{ msg.setup.title }}</h1>
8
8
  <div class="sa-setup-sub">{{ subtitleText }}</div>
9
9
  </div>
10
10
  <LocaleSwitcher class="sa-setup-locale" />
@@ -309,6 +309,7 @@ async function submitConfirm(): Promise<void> {
309
309
  text-transform: uppercase;
310
310
  }
311
311
  .sa-setup-title {
312
+ margin: 0;
312
313
  font-weight: 800;
313
314
  font-size: 18px;
314
315
  color: #0f172a;