@saasicat/ui-vue 0.26.0 → 0.27.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 (36) hide show
  1. package/LICENSE +90 -201
  2. package/README.md +23 -0
  3. package/dist/{catalog-Dch1Ryw0.d.cts → catalog-Dv2twuNi.d.cts} +205 -4
  4. package/dist/{catalog-Dch1Ryw0.d.ts → catalog-Dv2twuNi.d.ts} +205 -4
  5. package/dist/{chunk-BPF2BMCQ.js → chunk-4IZWT4UY.js} +414 -2
  6. package/dist/{chunk-O3J3ITF2.js → chunk-LK6YJDXC.js} +1 -1
  7. package/dist/{chunk-NEXTZZRQ.js → chunk-NRVF3JJJ.js} +2 -2
  8. package/dist/client/index.cjs +426 -5
  9. package/dist/client/index.d.cts +70 -4
  10. package/dist/client/index.d.ts +70 -4
  11. package/dist/client/index.js +22 -4
  12. package/dist/index.cjs +429 -8
  13. package/dist/index.d.cts +5 -5
  14. package/dist/index.d.ts +5 -5
  15. package/dist/index.js +23 -5
  16. package/dist/quasar/index.cjs +411 -2
  17. package/dist/quasar/index.d.cts +2 -2
  18. package/dist/quasar/index.d.ts +2 -2
  19. package/dist/quasar/index.js +2 -2
  20. package/dist/{resource-registry-D7Xjs-5f.d.cts → resource-registry-BxFpnzHY.d.cts} +1 -1
  21. package/dist/{resource-registry-TaR7rtq0.d.ts → resource-registry-Cn1Gy2LE.d.ts} +1 -1
  22. package/package.json +5 -4
  23. package/src/client/admin-error.ts +18 -1
  24. package/src/client/attach-cause.ts +21 -0
  25. package/src/client/nav-builder.ts +3 -1
  26. package/src/client/resources/bundles.resource.ts +145 -0
  27. package/src/client/resources/catalog.resource.ts +160 -0
  28. package/src/client/resources/discovery.resource.ts +108 -0
  29. package/src/client/resources/index.ts +52 -14
  30. package/src/client/resources/marketing.resource.ts +121 -0
  31. package/src/client/resources/promo-codes.resource.ts +63 -0
  32. package/src/client/resources/promotions.resource.ts +44 -0
  33. package/src/client/resources/subscriptions.resource.ts +19 -0
  34. package/src/client/resources/tenants.resource.ts +35 -9
  35. package/src/client/resources/users.resource.ts +31 -0
  36. package/src/pages-standard/SuperAdminSetupWizard.vue +7 -4
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Attach a cause to an error without the ES2022 `Error` constructor option.
3
+ *
4
+ * `new Error(message, { cause })` needs `lib: ES2022`. This package ships four
5
+ * of its export subpaths as `.vue`/`.ts` **source** — a consumer compiles those
6
+ * files, and everything they reach, with their own `tsconfig`. One of them is on
7
+ * ES2021, where that second argument is a compile error in code they did not
8
+ * write.
9
+ *
10
+ * Defining the property afterwards reads identically to anything inspecting
11
+ * `error.cause`, and needs nothing above ES5. `scripts/check-shipped-source.mjs`
12
+ * holds the floor.
13
+ */
14
+ export function attachCause<E extends Error>(error: E, cause: unknown): E {
15
+ Object.defineProperty(error, 'cause', {
16
+ value: cause,
17
+ configurable: true,
18
+ writable: true,
19
+ });
20
+ return error;
21
+ }
@@ -36,7 +36,9 @@ export const DEFAULT_STANDARD_PAGE_ROUTES: Record<StandardPageKey, string> = {
36
36
  };
37
37
 
38
38
  function isSupportedStandardPageKey(key: string): key is StandardPageKey {
39
- return Object.hasOwn(DEFAULT_STANDARD_PAGE_ROUTES, key);
39
+ // `Object.hasOwn` is ES2022 and this file is compiled by consumers.
40
+ // See `attach-cause.ts` for why the floor is ES2021.
41
+ return Object.prototype.hasOwnProperty.call(DEFAULT_STANDARD_PAGE_ROUTES, key);
40
42
  }
41
43
 
42
44
  export interface BuildRouteEntry {
@@ -0,0 +1,145 @@
1
+ // The bundle catalogue.
2
+ //
3
+ // Same two-stem shape as the plan catalogue, and for the same reason: a
4
+ // version is read and created through its bundle
5
+ // (`catalog/bundles/:bundleId/versions`), while every mutation of an existing
6
+ // version addresses the version directly (`catalog/bundle-versions/:id`).
7
+ //
8
+ // Path parameters are interpolated without encoding, matching `useBundles` and
9
+ // `useBundleVersions`. Encoding them would send ids containing `/`, `#` or `?`
10
+ // to a different URL than they reach today — a behaviour change, and one that
11
+ // belongs in a change that says so. The same note stands on `plans.resource`.
12
+
13
+ import type {
14
+ BundleRow,
15
+ BundleVersionMutationResult,
16
+ BundleVersionRow,
17
+ CreateBundleData,
18
+ CreateBundleVersionDraftData,
19
+ UpdateBundleData,
20
+ UpdateBundleVersionDraftData,
21
+ } from '@saasicat/types';
22
+
23
+ import { defineResource, type ResourceContext } from './define-resource.js';
24
+ import { requestJson, requestJsonBody } from './resource-request.js';
25
+
26
+ /** What `publish` may override at the moment a bundle version goes live. */
27
+ export interface PublishBundleVersionOptions {
28
+ forceRegressive?: boolean;
29
+ allowZeroPrice?: boolean;
30
+ validFrom?: string | null;
31
+ validUntil?: string | null;
32
+ }
33
+
34
+ function bundlesUrl(ctx: ResourceContext): string {
35
+ return `${ctx.apiBase}/catalog/bundles`;
36
+ }
37
+
38
+ function bundleVersionsUrl(ctx: ResourceContext): string {
39
+ return `${ctx.apiBase}/catalog/bundle-versions`;
40
+ }
41
+
42
+ /** `?projectKey=…` — the key is a literal, only the value is encoded. */
43
+ function scoped(url: string, ctx: ResourceContext): string {
44
+ return `${url}?projectKey=${encodeURIComponent(ctx.projectKey)}`;
45
+ }
46
+
47
+ /**
48
+ * The bundle catalogue — read per project, like the plan catalogue.
49
+ *
50
+ * `create` takes the project from the context rather than from the caller, for
51
+ * the reason spelled out on `plansResource.create`: the composable this
52
+ * replaces made it the caller's job, so `list()` and `create()` read two
53
+ * different sources for one value and a stale one creates a bundle outside the
54
+ * project the list is showing.
55
+ */
56
+ export const bundlesResource = defineResource(
57
+ 'bundles',
58
+ {
59
+ list: async (http, ctx): Promise<BundleRow[]> =>
60
+ (await requestJson<BundleRow[]>(http, scoped(bundlesUrl(ctx), ctx))) ?? [],
61
+
62
+ create: (http, ctx, data: Omit<CreateBundleData, 'projectKey'>): Promise<BundleRow> =>
63
+ requestJsonBody<BundleRow>(http, bundlesUrl(ctx), 'Create returned no body', {
64
+ method: 'POST',
65
+ body: { ...data, projectKey: ctx.projectKey },
66
+ }),
67
+
68
+ update: (http, ctx, bundleId: string, data: UpdateBundleData): Promise<BundleRow> =>
69
+ requestJsonBody<BundleRow>(
70
+ http,
71
+ `${bundlesUrl(ctx)}/${bundleId}`,
72
+ 'Update returned no body',
73
+ { method: 'PATCH', body: data },
74
+ ),
75
+
76
+ /** Marks the bundle deleted. */
77
+ softDelete: async (http, ctx, bundleId: string): Promise<void> => {
78
+ await requestJson(http, `${bundlesUrl(ctx)}/${bundleId}`, { method: 'DELETE' });
79
+ },
80
+ },
81
+ { projectScoped: true },
82
+ );
83
+
84
+ /**
85
+ * Bundle version lifecycle.
86
+ *
87
+ * `publish` is the request only. `useBundleVersions.publish` reloads the list
88
+ * afterwards, because publishing may supersede another version — but that is
89
+ * the composable keeping its own refs consistent, not part of the call. A
90
+ * descriptor operation that issued a second request would make every override
91
+ * inherit a reload it cannot see, and a page holding no list would pay for one
92
+ * it does not read.
93
+ */
94
+ export const bundleVersionsResource = defineResource('bundleVersions', {
95
+ listForBundle: async (http, ctx, bundleId: string): Promise<BundleVersionRow[]> =>
96
+ (await requestJson<BundleVersionRow[]>(http, `${bundlesUrl(ctx)}/${bundleId}/versions`)) ??
97
+ [],
98
+
99
+ createDraft: (
100
+ http,
101
+ ctx,
102
+ bundleId: string,
103
+ data: Omit<CreateBundleVersionDraftData, 'bundleId'>,
104
+ ): Promise<BundleVersionMutationResult> =>
105
+ requestJsonBody<BundleVersionMutationResult>(
106
+ http,
107
+ `${bundlesUrl(ctx)}/${bundleId}/versions`,
108
+ 'CreateDraft returned no body',
109
+ { method: 'POST', body: data },
110
+ ),
111
+
112
+ updateDraft: (
113
+ http,
114
+ ctx,
115
+ versionId: string,
116
+ data: UpdateBundleVersionDraftData,
117
+ ): Promise<BundleVersionMutationResult> =>
118
+ requestJsonBody<BundleVersionMutationResult>(
119
+ http,
120
+ `${bundleVersionsUrl(ctx)}/${versionId}`,
121
+ 'UpdateDraft returned no body',
122
+ { method: 'PATCH', body: data },
123
+ ),
124
+
125
+ publish: (
126
+ http,
127
+ ctx,
128
+ versionId: string,
129
+ options: PublishBundleVersionOptions = {},
130
+ ): Promise<BundleVersionMutationResult> =>
131
+ requestJsonBody<BundleVersionMutationResult>(
132
+ http,
133
+ `${bundleVersionsUrl(ctx)}/${versionId}/publish`,
134
+ 'Publish returned no body',
135
+ { method: 'POST', body: options },
136
+ ),
137
+
138
+ /**
139
+ * Discards a draft. A published version cannot be discarded — the API
140
+ * answers 422 `BUNDLE_VERSION_ALREADY_PUBLISHED`.
141
+ */
142
+ discardDraft: async (http, ctx, versionId: string): Promise<void> => {
143
+ await requestJson(http, `${bundleVersionsUrl(ctx)}/${versionId}`, { method: 'DELETE' });
144
+ },
145
+ });
@@ -0,0 +1,160 @@
1
+ // The capability / feature / quota catalogue.
2
+ //
3
+ // Every operation but one carries `?projectKey=…`, including the mutations —
4
+ // the keys are unique per project, so the server needs it to address the row.
5
+ // The exception is `syncDiscovery`, whose payload carries the snapshot and
6
+ // whose endpoint takes no query; that asymmetry is the server's.
7
+ //
8
+ // `load()` in `useCatalogEntries` fetches the three lists in one `Promise.all`
9
+ // and writes three refs. Here they are three operations: the descriptor's job
10
+ // is the request, and which of them a caller needs — a page showing only
11
+ // quotas asks for one — is the caller's to decide.
12
+
13
+ import type {
14
+ CapabilityCatalogEntryRow,
15
+ CatalogEntryI18n,
16
+ DiscoverySnapshot,
17
+ FeatureCatalogEntryRow,
18
+ QuotaCatalogEntryRow,
19
+ ReviewCatalogEntryData,
20
+ SyncDiscoveryResult,
21
+ UpdateCatalogEntryBaseData,
22
+ } from '@saasicat/types';
23
+
24
+ import { defineResource, type ResourceContext } from './define-resource.js';
25
+ import { requestJson, requestJsonBody } from './resource-request.js';
26
+
27
+ function catalogUrl(ctx: ResourceContext): string {
28
+ return `${ctx.apiBase}/catalog`;
29
+ }
30
+
31
+ /** `?projectKey=…` — the key is a literal, only the value is encoded. */
32
+ function scoped(url: string, ctx: ResourceContext): string {
33
+ return `${url}?projectKey=${encodeURIComponent(ctx.projectKey)}`;
34
+ }
35
+
36
+ /**
37
+ * The discovered catalogue and the review decisions taken on it.
38
+ *
39
+ * Entry keys are encoded on the way into the path — that is what
40
+ * `useCatalogEntries` does, and a feature key legitimately contains characters
41
+ * (`.`, `:`) that a code scan produces.
42
+ */
43
+ export const catalogResource = defineResource(
44
+ 'catalog',
45
+ {
46
+ capabilities: async (http, ctx): Promise<CapabilityCatalogEntryRow[]> =>
47
+ (await requestJson<CapabilityCatalogEntryRow[]>(
48
+ http,
49
+ scoped(`${catalogUrl(ctx)}/capabilities`, ctx),
50
+ )) ?? [],
51
+
52
+ features: async (http, ctx): Promise<FeatureCatalogEntryRow[]> =>
53
+ (await requestJson<FeatureCatalogEntryRow[]>(
54
+ http,
55
+ scoped(`${catalogUrl(ctx)}/features`, ctx),
56
+ )) ?? [],
57
+
58
+ quotas: async (http, ctx): Promise<QuotaCatalogEntryRow[]> =>
59
+ (await requestJson<QuotaCatalogEntryRow[]>(
60
+ http,
61
+ scoped(`${catalogUrl(ctx)}/quotas`, ctx),
62
+ )) ?? [],
63
+
64
+ reviewFeature: (
65
+ http,
66
+ ctx,
67
+ featureKey: string,
68
+ data: ReviewCatalogEntryData,
69
+ ): Promise<FeatureCatalogEntryRow> =>
70
+ requestJsonBody<FeatureCatalogEntryRow>(
71
+ http,
72
+ scoped(`${catalogUrl(ctx)}/features/${encodeURIComponent(featureKey)}/review`, ctx),
73
+ 'Review returned no body',
74
+ { method: 'PATCH', body: data },
75
+ ),
76
+
77
+ reviewQuota: (
78
+ http,
79
+ ctx,
80
+ quotaKey: string,
81
+ data: ReviewCatalogEntryData,
82
+ ): Promise<QuotaCatalogEntryRow> =>
83
+ requestJsonBody<QuotaCatalogEntryRow>(
84
+ http,
85
+ scoped(`${catalogUrl(ctx)}/quotas/${encodeURIComponent(quotaKey)}/review`, ctx),
86
+ 'Review returned no body',
87
+ { method: 'PATCH', body: data },
88
+ ),
89
+
90
+ /** The translations are wrapped in `{ i18n }` here, not by the caller. */
91
+ setFeatureI18n: (
92
+ http,
93
+ ctx,
94
+ featureKey: string,
95
+ i18n: CatalogEntryI18n,
96
+ ): Promise<FeatureCatalogEntryRow> =>
97
+ requestJsonBody<FeatureCatalogEntryRow>(
98
+ http,
99
+ scoped(`${catalogUrl(ctx)}/features/${encodeURIComponent(featureKey)}/i18n`, ctx),
100
+ 'i18n returned no body',
101
+ { method: 'PATCH', body: { i18n } },
102
+ ),
103
+
104
+ setQuotaI18n: (
105
+ http,
106
+ ctx,
107
+ quotaKey: string,
108
+ i18n: CatalogEntryI18n,
109
+ ): Promise<QuotaCatalogEntryRow> =>
110
+ requestJsonBody<QuotaCatalogEntryRow>(
111
+ http,
112
+ scoped(`${catalogUrl(ctx)}/quotas/${encodeURIComponent(quotaKey)}/i18n`, ctx),
113
+ 'i18n returned no body',
114
+ { method: 'PATCH', body: { i18n } },
115
+ ),
116
+
117
+ setFeatureBase: (
118
+ http,
119
+ ctx,
120
+ featureKey: string,
121
+ data: UpdateCatalogEntryBaseData,
122
+ ): Promise<FeatureCatalogEntryRow> =>
123
+ requestJsonBody<FeatureCatalogEntryRow>(
124
+ http,
125
+ scoped(`${catalogUrl(ctx)}/features/${encodeURIComponent(featureKey)}`, ctx),
126
+ 'Base returned no body',
127
+ { method: 'PATCH', body: data },
128
+ ),
129
+
130
+ setQuotaBase: (
131
+ http,
132
+ ctx,
133
+ quotaKey: string,
134
+ data: UpdateCatalogEntryBaseData,
135
+ ): Promise<QuotaCatalogEntryRow> =>
136
+ requestJsonBody<QuotaCatalogEntryRow>(
137
+ http,
138
+ scoped(`${catalogUrl(ctx)}/quotas/${encodeURIComponent(quotaKey)}`, ctx),
139
+ 'Base returned no body',
140
+ { method: 'PATCH', body: data },
141
+ ),
142
+
143
+ /**
144
+ * Writes a discovery snapshot into the catalogue.
145
+ *
146
+ * The request only. `useCatalogEntries.syncDiscovery` reloads the three
147
+ * lists afterwards, which is that composable keeping its refs true —
148
+ * not part of the call, and not something an override should inherit
149
+ * invisibly.
150
+ */
151
+ syncDiscovery: (http, ctx, snapshot: DiscoverySnapshot): Promise<SyncDiscoveryResult> =>
152
+ requestJsonBody<SyncDiscoveryResult>(
153
+ http,
154
+ `${catalogUrl(ctx)}/discovery/sync`,
155
+ 'Sync returned no body',
156
+ { method: 'POST', body: { snapshot } },
157
+ ),
158
+ },
159
+ { projectScoped: true },
160
+ );
@@ -0,0 +1,108 @@
1
+ // The discovery snapshot — what a code scan found, before anyone reviewed it.
2
+ //
3
+ // The one family that does not go through `requestJson`, and the reason is the
4
+ // ETag. `requestJson` collapses "204", "unparsable 2xx" and — since 304 is
5
+ // neither `>= 400` nor 204 — "not modified" into the same `null`, which is
6
+ // right for the catalogue family and wrong here: a caller that cannot tell a
7
+ // cache hit from an empty answer either re-parses a body it does not have or
8
+ // throws away the snapshot it already holds.
9
+ //
10
+ // So these two operations read the status themselves and answer with a
11
+ // discriminated result. The conditional request stays the caller's decision —
12
+ // the descriptor is given the tag it should revalidate against, rather than
13
+ // holding one, because a descriptor is bound once and shared by every page
14
+ // that asks for it.
15
+
16
+ import { AdminError } from '../admin-error.js';
17
+ import { requireServerAnswer } from '../http-json.js';
18
+ import type { DiscoverySnapshot } from '@saasicat/types';
19
+
20
+ import { defineResource, type ResourceContext } from './define-resource.js';
21
+
22
+ /**
23
+ * The answer to a conditional read.
24
+ *
25
+ * `unchanged` carries no snapshot on purpose: the server said the caller's copy
26
+ * is current, and returning a re-fetched one would defeat the request.
27
+ */
28
+ export type DiscoveryRead =
29
+ | { readonly status: 'unchanged' }
30
+ | {
31
+ readonly status: 'loaded';
32
+ readonly snapshot: DiscoverySnapshot;
33
+ /** `null` when the server sent none — revalidation is then a full read. */
34
+ readonly etag: string | null;
35
+ };
36
+
37
+ function discoveryUrl(ctx: ResourceContext): string {
38
+ return `${ctx.apiBase}/discovery`;
39
+ }
40
+
41
+ function fail(status: number, method: string, url: string, message: string): AdminError {
42
+ return new AdminError({ status, url, method, message });
43
+ }
44
+
45
+ export const discoveryResource = defineResource('discovery', {
46
+ /**
47
+ * Reads the snapshot, revalidating against `etag` when one is passed.
48
+ *
49
+ * Pass `null` (or nothing) to force a full read — that is what
50
+ * `useDiscovery.reload()` does when the operator asks for fresh data.
51
+ */
52
+ read: async (http, ctx, etag: string | null = null): Promise<DiscoveryRead> => {
53
+ const url = discoveryUrl(ctx);
54
+ const headers: Record<string, string> = {};
55
+ if (etag) headers['If-None-Match'] = etag;
56
+
57
+ const response = await http(url, { method: 'GET', headers });
58
+ requireServerAnswer(response.status, 'GET', url, (diagnostic) =>
59
+ fail(response.status, 'GET', url, diagnostic),
60
+ );
61
+
62
+ if (response.status === 304) return { status: 'unchanged' };
63
+ if (response.status !== 200) {
64
+ throw fail(
65
+ response.status,
66
+ 'GET',
67
+ url,
68
+ `Discovery endpoint responded with HTTP ${response.status}`,
69
+ );
70
+ }
71
+ return {
72
+ status: 'loaded',
73
+ snapshot: (await response.json()) as DiscoverySnapshot,
74
+ etag: response.headers.get('ETag'),
75
+ };
76
+ },
77
+
78
+ /**
79
+ * Re-runs the scan and returns what it found.
80
+ *
81
+ * Unconditional by construction: a rescan asks the server to look again,
82
+ * so revalidating against a tag the previous scan produced would answer
83
+ * with the snapshot the rescan was meant to replace.
84
+ */
85
+ rescan: async (http, ctx): Promise<{ snapshot: DiscoverySnapshot; etag: string | null }> => {
86
+ const url = `${discoveryUrl(ctx)}/rescan`;
87
+ const response = await http(url, {
88
+ method: 'POST',
89
+ headers: { 'content-type': 'application/json' },
90
+ });
91
+ requireServerAnswer(response.status, 'POST', url, (diagnostic) =>
92
+ fail(response.status, 'POST', url, diagnostic),
93
+ );
94
+
95
+ if (response.status !== 200 && response.status !== 201) {
96
+ throw fail(
97
+ response.status,
98
+ 'POST',
99
+ url,
100
+ `Discovery rescan responded with HTTP ${response.status}`,
101
+ );
102
+ }
103
+ return {
104
+ snapshot: (await response.json()) as DiscoverySnapshot,
105
+ etag: response.headers.get('ETag'),
106
+ };
107
+ },
108
+ });
@@ -1,41 +1,79 @@
1
1
  // The resource layer: every admin endpoint defined once, framework-free.
2
2
  //
3
- // Partial by design. The roster this is growing towards covers the whole admin
4
- // surface, and the descriptors land family by family as the composables that
5
- // own those endpoints are rebuilt on them — a descriptor nothing calls has
6
- // nothing keeping it honest.
3
+ // The roster is complete for what the platform serves. Three names from the
4
+ // original plan are deliberately absent, and each is absent for a reason a
5
+ // grep can confirm rather than a decision taken here:
7
6
  //
8
- // What is here so far: the plan catalogue, the tenant list and the audit
9
- // trail. What is not: bundles, catalog entries, discovery, marketing, promos,
10
- // users, pilots, email, subscriptions, dashboard.
7
+ // - `pilots` no platform controller serves `/admin/pilots`, and none is
8
+ // planned. The endpoints exist in one consumer's own backend
9
+ // (`vereinsfux/apps/api`), which is why `PilotsPage` takes them as props.
10
+ // A platform default would be an invented API that happens to match one
11
+ // app and breaks the next.
12
+ // - `email` — the same, for `/admin/platform-email/*`.
13
+ // - `dashboard` — the platform does serve `/admin/stats/dashboard`, but
14
+ // `DashboardPage` never asks for it: every KPI card fetches the `endpoint`
15
+ // its manifest entry declares. A descriptor with a hardcoded path would
16
+ // contradict `KpiCardDef.endpoint`, and nothing would call it.
11
17
  //
12
- // `tenants` and `audit` are called by `useResourceList`, not by `useTenants`
13
- // and `useAuditEntries` those keep their own implementation and their own
14
- // signatures. The two sides are held to the same request by
15
- // `tests/resources-match-the-composables.test.js`, which drives both and
16
- // compares what reaches the client, in either direction.
18
+ // What is here instead is one descriptor per family the platform owns, plus
19
+ // the split the sources forced: the plan's single `promos` turned out to be
20
+ // two unrelated resources `promoCodes` (`/admin/promo-codes`, a string a
21
+ // customer types) and `promotions` (`/admin/catalog/promotions`, a price rule
22
+ // on a catalogue entry). They share four operation names and nothing else.
23
+ //
24
+ // Every descriptor is measured against the implementation it mirrors:
25
+ // `tests/resources-match-the-composables.test.js` drives both sides with the
26
+ // same arguments and compares what reaches the client, in either direction. A
27
+ // descriptor with no such partner is not added — there would be nothing
28
+ // keeping it honest.
17
29
 
18
30
  export * from './define-resource.js';
19
31
  export * from './resource-request.js';
20
32
  export * from './list-resource.js';
21
33
  export * from './plans.resource.js';
34
+ export * from './bundles.resource.js';
35
+ export * from './catalog.resource.js';
36
+ export * from './discovery.resource.js';
37
+ export * from './marketing.resource.js';
38
+ export * from './promo-codes.resource.js';
39
+ export * from './promotions.resource.js';
22
40
  export * from './tenants.resource.js';
41
+ export * from './users.resource.js';
42
+ export * from './subscriptions.resource.js';
23
43
  export * from './audit.resource.js';
24
44
 
25
45
  import { auditResource } from './audit.resource.js';
46
+ import { bundleVersionsResource, bundlesResource } from './bundles.resource.js';
47
+ import { catalogResource } from './catalog.resource.js';
48
+ import { discoveryResource } from './discovery.resource.js';
49
+ import { marketingResource } from './marketing.resource.js';
26
50
  import { planVersionsResource, plansResource } from './plans.resource.js';
51
+ import { promoCodesResource } from './promo-codes.resource.js';
52
+ import { promotionsResource } from './promotions.resource.js';
53
+ import { subscriptionsResource } from './subscriptions.resource.js';
27
54
  import { tenantsResource } from './tenants.resource.js';
55
+ import { usersResource } from './users.resource.js';
28
56
 
29
57
  /**
30
58
  * Every resource the shell offers by default.
31
59
  *
32
- * The registry binds these; an app overrides by key. Partial while the roster
33
- * growsa page whose resource is not here yet keeps its props.
60
+ * The registry binds these; an app overrides by key. A page whose data is
61
+ * app-ownedpilots, platform e-mail keeps its props, because there is no
62
+ * platform default to override.
34
63
  */
35
64
  export const platformResources = {
36
65
  plans: plansResource,
37
66
  planVersions: planVersionsResource,
67
+ bundles: bundlesResource,
68
+ bundleVersions: bundleVersionsResource,
69
+ catalog: catalogResource,
70
+ discovery: discoveryResource,
71
+ marketing: marketingResource,
72
+ promoCodes: promoCodesResource,
73
+ promotions: promotionsResource,
38
74
  tenants: tenantsResource,
75
+ users: usersResource,
76
+ subscriptions: subscriptionsResource,
39
77
  audit: auditResource,
40
78
  };
41
79
 
@@ -0,0 +1,121 @@
1
+ // Marketing projections and the locale settings that go with them.
2
+ //
3
+ // Two endpoints under one key because they are one screen's data:
4
+ // `MarketingCatalogPage` reads the projections and the active-locale set
5
+ // together, and today it reaches the second one with a hand-written `fetch`
6
+ // through its own `httpClient` prop while the first goes through
7
+ // `useMarketingProjections`. One resource, two stems.
8
+ //
9
+ // The list filter keeps the composable's parameter order — `projectKey`,
10
+ // `targetType`, `targetVersionId`, `locale` — because that is the URL the
11
+ // page requests today and a reordered query string is a different request to
12
+ // anything that caches or logs one.
13
+
14
+ import type {
15
+ CreateMarketingProjectionData,
16
+ MarketingProjectionRow,
17
+ MarketingSettingsRow,
18
+ UpdateMarketingProjectionData,
19
+ } from '@saasicat/types';
20
+
21
+ import { defineResource, type ResourceContext } from './define-resource.js';
22
+ import { requestJson, requestJsonBody } from './resource-request.js';
23
+
24
+ /**
25
+ * What the projection list can be narrowed by, beyond the bound project.
26
+ *
27
+ * `projectKey` is deliberately absent: it comes from the context, so a page
28
+ * cannot list one project's projections while creating in another.
29
+ */
30
+ export interface MarketingProjectionsFilter {
31
+ targetType?: string;
32
+ targetVersionId?: string;
33
+ locale?: string;
34
+ }
35
+
36
+ function projectionsUrl(ctx: ResourceContext): string {
37
+ return `${ctx.apiBase}/catalog/marketing-projections`;
38
+ }
39
+
40
+ function settingsUrl(ctx: ResourceContext): string {
41
+ return `${ctx.apiBase}/catalog/marketing-settings`;
42
+ }
43
+
44
+ export const marketingResource = defineResource(
45
+ 'marketing',
46
+ {
47
+ listProjections: async (
48
+ http,
49
+ ctx,
50
+ filter: MarketingProjectionsFilter = {},
51
+ ): Promise<MarketingProjectionRow[]> => {
52
+ const params = new URLSearchParams();
53
+ params.set('projectKey', ctx.projectKey);
54
+ if (filter.targetType) params.set('targetType', filter.targetType);
55
+ if (filter.targetVersionId) params.set('targetVersionId', filter.targetVersionId);
56
+ if (filter.locale) params.set('locale', filter.locale);
57
+ return (
58
+ (await requestJson<MarketingProjectionRow[]>(
59
+ http,
60
+ `${projectionsUrl(ctx)}?${params.toString()}`,
61
+ )) ?? []
62
+ );
63
+ },
64
+
65
+ /**
66
+ * The request only. `useMarketingProjections.create` reloads the list
67
+ * afterwards because a unique-tuple insert can change what the filter
68
+ * matches — that is the composable keeping its refs true, not part of
69
+ * the call.
70
+ */
71
+ createProjection: (
72
+ http,
73
+ ctx,
74
+ data: CreateMarketingProjectionData,
75
+ ): Promise<MarketingProjectionRow> =>
76
+ requestJsonBody<MarketingProjectionRow>(
77
+ http,
78
+ projectionsUrl(ctx),
79
+ 'Create returned no body',
80
+ { method: 'POST', body: data },
81
+ ),
82
+
83
+ updateProjection: (
84
+ http,
85
+ ctx,
86
+ id: string,
87
+ data: UpdateMarketingProjectionData,
88
+ ): Promise<MarketingProjectionRow> =>
89
+ requestJsonBody<MarketingProjectionRow>(
90
+ http,
91
+ `${projectionsUrl(ctx)}/${id}`,
92
+ 'Update returned no body',
93
+ { method: 'PATCH', body: data },
94
+ ),
95
+
96
+ deleteProjection: async (http, ctx, id: string): Promise<void> => {
97
+ await requestJson(http, `${projectionsUrl(ctx)}/${id}`, { method: 'DELETE' });
98
+ },
99
+
100
+ /**
101
+ * The activated locale subset, or `null` when none was ever stored.
102
+ *
103
+ * `null` means the full pool is active — the server's answer for "no
104
+ * row", and the state the page falls back to.
105
+ */
106
+ settings: (http, ctx): Promise<MarketingSettingsRow | null> =>
107
+ requestJson<MarketingSettingsRow>(
108
+ http,
109
+ `${settingsUrl(ctx)}?projectKey=${encodeURIComponent(ctx.projectKey)}`,
110
+ ),
111
+
112
+ /** `PUT`, not `PATCH`: the endpoint replaces the set it is given. */
113
+ saveSettings: async (http, ctx, activeLocales: readonly string[]): Promise<void> => {
114
+ await requestJson(http, settingsUrl(ctx), {
115
+ method: 'PUT',
116
+ body: { projectKey: ctx.projectKey, activeLocales },
117
+ });
118
+ },
119
+ },
120
+ { projectScoped: true },
121
+ );