@cosmoops/connect-sdk 0.7.0 → 0.15.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 +662 -0
- package/README.md +376 -124
- package/dist/cjs/cli/doctor.js +3 -3
- package/dist/cjs/cli/init.js +3 -3
- package/dist/cjs/client/components/access-gate.js +59 -5
- 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 +80 -45
- package/dist/cjs/client/components/apps-grid.js +3 -2
- package/dist/cjs/client/components/ask-ai.js +353 -0
- package/dist/cjs/client/components/confirm-dialog.js +40 -0
- package/dist/cjs/client/components/cursor-pagination.js +22 -0
- package/dist/cjs/client/components/filter-tabs.js +24 -0
- package/dist/cjs/client/components/grievance-notice.js +30 -0
- package/dist/cjs/client/components/link-button.js +17 -10
- package/dist/cjs/client/components/logo.js +4 -3
- package/dist/cjs/client/components/nav.js +42 -1
- package/dist/cjs/client/components/plan-pricing.js +27 -0
- package/dist/cjs/client/components/plans.js +1 -1
- package/dist/cjs/client/components/platform-gate.js +48 -0
- package/dist/cjs/client/components/shell-context.js +149 -0
- package/dist/cjs/client/components/side-nav.js +50 -24
- package/dist/cjs/client/components/sidebar.js +127 -0
- package/dist/cjs/client/components/subscribe-dialog.js +2 -2
- package/dist/cjs/client/components/subscription.js +1 -1
- package/dist/cjs/client/components/user-menu.js +122 -40
- package/dist/cjs/client/components/workspace-switcher.js +100 -0
- package/dist/cjs/client/components/workspace-type-gate.js +48 -0
- package/dist/cjs/client/index.js +98 -3
- package/dist/cjs/client/provider.js +100 -2
- package/dist/cjs/client/use-checkout.js +9 -5
- package/dist/cjs/errors.js +1 -1
- package/dist/cjs/index.js +11 -1
- package/dist/cjs/next/index.js +2 -2
- package/dist/cjs/server/client.js +25 -2
- package/dist/cjs/server/handler.js +237 -16
- package/dist/cjs/server/http.js +52 -26
- package/dist/cjs/server/resources/acting-as.js +16 -0
- package/dist/cjs/server/resources/ai.js +15 -2
- package/dist/cjs/server/resources/data.js +374 -0
- package/dist/cjs/server/resources/entitlements.js +41 -0
- package/dist/cjs/server/resources/identity.js +9 -3
- package/dist/cjs/server/resources/kv.js +84 -0
- package/dist/cjs/server/resources/platform.js +43 -0
- package/dist/cjs/server/resources/sessions.js +1 -1
- package/dist/cjs/server/resources/workspaces.js +74 -0
- package/dist/cjs/store.js +152 -0
- package/dist/cjs/ui/empty-state.js +28 -0
- package/dist/cjs/ui/index.js +44 -0
- package/dist/cjs/ui/page-header.js +27 -0
- package/dist/cjs/ui/page-shell.js +44 -0
- package/dist/cjs/ui/section.js +20 -0
- package/dist/cjs/ui/setting-row.js +14 -0
- package/dist/cjs/ui/status-chip.js +17 -0
- package/dist/esm/cli/doctor.js +3 -3
- package/dist/esm/cli/init.js +3 -3
- package/dist/esm/client/components/access-gate.js +60 -6
- 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 +85 -50
- package/dist/esm/client/components/apps-grid.js +3 -2
- package/dist/esm/client/components/ask-ai.js +350 -0
- package/dist/esm/client/components/confirm-dialog.js +37 -0
- package/dist/esm/client/components/cursor-pagination.js +19 -0
- package/dist/esm/client/components/filter-tabs.js +21 -0
- package/dist/esm/client/components/grievance-notice.js +27 -0
- package/dist/esm/client/components/link-button.js +17 -10
- package/dist/esm/client/components/logo.js +4 -3
- package/dist/esm/client/components/nav.js +41 -1
- package/dist/esm/client/components/plan-pricing.js +25 -0
- package/dist/esm/client/components/plans.js +2 -2
- package/dist/esm/client/components/platform-gate.js +45 -0
- package/dist/esm/client/components/shell-context.js +141 -0
- package/dist/esm/client/components/side-nav.js +49 -22
- package/dist/esm/client/components/sidebar.js +120 -0
- package/dist/esm/client/components/subscribe-dialog.js +3 -3
- package/dist/esm/client/components/subscription.js +2 -2
- package/dist/esm/client/components/user-menu.js +121 -41
- package/dist/esm/client/components/workspace-switcher.js +97 -0
- package/dist/esm/client/components/workspace-type-gate.js +45 -0
- package/dist/esm/client/index.js +58 -5
- package/dist/esm/client/provider.js +94 -2
- package/dist/esm/client/use-checkout.js +9 -5
- package/dist/esm/errors.js +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/next/index.js +2 -2
- package/dist/esm/server/client.js +25 -2
- package/dist/esm/server/handler.js +237 -16
- package/dist/esm/server/http.js +52 -26
- package/dist/esm/server/resources/acting-as.js +13 -0
- package/dist/esm/server/resources/ai.js +15 -2
- package/dist/esm/server/resources/data.js +369 -0
- package/dist/esm/server/resources/entitlements.js +41 -0
- package/dist/esm/server/resources/identity.js +9 -3
- package/dist/esm/server/resources/kv.js +80 -0
- package/dist/esm/server/resources/platform.js +39 -0
- package/dist/esm/server/resources/sessions.js +1 -1
- package/dist/esm/server/resources/workspaces.js +70 -0
- package/dist/esm/store.js +141 -0
- package/dist/esm/ui/empty-state.js +25 -0
- package/dist/esm/ui/index.js +34 -0
- package/dist/esm/ui/page-header.js +24 -0
- package/dist/esm/ui/page-shell.js +41 -0
- package/dist/esm/ui/section.js +17 -0
- package/dist/esm/ui/setting-row.js +11 -0
- package/dist/esm/ui/status-chip.js +14 -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 +85 -31
- package/dist/types/client/components/apps-grid.d.ts +11 -2
- package/dist/types/client/components/ask-ai.d.ts +73 -0
- package/dist/types/client/components/confirm-dialog.d.ts +34 -0
- package/dist/types/client/components/cursor-pagination.d.ts +27 -0
- package/dist/types/client/components/filter-tabs.d.ts +26 -0
- package/dist/types/client/components/grievance-notice.d.ts +31 -0
- package/dist/types/client/components/link-button.d.ts +32 -9
- package/dist/types/client/components/logo.d.ts +18 -2
- package/dist/types/client/components/nav.d.ts +27 -3
- package/dist/types/client/components/plan-pricing.d.ts +10 -0
- package/dist/types/client/components/platform-gate.d.ts +53 -0
- package/dist/types/client/components/shell-context.d.ts +116 -0
- package/dist/types/client/components/side-nav.d.ts +28 -14
- package/dist/types/client/components/sidebar.d.ts +95 -0
- package/dist/types/client/components/user-menu.d.ts +70 -23
- package/dist/types/client/components/workspace-switcher.d.ts +49 -0
- package/dist/types/client/components/workspace-type-gate.d.ts +48 -0
- package/dist/types/client/index.d.ts +36 -7
- package/dist/types/client/provider.d.ts +149 -3
- package/dist/types/client/use-checkout.d.ts +8 -4
- package/dist/types/errors.d.ts +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/next/index.d.ts +1 -1
- package/dist/types/server/client.d.ts +25 -2
- package/dist/types/server/http.d.ts +14 -3
- package/dist/types/server/index.d.ts +4 -0
- package/dist/types/server/resources/acting-as.d.ts +11 -0
- package/dist/types/server/resources/ai.d.ts +9 -1
- package/dist/types/server/resources/data.d.ts +258 -0
- package/dist/types/server/resources/entitlements.d.ts +33 -1
- package/dist/types/server/resources/kv.d.ts +63 -0
- package/dist/types/server/resources/platform.d.ts +32 -0
- package/dist/types/server/resources/workspaces.d.ts +55 -0
- package/dist/types/store.d.ts +96 -0
- package/dist/types/types.d.ts +491 -13
- package/dist/types/ui/empty-state.d.ts +34 -0
- package/dist/types/ui/index.d.ts +40 -0
- package/dist/types/ui/page-header.d.ts +34 -0
- package/dist/types/ui/page-shell.d.ts +39 -0
- package/dist/types/ui/section.d.ts +26 -0
- package/dist/types/ui/setting-row.d.ts +18 -0
- package/dist/types/ui/status-chip.d.ts +36 -0
- package/package.json +13 -3
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { Button, Input, Label, TextField } from "@heroui/react";
|
|
5
|
+
import { Brain, ChevronDown, Send, Sparkles, Square, X } from "lucide-react";
|
|
6
|
+
import { Streamdown } from "streamdown";
|
|
7
|
+
import { useConnect } from "../provider.js";
|
|
8
|
+
import { AiChart, AiDetails, AiMetrics, AiSources, AiSteps, AiTable, AiTimeline } from "./ai-widgets.js";
|
|
9
|
+
/**
|
|
10
|
+
* The panel's open width. The *inner* pane always carries it, so the content
|
|
11
|
+
* keeps its layout while the outer shell's width animates to zero around it.
|
|
12
|
+
*/
|
|
13
|
+
const PANEL_WIDTH = "w-full lg:w-[400px]";
|
|
14
|
+
/**
|
|
15
|
+
* The outer shell's width, which is the thing that animates.
|
|
16
|
+
*
|
|
17
|
+
* Two mutually exclusive branches rather than `PANEL_WIDTH` plus an
|
|
18
|
+
* `lg:w-0` override, because that override never applied: `lg:w-[400px]` and
|
|
19
|
+
* `lg:w-0` are the same utility at the same breakpoint, so the winner is
|
|
20
|
+
* whichever Tailwind emits later - and it emits arbitrary values after scale
|
|
21
|
+
* values. The closed panel therefore measured a full 400px at `lg` and up and
|
|
22
|
+
* sat there permanently, eating that much of the content pane on every page
|
|
23
|
+
* with `askAi` on. Only the `translate-x` hid it below `lg`, which is why this
|
|
24
|
+
* looked correct on a phone and wrong on a laptop.
|
|
25
|
+
*/
|
|
26
|
+
function panelShellWidth(isOpen) {
|
|
27
|
+
return isOpen ? PANEL_WIDTH : "w-full lg:w-0 lg:overflow-hidden lg:border-s-0";
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* `blurIn` over the default `fadeIn`: Gemini arrives in bursts rather than an
|
|
31
|
+
* even token drip, and a blur masks a batch landing at once where a plain
|
|
32
|
+
* opacity fade just flickers. Hoisted to module scope because Streamdown
|
|
33
|
+
* compares `animated` by reference.
|
|
34
|
+
*/
|
|
35
|
+
const ANIMATED = {
|
|
36
|
+
animation: "blurIn",
|
|
37
|
+
duration: 220,
|
|
38
|
+
easing: "ease-out",
|
|
39
|
+
sep: "word",
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The "working on it" state - a highlight sweeping through the word rather
|
|
43
|
+
* than a spinner beside it. `connect-ai-shimmer` is defined in the SDK's
|
|
44
|
+
* `theme.css`, which every application platform already imports.
|
|
45
|
+
*/
|
|
46
|
+
function ThinkingShimmer({ label = "Thinking" }) {
|
|
47
|
+
return (_jsx("span", { className: "connect-ai-shimmer text-xs font-medium", role: "status", children: label }));
|
|
48
|
+
}
|
|
49
|
+
/** Open while it streams, collapsed once the answer lands. */
|
|
50
|
+
function ThinkingDisclosure({ text, isStreaming }) {
|
|
51
|
+
if (!text)
|
|
52
|
+
return null;
|
|
53
|
+
return (_jsxs("details", { className: "group mb-2", open: isStreaming, children: [_jsxs("summary", { className: "flex cursor-pointer list-none items-center gap-1 text-[11px] font-medium text-muted select-none hover:text-foreground", children: [_jsx(Brain, { className: "size-3" }), isStreaming ? _jsx(ThinkingShimmer, {}) : "Thinking", _jsx(ChevronDown, { className: "size-3 transition-transform group-open:rotate-180" })] }), _jsx("div", { className: "mt-1.5 border-s-2 border-border ps-2 text-xs whitespace-pre-wrap text-muted italic", children: text })] }));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Structural guards, one per widget.
|
|
57
|
+
*
|
|
58
|
+
* The published package carries no schema validator, and should not gain one
|
|
59
|
+
* for this - so each tool input is checked for the fields its renderer would
|
|
60
|
+
* otherwise crash on. Anything that fails renders nothing: the model's prose
|
|
61
|
+
* sits around it either way, so a missing widget reads as terse rather than
|
|
62
|
+
* broken, which is the better failure.
|
|
63
|
+
*/
|
|
64
|
+
const isArray = (value) => Array.isArray(value) && value.length > 0;
|
|
65
|
+
const field = (input, key) => typeof input === "object" && input !== null ? input[key] : undefined;
|
|
66
|
+
function renderTool(name, input, renderMarkdown) {
|
|
67
|
+
switch (name) {
|
|
68
|
+
case "showTable":
|
|
69
|
+
return isArray(field(input, "columns")) && Array.isArray(field(input, "rows")) ? (_jsx(AiTable, { ...input })) : null;
|
|
70
|
+
case "showChart":
|
|
71
|
+
return isArray(field(input, "series")) ? _jsx(AiChart, { ...input }) : null;
|
|
72
|
+
case "showMetrics":
|
|
73
|
+
return isArray(field(input, "metrics")) ? _jsx(AiMetrics, { ...input }) : null;
|
|
74
|
+
case "showTimeline":
|
|
75
|
+
return isArray(field(input, "events")) ? _jsx(AiTimeline, { ...input }) : null;
|
|
76
|
+
case "showDetails":
|
|
77
|
+
return isArray(field(input, "fields")) ? _jsx(AiDetails, { ...input }) : null;
|
|
78
|
+
case "showSteps":
|
|
79
|
+
return isArray(field(input, "steps")) ? (_jsx(AiSteps, { ...input, renderDetail: renderMarkdown })) : null;
|
|
80
|
+
case "showSources":
|
|
81
|
+
return isArray(field(input, "sources")) ? _jsx(AiSources, { ...input }) : null;
|
|
82
|
+
default:
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Placeholder while a tool's arguments are still arriving, sized like the real thing. */
|
|
87
|
+
function ToolSkeleton() {
|
|
88
|
+
return (_jsx("div", { className: "my-3 flex h-24 animate-pulse items-center justify-center rounded-lg border border-border bg-surface-secondary", role: "status", "aria-label": "Preparing result", children: _jsx(Sparkles, { className: "size-4 text-accent/60", "aria-hidden": true }) }));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One server-sent event stream, folded into the turn being built.
|
|
92
|
+
*
|
|
93
|
+
* Hand-rolled rather than pulled from `@ai-sdk/react`, and that is a deliberate
|
|
94
|
+
* trade: this package ships to partners, and `ai` + `@ai-sdk/react` is a large
|
|
95
|
+
* dependency to add to every application platform for one panel. The wire format
|
|
96
|
+
* is small, both ends of it are ours, and the chunk kinds below are all this
|
|
97
|
+
* panel can render.
|
|
98
|
+
*
|
|
99
|
+
* Tool arguments are read from `tool-input-available` - the complete object -
|
|
100
|
+
* rather than reassembled from `tool-input-delta` fragments. `tool-input-start`
|
|
101
|
+
* is enough to put a skeleton on screen in the meantime, and partial-JSON
|
|
102
|
+
* parsing buys nothing a placeholder does not.
|
|
103
|
+
*
|
|
104
|
+
* Chunks are newline-delimited `data:` frames; a frame can split across network
|
|
105
|
+
* reads, so the tail of each read is carried forward rather than parsed.
|
|
106
|
+
*/
|
|
107
|
+
async function readStream(response, onChunk) {
|
|
108
|
+
const body = response.body;
|
|
109
|
+
if (!body)
|
|
110
|
+
throw new Error("Ask AI returned no stream");
|
|
111
|
+
const reader = body.pipeThrough(new TextDecoderStream()).getReader();
|
|
112
|
+
let buffer = "";
|
|
113
|
+
for (;;) {
|
|
114
|
+
const { done, value } = await reader.read();
|
|
115
|
+
if (done)
|
|
116
|
+
break;
|
|
117
|
+
buffer += value;
|
|
118
|
+
// Everything up to the last newline is whole; whatever follows it is a
|
|
119
|
+
// partial frame and waits for the next read.
|
|
120
|
+
const lines = buffer.split("\n");
|
|
121
|
+
buffer = lines.pop() ?? "";
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
if (!line.startsWith("data:"))
|
|
124
|
+
continue;
|
|
125
|
+
const payload = line.slice(5).trim();
|
|
126
|
+
if (!payload || payload === "[DONE]")
|
|
127
|
+
continue;
|
|
128
|
+
let chunk;
|
|
129
|
+
try {
|
|
130
|
+
chunk = JSON.parse(payload);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// A frame we cannot parse is a frame we cannot render. Dropping it
|
|
134
|
+
// keeps the rest of the answer flowing, which is strictly better than
|
|
135
|
+
// failing the whole turn over one malformed chunk.
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
switch (chunk.type) {
|
|
139
|
+
case "text-delta":
|
|
140
|
+
if (chunk.delta)
|
|
141
|
+
onChunk({ text: chunk.delta });
|
|
142
|
+
break;
|
|
143
|
+
case "reasoning-delta":
|
|
144
|
+
if (chunk.delta)
|
|
145
|
+
onChunk({ reasoning: chunk.delta });
|
|
146
|
+
break;
|
|
147
|
+
case "tool-input-start":
|
|
148
|
+
if (chunk.toolCallId && chunk.toolName) {
|
|
149
|
+
onChunk({ toolStart: { id: chunk.toolCallId, name: chunk.toolName } });
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
case "tool-input-available":
|
|
153
|
+
if (chunk.toolCallId)
|
|
154
|
+
onChunk({ toolInput: { id: chunk.toolCallId, input: chunk.input } });
|
|
155
|
+
break;
|
|
156
|
+
case "error":
|
|
157
|
+
throw new Error(chunk.errorText || "Ask AI failed");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Index of the final text block, or `-1`. A reverse loop rather than
|
|
164
|
+
* `findLastIndex`, which is ES2023 - this package targets ES2022 so its
|
|
165
|
+
* published output stays usable on the runtimes its `engines` field claims.
|
|
166
|
+
*/
|
|
167
|
+
function lastTextBlockIndex(blocks) {
|
|
168
|
+
for (let index = blocks.length - 1; index >= 0; index--) {
|
|
169
|
+
if (blocks[index]?.kind === "text")
|
|
170
|
+
return index;
|
|
171
|
+
}
|
|
172
|
+
return -1;
|
|
173
|
+
}
|
|
174
|
+
/** Appends a text delta to the open text block, or opens one after a widget. */
|
|
175
|
+
function appendText(blocks, text) {
|
|
176
|
+
const last = blocks.at(-1);
|
|
177
|
+
if (last?.kind === "text") {
|
|
178
|
+
return [...blocks.slice(0, -1), { kind: "text", text: last.text + text }];
|
|
179
|
+
}
|
|
180
|
+
return [...blocks, { kind: "text", text }];
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Ask AI - a chat panel grounded in the signed-in workspace's Context Index,
|
|
184
|
+
* streamed through your own bridge.
|
|
185
|
+
*
|
|
186
|
+
* Answers are not only prose. Connect's model can call a small set of
|
|
187
|
+
* presentation tools - `showTable`, `showChart`, `showMetrics`, `showTimeline`,
|
|
188
|
+
* `showDetails`, `showSteps`, `showSources` - and this panel renders each one
|
|
189
|
+
* natively: a sortable table with a CSV download, a chart, a row of metric
|
|
190
|
+
* tiles. Values arrive raw with a format tag, so they are formatted here rather
|
|
191
|
+
* than by the model, which is what lets a column sort numerically.
|
|
192
|
+
*
|
|
193
|
+
* Nothing here holds a credential: it POSTs to `${basePath}/ai/chat` on your
|
|
194
|
+
* own origin, and `createConnectHandler` is what reaches Connect. The workspace
|
|
195
|
+
* is resolved from the caller's session there, never sent from the browser - so
|
|
196
|
+
* a user can only ever ask about the workspace they are signed into.
|
|
197
|
+
*
|
|
198
|
+
* ```tsx
|
|
199
|
+
* <AppShell name="Dispatch" nav={nav}>
|
|
200
|
+
* {children}
|
|
201
|
+
* </AppShell>
|
|
202
|
+
* ```
|
|
203
|
+
*
|
|
204
|
+
* `AppShell` mounts it by default. Mount it directly only when you are not
|
|
205
|
+
* using the shell: it renders a fixed corner trigger plus a panel that is an
|
|
206
|
+
* *in-flow flex sibling* at `lg` and up, so opening it pushes your content
|
|
207
|
+
* aside rather than covering it. That requires it to be the last child of a
|
|
208
|
+
* `flex` row owning the viewport height - which is exactly what `AppShell` is.
|
|
209
|
+
* Below `lg` it is a full-screen sheet with a backdrop instead; there is no
|
|
210
|
+
* room to push anything on a phone.
|
|
211
|
+
*
|
|
212
|
+
* **Host setup:** markdown rendering needs `streamdown` installed, one Tailwind
|
|
213
|
+
* `@source "…/node_modules/streamdown/dist/*.js"` line so its utility classes
|
|
214
|
+
* are generated, and `import "streamdown/styles.css"` for the streaming
|
|
215
|
+
* animation. The colour tokens Streamdown expects are already mapped in this
|
|
216
|
+
* SDK's `theme.css`.
|
|
217
|
+
*
|
|
218
|
+
* Answers require the workspace's Context Index to be turned on (a workspace
|
|
219
|
+
* admin toggle in Connect) and your API key to carry the `ai:chat` scope.
|
|
220
|
+
* Neither is something the browser can discover, so both surface as a message
|
|
221
|
+
* in the panel rather than as a hidden button.
|
|
222
|
+
*/
|
|
223
|
+
export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", placeholder = "Ask a question...", hint = "Ask a question about your workspace - answers are grounded in what your team has indexed.", defaultOpen = false, markdownPlugins, }) {
|
|
224
|
+
const { session, isLoading, streamResource } = useConnect();
|
|
225
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
226
|
+
const [turns, setTurns] = useState([]);
|
|
227
|
+
const [input, setInput] = useState("");
|
|
228
|
+
const [isBusy, setIsBusy] = useState(false);
|
|
229
|
+
const [error, setError] = useState(null);
|
|
230
|
+
const abortRef = useRef(null);
|
|
231
|
+
const bottomRef = useRef(null);
|
|
232
|
+
// Ids only have to be unique within one mounted conversation, so a counter is
|
|
233
|
+
// enough - and unlike `crypto.randomUUID()` it cannot differ between the
|
|
234
|
+
// server's render and the browser's.
|
|
235
|
+
const nextId = useRef(0);
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
238
|
+
}, [turns]);
|
|
239
|
+
// A conversation left mid-stream should stop costing tokens the moment the
|
|
240
|
+
// panel goes away.
|
|
241
|
+
useEffect(() => () => abortRef.current?.abort(), []);
|
|
242
|
+
const renderMarkdown = useCallback((text, isStreaming = false) => (_jsx(Streamdown, { className: "connect-ai-body", mode: isStreaming ? "streaming" : "static", isAnimating: isStreaming, animated: ANIMATED, caret: isStreaming ? "block" : undefined, plugins: markdownPlugins,
|
|
243
|
+
// Retrieved documents cite real URLs; an interstitial on every one of
|
|
244
|
+
// them turns a citation into a chore. The renderer still sanitises the
|
|
245
|
+
// markup itself, which is the part that matters.
|
|
246
|
+
linkSafety: LINK_SAFETY, children: text })), [markdownPlugins]);
|
|
247
|
+
const stop = useCallback(() => {
|
|
248
|
+
abortRef.current?.abort();
|
|
249
|
+
abortRef.current = null;
|
|
250
|
+
setIsBusy(false);
|
|
251
|
+
}, []);
|
|
252
|
+
const send = useCallback(async (text) => {
|
|
253
|
+
const controller = new AbortController();
|
|
254
|
+
abortRef.current = controller;
|
|
255
|
+
setError(null);
|
|
256
|
+
setIsBusy(true);
|
|
257
|
+
const userTurn = {
|
|
258
|
+
id: `u${nextId.current++}`,
|
|
259
|
+
role: "user",
|
|
260
|
+
blocks: [{ kind: "text", text }],
|
|
261
|
+
reasoning: "",
|
|
262
|
+
};
|
|
263
|
+
const answerId = `a${nextId.current++}`;
|
|
264
|
+
// The wire format Connect reads - part-based `UIMessage`s, not flat
|
|
265
|
+
// strings. Built from the turns *plus* this one rather than from state,
|
|
266
|
+
// which has not been committed yet at this point. Only text is sent back:
|
|
267
|
+
// a widget is a rendering of something the model already said.
|
|
268
|
+
const history = [...turns, userTurn].map((turn) => ({
|
|
269
|
+
id: turn.id,
|
|
270
|
+
role: turn.role,
|
|
271
|
+
parts: [
|
|
272
|
+
{
|
|
273
|
+
type: "text",
|
|
274
|
+
text: turn.blocks
|
|
275
|
+
.filter((block) => block.kind === "text")
|
|
276
|
+
.map((block) => block.text)
|
|
277
|
+
.join(""),
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
}));
|
|
281
|
+
setTurns((current) => [...current, userTurn]);
|
|
282
|
+
const patchAnswer = (patch) => setTurns((current) => current.map((turn) => (turn.id === answerId ? patch(turn) : turn)));
|
|
283
|
+
try {
|
|
284
|
+
// Relative to the provider's own `basePath` - `streamResource` prefixes
|
|
285
|
+
// it, the same way every other bridge call is addressed.
|
|
286
|
+
const response = await streamResource("/ai/chat", { messages: history }, { signal: controller.signal });
|
|
287
|
+
// The assistant's turn is appended only once the stream is live, so a
|
|
288
|
+
// failed request leaves an error under the question rather than an
|
|
289
|
+
// empty bubble that never fills.
|
|
290
|
+
setTurns((current) => [...current, { id: answerId, role: "assistant", blocks: [], reasoning: "" }]);
|
|
291
|
+
await readStream(response, (chunk) => {
|
|
292
|
+
if (chunk.text) {
|
|
293
|
+
patchAnswer((turn) => ({ ...turn, blocks: appendText(turn.blocks, chunk.text) }));
|
|
294
|
+
}
|
|
295
|
+
if (chunk.reasoning) {
|
|
296
|
+
patchAnswer((turn) => ({ ...turn, reasoning: turn.reasoning + chunk.reasoning }));
|
|
297
|
+
}
|
|
298
|
+
if (chunk.toolStart) {
|
|
299
|
+
const { id, name } = chunk.toolStart;
|
|
300
|
+
patchAnswer((turn) => ({ ...turn, blocks: [...turn.blocks, { kind: "tool", id, name, input: null }] }));
|
|
301
|
+
}
|
|
302
|
+
if (chunk.toolInput) {
|
|
303
|
+
const { id, input: toolInput } = chunk.toolInput;
|
|
304
|
+
patchAnswer((turn) => ({
|
|
305
|
+
...turn,
|
|
306
|
+
blocks: turn.blocks.map((block) => block.kind === "tool" && block.id === id ? { ...block, input: toolInput } : block),
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
catch (cause) {
|
|
312
|
+
// Pressing Stop aborts the fetch, which lands here - a decision, not a
|
|
313
|
+
// failure, and nothing to report.
|
|
314
|
+
if (!controller.signal.aborted) {
|
|
315
|
+
setError(cause instanceof Error ? cause.message : "Ask AI failed");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
if (abortRef.current === controller)
|
|
320
|
+
abortRef.current = null;
|
|
321
|
+
setIsBusy(false);
|
|
322
|
+
}
|
|
323
|
+
}, [streamResource, turns]);
|
|
324
|
+
const lastTurn = turns.at(-1);
|
|
325
|
+
const isAnswering = isBusy && lastTurn?.role === "assistant";
|
|
326
|
+
const rendered = useMemo(() => turns.map((turn) => {
|
|
327
|
+
const isLive = isAnswering && turn.id === lastTurn?.id;
|
|
328
|
+
const lastTextIndex = lastTextBlockIndex(turn.blocks);
|
|
329
|
+
return (_jsxs("div", { className: `connect-ai-host rounded-md border border-border p-2.5 text-sm ${turn.role === "user" ? "bg-surface-secondary" : "bg-accent/5"}`, children: [_jsx("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: turn.role === "user" ? "You" : assistantName }), turn.role === "assistant" && (_jsx(ThinkingDisclosure, { text: turn.reasoning, isStreaming: isLive && lastTextIndex === -1 })), turn.blocks.map((block, index) => block.kind === "text" ? (
|
|
330
|
+
// The user's own turn is shown as they typed it - running a
|
|
331
|
+
// question that happens to start with `#` through the markdown
|
|
332
|
+
// renderer would turn it into a heading.
|
|
333
|
+
_jsx("div", { children: turn.role === "user" ? (_jsx("p", { className: "whitespace-pre-wrap", children: block.text })) : (renderMarkdown(block.text, isLive && index === lastTextIndex)) }, index)) : block.input === null ? (_jsx(ToolSkeleton, {}, block.id)) : (_jsx("div", { children: renderTool(block.name, block.input, renderMarkdown) }, block.id))), isLive && lastTextIndex === -1 && turn.blocks.length === 0 && _jsx(ThinkingShimmer, {})] }, turn.id));
|
|
334
|
+
}), [turns, isAnswering, lastTurn?.id, assistantName, renderMarkdown]);
|
|
335
|
+
// Signed out there is no workspace to ground an answer in, and the bridge
|
|
336
|
+
// would answer 401 - so there is nothing to offer, not even a button.
|
|
337
|
+
if (isLoading || !session?.user)
|
|
338
|
+
return null;
|
|
339
|
+
const handleSubmit = (event) => {
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
const text = input.trim();
|
|
342
|
+
if (!text || isBusy)
|
|
343
|
+
return;
|
|
344
|
+
setInput("");
|
|
345
|
+
void send(text);
|
|
346
|
+
};
|
|
347
|
+
return (_jsxs(_Fragment, { children: [!isOpen && (_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": title, onPress: () => setIsOpen(true), className: "fixed bottom-4 end-4 z-30 size-12 rounded-full shadow-lg sm:bottom-6 sm:end-6", children: _jsx(Sparkles, { className: "size-5" }) })), _jsx("div", { className: `fixed inset-0 z-40 bg-backdrop transition-opacity lg:hidden ${isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`, onClick: () => setIsOpen(false), "aria-hidden": "true" }), _jsx("aside", { "aria-hidden": !isOpen, className: `fixed inset-y-0 end-0 z-50 flex flex-col border-s border-border bg-surface shadow-xl transition-transform duration-300 ease-in-out ${panelShellWidth(isOpen)} ${isOpen ? "translate-x-0" : "translate-x-full rtl:-translate-x-full"} lg:static lg:z-auto lg:translate-x-0 lg:shadow-none lg:transition-[width]`, children: _jsxs("div", { className: `flex h-full shrink-0 flex-col ${PANEL_WIDTH}`, children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border px-4 py-3", children: [_jsx(Sparkles, { className: "size-4 shrink-0 text-accent" }), _jsx("span", { className: "flex-1 text-sm font-semibold", children: title }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Close", onPress: () => setIsOpen(false), children: _jsx(X, { className: "size-4" }) })] }), _jsxs("div", { className: "flex-1 space-y-3 overflow-y-auto p-4", children: [turns.length === 0 && _jsx("p", { className: "text-sm text-muted", children: hint }), rendered, isBusy && lastTurn?.role === "user" && (_jsxs("div", { className: "rounded-md border border-border bg-accent/5 p-2.5 text-sm", children: [_jsx("p", { className: "mb-1 text-[11px] font-semibold tracking-wide text-muted uppercase", children: assistantName }), _jsx(ThinkingShimmer, {})] })), error && _jsx("p", { className: "text-xs text-danger", children: error }), _jsx("div", { ref: bottomRef })] }), _jsxs("form", { onSubmit: handleSubmit, className: "flex gap-2 border-t border-border p-3", children: [_jsxs(TextField, { variant: "secondary", className: "flex-1", children: [_jsx(Label, { className: "sr-only", children: placeholder }), _jsx(Input, { placeholder: placeholder, value: input, onChange: (event) => setInput(event.currentTarget.value), disabled: isBusy })] }), isBusy ? (_jsx(Button, { type: "button", isIconOnly: true, variant: "secondary", "aria-label": "Stop generating", onPress: stop, children: _jsx(Square, { className: "size-3.5" }) })) : (_jsx(Button, { type: "submit", isIconOnly: true, variant: "primary", "aria-label": "Send", isDisabled: !input.trim(), children: _jsx(Send, { className: "size-3.5" }) }))] })] }) })] }));
|
|
348
|
+
}
|
|
349
|
+
/** Hoisted for the same reference-equality reason as `ANIMATED` - Streamdown compares it by identity. */
|
|
350
|
+
const LINK_SAFETY = { enabled: false };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button, Modal } from "@heroui/react";
|
|
5
|
+
/**
|
|
6
|
+
* The confirmation in front of a destructive action.
|
|
7
|
+
*
|
|
8
|
+
* **It closes on confirm, not on response.** Holding the modal open until the
|
|
9
|
+
* server answers makes every delete feel like a form submission, and the error
|
|
10
|
+
* path already has somewhere to go - a toast, or the row reappearing. A dialog
|
|
11
|
+
* that stays open to report failure is right only where the failure is
|
|
12
|
+
* *expected* and correctable in place (a seat cap, a last-admin invariant),
|
|
13
|
+
* and that is a blocking form, not this.
|
|
14
|
+
*
|
|
15
|
+
* `onConfirm` may return a promise. It is not awaited before closing, but a
|
|
16
|
+
* rejection is caught here rather than escaping - an error thrown out of an
|
|
17
|
+
* async transition reaches the nearest error boundary and takes the page down.
|
|
18
|
+
*
|
|
19
|
+
* The anatomy is HeroUI's: `Modal.Backdrop` owns the open state,
|
|
20
|
+
* `Modal.Container` the sizing, `Modal.Dialog` the focus trap. The trigger
|
|
21
|
+
* goes inside `Modal.Trigger`, which *is* a React Aria button - so pass a
|
|
22
|
+
* plain element, never a `<Button>`, or the nested button swallows the press
|
|
23
|
+
* and the dialog silently never opens.
|
|
24
|
+
*/
|
|
25
|
+
export function ConnectConfirmDialog({ trigger, title, description, confirmLabel = "Confirm", cancelLabel = "Cancel", tone = "danger", onConfirm, }) {
|
|
26
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
27
|
+
const confirm = () => {
|
|
28
|
+
setIsOpen(false);
|
|
29
|
+
void Promise.resolve()
|
|
30
|
+
.then(() => onConfirm())
|
|
31
|
+
.catch(() => {
|
|
32
|
+
// Swallowed on purpose - see the note above. Reporting belongs to the
|
|
33
|
+
// caller, which owns the toast and the rollback.
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
return (_jsxs(_Fragment, { children: [_jsx(Modal.Trigger, { onClick: () => setIsOpen(true), children: trigger }), _jsx(Modal.Backdrop, { isOpen: isOpen, onOpenChange: setIsOpen, children: _jsx(Modal.Container, { size: "sm", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, { onPress: () => setIsOpen(false) }), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: title }) }), description && (_jsx(Modal.Body, { children: _jsx("p", { className: "text-sm text-muted", children: description }) })), _jsxs(Modal.Footer, { children: [_jsx(Button, { variant: "secondary", onPress: () => setIsOpen(false), children: cancelLabel }), _jsx(Button, { variant: tone === "danger" ? "danger" : "primary", onPress: confirm, children: confirmLabel })] })] }) }) })] }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { Button } from "@heroui/react";
|
|
4
|
+
/**
|
|
5
|
+
* Next / previous for a cursor-paginated list.
|
|
6
|
+
*
|
|
7
|
+
* Cursor rather than page numbers, and the component shape follows from that:
|
|
8
|
+
* there is no "page 7 of 42" because a keyset-paginated list does not know how
|
|
9
|
+
* many pages there are without a second count query over the same filter -
|
|
10
|
+
* which is a billed index scan for a number nobody acts on. What a user does
|
|
11
|
+
* act on is "more" and "back", and those are exact.
|
|
12
|
+
*
|
|
13
|
+
* The count is announced in a live region so a screen-reader user learns that
|
|
14
|
+
* filtering or paging changed the result set. Without it the table silently
|
|
15
|
+
* swaps its contents.
|
|
16
|
+
*/
|
|
17
|
+
export function ConnectCursorPagination({ shown, nextCursor, hasPrevious, onNext, onPrevious, isPending = false, noun = ["row", "rows"], }) {
|
|
18
|
+
return (_jsxs("div", { className: "flex items-center justify-between gap-3 pt-3", children: [_jsxs("p", { "aria-live": "polite", className: "text-xs text-muted", children: [shown, " ", shown === 1 ? noun[0] : noun[1]] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { size: "sm", variant: "secondary", isDisabled: !hasPrevious || isPending, onPress: onPrevious, children: "Previous" }), _jsx(Button, { size: "sm", variant: "secondary", isDisabled: nextCursor === null || isPending, onPress: () => nextCursor && onNext(nextCursor), children: "Next" })] })] }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Chip } from "@heroui/react";
|
|
4
|
+
/**
|
|
5
|
+
* A single-select filter strip - the "All / Open / Won / Lost" row above a
|
|
6
|
+
* list.
|
|
7
|
+
*
|
|
8
|
+
* Real radio semantics rather than a row of buttons: it is a single choice
|
|
9
|
+
* from a closed set, so arrow keys should move between options and the
|
|
10
|
+
* selected one should be announced as selected. A group of `<button>`s gets
|
|
11
|
+
* neither, and that is the usual hand-rolled shape.
|
|
12
|
+
*
|
|
13
|
+
* Scrolls horizontally rather than wrapping on a narrow viewport - a filter
|
|
14
|
+
* strip that wraps to three lines pushes the list itself off the screen.
|
|
15
|
+
*/
|
|
16
|
+
export function ConnectFilterTabs({ options, value, onChange, label, }) {
|
|
17
|
+
return (_jsx("div", { role: "radiogroup", "aria-label": label, className: "-mx-1 flex gap-2 overflow-x-auto px-1 pb-1", children: options.map((option) => {
|
|
18
|
+
const isSelected = option.value === value;
|
|
19
|
+
return (_jsx("button", { type: "button", role: "radio", "aria-checked": isSelected, onClick: () => onChange(option.value), className: "shrink-0 rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent", children: _jsxs(Chip, { size: "sm", variant: isSelected ? "primary" : "soft", color: isSelected ? "accent" : "default", children: [option.label, option.count !== undefined && _jsx("span", { className: "ml-1 opacity-70", children: option.count })] }) }, option.value ?? "__all"));
|
|
20
|
+
}) }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useConnectCompanyProfile } from "../provider.js";
|
|
4
|
+
/**
|
|
5
|
+
* The DPDP s.13 grievance channel, rendered.
|
|
6
|
+
*
|
|
7
|
+
* Every application on the platform is a Data Processor under one notice, so
|
|
8
|
+
* the wording lives here rather than being retyped per application - a user
|
|
9
|
+
* comparing two applications' privacy pages should not find two different
|
|
10
|
+
* accounts of who their Data Fiduciary is.
|
|
11
|
+
*
|
|
12
|
+
* Renders nothing at all while loading and nothing on failure, rather than a
|
|
13
|
+
* skeleton or an error. That is unusual here and deliberate: this component
|
|
14
|
+
* is almost always inside a larger privacy page that has its own content, and
|
|
15
|
+
* a red error box in the middle of a legal notice reads as the notice itself
|
|
16
|
+
* being broken. The address is also on the public web either way. Pass
|
|
17
|
+
* `useConnectCompanyProfile()` directly to your own layout if you want to
|
|
18
|
+
* handle those states yourself.
|
|
19
|
+
*/
|
|
20
|
+
export function ConnectGrievanceNotice({ title = "Grievance redressal", applicationName, className, }) {
|
|
21
|
+
const { data } = useConnectCompanyProfile();
|
|
22
|
+
const profile = data?.companyProfile;
|
|
23
|
+
if (!profile)
|
|
24
|
+
return null;
|
|
25
|
+
const { grievance } = profile;
|
|
26
|
+
return (_jsxs("section", { className: className ?? "space-y-3", children: [title !== null && _jsx("h3", { className: "text-base font-semibold", children: title }), _jsxs("p", { className: "text-sm text-muted", children: [applicationName ? `${applicationName} is operated on ` : "This service runs on ", "the CosmoOps Connect platform. ", _jsx("strong", { className: "font-medium text-foreground", children: profile.legalName }), " ", "is the Data Fiduciary for personal data processed here, under the Digital Personal Data Protection Act, 2023."] }), _jsxs("dl", { className: "grid grid-cols-1 gap-x-6 gap-y-3 text-sm sm:grid-cols-[auto_1fr]", children: [_jsx("dt", { className: "text-muted", children: "Grievance Officer" }), _jsx("dd", { className: "text-foreground", children: grievance.officerName }), _jsx("dt", { className: "text-muted", children: "Email" }), _jsx("dd", { children: _jsx("a", { className: "text-accent underline underline-offset-2", href: `mailto:${grievance.officerEmail}`, children: grievance.officerEmail }) }), _jsx("dt", { className: "text-muted", children: "Contactable" }), _jsx("dd", { className: "text-foreground", children: grievance.hours }), _jsx("dt", { className: "text-muted", children: "Registered address" }), _jsx("dd", { className: "text-foreground", children: profile.registeredAddress })] }), _jsxs("p", { className: "text-sm text-muted", children: ["Complaints are acknowledged and resolved within ", grievance.responseDays, " days of receipt."] })] }));
|
|
27
|
+
}
|
|
@@ -2,20 +2,27 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { Button } from "@heroui/react";
|
|
4
4
|
/**
|
|
5
|
-
* A HeroUI `Button` that renders a
|
|
5
|
+
* A HeroUI `Button` that renders a link.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* rule), so it cannot know which router - if any - the host app uses. An
|
|
10
|
-
* `<a href>` navigates under all of them, and a host that wants client-side
|
|
11
|
-
* routing can pass its own element via `render` on the underlying `Button`.
|
|
7
|
+
* HeroUI v3's `Button` has no `as` prop, so `<Button as={Link}>` does not type
|
|
8
|
+
* check and does not work - this is the supported route, via `render`.
|
|
12
9
|
*
|
|
13
10
|
* The cast is the same one HeroUI's own "Render Function" example needs:
|
|
14
11
|
* `render` types its argument as the props of the element `Button` would have
|
|
15
|
-
* emitted, which is a `<button
|
|
12
|
+
* emitted, which is a `<button>`, including a `ref` typed to
|
|
16
13
|
* `HTMLButtonElement`. Spreading that onto an anchor is sound at runtime and
|
|
17
|
-
* unrepresentable in the type, which is
|
|
14
|
+
* unrepresentable in the type, which is what the cast is for.
|
|
18
15
|
*/
|
|
19
|
-
export function ConnectLinkButton({ href, ...props }) {
|
|
20
|
-
return (_jsx(Button, { ...props, render: (renderProps) =>
|
|
16
|
+
export function ConnectLinkButton({ href, renderLink, ...props }) {
|
|
17
|
+
return (_jsx(Button, { ...props, render: (renderProps) => {
|
|
18
|
+
const anchorProps = renderProps;
|
|
19
|
+
if (renderLink) {
|
|
20
|
+
return renderLink({
|
|
21
|
+
href,
|
|
22
|
+
...(anchorProps.className ? { className: anchorProps.className } : {}),
|
|
23
|
+
children: anchorProps.children,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return _jsx("a", { ...anchorProps, href: href });
|
|
27
|
+
} }));
|
|
21
28
|
}
|
|
@@ -15,10 +15,11 @@ import { ConnectMark } from "../mark.js";
|
|
|
15
15
|
* as `text-sm`, with the badge, gap and tracking all in proportion. That is
|
|
16
16
|
* why there is no `size` prop.
|
|
17
17
|
*/
|
|
18
|
-
export function ConnectLogo({ name, icon, collapsed = false, className }) {
|
|
18
|
+
export function ConnectLogo({ name, icon, variant, collapsed = false, className }) {
|
|
19
|
+
const resolved = variant ?? (collapsed ? "mark" : "full");
|
|
19
20
|
const badge = (_jsxs("span", { className: "relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-[0.28em] bg-accent text-accent-foreground", style: { width: "2em", height: "2em" }, "aria-hidden": "true", children: [icon ? _jsx("span", { className: "inline-flex items-center justify-center [&>*]:size-[1.25em]", children: icon }) : null, !icon && _jsx(ConnectMark, { className: "absolute inset-0 size-full" })] }));
|
|
20
|
-
if (
|
|
21
|
+
if (resolved === "mark") {
|
|
21
22
|
return (_jsx("span", { className: `inline-flex text-sm leading-none ${className ?? ""}`, role: "img", "aria-label": name, children: badge }));
|
|
22
23
|
}
|
|
23
|
-
return (_jsxs("span", { className: `inline-flex min-w-0 items-center gap-[0.6em] text-sm leading-none ${className ?? ""}`, role: "img", "aria-label": `cosmoops ${name}`, children: [badge, _jsxs("span", { className: "flex min-w-0 flex-col leading-none", children: [_jsxs("span", { className: "inline-flex items-center font-extrabold tracking-tight text-foreground", "aria-hidden": "true", children: [_jsx("span", { children: "cosmo" }), _jsx("span", { className: "mx-[0.12em] inline-block size-[0.19em] shrink-0 rounded-[0.04em] bg-accent" }), _jsx("span", { children: "ops" })] }), _jsx("span", { className: "mt-[0.42em] truncate text-[0.6em] font-semibold uppercase tracking-[0.26em] text-accent", "aria-hidden": "true", children: name })] })] }));
|
|
24
|
+
return (_jsxs("span", { className: `inline-flex min-w-0 items-center gap-[0.6em] text-sm leading-none ${className ?? ""}`, role: "img", "aria-label": `cosmoops ${name}`, children: [resolved === "full" && badge, _jsxs("span", { className: "flex min-w-0 flex-col leading-none", children: [_jsxs("span", { className: "inline-flex items-center font-extrabold tracking-tight text-foreground", "aria-hidden": "true", children: [_jsx("span", { children: "cosmo" }), _jsx("span", { className: "mx-[0.12em] inline-block size-[0.19em] shrink-0 rounded-[0.04em] bg-accent" }), _jsx("span", { children: "ops" })] }), _jsx("span", { className: "mt-[0.42em] truncate text-[0.6em] font-semibold uppercase tracking-[0.26em] text-accent", "aria-hidden": "true", children: name })] })] }));
|
|
24
25
|
}
|
|
@@ -81,7 +81,47 @@ export function isNavItemActive(item, activeHref) {
|
|
|
81
81
|
return false;
|
|
82
82
|
return normalizePath(item.href) === activeHref;
|
|
83
83
|
}
|
|
84
|
-
/**
|
|
84
|
+
/**
|
|
85
|
+
* `nav` with every item the current session should not see removed - see
|
|
86
|
+
* `ConnectNavItem.audience`.
|
|
87
|
+
*
|
|
88
|
+
* Applied to nested `items` too, and a parent whose children all disappear is
|
|
89
|
+
* dropped along with them: a section header pointing at nothing reads as a
|
|
90
|
+
* broken link rather than as a hidden one.
|
|
91
|
+
*/
|
|
92
|
+
export function filterNavForAudience(nav, isStaff) {
|
|
93
|
+
const keep = (item) => item.audience === undefined || (item.audience === "staff") === isStaff;
|
|
94
|
+
const filterItem = (item) => {
|
|
95
|
+
if (!keep(item))
|
|
96
|
+
return null;
|
|
97
|
+
if (!item.items)
|
|
98
|
+
return item;
|
|
99
|
+
const items = item.items.filter(keep);
|
|
100
|
+
// A parent that had children and now has none, but is a real destination
|
|
101
|
+
// in its own right, still renders - only one that exists purely to hold
|
|
102
|
+
// them is dropped.
|
|
103
|
+
if (items.length === 0 && item.items.length > 0 && !item.href)
|
|
104
|
+
return null;
|
|
105
|
+
return { ...item, items };
|
|
106
|
+
};
|
|
107
|
+
// A plain loop rather than `flatMap`: the array's element type is a union,
|
|
108
|
+
// so a callback returning either shape widens past what `flatMap` accepts.
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const entry of nav) {
|
|
111
|
+
if ("items" in entry && !("href" in entry)) {
|
|
112
|
+
const section = entry;
|
|
113
|
+
const items = section.items.map(filterItem).filter((i) => i !== null);
|
|
114
|
+
if (items.length > 0)
|
|
115
|
+
out.push({ ...section, items });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const item = filterItem(entry);
|
|
119
|
+
if (item)
|
|
120
|
+
out.push(item);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
/** Hrefs declared `gateExempt` - the routes `AppShell` renders outside its `ConnectAccessGate`. */
|
|
85
125
|
export function gateExemptPaths(nav) {
|
|
86
126
|
return flattenNav(nav)
|
|
87
127
|
.filter((item) => item.gateExempt)
|
|
@@ -31,6 +31,31 @@ export function formatPrice(paise) {
|
|
|
31
31
|
export function monthlyOf(price, billingInterval) {
|
|
32
32
|
return billingInterval === "annual" ? price / 12 : price;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* How to name a plan's cadence, in the three forms the plan screens need.
|
|
36
|
+
*
|
|
37
|
+
* One table rather than a `=== "annual" ? "year" : "month"` at each call site.
|
|
38
|
+
* That ternary was correct while there were two intervals and quietly became
|
|
39
|
+
* wrong when `"one-time"` arrived: a plan paid for once was labelled
|
|
40
|
+
* "per month", which is not a rendering imperfection but a false price.
|
|
41
|
+
*/
|
|
42
|
+
const INTERVAL_LABELS = {
|
|
43
|
+
monthly: { per: "month", short: "mo", adverb: "monthly" },
|
|
44
|
+
annual: { per: "year", short: "yr", adverb: "annually" },
|
|
45
|
+
// "once" reads correctly in all three positions a cadence appears in -
|
|
46
|
+
// "₹4,999 once", "Total once", "Only sold once".
|
|
47
|
+
"one-time": { per: "once", short: "once", adverb: "once" },
|
|
48
|
+
};
|
|
49
|
+
export function intervalLabel(billingInterval) {
|
|
50
|
+
return INTERVAL_LABELS[billingInterval];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* `/month`, `/year` - and nothing at all for a one-time plan, where a price
|
|
54
|
+
* with no slash is the honest rendering.
|
|
55
|
+
*/
|
|
56
|
+
export function pricePerLabel(billingInterval) {
|
|
57
|
+
return billingInterval === "one-time" ? "" : `/${INTERVAL_LABELS[billingInterval].per}`;
|
|
58
|
+
}
|
|
34
59
|
export function discountLabel(discount) {
|
|
35
60
|
const amount = discount.discountType === "percentage" ? `${discount.value}% off` : `${formatPrice(discount.value)} off`;
|
|
36
61
|
if (discount.duration === "forever")
|
|
@@ -7,7 +7,7 @@ import { useConnect, useConnectEntitlements, useConnectPlans } from "../provider
|
|
|
7
7
|
import { buildCancelUrl, buildCheckoutUrl, useConnectCheckout, } from "../use-checkout.js";
|
|
8
8
|
import { AnimatePresence, ENTER, m, MotionProvider } from "./motion.js";
|
|
9
9
|
import { ConnectSubscribeDialog } from "./subscribe-dialog.js";
|
|
10
|
-
import { ACTION_LABEL, discountLabel, formatPrice, groupPlansByTier, resolveAction, } from "./plan-pricing.js";
|
|
10
|
+
import { ACTION_LABEL, discountLabel, formatPrice, groupPlansByTier, intervalLabel, pricePerLabel, resolveAction, } from "./plan-pricing.js";
|
|
11
11
|
/**
|
|
12
12
|
* The plan table: monthly/annual toggle, features, add-ons, the discount
|
|
13
13
|
* actually in effect, and the subscribe / upgrade / downgrade flows.
|
|
@@ -123,7 +123,7 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
123
123
|
(tier.monthly?.id === subscription.plan.id || tier.annual?.id === subscription.plan.id);
|
|
124
124
|
const isCurrent = action === "current";
|
|
125
125
|
const cadenceUnavailable = showAnnual ? tier.annual === null : tier.monthly === null;
|
|
126
|
-
return (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsxs(Card, { className: isCurrentTier ? "h-full border-accent" : "h-full", children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: tier.name }), _jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [tier.annualSavingPercent !== null && showAnnual && (_jsxs(Chip, { className: "bg-accent-soft text-accent", children: ["Save ", tier.annualSavingPercent, "%"] })), isCurrentTier && _jsx(Chip, { className: "bg-accent-soft text-accent", children: "Current" })] })] }), tier.description && _jsx(Card.Description, { children: tier.description })] }), _jsxs(Card.Content, { className: "flex flex-col gap-4", children: [_jsxs("div", { children: [_jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsxs(m.div, { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 }, children: [_jsx("span", { className: "text-2xl font-semibold text-foreground", children: formatPrice(plan.price) }),
|
|
126
|
+
return (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsxs(Card, { className: isCurrentTier ? "h-full border-accent" : "h-full", children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: tier.name }), _jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [tier.annualSavingPercent !== null && showAnnual && (_jsxs(Chip, { className: "bg-accent-soft text-accent", children: ["Save ", tier.annualSavingPercent, "%"] })), isCurrentTier && _jsx(Chip, { className: "bg-accent-soft text-accent", children: "Current" })] })] }), tier.description && _jsx(Card.Description, { children: tier.description })] }), _jsxs(Card.Content, { className: "flex flex-col gap-4", children: [_jsxs("div", { children: [_jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsxs(m.div, { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 }, children: [_jsx("span", { className: "text-2xl font-semibold text-foreground", children: formatPrice(plan.price) }), _jsx("span", { className: "text-sm text-muted", children: pricePerLabel(plan.billingInterval) })] }, plan.billingInterval) }), _jsxs("p", { className: "mt-1 text-xs text-muted", children: ["+", plan.gstRate, "% GST"] }), cadenceUnavailable && (_jsxs("p", { className: "mt-1 text-xs text-muted", children: ["Only sold ", intervalLabel(plan.billingInterval).adverb, "."] }))] }), plan.trialDays ? (_jsxs("p", { className: "text-xs font-medium text-accent", children: [plan.trialDays, "-day free trial"] })) : null, plan.features.length > 0 && (_jsx("ul", { className: "flex flex-col gap-1.5", children: plan.features.map((feature) => (
|
|
127
127
|
// `name`, not `key` - the key is an identifier
|
|
128
128
|
// ("campaign-module"), and printing it here is what this
|
|
129
129
|
// card used to do. The description rides along as a
|