@cosmoops/connect-sdk 0.7.0 → 0.9.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.
- package/CHANGELOG.md +165 -0
- package/README.md +273 -91
- package/dist/cjs/client/components/access-gate.js +39 -4
- package/dist/cjs/client/components/ai-format.js +135 -0
- package/dist/cjs/client/components/ai-widgets.js +232 -0
- package/dist/cjs/client/components/app-shell.js +7 -6
- package/dist/cjs/client/components/ask-ai.js +334 -0
- package/dist/cjs/client/components/nav.js +1 -1
- package/dist/cjs/client/components/side-nav.js +1 -1
- package/dist/cjs/client/components/user-menu.js +28 -5
- package/dist/cjs/client/components/workspace-type-gate.js +48 -0
- package/dist/cjs/client/index.js +31 -3
- package/dist/cjs/client/provider.js +45 -2
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +43 -0
- package/dist/cjs/server/http.js +46 -24
- package/dist/cjs/server/resources/entitlements.js +41 -0
- package/dist/cjs/server/resources/identity.js +2 -0
- package/dist/esm/client/components/access-gate.js +40 -5
- package/dist/esm/client/components/ai-format.js +129 -0
- package/dist/esm/client/components/ai-widgets.js +223 -0
- package/dist/esm/client/components/app-shell.js +6 -5
- package/dist/esm/client/components/ask-ai.js +331 -0
- package/dist/esm/client/components/nav.js +1 -1
- package/dist/esm/client/components/side-nav.js +1 -1
- package/dist/esm/client/components/user-menu.js +28 -5
- package/dist/esm/client/components/workspace-type-gate.js +45 -0
- package/dist/esm/client/index.js +13 -2
- package/dist/esm/client/provider.js +43 -2
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +43 -0
- package/dist/esm/server/http.js +46 -24
- package/dist/esm/server/resources/entitlements.js +41 -0
- package/dist/esm/server/resources/identity.js +2 -0
- package/dist/theme.css +87 -0
- package/dist/types/client/components/access-gate.d.ts +17 -3
- package/dist/types/client/components/ai-format.d.ts +24 -0
- package/dist/types/client/components/ai-widgets.d.ts +42 -0
- package/dist/types/client/components/app-shell.d.ts +21 -5
- package/dist/types/client/components/ask-ai.d.ts +73 -0
- package/dist/types/client/components/nav.d.ts +3 -3
- package/dist/types/client/components/side-nav.d.ts +1 -1
- package/dist/types/client/components/user-menu.d.ts +2 -1
- package/dist/types/client/components/workspace-type-gate.d.ts +48 -0
- package/dist/types/client/index.d.ts +10 -3
- package/dist/types/client/provider.d.ts +44 -1
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/http.d.ts +6 -1
- package/dist/types/server/resources/entitlements.d.ts +33 -1
- package/dist/types/types.d.ts +174 -0
- package/package.json +8 -2
|
@@ -5,6 +5,8 @@ exports.CONNECT_APP_URL = void 0;
|
|
|
5
5
|
exports.ConnectProvider = ConnectProvider;
|
|
6
6
|
exports.useConnect = useConnect;
|
|
7
7
|
exports.useConnectUser = useConnectUser;
|
|
8
|
+
exports.useConnectWorkspaceType = useConnectWorkspaceType;
|
|
9
|
+
exports.useConnectAudience = useConnectAudience;
|
|
8
10
|
exports.useConnectEntitlements = useConnectEntitlements;
|
|
9
11
|
exports.useConnectAddonUsage = useConnectAddonUsage;
|
|
10
12
|
exports.useConnectPlans = useConnectPlans;
|
|
@@ -124,6 +126,20 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
|
|
|
124
126
|
const invalidateResource = (0, react_1.useCallback)((path) => {
|
|
125
127
|
cache.current.delete(`${basePath}${path}`);
|
|
126
128
|
}, [basePath]);
|
|
129
|
+
const streamResource = (0, react_1.useCallback)(async (path, body, options) => {
|
|
130
|
+
const response = await (fetchImpl ?? globalThis.fetch)(`${basePath}${path}`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
credentials: "same-origin",
|
|
133
|
+
headers: { accept: "text/event-stream", "content-type": "application/json" },
|
|
134
|
+
body: JSON.stringify(body),
|
|
135
|
+
signal: options?.signal,
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
const envelope = await response.json().catch(() => null);
|
|
139
|
+
throw new Error(envelope?.error?.message ?? `Connect bridge returned ${response.status}`);
|
|
140
|
+
}
|
|
141
|
+
return response;
|
|
142
|
+
}, [basePath, fetchImpl]);
|
|
127
143
|
const refresh = (0, react_1.useCallback)(async () => {
|
|
128
144
|
cache.current.clear();
|
|
129
145
|
try {
|
|
@@ -135,7 +151,7 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
|
|
|
135
151
|
// error, so a Login button still renders rather than the UI hanging on
|
|
136
152
|
// skeletons forever.
|
|
137
153
|
setResult({
|
|
138
|
-
session: { user: null, subscription: null, applicationRole: null },
|
|
154
|
+
session: { user: null, subscription: null, applicationRole: null, audience: null },
|
|
139
155
|
error: cause instanceof Error ? cause.message : "Could not reach Connect",
|
|
140
156
|
});
|
|
141
157
|
}
|
|
@@ -184,7 +200,7 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
|
|
|
184
200
|
credentials: "same-origin",
|
|
185
201
|
}).catch(() => { });
|
|
186
202
|
cache.current.clear();
|
|
187
|
-
setResult({ session: { user: null, subscription: null, applicationRole: null }, error: null });
|
|
203
|
+
setResult({ session: { user: null, subscription: null, applicationRole: null, audience: null }, error: null });
|
|
188
204
|
}, [basePath, fetchImpl]);
|
|
189
205
|
const value = (0, react_1.useMemo)(() => ({
|
|
190
206
|
clientId,
|
|
@@ -198,6 +214,7 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
|
|
|
198
214
|
fetchResource,
|
|
199
215
|
mutateResource,
|
|
200
216
|
invalidateResource,
|
|
217
|
+
streamResource,
|
|
201
218
|
}), [
|
|
202
219
|
clientId,
|
|
203
220
|
appUrl,
|
|
@@ -210,6 +227,7 @@ function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri
|
|
|
210
227
|
fetchResource,
|
|
211
228
|
mutateResource,
|
|
212
229
|
invalidateResource,
|
|
230
|
+
streamResource,
|
|
213
231
|
]);
|
|
214
232
|
return (0, jsx_runtime_1.jsx)(ConnectContext.Provider, { value: value, children: children });
|
|
215
233
|
}
|
|
@@ -224,6 +242,31 @@ function useConnect() {
|
|
|
224
242
|
function useConnectUser() {
|
|
225
243
|
return useConnect().session?.user ?? null;
|
|
226
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Whether the signed-in workspace is a one-seat personal account or a team -
|
|
247
|
+
* `null` while the session is loading or signed out.
|
|
248
|
+
*
|
|
249
|
+
* Branch on this to stop rendering team UI at a solo account: a members
|
|
250
|
+
* table, an invite button and a seat meter are all dead controls on an
|
|
251
|
+
* `"individual"` workspace, which has exactly one seat and cannot gain
|
|
252
|
+
* another. `<ConnectWorkspaceTypeGate>` is the declarative form.
|
|
253
|
+
*/
|
|
254
|
+
function useConnectWorkspaceType() {
|
|
255
|
+
return useConnect().session?.user?.workspaceType ?? null;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Whether this account is the kind your application is sold to - `null` while
|
|
259
|
+
* the session is loading or signed out.
|
|
260
|
+
*
|
|
261
|
+
* `audience.matches === false` is a hard block, not a hint: readiness reports
|
|
262
|
+
* `audience_mismatch` and subscribing is refused. `<ConnectAccessGate>`
|
|
263
|
+
* already blocks the shell on it, so reach for this only when you need the
|
|
264
|
+
* verdict somewhere the gate does not run - a marketing page, say, or a
|
|
265
|
+
* sign-up CTA you would rather not show to an account that cannot complete it.
|
|
266
|
+
*/
|
|
267
|
+
function useConnectAudience() {
|
|
268
|
+
return useConnect().session?.audience ?? null;
|
|
269
|
+
}
|
|
227
270
|
/** Readiness gates and the live subscription, fetched on first use. */
|
|
228
271
|
function useConnectEntitlements() {
|
|
229
272
|
return useConnectResource("/entitlements");
|
|
@@ -196,6 +196,49 @@ function createConnectHandler(options) {
|
|
|
196
196
|
const { user } = await whoami(request);
|
|
197
197
|
return json({ apps: await client.apps.list(user.workspaceId) });
|
|
198
198
|
},
|
|
199
|
+
/**
|
|
200
|
+
* Ask AI. The one streaming route here, and the only one that does not
|
|
201
|
+
* answer JSON - it forwards Connect's server-sent event stream through
|
|
202
|
+
* untouched so `<ConnectAskAi>` can render tokens as they arrive.
|
|
203
|
+
*
|
|
204
|
+
* Same rule as every other route: the workspace comes from the caller's
|
|
205
|
+
* own session. It matters more here than anywhere else, because the
|
|
206
|
+
* answers are *retrieved from that workspace's Context Index* - a
|
|
207
|
+
* client-supplied `workspaceId` would be a read of another tenant's
|
|
208
|
+
* documents, laundered through a chat reply.
|
|
209
|
+
*
|
|
210
|
+
* Messages are forwarded as-is (part-based `UIMessage`s), and only their
|
|
211
|
+
* shape is checked: the conversation is the caller's own, and Connect
|
|
212
|
+
* validates it again on arrival.
|
|
213
|
+
*/
|
|
214
|
+
"POST /ai/chat": async (request) => {
|
|
215
|
+
const { user } = await whoami(request);
|
|
216
|
+
const body = (await request.json().catch(() => null));
|
|
217
|
+
if (!Array.isArray(body?.messages)) {
|
|
218
|
+
throw new errors_js_1.ConnectError({
|
|
219
|
+
status: 400,
|
|
220
|
+
code: "validation_error",
|
|
221
|
+
message: "messages is required",
|
|
222
|
+
endpoint: "POST /ai/chat",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const upstream = await client.ai.chat({
|
|
226
|
+
workspaceId: user.workspaceId,
|
|
227
|
+
messages: body.messages,
|
|
228
|
+
});
|
|
229
|
+
// `upstream.body` is piped, never buffered - awaiting it would collect
|
|
230
|
+
// the whole answer server-side and deliver it in one lump, which is the
|
|
231
|
+
// only thing streaming was for. The browser cancelling its read
|
|
232
|
+
// propagates down this pipe and stops generation upstream.
|
|
233
|
+
return new Response(upstream.body, {
|
|
234
|
+
status: upstream.status,
|
|
235
|
+
headers: {
|
|
236
|
+
"content-type": upstream.headers.get("content-type") ?? "text/event-stream",
|
|
237
|
+
"cache-control": "no-store",
|
|
238
|
+
"x-content-type-options": "nosniff",
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
},
|
|
199
242
|
"GET /integrations": async (request) => {
|
|
200
243
|
const { user } = await whoami(request);
|
|
201
244
|
return json({ toolkits: await client.integrations.list(user.workspaceId) });
|
package/dist/cjs/server/http.js
CHANGED
|
@@ -128,33 +128,55 @@ async function request(options, spec) {
|
|
|
128
128
|
// request for minutes.
|
|
129
129
|
await sleep(Math.min(lastError?.retryAfterMs ?? 250 * 2 ** (attempt - 1), 8_000));
|
|
130
130
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
// A cancellable timer rather than `AbortSignal.timeout`, because the
|
|
132
|
+
// timeout has to be *stopped* once this attempt is done reading - and a
|
|
133
|
+
// timeout signal cannot be cleared.
|
|
134
|
+
//
|
|
135
|
+
// That distinction is the whole point for a streaming endpoint. `fetch`
|
|
136
|
+
// resolves the moment the headers land, but its signal keeps governing
|
|
137
|
+
// the body: an `AbortSignal.timeout(20_000)` handed to `ai.chat` tears the
|
|
138
|
+
// SSE stream down mid-answer, twenty seconds in, on every conversation
|
|
139
|
+
// long enough to matter. Clearing the timer in `finally` bounds the
|
|
140
|
+
// request the same way it always did (a `raw` caller still gets its
|
|
141
|
+
// timeout on *reaching* Connect, and a JSON caller still gets it across
|
|
142
|
+
// the body read, which happens inside the `try`) while leaving a stream
|
|
143
|
+
// that has actually started to run as long as it needs. A caller that
|
|
144
|
+
// wants to bound the stream itself passes `spec.signal`.
|
|
145
|
+
const timeoutController = new AbortController();
|
|
146
|
+
const timer = setTimeout(() => timeoutController.abort(), options.timeoutMs);
|
|
147
|
+
const signal = spec.signal
|
|
148
|
+
? AbortSignal.any([spec.signal, timeoutController.signal])
|
|
149
|
+
: timeoutController.signal;
|
|
134
150
|
try {
|
|
135
|
-
response
|
|
151
|
+
let response;
|
|
152
|
+
try {
|
|
153
|
+
response = await options.fetch(url.toString(), { method: spec.method, headers, body, signal });
|
|
154
|
+
}
|
|
155
|
+
catch (cause) {
|
|
156
|
+
// A caller-supplied abort is a decision, not a fault - never retried.
|
|
157
|
+
if (spec.signal?.aborted)
|
|
158
|
+
throw cause;
|
|
159
|
+
lastError = new errors_js_1.ConnectError({
|
|
160
|
+
status: 0,
|
|
161
|
+
code: "network_error",
|
|
162
|
+
message: `${endpoint} could not reach Connect`,
|
|
163
|
+
endpoint,
|
|
164
|
+
cause,
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (response.ok) {
|
|
169
|
+
if (spec.raw)
|
|
170
|
+
return response;
|
|
171
|
+
return (await readBody(response));
|
|
172
|
+
}
|
|
173
|
+
lastError = toConnectError(response, await readBody(response), endpoint);
|
|
174
|
+
if (!RETRYABLE_STATUS.has(response.status))
|
|
175
|
+
throw lastError;
|
|
136
176
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (spec.signal?.aborted)
|
|
140
|
-
throw cause;
|
|
141
|
-
lastError = new errors_js_1.ConnectError({
|
|
142
|
-
status: 0,
|
|
143
|
-
code: "network_error",
|
|
144
|
-
message: `${endpoint} could not reach Connect`,
|
|
145
|
-
endpoint,
|
|
146
|
-
cause,
|
|
147
|
-
});
|
|
148
|
-
continue;
|
|
177
|
+
finally {
|
|
178
|
+
clearTimeout(timer);
|
|
149
179
|
}
|
|
150
|
-
if (response.ok) {
|
|
151
|
-
if (spec.raw)
|
|
152
|
-
return response;
|
|
153
|
-
return (await readBody(response));
|
|
154
|
-
}
|
|
155
|
-
lastError = toConnectError(response, await readBody(response), endpoint);
|
|
156
|
-
if (!RETRYABLE_STATUS.has(response.status))
|
|
157
|
-
throw lastError;
|
|
158
180
|
}
|
|
159
181
|
throw lastError;
|
|
160
182
|
}
|
|
@@ -45,5 +45,46 @@ class EntitlementsResource {
|
|
|
45
45
|
const result = await this.getSubscription(workspaceId);
|
|
46
46
|
return result.hasActiveSubscription && (result.plan?.features.includes(feature) ?? false);
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* One page of the workspaces subscribed to your application, newest first.
|
|
50
|
+
*
|
|
51
|
+
* The inverse of `getSubscription`, which asks about one workspace you
|
|
52
|
+
* already know. This asks who your subscribers *are* - what a partner
|
|
53
|
+
* dashboard, an MRR total or a churn report is built on.
|
|
54
|
+
*
|
|
55
|
+
* Rate-limited far more tightly than the per-workspace read (10/min per
|
|
56
|
+
* key, against 60): it walks your whole subscriber base and is a reporting
|
|
57
|
+
* call, not a per-request gate. Use `getSubscription` in a hot path.
|
|
58
|
+
*
|
|
59
|
+
* Your application is resolved from the API key, so there is no parameter
|
|
60
|
+
* for it and no way to ask about anyone else's.
|
|
61
|
+
*/
|
|
62
|
+
listSubscribers(options = {}) {
|
|
63
|
+
return (0, http_js_1.request)(this.transport, {
|
|
64
|
+
method: "GET",
|
|
65
|
+
path: "/subscriptions",
|
|
66
|
+
query: { status: options.status, before: options.before, limit: options.limit },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Every subscriber, paged through for you.
|
|
71
|
+
*
|
|
72
|
+
* An async generator rather than an array: a large subscriber base is
|
|
73
|
+
* exactly where an accidental full materialisation hurts, and `for await`
|
|
74
|
+
* reads the same as a loop over one page.
|
|
75
|
+
*/
|
|
76
|
+
async *iterateSubscribers(options = {}) {
|
|
77
|
+
let before;
|
|
78
|
+
do {
|
|
79
|
+
const page = await this.listSubscribers({
|
|
80
|
+
status: options.status,
|
|
81
|
+
limit: options.pageSize,
|
|
82
|
+
before,
|
|
83
|
+
});
|
|
84
|
+
for (const item of page.items)
|
|
85
|
+
yield item;
|
|
86
|
+
before = page.nextBefore ?? undefined;
|
|
87
|
+
} while (before);
|
|
88
|
+
}
|
|
48
89
|
}
|
|
49
90
|
exports.EntitlementsResource = EntitlementsResource;
|
|
@@ -71,6 +71,7 @@ class IdentityResource {
|
|
|
71
71
|
user: wire.user,
|
|
72
72
|
subscription: wire.subscription,
|
|
73
73
|
applicationRole: wire.applicationRole,
|
|
74
|
+
audience: wire.audience,
|
|
74
75
|
};
|
|
75
76
|
}
|
|
76
77
|
/**
|
|
@@ -141,6 +142,7 @@ class IdentityResource {
|
|
|
141
142
|
user: wire.user,
|
|
142
143
|
subscription: wire.subscription,
|
|
143
144
|
applicationRole: wire.applicationRole,
|
|
145
|
+
audience: wire.audience,
|
|
144
146
|
sessionExpiresAt: wire.session_expires_at,
|
|
145
147
|
};
|
|
146
148
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { Ban, AlertTriangle, Clock, CreditCard, ExternalLink, X } from "lucide-react";
|
|
4
|
+
import { Ban, AlertTriangle, Building2, Clock, CreditCard, ExternalLink, User, X } from "lucide-react";
|
|
5
5
|
import { Button, Card, Skeleton } from "@heroui/react";
|
|
6
6
|
import { blockingBlockers } from "../../helpers.js";
|
|
7
7
|
import { useConnect, useConnectEntitlements } from "../provider.js";
|
|
@@ -21,9 +21,17 @@ function formatDate(iso) {
|
|
|
21
21
|
* `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
|
|
22
22
|
* is a priority ordering over the same data, not a second opinion about it.
|
|
23
23
|
*
|
|
24
|
-
* Priority, first match wins: `suspended` > `
|
|
25
|
-
* `onboarding_pending` > any other blocking code
|
|
26
|
-
* `payment_retry` (advisory - `children` still render) >
|
|
24
|
+
* Priority, first match wins: `suspended` > `audience_mismatch` >
|
|
25
|
+
* `no_subscription` > `onboarding_pending` > any other blocking code
|
|
26
|
+
* (`blocked`) > `payment_retry` (advisory - `children` still render) >
|
|
27
|
+
* `clear`.
|
|
28
|
+
*
|
|
29
|
+
* `audience_mismatch` outranks `no_subscription` deliberately, and the
|
|
30
|
+
* ordering is the whole point of it: a workspace of the wrong account type
|
|
31
|
+
* carries BOTH blockers at once, and showing the subscription one first sent
|
|
32
|
+
* the customer to a plan picker whose subscribe call
|
|
33
|
+
* (`ActivateSubscriptionUseCase`) rejects them for the audience anyway. The
|
|
34
|
+
* account type is the reason; the missing subscription is a consequence.
|
|
27
35
|
*/
|
|
28
36
|
export function useConnectAccessGate() {
|
|
29
37
|
const { session, isLoading: sessionLoading } = useConnect();
|
|
@@ -46,6 +54,10 @@ export function useConnectAccessGate() {
|
|
|
46
54
|
const suspended = blocking.find((b) => b.code === "workspace_suspended");
|
|
47
55
|
if (suspended)
|
|
48
56
|
return { state: "suspended", blocker: suspended, paymentRetry: null };
|
|
57
|
+
const audienceMismatch = blocking.find((b) => b.code === "audience_mismatch");
|
|
58
|
+
if (audienceMismatch) {
|
|
59
|
+
return { state: "audience_mismatch", blocker: audienceMismatch, paymentRetry: null };
|
|
60
|
+
}
|
|
49
61
|
const noSubscription = blocking.find((b) => b.code === "subscription_missing");
|
|
50
62
|
if (noSubscription)
|
|
51
63
|
return { state: "no_subscription", blocker: noSubscription, paymentRetry: null };
|
|
@@ -105,6 +117,29 @@ export function ConnectAccessGate({ children, fallback, loading, className }) {
|
|
|
105
117
|
return (_jsx(_Fragment, { children: fallback ?? _jsx(AccessGateFallback, { state: gate.state, blocker: gate.blocker, className: className }) }));
|
|
106
118
|
}
|
|
107
119
|
function AccessGateFallback({ state, blocker, className, }) {
|
|
120
|
+
if (state === "audience_mismatch") {
|
|
121
|
+
// `requiredWorkspaceType` rather than the message, which is documented as
|
|
122
|
+
// human-facing and subject to rewording - the title has to name the right
|
|
123
|
+
// account type even after someone rephrases the sentence below it.
|
|
124
|
+
const required = blocker?.requiredWorkspaceType ?? null;
|
|
125
|
+
return (_jsx(GateCard, { icon: required === "individual" ? User : Building2, iconClassName: "text-muted", title: required === "business"
|
|
126
|
+
? "Business account required"
|
|
127
|
+
: required === "individual"
|
|
128
|
+
? "Individual account required"
|
|
129
|
+
: "Not available for this account type",
|
|
130
|
+
// The server names the application in its own message, which reads
|
|
131
|
+
// better than anything composable here; the fallback covers a blocker
|
|
132
|
+
// that arrived without one.
|
|
133
|
+
message: blocker?.message ??
|
|
134
|
+
(required
|
|
135
|
+
? `This application is only available to ${required} accounts.`
|
|
136
|
+
: "This application isn't available to this account's type."),
|
|
137
|
+
// Deliberately no action, even if one were sent: `Workspace.type` is
|
|
138
|
+
// set at sign-up and no screen changes it, so any button here would
|
|
139
|
+
// only move the dead end somewhere else. Switching means a different
|
|
140
|
+
// account, which is a conversation with support, not a link.
|
|
141
|
+
resolveUrl: null, className: className }));
|
|
142
|
+
}
|
|
108
143
|
if (state === "no_subscription") {
|
|
109
144
|
return (_jsx(GateCard, { icon: CreditCard, iconClassName: "text-accent", title: "Choose a plan to continue", message: blocker?.message ?? "This workspace isn't subscribed to this application yet.", actionLabel: "See plans", resolveUrl: blocker?.resolveUrl ?? null, className: className }));
|
|
110
145
|
}
|
|
@@ -114,7 +149,7 @@ function AccessGateFallback({ state, blocker, className, }) {
|
|
|
114
149
|
if (state === "onboarding_pending") {
|
|
115
150
|
return (_jsx(GateCard, { icon: Clock, iconClassName: "text-muted", title: "Waiting on approval", message: blocker?.message ?? "This workspace is waiting on approval before it can use this application.", resolveUrl: null, className: className }));
|
|
116
151
|
}
|
|
117
|
-
// "blocked" - any other blocking code (verification,
|
|
152
|
+
// "blocked" - any other blocking code (verification, seats, ...).
|
|
118
153
|
// Full fidelity for a code this component doesn't special-case: the same
|
|
119
154
|
// banner-in-a-card `ConnectReadinessGate` falls back to, rather than
|
|
120
155
|
// `GateCard`'s single-blocker shape - there can be more than one of these
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place a model-supplied value becomes a string.
|
|
3
|
+
*
|
|
4
|
+
* Ask AI's presentation tools send raw values with a `format` tag beside them
|
|
5
|
+
* rather than pre-formatted strings (see `AiValueFormat`), and this is the half
|
|
6
|
+
* that makes that worth doing: an amount renders in the viewer's own locale, a
|
|
7
|
+
* table sorts numerically, and the same figure reads identically on a chart
|
|
8
|
+
* axis, in a tooltip and in a metric tile.
|
|
9
|
+
*
|
|
10
|
+
* Every branch is total. A value that cannot be formatted the way it was
|
|
11
|
+
* tagged - a `date` that is not a date, a `currency` that is not a number -
|
|
12
|
+
* falls back to its plain string rather than throwing inside a render.
|
|
13
|
+
*/
|
|
14
|
+
/** Renders in place of a missing value - an em dash, not the word "null". */
|
|
15
|
+
export const AI_EMPTY_VALUE = "—";
|
|
16
|
+
const NUMBER = new Intl.NumberFormat("en-IN");
|
|
17
|
+
const PERCENT = new Intl.NumberFormat("en-IN", { style: "percent", maximumFractionDigits: 1 });
|
|
18
|
+
/**
|
|
19
|
+
* Rupees, not paise - the tool contract says so, because the model is emitting
|
|
20
|
+
* a figure a person would say out loud. `formatPrice` in `plan-pricing.ts` is
|
|
21
|
+
* the paise-denominated twin, for amounts that came out of Connect's billing.
|
|
22
|
+
*/
|
|
23
|
+
const CURRENCY = new Intl.NumberFormat("en-IN", {
|
|
24
|
+
style: "currency",
|
|
25
|
+
currency: "INR",
|
|
26
|
+
maximumFractionDigits: 2,
|
|
27
|
+
});
|
|
28
|
+
const DATE = new Intl.DateTimeFormat("en-IN", { day: "numeric", month: "short", year: "numeric" });
|
|
29
|
+
const DATE_TIME = new Intl.DateTimeFormat("en-IN", {
|
|
30
|
+
day: "numeric",
|
|
31
|
+
month: "short",
|
|
32
|
+
year: "numeric",
|
|
33
|
+
hour: "numeric",
|
|
34
|
+
minute: "2-digit",
|
|
35
|
+
});
|
|
36
|
+
function formatBytes(bytes) {
|
|
37
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
38
|
+
let value = bytes;
|
|
39
|
+
let unit = 0;
|
|
40
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
41
|
+
value /= 1024;
|
|
42
|
+
unit++;
|
|
43
|
+
}
|
|
44
|
+
return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
|
|
45
|
+
}
|
|
46
|
+
function formatDuration(seconds) {
|
|
47
|
+
if (seconds < 60)
|
|
48
|
+
return `${Math.round(seconds)}s`;
|
|
49
|
+
const minutes = Math.floor(seconds / 60);
|
|
50
|
+
if (minutes < 60)
|
|
51
|
+
return `${minutes}m`;
|
|
52
|
+
const hours = Math.floor(minutes / 60);
|
|
53
|
+
const remainder = minutes % 60;
|
|
54
|
+
if (hours < 24)
|
|
55
|
+
return remainder ? `${hours}h ${remainder}m` : `${hours}h`;
|
|
56
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
57
|
+
}
|
|
58
|
+
function asDate(value) {
|
|
59
|
+
if (typeof value !== "string" && typeof value !== "number")
|
|
60
|
+
return null;
|
|
61
|
+
const date = new Date(value);
|
|
62
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
63
|
+
}
|
|
64
|
+
export function formatAiValue(value, format = "text") {
|
|
65
|
+
if (value === null || value === undefined || value === "")
|
|
66
|
+
return AI_EMPTY_VALUE;
|
|
67
|
+
switch (format) {
|
|
68
|
+
case "number":
|
|
69
|
+
return typeof value === "number" ? NUMBER.format(value) : String(value);
|
|
70
|
+
case "currency":
|
|
71
|
+
return typeof value === "number" ? CURRENCY.format(value) : String(value);
|
|
72
|
+
case "percent":
|
|
73
|
+
return typeof value === "number" ? PERCENT.format(value) : String(value);
|
|
74
|
+
case "bytes":
|
|
75
|
+
return typeof value === "number" ? formatBytes(value) : String(value);
|
|
76
|
+
case "duration":
|
|
77
|
+
return typeof value === "number" ? formatDuration(value) : String(value);
|
|
78
|
+
case "date": {
|
|
79
|
+
const date = asDate(value);
|
|
80
|
+
return date ? DATE.format(date) : String(value);
|
|
81
|
+
}
|
|
82
|
+
case "datetime": {
|
|
83
|
+
const date = asDate(value);
|
|
84
|
+
return date ? DATE_TIME.format(date) : String(value);
|
|
85
|
+
}
|
|
86
|
+
default:
|
|
87
|
+
return String(value);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Numeric columns read far better right-aligned; everything else stays with the text. */
|
|
91
|
+
export function isNumericAiFormat(format = "text") {
|
|
92
|
+
return (format === "number" ||
|
|
93
|
+
format === "currency" ||
|
|
94
|
+
format === "percent" ||
|
|
95
|
+
format === "bytes" ||
|
|
96
|
+
format === "duration");
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* A status word to a tone. A small keyword list rather than an enum on the
|
|
100
|
+
* wire, because the model names states from the partner's own domain
|
|
101
|
+
* ("provisioning", "in transit") - and anything unrecognised is neutral, which
|
|
102
|
+
* is the right answer for a word nobody can interpret.
|
|
103
|
+
*/
|
|
104
|
+
const POSITIVE = new Set(["active", "paid", "succeeded", "success", "complete", "completed", "approved", "healthy", "live", "enabled", "delivered", "on", "yes"]);
|
|
105
|
+
const WARNING = new Set(["pending", "processing", "trialing", "trial", "grace", "review", "queued", "partial", "draft", "in_transit"]);
|
|
106
|
+
const DANGER = new Set(["failed", "error", "overdue", "suspended", "cancelled", "canceled", "rejected", "expired", "past_due", "blocked", "disabled", "off"]);
|
|
107
|
+
export function aiStatusTone(value) {
|
|
108
|
+
const word = String(value ?? "").trim().toLowerCase().replace(/\s+/g, "_");
|
|
109
|
+
if (POSITIVE.has(word))
|
|
110
|
+
return "positive";
|
|
111
|
+
if (WARNING.has(word))
|
|
112
|
+
return "warning";
|
|
113
|
+
if (DANGER.has(word))
|
|
114
|
+
return "danger";
|
|
115
|
+
return "neutral";
|
|
116
|
+
}
|
|
117
|
+
/** HeroUI semantic tokens only, so these inherit whatever theme the host defined. */
|
|
118
|
+
export const AI_TONE_CHIP = {
|
|
119
|
+
neutral: "bg-surface-tertiary text-foreground",
|
|
120
|
+
positive: "bg-success/15 text-success",
|
|
121
|
+
warning: "bg-warning/20 text-warning",
|
|
122
|
+
danger: "bg-danger/15 text-danger",
|
|
123
|
+
};
|
|
124
|
+
export const AI_TONE_DOT = {
|
|
125
|
+
neutral: "bg-muted",
|
|
126
|
+
positive: "bg-success",
|
|
127
|
+
warning: "bg-warning",
|
|
128
|
+
danger: "bg-danger",
|
|
129
|
+
};
|