@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.
Files changed (154) hide show
  1. package/CHANGELOG.md +662 -0
  2. package/README.md +376 -124
  3. package/dist/cjs/cli/doctor.js +3 -3
  4. package/dist/cjs/cli/init.js +3 -3
  5. package/dist/cjs/client/components/access-gate.js +59 -5
  6. package/dist/cjs/client/components/ai-format.js +135 -0
  7. package/dist/cjs/client/components/ai-widgets.js +232 -0
  8. package/dist/cjs/client/components/app-shell.js +80 -45
  9. package/dist/cjs/client/components/apps-grid.js +3 -2
  10. package/dist/cjs/client/components/ask-ai.js +353 -0
  11. package/dist/cjs/client/components/confirm-dialog.js +40 -0
  12. package/dist/cjs/client/components/cursor-pagination.js +22 -0
  13. package/dist/cjs/client/components/filter-tabs.js +24 -0
  14. package/dist/cjs/client/components/grievance-notice.js +30 -0
  15. package/dist/cjs/client/components/link-button.js +17 -10
  16. package/dist/cjs/client/components/logo.js +4 -3
  17. package/dist/cjs/client/components/nav.js +42 -1
  18. package/dist/cjs/client/components/plan-pricing.js +27 -0
  19. package/dist/cjs/client/components/plans.js +1 -1
  20. package/dist/cjs/client/components/platform-gate.js +48 -0
  21. package/dist/cjs/client/components/shell-context.js +149 -0
  22. package/dist/cjs/client/components/side-nav.js +50 -24
  23. package/dist/cjs/client/components/sidebar.js +127 -0
  24. package/dist/cjs/client/components/subscribe-dialog.js +2 -2
  25. package/dist/cjs/client/components/subscription.js +1 -1
  26. package/dist/cjs/client/components/user-menu.js +122 -40
  27. package/dist/cjs/client/components/workspace-switcher.js +100 -0
  28. package/dist/cjs/client/components/workspace-type-gate.js +48 -0
  29. package/dist/cjs/client/index.js +98 -3
  30. package/dist/cjs/client/provider.js +100 -2
  31. package/dist/cjs/client/use-checkout.js +9 -5
  32. package/dist/cjs/errors.js +1 -1
  33. package/dist/cjs/index.js +11 -1
  34. package/dist/cjs/next/index.js +2 -2
  35. package/dist/cjs/server/client.js +25 -2
  36. package/dist/cjs/server/handler.js +237 -16
  37. package/dist/cjs/server/http.js +52 -26
  38. package/dist/cjs/server/resources/acting-as.js +16 -0
  39. package/dist/cjs/server/resources/ai.js +15 -2
  40. package/dist/cjs/server/resources/data.js +374 -0
  41. package/dist/cjs/server/resources/entitlements.js +41 -0
  42. package/dist/cjs/server/resources/identity.js +9 -3
  43. package/dist/cjs/server/resources/kv.js +84 -0
  44. package/dist/cjs/server/resources/platform.js +43 -0
  45. package/dist/cjs/server/resources/sessions.js +1 -1
  46. package/dist/cjs/server/resources/workspaces.js +74 -0
  47. package/dist/cjs/store.js +152 -0
  48. package/dist/cjs/ui/empty-state.js +28 -0
  49. package/dist/cjs/ui/index.js +44 -0
  50. package/dist/cjs/ui/page-header.js +27 -0
  51. package/dist/cjs/ui/page-shell.js +44 -0
  52. package/dist/cjs/ui/section.js +20 -0
  53. package/dist/cjs/ui/setting-row.js +14 -0
  54. package/dist/cjs/ui/status-chip.js +17 -0
  55. package/dist/esm/cli/doctor.js +3 -3
  56. package/dist/esm/cli/init.js +3 -3
  57. package/dist/esm/client/components/access-gate.js +60 -6
  58. package/dist/esm/client/components/ai-format.js +129 -0
  59. package/dist/esm/client/components/ai-widgets.js +223 -0
  60. package/dist/esm/client/components/app-shell.js +85 -50
  61. package/dist/esm/client/components/apps-grid.js +3 -2
  62. package/dist/esm/client/components/ask-ai.js +350 -0
  63. package/dist/esm/client/components/confirm-dialog.js +37 -0
  64. package/dist/esm/client/components/cursor-pagination.js +19 -0
  65. package/dist/esm/client/components/filter-tabs.js +21 -0
  66. package/dist/esm/client/components/grievance-notice.js +27 -0
  67. package/dist/esm/client/components/link-button.js +17 -10
  68. package/dist/esm/client/components/logo.js +4 -3
  69. package/dist/esm/client/components/nav.js +41 -1
  70. package/dist/esm/client/components/plan-pricing.js +25 -0
  71. package/dist/esm/client/components/plans.js +2 -2
  72. package/dist/esm/client/components/platform-gate.js +45 -0
  73. package/dist/esm/client/components/shell-context.js +141 -0
  74. package/dist/esm/client/components/side-nav.js +49 -22
  75. package/dist/esm/client/components/sidebar.js +120 -0
  76. package/dist/esm/client/components/subscribe-dialog.js +3 -3
  77. package/dist/esm/client/components/subscription.js +2 -2
  78. package/dist/esm/client/components/user-menu.js +121 -41
  79. package/dist/esm/client/components/workspace-switcher.js +97 -0
  80. package/dist/esm/client/components/workspace-type-gate.js +45 -0
  81. package/dist/esm/client/index.js +58 -5
  82. package/dist/esm/client/provider.js +94 -2
  83. package/dist/esm/client/use-checkout.js +9 -5
  84. package/dist/esm/errors.js +1 -1
  85. package/dist/esm/index.js +1 -0
  86. package/dist/esm/next/index.js +2 -2
  87. package/dist/esm/server/client.js +25 -2
  88. package/dist/esm/server/handler.js +237 -16
  89. package/dist/esm/server/http.js +52 -26
  90. package/dist/esm/server/resources/acting-as.js +13 -0
  91. package/dist/esm/server/resources/ai.js +15 -2
  92. package/dist/esm/server/resources/data.js +369 -0
  93. package/dist/esm/server/resources/entitlements.js +41 -0
  94. package/dist/esm/server/resources/identity.js +9 -3
  95. package/dist/esm/server/resources/kv.js +80 -0
  96. package/dist/esm/server/resources/platform.js +39 -0
  97. package/dist/esm/server/resources/sessions.js +1 -1
  98. package/dist/esm/server/resources/workspaces.js +70 -0
  99. package/dist/esm/store.js +141 -0
  100. package/dist/esm/ui/empty-state.js +25 -0
  101. package/dist/esm/ui/index.js +34 -0
  102. package/dist/esm/ui/page-header.js +24 -0
  103. package/dist/esm/ui/page-shell.js +41 -0
  104. package/dist/esm/ui/section.js +17 -0
  105. package/dist/esm/ui/setting-row.js +11 -0
  106. package/dist/esm/ui/status-chip.js +14 -0
  107. package/dist/theme.css +87 -0
  108. package/dist/types/client/components/access-gate.d.ts +17 -3
  109. package/dist/types/client/components/ai-format.d.ts +24 -0
  110. package/dist/types/client/components/ai-widgets.d.ts +42 -0
  111. package/dist/types/client/components/app-shell.d.ts +85 -31
  112. package/dist/types/client/components/apps-grid.d.ts +11 -2
  113. package/dist/types/client/components/ask-ai.d.ts +73 -0
  114. package/dist/types/client/components/confirm-dialog.d.ts +34 -0
  115. package/dist/types/client/components/cursor-pagination.d.ts +27 -0
  116. package/dist/types/client/components/filter-tabs.d.ts +26 -0
  117. package/dist/types/client/components/grievance-notice.d.ts +31 -0
  118. package/dist/types/client/components/link-button.d.ts +32 -9
  119. package/dist/types/client/components/logo.d.ts +18 -2
  120. package/dist/types/client/components/nav.d.ts +27 -3
  121. package/dist/types/client/components/plan-pricing.d.ts +10 -0
  122. package/dist/types/client/components/platform-gate.d.ts +53 -0
  123. package/dist/types/client/components/shell-context.d.ts +116 -0
  124. package/dist/types/client/components/side-nav.d.ts +28 -14
  125. package/dist/types/client/components/sidebar.d.ts +95 -0
  126. package/dist/types/client/components/user-menu.d.ts +70 -23
  127. package/dist/types/client/components/workspace-switcher.d.ts +49 -0
  128. package/dist/types/client/components/workspace-type-gate.d.ts +48 -0
  129. package/dist/types/client/index.d.ts +36 -7
  130. package/dist/types/client/provider.d.ts +149 -3
  131. package/dist/types/client/use-checkout.d.ts +8 -4
  132. package/dist/types/errors.d.ts +1 -1
  133. package/dist/types/index.d.ts +2 -0
  134. package/dist/types/next/index.d.ts +1 -1
  135. package/dist/types/server/client.d.ts +25 -2
  136. package/dist/types/server/http.d.ts +14 -3
  137. package/dist/types/server/index.d.ts +4 -0
  138. package/dist/types/server/resources/acting-as.d.ts +11 -0
  139. package/dist/types/server/resources/ai.d.ts +9 -1
  140. package/dist/types/server/resources/data.d.ts +258 -0
  141. package/dist/types/server/resources/entitlements.d.ts +33 -1
  142. package/dist/types/server/resources/kv.d.ts +63 -0
  143. package/dist/types/server/resources/platform.d.ts +32 -0
  144. package/dist/types/server/resources/workspaces.d.ts +55 -0
  145. package/dist/types/store.d.ts +96 -0
  146. package/dist/types/types.d.ts +491 -13
  147. package/dist/types/ui/empty-state.d.ts +34 -0
  148. package/dist/types/ui/index.d.ts +40 -0
  149. package/dist/types/ui/page-header.d.ts +34 -0
  150. package/dist/types/ui/page-shell.d.ts +39 -0
  151. package/dist/types/ui/section.d.ts +26 -0
  152. package/dist/types/ui/setting-row.d.ts +18 -0
  153. package/dist/types/ui/status-chip.d.ts +36 -0
  154. 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 real `<a>`.
5
+ * A HeroUI `Button` that renders a link.
6
6
  *
7
- * A plain anchor, deliberately not a router link: this package imports nothing
8
- * from `next` (see the `./next` entry's doc comment for why that is a hard
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>` - including a `ref` typed to
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 exactly what a cast is for.
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) => _jsx("a", { ...renderProps, href: href }) }));
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 (collapsed) {
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
- /** Hrefs declared `gateExempt` - the routes `ConnectAppShell` renders outside its `ConnectAccessGate`. */
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) }), _jsxs("span", { className: "text-sm text-muted", children: ["/", plan.billingInterval === "annual" ? "year" : "month"] })] }, 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 ", plan.billingInterval === "annual" ? "annually" : "monthly", "."] }))] }), 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) => (
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