@cosmoops/connect-sdk 0.9.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 +497 -0
- package/README.md +111 -41
- package/dist/cjs/cli/doctor.js +3 -3
- package/dist/cjs/cli/init.js +3 -3
- package/dist/cjs/client/components/access-gate.js +20 -1
- package/dist/cjs/client/components/app-shell.js +75 -41
- package/dist/cjs/client/components/apps-grid.js +3 -2
- package/dist/cjs/client/components/ask-ai.js +20 -1
- 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 +41 -0
- 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 +49 -23
- 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 +96 -37
- package/dist/cjs/client/components/workspace-switcher.js +100 -0
- package/dist/cjs/client/index.js +69 -2
- package/dist/cjs/client/provider.js +57 -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 +196 -18
- package/dist/cjs/server/http.js +6 -2
- 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/identity.js +7 -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 +20 -1
- package/dist/esm/client/components/app-shell.js +81 -47
- package/dist/esm/client/components/apps-grid.js +3 -2
- package/dist/esm/client/components/ask-ai.js +20 -1
- 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 +40 -0
- 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 +48 -21
- 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 +95 -38
- package/dist/esm/client/components/workspace-switcher.js +97 -0
- package/dist/esm/client/index.js +46 -4
- package/dist/esm/client/provider.js +53 -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 +196 -18
- package/dist/esm/server/http.js +6 -2
- 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/identity.js +7 -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/types/client/components/app-shell.d.ts +65 -27
- package/dist/types/client/components/apps-grid.d.ts +11 -2
- 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 +24 -0
- 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 +27 -13
- package/dist/types/client/components/sidebar.d.ts +95 -0
- package/dist/types/client/components/user-menu.d.ts +68 -22
- package/dist/types/client/components/workspace-switcher.d.ts +49 -0
- package/dist/types/client/index.d.ts +27 -5
- package/dist/types/client/provider.d.ts +109 -6
- 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 +8 -2
- 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/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 +319 -15
- 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 +6 -2
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useState, useSyncExternalStore } from "react";
|
|
4
|
-
import { Button, Drawer
|
|
5
|
-
import { Menu
|
|
3
|
+
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { Button, Drawer } from "@heroui/react";
|
|
5
|
+
import { Menu } from "lucide-react";
|
|
6
6
|
import { useConnect } from "../provider.js";
|
|
7
7
|
import { ConnectAccessGate } from "./access-gate.js";
|
|
8
8
|
import { ConnectAppsGrid } from "./apps-grid.js";
|
|
9
9
|
import { ConnectAskAi } from "./ask-ai.js";
|
|
10
10
|
import { ConnectLoginButton, ConnectRegisterButton } from "./auth-buttons.js";
|
|
11
11
|
import { ConnectLogo } from "./logo.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
12
|
+
import { MotionProvider } from "./motion.js";
|
|
13
|
+
import { ConnectSidebar, ConnectSidebarBody, ConnectSidebarFooter, ConnectSidebarHeader, } from "./sidebar.js";
|
|
14
|
+
import { ConnectSidebarNav } from "./side-nav.js";
|
|
15
|
+
import { ConnectShellProvider, defaultRenderLink } from "./shell-context.js";
|
|
16
|
+
import { ConnectWorkspaceSwitcher } from "./workspace-switcher.js";
|
|
17
|
+
import { ConnectUserButton } from "./user-menu.js";
|
|
18
|
+
import { filterNavForAudience, gateExemptPaths, normalizePath, } from "./nav.js";
|
|
15
19
|
import { useActivePath } from "../use-active-path.js";
|
|
16
20
|
const COLLAPSED_KEY = "connect_sidenav_collapsed";
|
|
17
21
|
const COLLAPSED_EVENT = "connect:sidenav-collapsed";
|
|
@@ -34,7 +38,7 @@ function collapsedSnapshot() {
|
|
|
34
38
|
/**
|
|
35
39
|
* No preference on the server, which is the point: `useSyncExternalStore` uses
|
|
36
40
|
* this for the hydrating render and only then swaps in the real reading, so a
|
|
37
|
-
* returning user's
|
|
41
|
+
* returning user's rail state can never make the server's HTML and the
|
|
38
42
|
* client's first pass disagree. Reading storage during render instead is a
|
|
39
43
|
* hydration mismatch waiting for its first user.
|
|
40
44
|
*/
|
|
@@ -52,36 +56,21 @@ function subscribeCollapsed(onStoreChange) {
|
|
|
52
56
|
};
|
|
53
57
|
}
|
|
54
58
|
/**
|
|
55
|
-
* The
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return renderLink({
|
|
59
|
-
href: homeHref,
|
|
60
|
-
className: `flex items-center rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus ${collapsed ? "justify-center" : ""}`,
|
|
61
|
-
// `ConnectLogo` names itself, but it does it with `role="img"` on a
|
|
62
|
-
// `<span>`, which the `<a>` wrapping it does not inherit as its own name.
|
|
63
|
-
"aria-label": `${name} home`,
|
|
64
|
-
children: _jsx(ConnectLogo, { name: name, icon: icon, collapsed: collapsed }),
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* The application frame every CosmoOps application platform runs inside: a
|
|
69
|
-
* collapsible sidebar, a top bar carrying the app launcher and the account
|
|
70
|
-
* menu, and your pages in the middle.
|
|
59
|
+
* The application frame every CosmoOps application platform runs inside: one
|
|
60
|
+
* rail down the side holding the brand, the workspace, the navigation and the
|
|
61
|
+
* account, and your pages in the rest.
|
|
71
62
|
*
|
|
72
63
|
* This is the piece each partner was otherwise writing by hand, and writing
|
|
73
|
-
* slightly differently - which is how a family of applications stops looking
|
|
74
|
-
* one product. Everything that should be identical across platforms
|
|
75
|
-
*
|
|
76
|
-
* account menu, the access gate) lives here; everything that genuinely
|
|
77
|
-
* differs is a prop:
|
|
64
|
+
* slightly differently - which is how a family of applications stops looking
|
|
65
|
+
* like one product. Everything that should be identical across platforms
|
|
66
|
+
* lives here; everything that genuinely differs is a prop:
|
|
78
67
|
*
|
|
79
68
|
* ```tsx
|
|
80
69
|
* "use client";
|
|
81
70
|
* import Link from "next/link";
|
|
82
71
|
* import { usePathname } from "next/navigation";
|
|
83
72
|
* import { AppShell } from "@cosmoops/connect-sdk/client";
|
|
84
|
-
* import { LayoutDashboard, Truck } from "lucide-react";
|
|
73
|
+
* import { LayoutDashboard, Settings, Truck } from "lucide-react";
|
|
85
74
|
*
|
|
86
75
|
* export function Shell({ children }: { children: React.ReactNode }) {
|
|
87
76
|
* return (
|
|
@@ -92,7 +81,10 @@ function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
|
|
|
92
81
|
* renderLink={(props) => <Link {...props} />}
|
|
93
82
|
* nav={[
|
|
94
83
|
* { label: "Overview", href: "/", icon: <LayoutDashboard className="size-5" /> },
|
|
95
|
-
* { label: "
|
|
84
|
+
* { label: "Shipments", href: "/shipments", icon: <Truck className="size-5" /> },
|
|
85
|
+
* ]}
|
|
86
|
+
* secondaryNav={[
|
|
87
|
+
* { label: "Settings", href: "/settings", icon: <Settings className="size-5" /> },
|
|
96
88
|
* ]}
|
|
97
89
|
* >
|
|
98
90
|
* {children}
|
|
@@ -101,16 +93,36 @@ function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
|
|
|
101
93
|
* }
|
|
102
94
|
* ```
|
|
103
95
|
*
|
|
96
|
+
* **There is no top bar.** There used to be one, carrying a page title, the
|
|
97
|
+
* launcher and the account menu - and it was a second, half-width strip of
|
|
98
|
+
* chrome saying things the rail was better placed to say. The launcher and
|
|
99
|
+
* the account row moved into the rail's footer, where they are one press from
|
|
100
|
+
* wherever the pointer already is; the page title moved into the page, where
|
|
101
|
+
* `ConnectPageShell` renders it next to that page's own actions and
|
|
102
|
+
* breadcrumbs. What is left is a single column of chrome and a full-height
|
|
103
|
+
* content pane, which is what every application this shell is modelled on
|
|
104
|
+
* settled at.
|
|
105
|
+
*
|
|
106
|
+
* The one exception is below `md`, where a slim bar carries the drawer
|
|
107
|
+
* trigger. A phone has no rail to put it in, and a navigation you cannot open
|
|
108
|
+
* is not a smaller navigation.
|
|
109
|
+
*
|
|
110
|
+
* **The rail starts collapsed.** `defaultCollapsed` is `true`: the icon rail
|
|
111
|
+
* is the resting state, and expanding is the deliberate act. That is the right
|
|
112
|
+
* default because the rail's job is to be *there*, not to be read - a person
|
|
113
|
+
* learns five glyphs in a day and then wants the 200px back for their data.
|
|
114
|
+
* A stored preference from the user's last visit always wins over it.
|
|
115
|
+
*
|
|
104
116
|
* There is no `isSignedIn` branch anywhere in here, and there should be none
|
|
105
|
-
* in yours: the auth buttons render nothing once a session exists, the
|
|
106
|
-
*
|
|
117
|
+
* in yours: the auth buttons render nothing once a session exists, the account
|
|
118
|
+
* row renders nothing without one, and the launcher renders nothing when
|
|
107
119
|
* signed out. The swap happens inside the components.
|
|
108
120
|
*
|
|
109
121
|
* It renders no provider of its own - mount `ConnectProvider` above it, once,
|
|
110
122
|
* so a page with a shell, a plan table and a usage meter still makes one
|
|
111
123
|
* session read.
|
|
112
124
|
*/
|
|
113
|
-
export function AppShell({ name, icon, homeHref = "/", nav, activePath, renderLink = defaultRenderLink,
|
|
125
|
+
export function AppShell({ name, icon, homeHref = "/", nav, secondaryNav, activePath, renderLink = defaultRenderLink, sidebarFooter, workspaceSwitcher, footer, defaultCollapsed = true, collapsed: controlledCollapsed, onCollapsedChange, collapsible = true, showAppsGrid = true, showAuthButtons = true, theme, onThemeChange, gate = true, gateExemptRoutes, askAi = true, className, contentClassName, children, }) {
|
|
114
126
|
const { session, isLoading } = useConnect();
|
|
115
127
|
const pathname = useActivePath(activePath);
|
|
116
128
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
@@ -120,8 +132,7 @@ export function AppShell({ name, icon, homeHref = "/", nav, activePath, renderLi
|
|
|
120
132
|
const stored = useSyncExternalStore(subscribeCollapsed, collapsedSnapshot, serverCollapsedSnapshot);
|
|
121
133
|
const isControlled = controlledCollapsed !== undefined;
|
|
122
134
|
const collapsed = collapsible ? (isControlled ? controlledCollapsed : (stored ?? defaultCollapsed)) : false;
|
|
123
|
-
const
|
|
124
|
-
const next = !collapsed;
|
|
135
|
+
const setCollapsed = useCallback((next) => {
|
|
125
136
|
onCollapsedChange?.(next);
|
|
126
137
|
if (isControlled)
|
|
127
138
|
return;
|
|
@@ -132,24 +143,47 @@ export function AppShell({ name, icon, homeHref = "/", nav, activePath, renderLi
|
|
|
132
143
|
// Not persisted - but the event below still moves the rail for this session.
|
|
133
144
|
}
|
|
134
145
|
window.dispatchEvent(new Event(COLLAPSED_EVENT));
|
|
135
|
-
}, [
|
|
146
|
+
}, [isControlled, onCollapsedChange]);
|
|
136
147
|
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
|
148
|
+
// Connect platform staff see a different set of routes - see
|
|
149
|
+
// `ConnectNavItem.audience`. Derived from the session rather than passed in,
|
|
150
|
+
// so a host app never has to thread "is this an operator" through its own
|
|
151
|
+
// layout to get the right rail.
|
|
152
|
+
const isStaff = session?.staff === true;
|
|
153
|
+
const visibleNav = useMemo(() => filterNavForAudience(nav, isStaff), [nav, isStaff]);
|
|
154
|
+
const visibleSecondaryNav = useMemo(() => (secondaryNav ? filterNavForAudience(secondaryNav, isStaff) : undefined), [secondaryNav, isStaff]);
|
|
137
155
|
const currentPath = normalizePath(pathname);
|
|
138
|
-
|
|
156
|
+
// Computed from the *unfiltered* nav on purpose: `gateExempt` is a fact
|
|
157
|
+
// about a route, not about who can see it, and reading it from the filtered
|
|
158
|
+
// list would quietly re-gate a route the moment it became staff-only. Both
|
|
159
|
+
// navs contribute - a `gateExempt` "Plans" route is just as often in the
|
|
160
|
+
// secondary group as the primary one.
|
|
161
|
+
const exempt = [
|
|
162
|
+
...gateExemptPaths(nav),
|
|
163
|
+
...(secondaryNav ? gateExemptPaths(secondaryNav) : []),
|
|
164
|
+
...(gateExemptRoutes ?? []).map(normalizePath),
|
|
165
|
+
];
|
|
139
166
|
const isExempt = exempt.some((path) => currentPath === path || currentPath.startsWith(`${path}/`));
|
|
140
167
|
// A signed-out visitor has no readiness to check and nothing to be blocked
|
|
141
168
|
// by; `ConnectAccessGate` passes them straight through, but mounting it at
|
|
142
169
|
// all costs an entitlements read that cannot succeed.
|
|
143
170
|
const signedOut = !isLoading && !session?.user;
|
|
144
171
|
const gated = gate && !isExempt && !signedOut;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
172
|
+
// Two shell contexts, one set of children. The drawer's is always expanded
|
|
173
|
+
// and closes itself on navigation; the rail's carries the real collapse
|
|
174
|
+
// state. Building them here rather than inside the sidebar is what lets the
|
|
175
|
+
// same `railContent` render in both places with no `mobile` prop threaded
|
|
176
|
+
// through every part.
|
|
177
|
+
const railShell = useMemo(() => ({ collapsed, collapsible, setCollapsed, renderLink, activePath }), [collapsed, collapsible, setCollapsed, renderLink, activePath]);
|
|
178
|
+
const drawerShell = useMemo(() => ({
|
|
179
|
+
collapsed: false,
|
|
180
|
+
collapsible: false,
|
|
181
|
+
setCollapsed: () => { },
|
|
182
|
+
renderLink,
|
|
183
|
+
activePath,
|
|
184
|
+
onNavigate: closeMobile,
|
|
185
|
+
}), [renderLink, activePath, closeMobile]);
|
|
186
|
+
const accountRow = signedOut ? (showAuthButtons && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ConnectLoginButton, { size: "sm", fullWidth: true }), _jsx(ConnectRegisterButton, { size: "sm", fullWidth: true })] }))) : (_jsx(ConnectUserButton, { variant: "row", theme: theme, onThemeChange: onThemeChange }));
|
|
187
|
+
const railContent = (_jsxs(_Fragment, { children: [_jsx(ConnectSidebarHeader, { name: name, icon: icon, homeHref: homeHref }), workspaceSwitcher !== false && (_jsx("div", { className: "shrink-0 px-3 pb-1", children: _jsx(ConnectWorkspaceSwitcher, {}) })), _jsx(ConnectSidebarBody, { children: _jsx(ConnectSidebarNav, { nav: visibleNav }) }), (visibleSecondaryNav || sidebarFooter || showAppsGrid) && (_jsxs(ConnectSidebarFooter, { separated: false, children: [visibleSecondaryNav && _jsx(ConnectSidebarNav, { nav: visibleSecondaryNav }), sidebarFooter, showAppsGrid && _jsx(ConnectAppsGrid, { variant: "row" })] })), accountRow && _jsx(ConnectSidebarFooter, { children: accountRow })] }));
|
|
188
|
+
return (_jsx(MotionProvider, { children: _jsxs("div", { className: `flex h-screen bg-background ${className ?? ""}`, children: [_jsx(ConnectShellProvider, { value: railShell, children: _jsx(ConnectSidebar, { children: railContent }) }), _jsx(Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: _jsx(Drawer.Content, { placement: "left", className: "w-[min(20rem,85vw)]", children: _jsx(Drawer.Dialog, { "aria-label": "Navigation menu", className: "p-0", children: _jsx(ConnectShellProvider, { value: drawerShell, children: _jsx("div", { className: "flex h-full flex-col", children: railContent }) }) }) }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border bg-surface px-3 md:hidden", children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: _jsx(Menu, { className: "size-5" }) }), _jsx(ConnectLogo, { name: name, icon: icon })] }), _jsx("main", { className: "flex-1 overflow-y-auto", children: _jsxs("div", { className: contentClassName ?? "mx-auto w-full max-w-6xl px-4 py-6 md:px-8 md:py-8", children: [gated ? _jsx(ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && _jsx(ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }) }));
|
|
155
189
|
}
|
|
@@ -4,6 +4,7 @@ import { Button, Modal, Skeleton } from "@heroui/react";
|
|
|
4
4
|
import { AlertCircle, ExternalLink, LayoutGrid } from "lucide-react";
|
|
5
5
|
import { useConnectApps, useConnect } from "../provider.js";
|
|
6
6
|
import { ENTER, m, MotionProvider } from "./motion.js";
|
|
7
|
+
import { SidebarLabel, sidebarRowClass } from "./shell-context.js";
|
|
7
8
|
function AppTile({ app }) {
|
|
8
9
|
const disabled = app.launchUrl === null;
|
|
9
10
|
const body = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "relative flex size-12 items-center justify-center overflow-hidden rounded-xl bg-surface-secondary", children: [app.iconUrl ? (
|
|
@@ -30,13 +31,13 @@ function AppTile({ app }) {
|
|
|
30
31
|
* Renders nothing when signed out: a launcher with nothing to launch is just a
|
|
31
32
|
* button that opens an empty box.
|
|
32
33
|
*/
|
|
33
|
-
export function ConnectAppsGrid({ className, label = "Switch app" }) {
|
|
34
|
+
export function ConnectAppsGrid({ className, label = "Switch app", variant = "row" }) {
|
|
34
35
|
const { session, isLoading: sessionLoading } = useConnect();
|
|
35
36
|
const { data, isLoading, error } = useConnectApps();
|
|
36
37
|
if (sessionLoading || !session?.user)
|
|
37
38
|
return null;
|
|
38
39
|
const apps = data?.apps ?? [];
|
|
39
|
-
return (_jsx(MotionProvider, { children: _jsxs(Modal, { children: [_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: _jsx(LayoutGrid, { className: "size-5" }) }), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-md", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Your apps" }) }), _jsxs(Modal.Body, { children: [isLoading && (_jsx("div", { className: "grid grid-cols-3 gap-2", children: Array.from({ length: 6 }, (_, index) => (_jsxs("div", { className: "flex flex-col items-center gap-2 p-3", children: [_jsx(Skeleton, { className: "size-12 rounded-xl" }), _jsx(Skeleton, { className: "h-3 w-14" })] }, index))) })), !isLoading && error && (_jsx("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), !isLoading && !error && apps.length === 0 && (_jsx("p", { className: "py-6 text-center text-sm text-muted", children: "No other apps yet. Anything this workspace subscribes to shows up here." })), !isLoading && !error && apps.length > 0 && (_jsx(m.div, { className: "grid grid-cols-3 gap-2", variants: { show: { transition: { staggerChildren: 0.04 } } }, initial: "hidden", animate: "show", children: apps.map((app) => (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsx(AppTile, { app: app }) }, app.applicationId))) }))] }), _jsx(Modal.Footer, { children: _jsx(ConnectBrowseAllLink, {}) })] }) }) })] }) }));
|
|
40
|
+
return (_jsx(MotionProvider, { children: _jsxs(Modal, { children: [variant === "row" ? (_jsxs(Button, { variant: "tertiary", "aria-label": label, className: sidebarRowClass({ className }), children: [_jsx("span", { className: "flex size-5 shrink-0 items-center justify-center", children: _jsx(LayoutGrid, { className: "size-5" }) }), _jsx(SidebarLabel, { children: label })] })) : (_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: _jsx(LayoutGrid, { className: "size-5" }) })), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-md", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Your apps" }) }), _jsxs(Modal.Body, { children: [isLoading && (_jsx("div", { className: "grid grid-cols-3 gap-2", children: Array.from({ length: 6 }, (_, index) => (_jsxs("div", { className: "flex flex-col items-center gap-2 p-3", children: [_jsx(Skeleton, { className: "size-12 rounded-xl" }), _jsx(Skeleton, { className: "h-3 w-14" })] }, index))) })), !isLoading && error && (_jsx("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), !isLoading && !error && apps.length === 0 && (_jsx("p", { className: "py-6 text-center text-sm text-muted", children: "No other apps yet. Anything this workspace subscribes to shows up here." })), !isLoading && !error && apps.length > 0 && (_jsx(m.div, { className: "grid grid-cols-3 gap-2", variants: { show: { transition: { staggerChildren: 0.04 } } }, initial: "hidden", animate: "show", children: apps.map((app) => (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsx(AppTile, { app: app }) }, app.applicationId))) }))] }), _jsx(Modal.Footer, { children: _jsx(ConnectBrowseAllLink, {}) })] }) }) })] }) }));
|
|
40
41
|
}
|
|
41
42
|
function ConnectBrowseAllLink() {
|
|
42
43
|
const { appUrl } = useConnect();
|
|
@@ -6,7 +6,26 @@ import { Brain, ChevronDown, Send, Sparkles, Square, X } from "lucide-react";
|
|
|
6
6
|
import { Streamdown } from "streamdown";
|
|
7
7
|
import { useConnect } from "../provider.js";
|
|
8
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
|
+
*/
|
|
9
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
|
+
}
|
|
10
29
|
/**
|
|
11
30
|
* `blurIn` over the default `fadeIn`: Gemini arrives in bursts rather than an
|
|
12
31
|
* even token drip, and a blur masks a batch landing at once where a plain
|
|
@@ -325,7 +344,7 @@ export function ConnectAskAi({ title = "Ask AI", assistantName = "Assistant", pl
|
|
|
325
344
|
setInput("");
|
|
326
345
|
void send(text);
|
|
327
346
|
};
|
|
328
|
-
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 ${
|
|
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" }) }))] })] }) })] }));
|
|
329
348
|
}
|
|
330
349
|
/** Hoisted for the same reference-equality reason as `ANIMATED` - Streamdown compares it by identity. */
|
|
331
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,6 +81,46 @@ export function isNavItemActive(item, activeHref) {
|
|
|
81
81
|
return false;
|
|
82
82
|
return normalizePath(item.href) === activeHref;
|
|
83
83
|
}
|
|
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
|
+
}
|
|
84
124
|
/** Hrefs declared `gateExempt` - the routes `AppShell` renders outside its `ConnectAccessGate`. */
|
|
85
125
|
export function gateExemptPaths(nav) {
|
|
86
126
|
return flattenNav(nav)
|
|
@@ -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
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useConnectPlatformAccess } from "../provider.js";
|
|
4
|
+
/**
|
|
5
|
+
* Renders `children` only for a Connect platform staff session.
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <ConnectPlatformGate>
|
|
9
|
+
* <LinkMasterConsole />
|
|
10
|
+
* </ConnectPlatformGate>
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Staff are Connect's own operators - `platform_admin`, `platform_manager`,
|
|
14
|
+
* `developer` - signing into *your* application. They belong to no workspace,
|
|
15
|
+
* so a staff session carries no `workspaceId`, no subscription and no member
|
|
16
|
+
* role; see `SessionAccess.staff`. Staff access is off by default and turned on
|
|
17
|
+
* per application by a Connect platform admin, so this gate renders nothing
|
|
18
|
+
* until that happens.
|
|
19
|
+
*
|
|
20
|
+
* **This is a UX gate, not a security boundary** - the same caveat every gate
|
|
21
|
+
* in this package carries, and it matters more here than anywhere else,
|
|
22
|
+
* because what is behind this one is usually an operator console. The browser
|
|
23
|
+
* can ignore this component entirely. Every route it reveals must re-check
|
|
24
|
+
* `staff` on your own server:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* const { staff, role } = await client.identity.getUser(sessionToken);
|
|
28
|
+
* if (!staff) return forbidden();
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* Renders `loading` - not `fallback` - while the session is unknown, so an
|
|
32
|
+
* operator never flickers through the customer layout on the way in.
|
|
33
|
+
*/
|
|
34
|
+
export function ConnectPlatformGate({ roles, children, fallback = null, loading = null, }) {
|
|
35
|
+
const { isStaff, roles: held, isLoading } = useConnectPlatformAccess();
|
|
36
|
+
if (isLoading)
|
|
37
|
+
return _jsx(_Fragment, { children: loading });
|
|
38
|
+
if (!isStaff)
|
|
39
|
+
return _jsx(_Fragment, { children: fallback });
|
|
40
|
+
// An empty `roles` array is treated as "no constraint", same as omitting it.
|
|
41
|
+
// The alternative - refusing everyone - would turn a `roles={[]}` computed
|
|
42
|
+
// from an empty config into a silently dead console.
|
|
43
|
+
const accepted = !roles || roles.length === 0 || roles.some((role) => held.includes(role));
|
|
44
|
+
return _jsx(_Fragment, { children: accepted ? children : fallback });
|
|
45
|
+
}
|