@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.
Files changed (139) hide show
  1. package/CHANGELOG.md +497 -0
  2. package/README.md +111 -41
  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 +20 -1
  6. package/dist/cjs/client/components/app-shell.js +75 -41
  7. package/dist/cjs/client/components/apps-grid.js +3 -2
  8. package/dist/cjs/client/components/ask-ai.js +20 -1
  9. package/dist/cjs/client/components/confirm-dialog.js +40 -0
  10. package/dist/cjs/client/components/cursor-pagination.js +22 -0
  11. package/dist/cjs/client/components/filter-tabs.js +24 -0
  12. package/dist/cjs/client/components/grievance-notice.js +30 -0
  13. package/dist/cjs/client/components/link-button.js +17 -10
  14. package/dist/cjs/client/components/logo.js +4 -3
  15. package/dist/cjs/client/components/nav.js +41 -0
  16. package/dist/cjs/client/components/plan-pricing.js +27 -0
  17. package/dist/cjs/client/components/plans.js +1 -1
  18. package/dist/cjs/client/components/platform-gate.js +48 -0
  19. package/dist/cjs/client/components/shell-context.js +149 -0
  20. package/dist/cjs/client/components/side-nav.js +49 -23
  21. package/dist/cjs/client/components/sidebar.js +127 -0
  22. package/dist/cjs/client/components/subscribe-dialog.js +2 -2
  23. package/dist/cjs/client/components/subscription.js +1 -1
  24. package/dist/cjs/client/components/user-menu.js +96 -37
  25. package/dist/cjs/client/components/workspace-switcher.js +100 -0
  26. package/dist/cjs/client/index.js +69 -2
  27. package/dist/cjs/client/provider.js +57 -2
  28. package/dist/cjs/client/use-checkout.js +9 -5
  29. package/dist/cjs/errors.js +1 -1
  30. package/dist/cjs/index.js +11 -1
  31. package/dist/cjs/next/index.js +2 -2
  32. package/dist/cjs/server/client.js +25 -2
  33. package/dist/cjs/server/handler.js +196 -18
  34. package/dist/cjs/server/http.js +6 -2
  35. package/dist/cjs/server/resources/acting-as.js +16 -0
  36. package/dist/cjs/server/resources/ai.js +15 -2
  37. package/dist/cjs/server/resources/data.js +374 -0
  38. package/dist/cjs/server/resources/identity.js +7 -3
  39. package/dist/cjs/server/resources/kv.js +84 -0
  40. package/dist/cjs/server/resources/platform.js +43 -0
  41. package/dist/cjs/server/resources/sessions.js +1 -1
  42. package/dist/cjs/server/resources/workspaces.js +74 -0
  43. package/dist/cjs/store.js +152 -0
  44. package/dist/cjs/ui/empty-state.js +28 -0
  45. package/dist/cjs/ui/index.js +44 -0
  46. package/dist/cjs/ui/page-header.js +27 -0
  47. package/dist/cjs/ui/page-shell.js +44 -0
  48. package/dist/cjs/ui/section.js +20 -0
  49. package/dist/cjs/ui/setting-row.js +14 -0
  50. package/dist/cjs/ui/status-chip.js +17 -0
  51. package/dist/esm/cli/doctor.js +3 -3
  52. package/dist/esm/cli/init.js +3 -3
  53. package/dist/esm/client/components/access-gate.js +20 -1
  54. package/dist/esm/client/components/app-shell.js +81 -47
  55. package/dist/esm/client/components/apps-grid.js +3 -2
  56. package/dist/esm/client/components/ask-ai.js +20 -1
  57. package/dist/esm/client/components/confirm-dialog.js +37 -0
  58. package/dist/esm/client/components/cursor-pagination.js +19 -0
  59. package/dist/esm/client/components/filter-tabs.js +21 -0
  60. package/dist/esm/client/components/grievance-notice.js +27 -0
  61. package/dist/esm/client/components/link-button.js +17 -10
  62. package/dist/esm/client/components/logo.js +4 -3
  63. package/dist/esm/client/components/nav.js +40 -0
  64. package/dist/esm/client/components/plan-pricing.js +25 -0
  65. package/dist/esm/client/components/plans.js +2 -2
  66. package/dist/esm/client/components/platform-gate.js +45 -0
  67. package/dist/esm/client/components/shell-context.js +141 -0
  68. package/dist/esm/client/components/side-nav.js +48 -21
  69. package/dist/esm/client/components/sidebar.js +120 -0
  70. package/dist/esm/client/components/subscribe-dialog.js +3 -3
  71. package/dist/esm/client/components/subscription.js +2 -2
  72. package/dist/esm/client/components/user-menu.js +95 -38
  73. package/dist/esm/client/components/workspace-switcher.js +97 -0
  74. package/dist/esm/client/index.js +46 -4
  75. package/dist/esm/client/provider.js +53 -2
  76. package/dist/esm/client/use-checkout.js +9 -5
  77. package/dist/esm/errors.js +1 -1
  78. package/dist/esm/index.js +1 -0
  79. package/dist/esm/next/index.js +2 -2
  80. package/dist/esm/server/client.js +25 -2
  81. package/dist/esm/server/handler.js +196 -18
  82. package/dist/esm/server/http.js +6 -2
  83. package/dist/esm/server/resources/acting-as.js +13 -0
  84. package/dist/esm/server/resources/ai.js +15 -2
  85. package/dist/esm/server/resources/data.js +369 -0
  86. package/dist/esm/server/resources/identity.js +7 -3
  87. package/dist/esm/server/resources/kv.js +80 -0
  88. package/dist/esm/server/resources/platform.js +39 -0
  89. package/dist/esm/server/resources/sessions.js +1 -1
  90. package/dist/esm/server/resources/workspaces.js +70 -0
  91. package/dist/esm/store.js +141 -0
  92. package/dist/esm/ui/empty-state.js +25 -0
  93. package/dist/esm/ui/index.js +34 -0
  94. package/dist/esm/ui/page-header.js +24 -0
  95. package/dist/esm/ui/page-shell.js +41 -0
  96. package/dist/esm/ui/section.js +17 -0
  97. package/dist/esm/ui/setting-row.js +11 -0
  98. package/dist/esm/ui/status-chip.js +14 -0
  99. package/dist/types/client/components/app-shell.d.ts +65 -27
  100. package/dist/types/client/components/apps-grid.d.ts +11 -2
  101. package/dist/types/client/components/confirm-dialog.d.ts +34 -0
  102. package/dist/types/client/components/cursor-pagination.d.ts +27 -0
  103. package/dist/types/client/components/filter-tabs.d.ts +26 -0
  104. package/dist/types/client/components/grievance-notice.d.ts +31 -0
  105. package/dist/types/client/components/link-button.d.ts +32 -9
  106. package/dist/types/client/components/logo.d.ts +18 -2
  107. package/dist/types/client/components/nav.d.ts +24 -0
  108. package/dist/types/client/components/plan-pricing.d.ts +10 -0
  109. package/dist/types/client/components/platform-gate.d.ts +53 -0
  110. package/dist/types/client/components/shell-context.d.ts +116 -0
  111. package/dist/types/client/components/side-nav.d.ts +27 -13
  112. package/dist/types/client/components/sidebar.d.ts +95 -0
  113. package/dist/types/client/components/user-menu.d.ts +68 -22
  114. package/dist/types/client/components/workspace-switcher.d.ts +49 -0
  115. package/dist/types/client/index.d.ts +27 -5
  116. package/dist/types/client/provider.d.ts +109 -6
  117. package/dist/types/client/use-checkout.d.ts +8 -4
  118. package/dist/types/errors.d.ts +1 -1
  119. package/dist/types/index.d.ts +2 -0
  120. package/dist/types/next/index.d.ts +1 -1
  121. package/dist/types/server/client.d.ts +25 -2
  122. package/dist/types/server/http.d.ts +8 -2
  123. package/dist/types/server/index.d.ts +4 -0
  124. package/dist/types/server/resources/acting-as.d.ts +11 -0
  125. package/dist/types/server/resources/ai.d.ts +9 -1
  126. package/dist/types/server/resources/data.d.ts +258 -0
  127. package/dist/types/server/resources/kv.d.ts +63 -0
  128. package/dist/types/server/resources/platform.d.ts +32 -0
  129. package/dist/types/server/resources/workspaces.d.ts +55 -0
  130. package/dist/types/store.d.ts +96 -0
  131. package/dist/types/types.d.ts +319 -15
  132. package/dist/types/ui/empty-state.d.ts +34 -0
  133. package/dist/types/ui/index.d.ts +40 -0
  134. package/dist/types/ui/page-header.d.ts +34 -0
  135. package/dist/types/ui/page-shell.d.ts +39 -0
  136. package/dist/types/ui/section.d.ts +26 -0
  137. package/dist/types/ui/setting-row.d.ts +18 -0
  138. package/dist/types/ui/status-chip.d.ts +36 -0
  139. 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, Tooltip } from "@heroui/react";
5
- import { Menu, PanelLeftClose, PanelLeftOpen } from "lucide-react";
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 { ConnectSideNav, defaultRenderLink } from "./side-nav.js";
13
- import { ConnectUserMenu } from "./user-menu.js";
14
- import { gateExemptPaths, normalizePath, } from "./nav.js";
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 collapsed rail can never make the server's HTML and the
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 sidebar's brand row: the lockup when expanded, the badge alone when not.
56
- */
57
- function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
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 like
74
- * one product. Everything that should be identical across platforms (the
75
- * lockup, the rail geometry, the active-route treatment, the launcher, the
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: "Plans", href: "/plans", icon: <Truck className="size-5" />, gateExempt: true },
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 user
106
- * menu renders nothing without one, and the launcher renders nothing when
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, title, headerActions, sidebarFooter, footer, defaultCollapsed = false, collapsed: controlledCollapsed, onCollapsedChange, collapsible = true, showAppsGrid = true, showAuthButtons = true, theme, onThemeChange, gate = true, gateExemptRoutes, askAi = true, className, contentClassName, children, }) {
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 toggleCollapsed = useCallback(() => {
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
- }, [collapsed, isControlled, onCollapsedChange]);
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
- const exempt = [...gateExemptPaths(nav), ...(gateExemptRoutes ?? []).map(normalizePath)];
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
- const sideNav = (mobile) => (_jsx(ConnectSideNav, { nav: nav, activePath: pathname, renderLink: renderLink, collapsed: mobile ? false : collapsed, onNavigate: mobile ? closeMobile : undefined }));
146
- return (_jsxs("div", { className: `flex h-screen bg-background ${className ?? ""}`, children: [_jsxs("aside", { "aria-label": "Sidebar", className: `hidden shrink-0 flex-col border-r border-border bg-surface transition-[width] duration-200 ease-in-out will-change-[width] md:flex ${collapsed ? "w-16" : "w-64"}`, children: [_jsx("div", { className: `flex h-16 shrink-0 items-center border-b border-border ${collapsed ? "justify-center px-2" : "px-4"}`, children: _jsx(BrandLink, { name: name, icon: icon, homeHref: homeHref, collapsed: collapsed, renderLink: renderLink }) }), _jsx("div", { className: `flex-1 overflow-y-auto py-4 ${collapsed ? "px-2" : "px-3"}`, children: sideNav(false) }), _jsxs("div", { className: `shrink-0 border-t border-border ${collapsed ? "flex flex-col items-center gap-2 p-2" : "flex flex-col gap-2 p-3"}`, children: [!collapsed && sidebarFooter, collapsible && (
147
- // The button goes in as a *direct* child, the way `Modal` and
148
- // `Dropdown` take theirs. `Tooltip.Trigger` renders a wrapper
149
- // `<div>` and puts the props it is given on that div - so an
150
- // `aria-label` passed there names a div nobody can reach and
151
- // leaves this icon-only button with no accessible name at all.
152
- // The wrapper is for trigger elements React Aria cannot drive
153
- // directly; a HeroUI `Button` is not one.
154
- _jsxs(Tooltip, { delay: 200, children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": collapsed ? "Expand sidebar" : "Collapse sidebar", className: collapsed ? "" : "self-start", onPress: toggleCollapsed, children: collapsed ? _jsx(PanelLeftOpen, { className: "size-4" }) : _jsx(PanelLeftClose, { className: "size-4" }) }), _jsx(Tooltip.Content, { placement: "right", children: collapsed ? "Expand sidebar" : "Collapse sidebar" })] }))] })] }), _jsx(Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: _jsx(Drawer.Content, { placement: "left", className: "w-72 max-w-[80vw]", children: _jsxs(Drawer.Dialog, { "aria-label": "Navigation menu", children: [_jsx(Drawer.CloseTrigger, {}), _jsx(Drawer.Header, { children: _jsx(Drawer.Heading, { "aria-label": name, children: _jsx(ConnectLogo, { name: name, icon: icon }) }) }), _jsx(Drawer.Body, { children: sideNav(true) }), sidebarFooter && _jsx(Drawer.Footer, { className: "flex flex-col items-stretch gap-3", children: sidebarFooter })] }) }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex h-16 shrink-0 items-center gap-3 border-b border-border bg-surface px-4 md:px-6", children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", className: "md:hidden", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: _jsx(Menu, { className: "size-5" }) }), typeof title === "string" ? (_jsx("h1", { className: "truncate text-lg font-semibold text-foreground", children: title })) : (title), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [headerActions, showAppsGrid && _jsx(ConnectAppsGrid, {}), _jsx(ConnectUserMenu, { compact: true, theme: theme, onThemeChange: onThemeChange }), showAuthButtons && (_jsxs(_Fragment, { children: [_jsx(ConnectLoginButton, { size: "sm" }), _jsx(ConnectRegisterButton, { size: "sm" })] }))] })] }), _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-6 md:py-8", children: [gated ? _jsx(ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && _jsx(ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }));
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 ${PANEL_WIDTH} ${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] ${isOpen ? "" : "lg:w-0 lg:overflow-hidden lg:border-s-0"}`, 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" }) }))] })] }) })] }));
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 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,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) }), _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
@@ -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
+ }