@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,176 @@
1
+ // What a paginated admin list asks for, and what it gets back.
2
+ //
3
+ // One decision, written once. `useApiList` assembles `?page=…&pageSize=…` plus
4
+ // the filter, `createAdminResourceClient` assembles the filter half of the same
5
+ // thing for the endpoints that do not page, and every list descriptor would
6
+ // have been a third and fourth expression of it. They agreed by coincidence
7
+ // rather than by construction: three copies of "an empty value is omitted" is
8
+ // three chances for one of them to start sending `status=null` and filter every
9
+ // row away.
10
+ //
11
+ // Framework-free like the rest of `src/client/`: a URL, a body shape and the
12
+ // arithmetic around the page number. The reactive half — refs, watchers, the
13
+ // two controls — is `useResourceList`'s.
14
+
15
+ import type { ResourceContext, ResourceOp } from './define-resource.js';
16
+ import { requestJson } from './resource-request.js';
17
+
18
+ /** 1-based: the page a list starts on, and the one a changed filter returns to. */
19
+ export const LIST_FIRST_PAGE = 1;
20
+
21
+ /**
22
+ * What the admin API falls back to when `pageSize` is absent. Sent explicitly
23
+ * rather than relied on, so the URL states what it asked for.
24
+ */
25
+ export const LIST_PAGE_SIZE_DEFAULT = 50;
26
+
27
+ /**
28
+ * The largest page the admin API serves. There is no "all" to pass on, which is
29
+ * why `SERVER_PAGE_SIZE_OPTIONS` leaves Quasar's `ALL_ROWS` out as well.
30
+ */
31
+ export const LIST_PAGE_SIZE_MAX = 200;
32
+
33
+ /** The two parameters the pagination owns; a filter may not carry them. */
34
+ export const LIST_PAGINATION_PARAMS = ['page', 'pageSize'] as const;
35
+
36
+ /**
37
+ * A filter without the two keys the pagination owns.
38
+ *
39
+ * The platform's filter types (`TenantListFilter`, `AuditQuery`) declare
40
+ * `page` and `pageSize` because they were once passed as one object. Serialised
41
+ * after the pagination they silently overwrite it — `goToPage(3)` moves the ref
42
+ * while the request asks for page 7 — so a descriptor takes the filter without
43
+ * them and the page number travels in its own field.
44
+ */
45
+ export type ListFilterOf<TFilter> = Omit<TFilter, 'page' | 'pageSize'>;
46
+
47
+ /** Everything one list request varies: which page, how big, filtered by what. */
48
+ export interface ListQuery<TFilter extends object = Record<string, unknown>> {
49
+ /** 1-based. Defaults to `LIST_FIRST_PAGE`. */
50
+ page?: number;
51
+ /** Defaults to `LIST_PAGE_SIZE_DEFAULT`. */
52
+ pageSize?: number;
53
+ /** Serialised as `?key=value`; see `isSentInQuery` for what is left out. */
54
+ filter?: TFilter;
55
+ }
56
+
57
+ /**
58
+ * One page of a list, as the admin API answers it.
59
+ *
60
+ * `total` is optional because it is genuinely absent from some answers: a
61
+ * controller returning a bare array has reported the rows it sent and nothing
62
+ * about the rest, and an envelope may omit the field altogether. A number
63
+ * invented here would read as "rows matching the filter" under a paginator that
64
+ * then offers pages nobody can reach.
65
+ */
66
+ export interface ResourceListPage<T> {
67
+ items: T[];
68
+ /** Rows matching the filter — only when the server said so. */
69
+ total?: number;
70
+ /** What the server echoed back about the page it served, when it echoed anything. */
71
+ page?: number;
72
+ pageSize?: number;
73
+ }
74
+
75
+ /** A list operation: the query in, one page out. */
76
+ export type ListOp<TRow, TFilter extends object = Record<string, unknown>> = ResourceOp<
77
+ [query?: ListQuery<TFilter>],
78
+ ResourceListPage<TRow>
79
+ >;
80
+
81
+ /**
82
+ * Whether a filter value reaches the query string at all.
83
+ *
84
+ * `undefined`, `null` and `''` are the three spellings of "not filtered" that
85
+ * a form produces, and sending them would filter on the literal text `null`.
86
+ * Everything else goes as `String(value)` — including `0` and `false`, which
87
+ * are answers, not absences.
88
+ */
89
+ export function isSentInQuery(value: unknown): boolean {
90
+ return value !== undefined && value !== null && value !== '';
91
+ }
92
+
93
+ function appendFilter(params: URLSearchParams, filter: object | undefined): void {
94
+ for (const [key, value] of Object.entries(filter ?? {})) {
95
+ if (!isSentInQuery(value)) continue;
96
+ params.set(key, String(value));
97
+ }
98
+ }
99
+
100
+ /**
101
+ * `?a=b` for a filter on its own, or `''` when nothing survives — the form the
102
+ * admin endpoints that do not page take their filter in.
103
+ */
104
+ export function filterQueryString(filter: object): string {
105
+ const params = new URLSearchParams();
106
+ appendFilter(params, filter);
107
+ const query = params.toString();
108
+ return query ? `?${query}` : '';
109
+ }
110
+
111
+ /**
112
+ * The URL one page of a list is requested from.
113
+ *
114
+ * `page` and `pageSize` are always present and always first — an endpoint that
115
+ * ignores them is free to, but the request says which page it meant. The filter
116
+ * follows in its own insertion order, and the endpoint may already carry a
117
+ * query of its own.
118
+ */
119
+ export function listUrl<TFilter extends object>(
120
+ endpoint: string,
121
+ query: ListQuery<TFilter> = {},
122
+ ): string {
123
+ const params = new URLSearchParams();
124
+ params.set('page', String(query.page ?? LIST_FIRST_PAGE));
125
+ params.set('pageSize', String(query.pageSize ?? LIST_PAGE_SIZE_DEFAULT));
126
+ appendFilter(params, query.filter);
127
+ return `${endpoint}${endpoint.includes('?') ? '&' : '?'}${params.toString()}`;
128
+ }
129
+
130
+ /**
131
+ * Reads whatever a list endpoint answered into one page.
132
+ *
133
+ * Both shapes are real: some controllers return a bare array, others the
134
+ * `{ items, page, pageSize, total }` envelope. Accepting only the envelope is
135
+ * what once rendered three tenants as an empty table (see
136
+ * `use-api-list-shape.test.js`). What the answer did not state stays absent
137
+ * rather than being filled in — the caller decides what "no total" means for
138
+ * its paginator.
139
+ */
140
+ export function readListPage<T>(raw: unknown): ResourceListPage<T> {
141
+ if (Array.isArray(raw)) return { items: raw as T[], total: raw.length };
142
+ if (raw === null || typeof raw !== 'object') return { items: [], total: 0 };
143
+ const body = raw as { items?: unknown; total?: unknown; page?: unknown; pageSize?: unknown };
144
+ const page: ResourceListPage<T> = {
145
+ items: Array.isArray(body.items) ? (body.items as T[]) : [],
146
+ };
147
+ if (typeof body.total === 'number') page.total = body.total;
148
+ if (typeof body.page === 'number') page.page = body.page;
149
+ if (typeof body.pageSize === 'number') page.pageSize = body.pageSize;
150
+ return page;
151
+ }
152
+
153
+ /** Keeps a requested page on the 1-based scale the API counts on. */
154
+ export function clampListPage(page: number): number {
155
+ return Math.max(LIST_FIRST_PAGE, Math.floor(page));
156
+ }
157
+
158
+ /** Keeps a requested page size inside the 1..`LIST_PAGE_SIZE_MAX` the API serves. */
159
+ export function clampListPageSize(size: number): number {
160
+ return Math.min(LIST_PAGE_SIZE_MAX, Math.max(1, Math.floor(size)));
161
+ }
162
+
163
+ /**
164
+ * A list operation over one endpoint: the second of a pair, and the reason it
165
+ * is a function rather than two hand-written descriptors.
166
+ *
167
+ * The endpoint is a function of the context so a `context` override on the
168
+ * registry — an app pointing one resource at a legacy base — reaches it without
169
+ * the descriptor knowing.
170
+ */
171
+ export function defineListOp<TRow, TFilter extends object = Record<string, unknown>>(
172
+ endpoint: (ctx: ResourceContext) => string,
173
+ ): ListOp<TRow, TFilter> {
174
+ return async (http, ctx, query) =>
175
+ readListPage<TRow>(await requestJson(http, listUrl(endpoint(ctx), query)));
176
+ }
@@ -0,0 +1,169 @@
1
+ // The plan catalogue endpoints.
2
+ //
3
+ // Two path stems, and the asymmetry is the server's, not a mistake here:
4
+ // reading and creating versions goes through the plan
5
+ // (`catalog/plans/:planId/versions`), while every mutation of an existing
6
+ // version addresses the version directly (`catalog/plan-versions/:id`).
7
+ //
8
+ // Path parameters are interpolated without encoding, which is what the
9
+ // composables do today. Encoding them would be the safer default — but ids
10
+ // containing `/`, `#` or `?` would then reach a different URL than they reach
11
+ // now, and that is a behaviour change, not a fix. It belongs in a change that
12
+ // says so.
13
+
14
+ import type {
15
+ CreatePlanData,
16
+ CreatePlanVersionDraftData,
17
+ PlanRow,
18
+ PlanVersionMutationResult,
19
+ PlanVersionRow,
20
+ UpdatePlanData,
21
+ UpdatePlanVersionDraftData,
22
+ } from '@saasicat/types';
23
+
24
+ import { defineResource, type ResourceContext } from './define-resource.js';
25
+
26
+ import { requestJson, requestJsonBody } from './resource-request.js';
27
+
28
+ /** What `publish` may override at the moment a version goes live. */
29
+ export interface PublishPlanVersionOptions {
30
+ forceRegressive?: boolean;
31
+ allowZeroPrice?: boolean;
32
+ validFrom?: string | null;
33
+ validUntil?: string | null;
34
+ }
35
+
36
+ function plansUrl(ctx: ResourceContext): string {
37
+ return `${ctx.apiBase}/catalog/plans`;
38
+ }
39
+
40
+ function versionsUrl(ctx: ResourceContext): string {
41
+ return `${ctx.apiBase}/catalog/plan-versions`;
42
+ }
43
+
44
+ /** `?projectKey=…` — the key is a literal, only the value is encoded. */
45
+ function scoped(url: string, ctx: ResourceContext): string {
46
+ return `${url}?projectKey=${encodeURIComponent(ctx.projectKey)}`;
47
+ }
48
+
49
+ /**
50
+ * The plan catalogue — the one platform resource that is read per project.
51
+ *
52
+ * `list`, `tenantCounts` and `create` all carry `ctx.projectKey`, so binding
53
+ * this against a context that names no project is refused rather than sent (see
54
+ * `bindResource`). Its three siblings address a plan or a version by id and are
55
+ * not declared here.
56
+ */
57
+ export const plansResource = defineResource(
58
+ 'plans',
59
+ {
60
+ list: async (http, ctx): Promise<PlanRow[]> =>
61
+ (await requestJson<PlanRow[]>(http, scoped(plansUrl(ctx), ctx))) ?? [],
62
+
63
+ tenantCounts: async (http, ctx): Promise<Record<string, number>> =>
64
+ (await requestJson<Record<string, number>>(
65
+ http,
66
+ scoped(`${plansUrl(ctx)}/tenant-counts`, ctx),
67
+ )) ?? {},
68
+
69
+ /**
70
+ * The project comes from the context, not from the caller.
71
+ *
72
+ * The endpoint takes it in the body, and the composable this replaces
73
+ * made that the caller's job — so a page had to keep hold of the
74
+ * project it was administering just to create in it, and `list()` and
75
+ * `create()` read two different sources for one value. A stale one
76
+ * creates a plan outside the project the list is showing.
77
+ */
78
+ create: (http, ctx, data: Omit<CreatePlanData, 'projectKey'>): Promise<PlanRow> =>
79
+ requestJsonBody<PlanRow>(http, plansUrl(ctx), 'Create returned no body', {
80
+ method: 'POST',
81
+ body: { ...data, projectKey: ctx.projectKey },
82
+ }),
83
+
84
+ update: (http, ctx, planId: string, data: UpdatePlanData): Promise<PlanRow> =>
85
+ requestJsonBody<PlanRow>(
86
+ http,
87
+ `${plansUrl(ctx)}/${planId}`,
88
+ 'Update returned no body',
89
+ {
90
+ method: 'PATCH',
91
+ body: data,
92
+ },
93
+ ),
94
+
95
+ /** Marks the plan deleted. Fails while it still has versions. */
96
+ softDelete: async (http, ctx, planId: string): Promise<void> => {
97
+ await requestJson(http, `${plansUrl(ctx)}/${planId}`, { method: 'DELETE' });
98
+ },
99
+
100
+ /** Removes the plan for good. 422 `PLAN_HAS_VERSIONS` when it cannot. */
101
+ hardDelete: async (http, ctx, planId: string): Promise<void> => {
102
+ await requestJson(http, `${plansUrl(ctx)}/${planId}/purge`, { method: 'DELETE' });
103
+ },
104
+ },
105
+ { projectScoped: true },
106
+ );
107
+
108
+ export const planVersionsResource = defineResource('planVersions', {
109
+ listForPlan: async (http, ctx, planId: string): Promise<PlanVersionRow[]> =>
110
+ (await requestJson<PlanVersionRow[]>(http, `${plansUrl(ctx)}/${planId}/versions`)) ?? [],
111
+
112
+ createDraft: (
113
+ http,
114
+ ctx,
115
+ planId: string,
116
+ data: Omit<CreatePlanVersionDraftData, 'planId'>,
117
+ ): Promise<PlanVersionMutationResult> =>
118
+ requestJsonBody<PlanVersionMutationResult>(
119
+ http,
120
+ `${plansUrl(ctx)}/${planId}/versions`,
121
+ 'CreateDraft returned no body',
122
+ { method: 'POST', body: data },
123
+ ),
124
+
125
+ updateDraft: (
126
+ http,
127
+ ctx,
128
+ versionId: string,
129
+ data: UpdatePlanVersionDraftData,
130
+ ): Promise<PlanVersionMutationResult> =>
131
+ requestJsonBody<PlanVersionMutationResult>(
132
+ http,
133
+ `${versionsUrl(ctx)}/${versionId}`,
134
+ 'UpdateDraft returned no body',
135
+ { method: 'PATCH', body: data },
136
+ ),
137
+
138
+ publish: (
139
+ http,
140
+ ctx,
141
+ versionId: string,
142
+ options: PublishPlanVersionOptions = {},
143
+ ): Promise<PlanVersionMutationResult> =>
144
+ requestJsonBody<PlanVersionMutationResult>(
145
+ http,
146
+ `${versionsUrl(ctx)}/${versionId}/publish`,
147
+ 'Publish returned no body',
148
+ { method: 'POST', body: options },
149
+ ),
150
+
151
+ discardDraft: async (http, ctx, versionId: string): Promise<void> => {
152
+ await requestJson(http, `${versionsUrl(ctx)}/${versionId}`, { method: 'DELETE' });
153
+ },
154
+
155
+ /**
156
+ * Ends a live version on `endsAt`, with no successor. Idempotent — a
157
+ * second call with another date overwrites the first.
158
+ *
159
+ * `endsAt` is the date itself, not a payload: the endpoint's body is
160
+ * `{ endsAt }` and wrapping it is this operation's job, not the caller's.
161
+ */
162
+ terminate: (http, ctx, versionId: string, endsAt: string): Promise<PlanVersionRow> =>
163
+ requestJsonBody<PlanVersionRow>(
164
+ http,
165
+ `${versionsUrl(ctx)}/${versionId}/terminate`,
166
+ 'Terminate returned no body',
167
+ { method: 'POST', body: { endsAt } },
168
+ ),
169
+ });
@@ -0,0 +1,117 @@
1
+ // The JSON request/response policy the admin catalogue endpoints share.
2
+ //
3
+ // This exact function is written out three times in the package today —
4
+ // `use-plans.ts` contains two byte-identical copies of it in one file, and
5
+ // `use-bundles.ts` and `use-catalog-entries.ts` a third and fourth — differing
6
+ // only in the message they put on the error. The policy itself is one
7
+ // decision, so it is written once here.
8
+ //
9
+ // It is NOT the policy the tenant-side billing composables use: those check
10
+ // `>= 400` before the 204 branch, read the error body with `text()` as a
11
+ // fallback, and let an unparsable 2xx reject with the raw parse error. Those
12
+ // three are mutually incompatible and belong to a different audience — see the
13
+ // tenant package split. Nothing here should be pointed at them.
14
+
15
+ import { AdminError, readErrorCode, readErrorDetail } from '../admin-error.js';
16
+ import { requireServerAnswer } from '../http-json.js';
17
+ import type { HttpClient } from '../types.js';
18
+
19
+ export interface ResourceRequestInit {
20
+ /** Default `GET`. */
21
+ method?: string;
22
+ /** Serialised here — the `HttpClient` contract only carries strings. */
23
+ body?: unknown;
24
+ /** Merged over the defaults, so a caller's header wins. */
25
+ headers?: Record<string, string>;
26
+ }
27
+
28
+ /**
29
+ * Sends a request and returns the parsed body, or `null` when there was none.
30
+ *
31
+ * `null` covers both a `204` and a 2xx whose body would not parse. The two are
32
+ * deliberately not distinguished: every caller treats them the same way — a
33
+ * list turns `null` into `[]`, a mutation turns it into a failure — and no
34
+ * endpoint in this family answers a mutation with an empty 200 on purpose.
35
+ *
36
+ * A non-2xx throws an `AdminError` carrying the parsed body, so a caller can
37
+ * still branch on `status` and `body.code` the way the pages do today.
38
+ *
39
+ * A client that resolved without an HTTP status throws before any of that: no
40
+ * status means the request never reached the server, which is a transport
41
+ * failure and not an answer this function may read.
42
+ */
43
+ export async function requestJson<T>(
44
+ http: HttpClient,
45
+ url: string,
46
+ init: ResourceRequestInit = {},
47
+ ): Promise<T | null> {
48
+ const method = init.method ?? 'GET';
49
+ const response = await http(url, {
50
+ method,
51
+ headers: { 'content-type': 'application/json', ...init.headers },
52
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
53
+ });
54
+
55
+ // Before anything is read off the response: a client that reports a
56
+ // network, CORS or abort failure by RESOLVING with `status: 0` — the shape
57
+ // `HttpClient` permits and `requireServerAnswer` exists for — otherwise
58
+ // arrives here as a successful answer with no body. A list would then be
59
+ // empty with nothing wrong on screen, and a mutation would tell the
60
+ // operator their change may have been applied when the request never left
61
+ // the machine.
62
+ requireServerAnswer(
63
+ response.status,
64
+ method,
65
+ url,
66
+ (diagnostic) =>
67
+ new AdminError({ status: response.status, url, method, message: diagnostic }),
68
+ );
69
+
70
+ if (response.status === 204) return null;
71
+ const body = await response.json().catch(() => null);
72
+ if (response.status >= 400) {
73
+ throw new AdminError({
74
+ status: response.status,
75
+ code: readErrorCode(body),
76
+ body,
77
+ // The third place this had to be read, and the second time it was
78
+ // read a narrower way: without `detail` a page migrating onto these
79
+ // resources loses "Plan already exists" and shows the generic
80
+ // status sentence instead. One decision, one function.
81
+ detail: readErrorDetail(body),
82
+ url,
83
+ method,
84
+ });
85
+ }
86
+ return body as T | null;
87
+ }
88
+
89
+ /**
90
+ * Reads the body of a request that must answer with one.
91
+ *
92
+ * The empty case is a failure rather than a `null` the caller has to re-check:
93
+ * a create that returns nothing has not told us what it created. Raised with
94
+ * `status: 0`, because no HTTP status went wrong — the response was a 2xx.
95
+ */
96
+ export async function requestJsonBody<T>(
97
+ http: HttpClient,
98
+ url: string,
99
+ what: string,
100
+ init: ResourceRequestInit = {},
101
+ ): Promise<T> {
102
+ const body = await requestJson<T>(http, url, init);
103
+ if (body === null || body === undefined) {
104
+ throw new AdminError({
105
+ status: 0,
106
+ url,
107
+ method: init.method ?? 'GET',
108
+ // `what` is a diagnostic — "Create returned no body" — so it goes
109
+ // on `message`, not `detail`. As `detail` it out-ranked the
110
+ // localized wording and the operator read an English internal
111
+ // string instead of being told the change may already have landed.
112
+ message: what,
113
+ emptyResponse: true,
114
+ });
115
+ }
116
+ return body;
117
+ }
@@ -0,0 +1,27 @@
1
+ // The tenant list.
2
+ //
3
+ // One operation, deliberately: the list `useTenants` requests today. Tenant
4
+ // detail, suspend and reactivate still live in `createAdminResourceClient` and
5
+ // move here with the page that calls them — a descriptor nothing calls has
6
+ // nothing keeping it honest.
7
+ //
8
+ // The request pages; the controller does not. `AdminResourcesPort.listTenants`
9
+ // takes `status`/`plan`/`search` and answers with a bare array — no `page`, no
10
+ // `total` — while the paginated `TenantPort.list` no controller exposes returns
11
+ // `Paginated<TenantDto>`. So `page` and `pageSize` go out on every request and
12
+ // today nothing on the far side reads them. That is exactly what `useApiList`
13
+ // has been sending, and reproducing it is the point: what the server does with
14
+ // the parameters is the server's to change, and a descriptor that quietly sent
15
+ // less would be a different request wearing the same name.
16
+
17
+ import type { TenantDto, TenantListFilter } from '@saasicat/types';
18
+
19
+ import { defineResource } from './define-resource.js';
20
+ import { defineListOp, type ListFilterOf } from './list-resource.js';
21
+
22
+ /** What the tenants list can be narrowed by. The page number is not a filter. */
23
+ export type TenantsListFilter = ListFilterOf<TenantListFilter>;
24
+
25
+ export const tenantsResource = defineResource('tenants', {
26
+ list: defineListOp<TenantDto, TenantsListFilter>((ctx) => `${ctx.apiBase}/tenants`),
27
+ });
@@ -1,5 +1,7 @@
1
1
  // Shared configuration types for all UI-Vue loaders/composables.
2
2
 
3
+ import { createFetchHttpClient } from './http/fetch-http-client.js';
4
+
3
5
  /**
4
6
  * Minimal abstraction over `fetch`. Consumers may pass their own
5
7
  * implementation (e.g. an axios wrapper that already injects auth headers
@@ -87,9 +89,14 @@ function resolveLocalStorage(): Storage | null {
87
89
  }
88
90
 
89
91
  /**
90
- * Default `HttpClient` over `fetch`. Consumers pass their own variant
91
- * through when they need auth headers / tenant headers / retry logic.
92
+ * Default `HttpClient` over `fetch`, with no base URL and no headers of its
93
+ * own. Consumers pass their own variant when they need auth headers, tenant
94
+ * headers or retry logic — `createFetchHttpClient({ headers })` and
95
+ * `createAxiosHttpClient(instance)` are the two the package ships.
96
+ *
97
+ * It delegates rather than calling `fetch` itself so that there is one
98
+ * implementation to reason about instead of two that drift.
92
99
  */
93
100
  export function defaultHttpClient(): HttpClient {
94
- return (url, init) => fetch(url, init);
101
+ return createFetchHttpClient();
95
102
  }
@@ -50,7 +50,7 @@
50
50
 
51
51
  <div>
52
52
  <q-toggle v-model="allowZeroPrice" :label="msg.publishDialog.allowZeroPrice" />
53
- <p class="bvpd__label" style="margin-top: 6px">
53
+ <p class="bvpd__label" style="margin-top: var(--sa-space-3)">
54
54
  {{ msg.publishDialog.allowZeroPriceHint }}
55
55
  </p>
56
56
  </div>