@cosmoops/connect-sdk 0.3.0 → 0.7.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 +125 -0
- package/README.md +215 -6
- package/dist/cjs/cli/doctor.js +3 -3
- package/dist/cjs/cli/init.js +3 -3
- package/dist/cjs/client/components/access-gate.js +162 -0
- package/dist/cjs/client/components/account-sessions.js +24 -22
- package/dist/cjs/client/components/app-shell.js +157 -0
- package/dist/cjs/client/components/apps-grid.js +4 -3
- package/dist/cjs/client/components/feature-gate.js +2 -2
- package/dist/cjs/client/components/integrations-modal.js +95 -0
- package/dist/cjs/client/components/logo.js +3 -3
- package/dist/cjs/client/components/motion.js +27 -0
- package/dist/cjs/client/components/nav.js +97 -0
- package/dist/cjs/client/components/plan-pricing.js +1 -1
- package/dist/cjs/client/components/plans.js +42 -40
- package/dist/cjs/client/components/readiness.js +6 -4
- package/dist/cjs/client/components/side-nav.js +58 -0
- package/dist/cjs/client/components/subscribe-dialog.js +2 -2
- package/dist/cjs/client/components/subscription.js +3 -2
- package/dist/cjs/client/components/surfaces.js +54 -0
- package/dist/cjs/client/components/usage-meter.js +4 -3
- package/dist/cjs/client/components/user-menu.js +33 -6
- package/dist/cjs/client/index.js +30 -2
- package/dist/cjs/client/mark.js +1 -1
- package/dist/cjs/client/provider.js +60 -8
- package/dist/cjs/client/use-active-path.js +77 -0
- package/dist/cjs/client/use-checkout.js +3 -3
- package/dist/cjs/errors.js +4 -4
- package/dist/cjs/helpers.js +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/next/index.js +96 -15
- package/dist/cjs/server/client.js +22 -14
- package/dist/cjs/server/handler.js +99 -16
- package/dist/cjs/server/http.js +1 -1
- package/dist/cjs/server/index.js +8 -1
- package/dist/cjs/server/resources/access.js +3 -3
- package/dist/cjs/server/resources/ai.js +1 -1
- package/dist/cjs/server/resources/analytics.js +66 -0
- package/dist/cjs/server/resources/apps.js +4 -4
- package/dist/cjs/server/resources/billing.js +5 -5
- package/dist/cjs/server/resources/drive.js +4 -4
- package/dist/cjs/server/resources/entitlements.js +7 -7
- package/dist/cjs/server/resources/identity.js +4 -4
- package/dist/cjs/server/resources/integrations.js +66 -0
- package/dist/cjs/server/resources/logs.js +3 -3
- package/dist/cjs/server/resources/sessions.js +6 -6
- package/dist/cjs/server/webhooks.js +127 -0
- package/dist/cjs/types.js +1 -1
- package/dist/esm/cli/doctor.js +3 -3
- package/dist/esm/cli/init.js +3 -3
- package/dist/esm/client/components/access-gate.js +158 -0
- package/dist/esm/client/components/account-sessions.js +24 -22
- package/dist/esm/client/components/app-shell.js +154 -0
- package/dist/esm/client/components/apps-grid.js +4 -3
- package/dist/esm/client/components/feature-gate.js +2 -2
- package/dist/esm/client/components/integrations-modal.js +92 -0
- package/dist/esm/client/components/logo.js +3 -3
- package/dist/esm/client/components/motion.js +21 -0
- package/dist/esm/client/components/nav.js +89 -0
- package/dist/esm/client/components/plan-pricing.js +1 -1
- package/dist/esm/client/components/plans.js +42 -40
- package/dist/esm/client/components/readiness.js +6 -4
- package/dist/esm/client/components/side-nav.js +53 -0
- package/dist/esm/client/components/subscribe-dialog.js +2 -2
- package/dist/esm/client/components/subscription.js +3 -2
- package/dist/esm/client/components/surfaces.js +51 -0
- package/dist/esm/client/components/usage-meter.js +4 -3
- package/dist/esm/client/components/user-menu.js +34 -7
- package/dist/esm/client/index.js +16 -2
- package/dist/esm/client/mark.js +1 -1
- package/dist/esm/client/provider.js +59 -8
- package/dist/esm/client/use-active-path.js +74 -0
- package/dist/esm/client/use-checkout.js +3 -3
- package/dist/esm/errors.js +4 -4
- package/dist/esm/helpers.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/next/index.js +95 -15
- package/dist/esm/server/client.js +22 -14
- package/dist/esm/server/handler.js +99 -16
- package/dist/esm/server/http.js +1 -1
- package/dist/esm/server/index.js +5 -0
- package/dist/esm/server/resources/access.js +3 -3
- package/dist/esm/server/resources/ai.js +1 -1
- package/dist/esm/server/resources/analytics.js +62 -0
- package/dist/esm/server/resources/apps.js +4 -4
- package/dist/esm/server/resources/billing.js +5 -5
- package/dist/esm/server/resources/drive.js +4 -4
- package/dist/esm/server/resources/entitlements.js +7 -7
- package/dist/esm/server/resources/identity.js +4 -4
- package/dist/esm/server/resources/integrations.js +62 -0
- package/dist/esm/server/resources/logs.js +3 -3
- package/dist/esm/server/resources/sessions.js +6 -6
- package/dist/esm/server/webhooks.js +123 -0
- package/dist/esm/types.js +1 -1
- package/dist/theme.css +28 -0
- package/dist/types/client/components/access-gate.d.ts +79 -0
- package/dist/types/client/components/account-sessions.d.ts +1 -1
- package/dist/types/client/components/app-shell.d.ts +115 -0
- package/dist/types/client/components/apps-grid.d.ts +1 -1
- package/dist/types/client/components/feature-gate.d.ts +1 -1
- package/dist/types/client/components/integrations-modal.d.ts +21 -0
- package/dist/types/client/components/logo.d.ts +5 -5
- package/dist/types/client/components/motion.d.ts +22 -0
- package/dist/types/client/components/nav.d.ts +110 -0
- package/dist/types/client/components/plan-pricing.d.ts +2 -2
- package/dist/types/client/components/readiness.d.ts +2 -2
- package/dist/types/client/components/side-nav.d.ts +31 -0
- package/dist/types/client/components/subscribe-dialog.d.ts +3 -3
- package/dist/types/client/components/surfaces.d.ts +51 -0
- package/dist/types/client/components/usage-meter.d.ts +1 -1
- package/dist/types/client/components/user-menu.d.ts +2 -2
- package/dist/types/client/index.d.ts +13 -2
- package/dist/types/client/mark.d.ts +1 -1
- package/dist/types/client/provider.d.ts +44 -9
- package/dist/types/client/use-active-path.d.ts +8 -0
- package/dist/types/client/use-checkout.d.ts +3 -3
- package/dist/types/errors.d.ts +4 -4
- package/dist/types/helpers.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/next/index.d.ts +40 -0
- package/dist/types/server/client.d.ts +17 -11
- package/dist/types/server/handler.d.ts +2 -2
- package/dist/types/server/index.d.ts +7 -0
- package/dist/types/server/resources/access.d.ts +3 -3
- package/dist/types/server/resources/ai.d.ts +2 -2
- package/dist/types/server/resources/analytics.d.ts +31 -0
- package/dist/types/server/resources/apps.d.ts +4 -4
- package/dist/types/server/resources/billing.d.ts +4 -4
- package/dist/types/server/resources/drive.d.ts +4 -4
- package/dist/types/server/resources/entitlements.d.ts +4 -4
- package/dist/types/server/resources/identity.d.ts +14 -4
- package/dist/types/server/resources/integrations.d.ts +40 -0
- package/dist/types/server/resources/logs.d.ts +3 -3
- package/dist/types/server/resources/sessions.d.ts +5 -5
- package/dist/types/server/webhooks.d.ts +67 -0
- package/dist/types/types.d.ts +220 -39
- package/package.json +10 -4
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
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
|
+
import { ENTER, m, MotionProvider } from "./motion.js";
|
|
6
7
|
function AppTile({ app }) {
|
|
7
8
|
const disabled = app.launchUrl === null;
|
|
8
9
|
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 ? (
|
|
@@ -18,7 +19,7 @@ function AppTile({ app }) {
|
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
20
21
|
* The Google-style app launcher: a grid icon in your top bar that opens a
|
|
21
|
-
* modal of every Connect
|
|
22
|
+
* modal of every Connect application this workspace can actually open.
|
|
22
23
|
*
|
|
23
24
|
* Only launchable apps appear, so no tile is a dead end - a paused
|
|
24
25
|
* subscription still shows, badged, because a dunning cycle should not make a
|
|
@@ -35,9 +36,9 @@ export function ConnectAppsGrid({ className, label = "Switch app" }) {
|
|
|
35
36
|
if (sessionLoading || !session?.user)
|
|
36
37
|
return null;
|
|
37
38
|
const apps = data?.apps ?? [];
|
|
38
|
-
return (_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(
|
|
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, {}) })] }) }) })] }) }));
|
|
39
40
|
}
|
|
40
41
|
function ConnectBrowseAllLink() {
|
|
41
42
|
const { appUrl } = useConnect();
|
|
42
|
-
return (_jsxs(Button, { variant: "tertiary", className: "w-full", onPress: () => window.open(`${appUrl}/
|
|
43
|
+
return (_jsxs(Button, { variant: "tertiary", className: "w-full", onPress: () => window.open(`${appUrl}/applications`, "_blank", "noopener,noreferrer"), children: ["Browse all applications", _jsx(ExternalLink, { className: "size-3.5 text-muted" })] }));
|
|
43
44
|
}
|
|
@@ -38,7 +38,7 @@ export function useConnectFeature(feature) {
|
|
|
38
38
|
*
|
|
39
39
|
* Like every gate in this package, it is a **UX gate, not a security
|
|
40
40
|
* boundary**: the entitlement is read through your own origin and the browser
|
|
41
|
-
* can ignore it. Check `client.entitlements.hasFeature(
|
|
41
|
+
* can ignore it. Check `client.entitlements.hasFeature(workspaceId, feature)` on the
|
|
42
42
|
* server before doing the thing the feature unlocks.
|
|
43
43
|
*
|
|
44
44
|
* Renders `loading` - not `fallback` - while the answer is unknown, so a paying
|
|
@@ -53,5 +53,5 @@ export function ConnectFeatureGate({ feature, children, fallback, loading = null
|
|
|
53
53
|
return _jsx(_Fragment, { children: children });
|
|
54
54
|
if (fallback !== undefined)
|
|
55
55
|
return _jsx(_Fragment, { children: fallback });
|
|
56
|
-
return (_jsx(ConnectNoSubscriptionCard, { plansHref: plansHref, title: title ?? "Not included in your plan", description: description ?? "This part of the
|
|
56
|
+
return (_jsx(ConnectNoSubscriptionCard, { plansHref: plansHref, title: title ?? "Not included in your plan", description: description ?? "This part of the application is on a higher plan. Compare plans to see what unlocks it.", className: className }));
|
|
57
57
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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 { AlertDialog, Button, Chip, Modal, Skeleton } from "@heroui/react";
|
|
5
|
+
import { AlertCircle, Puzzle } from "lucide-react";
|
|
6
|
+
import { useConnect, useConnectIntegrations } from "../provider.js";
|
|
7
|
+
import { ENTER, m, MotionProvider } from "./motion.js";
|
|
8
|
+
const STATUS_LABEL = {
|
|
9
|
+
not_connected: "Not connected",
|
|
10
|
+
pending: "Connecting…",
|
|
11
|
+
active: "Connected",
|
|
12
|
+
failed: "Failed",
|
|
13
|
+
disconnected: "Disconnected",
|
|
14
|
+
expired: "Expired",
|
|
15
|
+
};
|
|
16
|
+
const STATUS_COLOR = {
|
|
17
|
+
not_connected: "default",
|
|
18
|
+
pending: "warning",
|
|
19
|
+
active: "success",
|
|
20
|
+
failed: "danger",
|
|
21
|
+
disconnected: "default",
|
|
22
|
+
expired: "danger",
|
|
23
|
+
};
|
|
24
|
+
const CAN_CONNECT = new Set([
|
|
25
|
+
"not_connected",
|
|
26
|
+
"failed",
|
|
27
|
+
"disconnected",
|
|
28
|
+
"expired",
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* One toolkit's icon, status, and whatever action its current status allows.
|
|
32
|
+
* Never a dead end the way `AppTile` avoids one for a missing `launchUrl`:
|
|
33
|
+
* every status renders *some* affordance - connect, reconnect, disconnect,
|
|
34
|
+
* or a cancel for a stuck `pending` attempt.
|
|
35
|
+
*/
|
|
36
|
+
function ToolkitTile({ toolkit, isPending, onConnect, onDisconnect }) {
|
|
37
|
+
const [confirmingDisconnect, setConfirmingDisconnect] = useState(false);
|
|
38
|
+
return (_jsxs("div", { className: "flex flex-col items-center gap-2 rounded-xl border border-separator p-3", children: [_jsxs("span", { className: "relative flex size-12 items-center justify-center overflow-hidden rounded-xl bg-surface-secondary", children: [toolkit.logoUrl ? (
|
|
39
|
+
// Deliberately a plain `<img>`: this package ships into any React app
|
|
40
|
+
// and cannot assume `next/image` or any other framework loader.
|
|
41
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
42
|
+
_jsx("img", { src: toolkit.logoUrl, alt: "", className: "size-full object-cover" })) : (_jsx(Puzzle, { className: "size-6 text-muted", "aria-hidden": "true" })), toolkit.status === "failed" && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-danger text-danger-foreground", children: _jsx(AlertCircle, { className: "size-3", "aria-hidden": "true" }) }))] }), _jsx("span", { className: "line-clamp-2 text-center text-xs font-medium text-foreground", children: toolkit.name }), _jsx(Chip, { size: "sm", variant: "soft", color: STATUS_COLOR[toolkit.status], children: STATUS_LABEL[toolkit.status] }), _jsxs("div", { className: "flex w-full flex-col gap-1.5 pt-1", children: [CAN_CONNECT.has(toolkit.status) && (_jsx(Button, { size: "sm", variant: "secondary", className: "w-full text-xs", isPending: isPending, isDisabled: isPending, onPress: onConnect, children: "Connect" })), toolkit.status === "active" && (_jsxs(_Fragment, { children: [_jsx(Button, { size: "sm", variant: "secondary", className: "w-full text-xs", isPending: isPending, isDisabled: isPending, onPress: onConnect, children: "Reconnect" }), _jsxs(AlertDialog, { isOpen: confirmingDisconnect, onOpenChange: setConfirmingDisconnect, children: [_jsx(Button, { size: "sm", variant: "tertiary", className: "w-full text-xs text-danger", isDisabled: isPending, children: "Disconnect" }), _jsx(AlertDialog.Backdrop, { children: _jsx(AlertDialog.Container, { children: _jsxs(AlertDialog.Dialog, { className: "sm:max-w-100", children: [_jsx(AlertDialog.CloseTrigger, {}), _jsxs(AlertDialog.Header, { children: [_jsx(AlertDialog.Icon, { status: "danger" }), _jsxs(AlertDialog.Heading, { children: ["Disconnect ", toolkit.name, "?"] })] }), _jsx(AlertDialog.Body, { children: _jsx("p", { className: "text-sm text-muted", children: "Every member loses this connection immediately - it can be reconnected later." }) }), _jsxs(AlertDialog.Footer, { children: [_jsx(Button, { slot: "close", variant: "secondary", className: "text-xs", children: "Cancel" }), _jsx(Button, { slot: "close", variant: "danger", className: "text-xs", onPress: onDisconnect, children: "Disconnect" })] })] }) }) })] })] })), toolkit.status === "pending" && (_jsx(Button, { size: "sm", variant: "tertiary", className: "w-full text-xs text-danger", isDisabled: isPending, onPress: onDisconnect, children: "Cancel" }))] })] }));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A grid of every Composio-hosted toolkit this workspace can connect -
|
|
46
|
+
* Google Calendar, Slack, Twitter/X, whatever a platform admin enabled in
|
|
47
|
+
* Connect's own Console. Each tile carries its own connect/reconnect/
|
|
48
|
+
* disconnect action and its own pending state, independent of every other
|
|
49
|
+
* tile - see the doc comment on `pendingSlug` below for why that matters.
|
|
50
|
+
*
|
|
51
|
+
* Not optimistic: a tile stays whatever it already was after "Connect" is
|
|
52
|
+
* pressed, because the connection isn't settled until Composio's webhook
|
|
53
|
+
* confirms it - `mutateResource` here only ever gets as far as `pending`
|
|
54
|
+
* before the browser navigates away to complete the provider's OAuth screen.
|
|
55
|
+
*
|
|
56
|
+
* Renders nothing when signed out, same as `ConnectAppsGrid`.
|
|
57
|
+
*/
|
|
58
|
+
export function ConnectIntegrationsModal({ className, label = "Integrations" }) {
|
|
59
|
+
const { session, isLoading: sessionLoading, mutateResource, invalidateResource } = useConnect();
|
|
60
|
+
const { data, isLoading, error, refetch } = useConnectIntegrations();
|
|
61
|
+
// Per-toolkit, not one shared flag - a grid of independent connections is
|
|
62
|
+
// exactly the "list of rows" case a single `isPending` disables wholesale
|
|
63
|
+
// (see CLAUDE.md's mutation-conventions note on this).
|
|
64
|
+
const [pendingSlug, setPendingSlug] = useState(null);
|
|
65
|
+
if (sessionLoading || !session?.user)
|
|
66
|
+
return null;
|
|
67
|
+
const toolkits = data?.toolkits ?? [];
|
|
68
|
+
const connect = (slug) => {
|
|
69
|
+
setPendingSlug(slug);
|
|
70
|
+
void mutateResource("/integrations/connect", {
|
|
71
|
+
toolkitSlug: slug,
|
|
72
|
+
returnUrl: window.location.href,
|
|
73
|
+
})
|
|
74
|
+
.then(({ redirectUrl }) => {
|
|
75
|
+
window.location.href = redirectUrl;
|
|
76
|
+
})
|
|
77
|
+
.catch(() => setPendingSlug(null));
|
|
78
|
+
// No `finally(() => setPendingSlug(null))` on the success path: the
|
|
79
|
+
// browser is about to navigate away, and clearing the flag first would
|
|
80
|
+
// let a slow navigation show the button as re-enabled for a beat.
|
|
81
|
+
};
|
|
82
|
+
const disconnect = (slug) => {
|
|
83
|
+
setPendingSlug(slug);
|
|
84
|
+
void mutateResource("/integrations/disconnect", { toolkitSlug: slug })
|
|
85
|
+
.then(() => {
|
|
86
|
+
invalidateResource("/integrations");
|
|
87
|
+
refetch();
|
|
88
|
+
})
|
|
89
|
+
.finally(() => setPendingSlug(null));
|
|
90
|
+
};
|
|
91
|
+
return (_jsx(MotionProvider, { children: _jsxs(Modal, { children: [_jsxs(Button, { variant: "secondary", className: className, children: [_jsx(Puzzle, { className: "size-4" }), label] }), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-lg", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Integrations" }) }), _jsxs(Modal.Body, { children: [isLoading && (_jsx("div", { className: "grid grid-cols-2 gap-3 sm:grid-cols-3", children: Array.from({ length: 6 }, (_, index) => (_jsxs("div", { className: "flex flex-col items-center gap-2 rounded-xl border border-separator p-3", children: [_jsx(Skeleton, { className: "size-12 rounded-xl" }), _jsx(Skeleton, { className: "h-3 w-16" }), _jsx(Skeleton, { className: "h-7 w-full rounded-lg" })] }, index))) })), !isLoading && error && (_jsx("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load integrations." })), !isLoading && !error && toolkits.length === 0 && (_jsx("p", { className: "py-6 text-center text-sm text-muted", children: "No integrations are enabled on this platform yet." })), !isLoading && !error && toolkits.length > 0 && (_jsx(m.div, { className: "grid grid-cols-2 gap-3 sm:grid-cols-3", variants: { show: { transition: { staggerChildren: 0.04 } } }, initial: "hidden", animate: "show", children: toolkits.map((toolkit) => (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsx(ToolkitTile, { toolkit: toolkit, isPending: pendingSlug === toolkit.slug, onConnect: () => connect(toolkit.slug), onDisconnect: () => disconnect(toolkit.slug) }) }, toolkit.slug))) }))] })] }) }) })] }) }));
|
|
92
|
+
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { ConnectMark } from "../mark.js";
|
|
4
4
|
/**
|
|
5
|
-
* The shared platform lockup:
|
|
6
|
-
* `cosmo·ops` beside it, and the
|
|
5
|
+
* The shared platform lockup: an application's own glyph on the CosmoOps badge,
|
|
6
|
+
* `cosmo·ops` beside it, and the application name underneath.
|
|
7
7
|
*
|
|
8
|
-
* This is what makes a family of
|
|
8
|
+
* This is what makes a family of application platforms look like one product. Each
|
|
9
9
|
* platform passes only what differs - its `name` and its `icon` - and inherits
|
|
10
10
|
* the mark geometry, the wordmark treatment, the separator, and the accent from
|
|
11
11
|
* one place. Change the lockup here and every platform follows.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { LazyMotion, MotionConfig, domAnimation } from "motion/react";
|
|
4
|
+
export { m, AnimatePresence } from "motion/react";
|
|
5
|
+
/** HeroUI's --ease-out-quart, transcribed for motion transitions. */
|
|
6
|
+
export const EASE_OUT_QUART = [0.165, 0.84, 0.44, 1];
|
|
7
|
+
export const ENTER = { duration: 0.3, ease: EASE_OUT_QUART };
|
|
8
|
+
export const METER_SPRING = {
|
|
9
|
+
type: "spring",
|
|
10
|
+
stiffness: 170,
|
|
11
|
+
damping: 26,
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Per-component motion root. domAnimation keeps the feature bundle small,
|
|
15
|
+
* `strict` restricts rendering to `m.*` components, and reducedMotion="user"
|
|
16
|
+
* drops transform animation (keeping opacity) under prefers-reduced-motion -
|
|
17
|
+
* the same contract HeroUI's own `motion-reduce` styles follow.
|
|
18
|
+
*/
|
|
19
|
+
export function MotionProvider({ children }) {
|
|
20
|
+
return (_jsx(MotionConfig, { reducedMotion: "user", children: _jsx(LazyMotion, { features: domAnimation, strict: true, children: children }) }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
function isSection(entry) {
|
|
2
|
+
return !("href" in entry) && Array.isArray(entry.items);
|
|
3
|
+
}
|
|
4
|
+
/** Folds a mixed `ConnectNav` into sections, gathering consecutive bare items into one untitled group. */
|
|
5
|
+
export function normalizeNav(nav) {
|
|
6
|
+
const sections = [];
|
|
7
|
+
let loose = null;
|
|
8
|
+
for (const entry of nav) {
|
|
9
|
+
if (isSection(entry)) {
|
|
10
|
+
loose = null;
|
|
11
|
+
sections.push(entry);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (!loose) {
|
|
15
|
+
loose = [];
|
|
16
|
+
sections.push({ items: loose });
|
|
17
|
+
}
|
|
18
|
+
loose.push(entry);
|
|
19
|
+
}
|
|
20
|
+
return sections;
|
|
21
|
+
}
|
|
22
|
+
/** Every item in the tree, parents and children alike, in render order. */
|
|
23
|
+
export function flattenNav(nav) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const section of normalizeNav(nav)) {
|
|
26
|
+
for (const item of section.items) {
|
|
27
|
+
out.push(item);
|
|
28
|
+
if (item.items)
|
|
29
|
+
out.push(...item.items);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `https://x.test/billing/?tab=1#top` -> `/billing`.
|
|
36
|
+
*
|
|
37
|
+
* Trailing slashes go too, so `/billing/` and `/billing` are the same route -
|
|
38
|
+
* they are, to every router, and treating them as different is how a nav item
|
|
39
|
+
* ends up unhighlighted on exactly one of the two URLs a user can arrive at.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizePath(path) {
|
|
42
|
+
const withoutOrigin = path.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]*/i, "");
|
|
43
|
+
const withoutQuery = withoutOrigin.split(/[?#]/)[0] ?? "";
|
|
44
|
+
const trimmed = withoutQuery.replace(/\/+$/, "");
|
|
45
|
+
return trimmed === "" ? "/" : trimmed;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The href of the nav item the current path belongs to, or `null`.
|
|
49
|
+
*
|
|
50
|
+
* Longest prefix wins, so `/settings/billing` lights up Billing and not also
|
|
51
|
+
* its parent Settings - a plain `startsWith` over an unordered list lights up
|
|
52
|
+
* both, and the sidebar grows two highlights.
|
|
53
|
+
*
|
|
54
|
+
* External items never match: they leave the app, so no path inside it is
|
|
55
|
+
* ever "on" them.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveActivePath(pathname, nav) {
|
|
58
|
+
const current = normalizePath(pathname);
|
|
59
|
+
let best = null;
|
|
60
|
+
for (const item of flattenNav(nav)) {
|
|
61
|
+
if (item.external)
|
|
62
|
+
continue;
|
|
63
|
+
const href = normalizePath(item.href);
|
|
64
|
+
const exact = item.exact || href === "/";
|
|
65
|
+
const matches = current === href || (!exact && current.startsWith(`${href}/`));
|
|
66
|
+
if (matches && (best === null || href.length > best.length))
|
|
67
|
+
best = href;
|
|
68
|
+
}
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether `item` is the destination `resolveActivePath` picked.
|
|
73
|
+
*
|
|
74
|
+
* Not just `normalizePath(item.href) === activeHref`, which is the trap this
|
|
75
|
+
* exists to close: `normalizePath` strips the origin off an absolute URL, so
|
|
76
|
+
* an external item pointed at `https://docs.example.com` normalizes to `/` and
|
|
77
|
+
* lights up on every app's home route. External items are never active.
|
|
78
|
+
*/
|
|
79
|
+
export function isNavItemActive(item, activeHref) {
|
|
80
|
+
if (activeHref === null || item.external)
|
|
81
|
+
return false;
|
|
82
|
+
return normalizePath(item.href) === activeHref;
|
|
83
|
+
}
|
|
84
|
+
/** Hrefs declared `gateExempt` - the routes `ConnectAppShell` renders outside its `ConnectAccessGate`. */
|
|
85
|
+
export function gateExemptPaths(nav) {
|
|
86
|
+
return flattenNav(nav)
|
|
87
|
+
.filter((item) => item.gateExempt)
|
|
88
|
+
.map((item) => normalizePath(item.href));
|
|
89
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* an upgrade while the card behind it said downgrade would be worse than
|
|
5
5
|
* either label being wrong on its own.
|
|
6
6
|
*
|
|
7
|
-
* Mirrors `packages/data/src/
|
|
7
|
+
* Mirrors `packages/data/src/applications/catalog.ts` and
|
|
8
8
|
* `packages/data/src/payments/plan-change.ts`, which are the copies Connect's
|
|
9
9
|
* own UI uses and which decide which use-case actually runs. The duplication
|
|
10
10
|
* is deliberate and load-bearing: this package carries no dependency on
|
|
@@ -5,6 +5,7 @@ import { Check, CheckCircle2, ExternalLink, Loader2, Sparkles, Tag } from "lucid
|
|
|
5
5
|
import { useMemo, useState } from "react";
|
|
6
6
|
import { useConnect, useConnectEntitlements, useConnectPlans } from "../provider.js";
|
|
7
7
|
import { buildCancelUrl, buildCheckoutUrl, useConnectCheckout, } from "../use-checkout.js";
|
|
8
|
+
import { AnimatePresence, ENTER, m, MotionProvider } from "./motion.js";
|
|
8
9
|
import { ConnectSubscribeDialog } from "./subscribe-dialog.js";
|
|
9
10
|
import { ACTION_LABEL, discountLabel, formatPrice, groupPlansByTier, resolveAction, } from "./plan-pricing.js";
|
|
10
11
|
/**
|
|
@@ -43,7 +44,7 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
43
44
|
/** The plan under review in the confirmation dialog, or `null` when it is closed. */
|
|
44
45
|
const [pending, setPending] = useState(null);
|
|
45
46
|
const subscription = entitlements?.subscription ?? null;
|
|
46
|
-
const
|
|
47
|
+
const applicationId = subscription?.applicationId ?? null;
|
|
47
48
|
const allPlans = useMemo(() => {
|
|
48
49
|
const plans = catalog?.plans ?? [];
|
|
49
50
|
if (!planIds)
|
|
@@ -81,12 +82,12 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
81
82
|
* then would misreport a completed checkout as an abandoned one.
|
|
82
83
|
*/
|
|
83
84
|
function startCheckout(plan) {
|
|
84
|
-
if (!
|
|
85
|
+
if (!applicationId)
|
|
85
86
|
return;
|
|
86
87
|
checkout.start({
|
|
87
88
|
url: buildCheckoutUrl({
|
|
88
89
|
appUrl,
|
|
89
|
-
|
|
90
|
+
applicationId,
|
|
90
91
|
planId: plan.id,
|
|
91
92
|
subscriptionId: subscription?.subscription?.id ?? null,
|
|
92
93
|
coupon,
|
|
@@ -104,41 +105,41 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
104
105
|
if (error) {
|
|
105
106
|
return _jsx("p", { className: "text-sm text-danger", children: "Could not load plans." });
|
|
106
107
|
}
|
|
107
|
-
return (_jsxs("div", { className: `flex flex-col gap-6 ${className ?? ""}`, children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-4", children: [hasAnnual && hasMonthly && (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: showAnnual ? "text-sm text-muted" : "text-sm font-medium text-foreground", children: "Monthly" }), _jsx(Switch, { "aria-label": "Bill annually", isSelected: showAnnual, onChange: setAnnual, children: _jsx(Switch.Content, { children: _jsx(Switch.Control, { children: _jsx(Switch.Thumb, {}) }) }) }), _jsx("span", { className: showAnnual ? "text-sm font-medium text-foreground" : "text-sm text-muted", children: "Annual" }), bestSaving !== null && (_jsxs(Chip, { className: "bg-accent-soft text-accent", children: ["Save up to ", bestSaving, "%"] }))] })), !hideCoupon && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Tag, { className: "size-4 text-muted", "aria-hidden": "true" }), _jsx(Input, { "aria-label": "Coupon code", placeholder: "Coupon code", value: coupon, onChange: (event) => setCoupon(event.target.value.toUpperCase()), className: "w-40" })] }))] }), subscription?.subscription?.appliedDiscount && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", children: [_jsx(Sparkles, { className: "size-4 shrink-0", "aria-hidden": "true" }), _jsxs("span", { children: [discountLabel(subscription.subscription.appliedDiscount), subscription.subscription.appliedDiscount.code
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
108
|
+
return (_jsx(MotionProvider, { children: _jsxs("div", { className: `flex flex-col gap-6 ${className ?? ""}`, children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-4", children: [hasAnnual && hasMonthly && (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: showAnnual ? "text-sm text-muted" : "text-sm font-medium text-foreground", children: "Monthly" }), _jsx(Switch, { "aria-label": "Bill annually", isSelected: showAnnual, onChange: setAnnual, children: _jsx(Switch.Content, { children: _jsx(Switch.Control, { children: _jsx(Switch.Thumb, {}) }) }) }), _jsx("span", { className: showAnnual ? "text-sm font-medium text-foreground" : "text-sm text-muted", children: "Annual" }), bestSaving !== null && (_jsxs(Chip, { className: "bg-accent-soft text-accent", children: ["Save up to ", bestSaving, "%"] }))] })), !hideCoupon && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Tag, { className: "size-4 text-muted", "aria-hidden": "true" }), _jsx(Input, { "aria-label": "Coupon code", placeholder: "Coupon code", value: coupon, onChange: (event) => setCoupon(event.target.value.toUpperCase()), className: "w-40" })] }))] }), subscription?.subscription?.appliedDiscount && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", children: [_jsx(Sparkles, { className: "size-4 shrink-0", "aria-hidden": "true" }), _jsxs("span", { children: [discountLabel(subscription.subscription.appliedDiscount), subscription.subscription.appliedDiscount.code
|
|
109
|
+
? ` - code ${subscription.subscription.appliedDiscount.code}`
|
|
110
|
+
: ""] })] })), _jsx(ConnectCheckoutStatusRow, { checkout: checkout }), _jsx(m.div, { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3", variants: { show: { transition: { staggerChildren: 0.06 } } }, initial: "hidden", animate: "show", children: tiers.map((tier) => {
|
|
111
|
+
// The price point this card is showing. A tier missing the toggled
|
|
112
|
+
// cadence falls back to the one it does sell rather than dropping
|
|
113
|
+
// out of the grid, and says so under the price.
|
|
114
|
+
const plan = pickTierPlan(tier, showAnnual);
|
|
115
|
+
if (!plan)
|
|
116
|
+
return null;
|
|
117
|
+
const action = resolveAction(plan, subscription);
|
|
118
|
+
// "Current" belongs to the tier, not just the exact price point: an
|
|
119
|
+
// workspace on Growth annual looking at monthly pricing is still on the
|
|
120
|
+
// Growth tier, and marking only the row it literally subscribes to
|
|
121
|
+
// would leave its own card unmarked half the time.
|
|
122
|
+
const isCurrentTier = subscription?.plan != null &&
|
|
123
|
+
(tier.monthly?.id === subscription.plan.id || tier.annual?.id === subscription.plan.id);
|
|
124
|
+
const isCurrent = action === "current";
|
|
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) => (
|
|
127
|
+
// `name`, not `key` - the key is an identifier
|
|
128
|
+
// ("campaign-module"), and printing it here is what this
|
|
129
|
+
// card used to do. The description rides along as a
|
|
130
|
+
// title so a feature that needs explaining can have one
|
|
131
|
+
// without a second line in every card.
|
|
132
|
+
_jsxs("li", { className: "flex items-start gap-2 text-sm text-foreground", title: feature.description || undefined, children: [_jsx(Check, { className: "mt-0.5 size-4 shrink-0 text-accent", "aria-hidden": "true" }), _jsx("span", { children: feature.name })] }, feature.key))) })), plan.addons.length > 0 && (_jsxs("div", { className: "flex flex-col gap-1.5 border-t border-separator pt-3", children: [_jsx("p", { className: "text-xs font-medium uppercase tracking-wide text-muted", children: "Add-ons" }), plan.addons.map((addon) => (_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsxs("span", { className: "text-foreground", children: [addon.name, addon.consumable && (_jsxs("span", { className: "text-muted", children: [" ", "- ", addon.consumable.includedPerCycle.toLocaleString("en-IN"), " ", addon.consumable.unit, "/cycle"] }))] }), _jsx("span", { className: "shrink-0 text-muted", children: formatPrice(addon.price) })] }, addon.id)))] })), _jsxs("p", { className: "text-xs text-muted", children: [plan.seats.model === "unlimited" && "Unlimited seats", plan.seats.model === "fixed" && `${plan.seats.included} seats included`, plan.seats.model === "per-seat" &&
|
|
133
|
+
`${plan.seats.included ?? 0} seats included, then ${formatPrice(plan.seats.pricePerExtraSeat ?? 0)} each`] })] }), _jsx(Card.Footer, { children: _jsxs(Button, { className: "w-full", variant: action === "upgrade" || action === "subscribe" || action === "switch-to-annual"
|
|
134
|
+
? "primary"
|
|
135
|
+
: "secondary",
|
|
136
|
+
// Disabled while a hand-off is in flight, not just on the
|
|
137
|
+
// card that started it: the workspace has one subscription per
|
|
138
|
+
// application, so a second checkout opened against a different
|
|
139
|
+
// plan would race the first and one of them would be
|
|
140
|
+
// rejected on arrival.
|
|
141
|
+
isDisabled: isCurrent || checkout.status === "waiting", onPress: () => setPending({ plan, action }), children: [ACTION_LABEL[action], !isCurrent && _jsx(ExternalLink, { className: "size-3.5" })] }) })] }) }, tier.planGroup));
|
|
142
|
+
}) }), subscription?.subscription && (_jsx(ConnectUnsubscribeRow, { subscription: subscription.subscription, appUrl: appUrl, checkout: checkout })), _jsx(ConnectSubscribeDialog, { plan: pending?.plan ?? null, action: pending?.action ?? "subscribe", current: subscription, coupon: hideCoupon ? undefined : coupon, onClose: () => setPending(null), onConfirm: () => pending && startCheckout(pending.plan) })] }) }));
|
|
142
143
|
}
|
|
143
144
|
/**
|
|
144
145
|
* What the page says while the customer is off in Connect, and after.
|
|
@@ -150,8 +151,9 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
150
151
|
* read as one.
|
|
151
152
|
*/
|
|
152
153
|
function ConnectCheckoutStatusRow({ checkout, }) {
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
return (_jsx(AnimatePresence, { mode: "wait", initial: false, children: checkout.status !== "idle" && (_jsx(m.div, { initial: { opacity: 0, y: 4 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -4 }, transition: { duration: 0.18 }, children: _jsx(CheckoutStatusRowContent, { checkout: checkout }) }, checkout.status)) }));
|
|
155
|
+
}
|
|
156
|
+
function CheckoutStatusRowContent({ checkout, }) {
|
|
155
157
|
if (checkout.status === "waiting") {
|
|
156
158
|
return (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-separator px-3 py-2 text-sm text-muted", children: [_jsx(Loader2, { className: "size-4 shrink-0 animate-spin", "aria-hidden": "true" }), _jsx("span", { children: "Finish up in the Connect window. This page updates on its own." })] }));
|
|
157
159
|
}
|
|
@@ -4,6 +4,7 @@ import { Button, Card } from "@heroui/react";
|
|
|
4
4
|
import { AlertTriangle, ExternalLink, Info, Loader2 } from "lucide-react";
|
|
5
5
|
import { blockingBlockers } from "../../helpers.js";
|
|
6
6
|
import { useConnect, useConnectEntitlements } from "../provider.js";
|
|
7
|
+
import { ENTER, m, MotionProvider } from "./motion.js";
|
|
7
8
|
/**
|
|
8
9
|
* Codes that mean "somebody at Connect is looking at it" - there is no action
|
|
9
10
|
* for the customer to take, so these get an informational treatment and no
|
|
@@ -22,12 +23,13 @@ const ACTION_LABELS = {
|
|
|
22
23
|
verification_rejected: "Review verification",
|
|
23
24
|
verification_expired: "Renew verification",
|
|
24
25
|
seat_limit_reached: "Manage seats",
|
|
26
|
+
payment_retry_pending: "Resolve payment",
|
|
25
27
|
};
|
|
26
28
|
function actionLabel(blocker) {
|
|
27
29
|
return ACTION_LABELS[blocker.code] ?? "Resolve on Connect";
|
|
28
30
|
}
|
|
29
31
|
/**
|
|
30
|
-
* Every gate between this workspace and your
|
|
32
|
+
* Every gate between this workspace and your application, with the way out of each.
|
|
31
33
|
*
|
|
32
34
|
* This is the component that keeps a blocked customer from staring at a broken
|
|
33
35
|
* app. `getReadiness` resolves subscription state, KYC/KYB, a chargeable
|
|
@@ -35,7 +37,7 @@ function actionLabel(blocker) {
|
|
|
35
37
|
* `resolveUrl` into the exact Connect screen that fixes it** - so being blocked
|
|
36
38
|
* comes with somewhere to go rather than a refusal.
|
|
37
39
|
*
|
|
38
|
-
* Render it high on any page your
|
|
40
|
+
* Render it high on any page your application gates. It draws nothing at all when
|
|
39
41
|
* the workspace is ready, so it is safe to leave mounted permanently.
|
|
40
42
|
*
|
|
41
43
|
* Two deliberate refusals:
|
|
@@ -62,7 +64,7 @@ export function ConnectReadinessBanner({ includeAdvisory = false, hideWhenReady
|
|
|
62
64
|
const blockers = includeAdvisory ? data.readiness.readiness.blockers : blockingBlockers(data.readiness);
|
|
63
65
|
if (blockers.length === 0)
|
|
64
66
|
return hideWhenReady ? null : _jsx(ReadyRow, { className: className });
|
|
65
|
-
return (_jsx(
|
|
67
|
+
return (_jsx(MotionProvider, { children: _jsx(m.div, { className: `flex flex-col gap-2 ${className ?? ""}`, role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: ENTER, children: blockers.map((blocker) => (_jsx(BlockerRow, { blocker: blocker }, `${blocker.code}-${blocker.message}`))) }) }));
|
|
66
68
|
}
|
|
67
69
|
function BlockerRow({ blocker }) {
|
|
68
70
|
const inProgress = IN_PROGRESS_CODES.has(blocker.code);
|
|
@@ -70,7 +72,7 @@ function BlockerRow({ blocker }) {
|
|
|
70
72
|
return (_jsxs("div", { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm ${inProgress || !isBlocking ? "bg-surface text-muted" : "bg-danger-soft text-danger"}`, children: [_jsxs("span", { className: "flex items-center gap-2", children: [inProgress || !isBlocking ? (_jsx(Info, { className: "size-4 shrink-0", "aria-hidden": "true" })) : (_jsx(AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" })), blocker.message] }), blocker.resolveUrl && !inProgress && (_jsxs(Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel(blocker), _jsx(ExternalLink, { className: "size-3.5" })] }))] }));
|
|
71
73
|
}
|
|
72
74
|
function ReadyRow({ className }) {
|
|
73
|
-
return (_jsx("p", { className: `text-sm text-muted ${className ?? ""}`, children: "This workspace is cleared to use every part of the
|
|
75
|
+
return (_jsx("p", { className: `text-sm text-muted ${className ?? ""}`, children: "This workspace is cleared to use every part of the application." }));
|
|
74
76
|
}
|
|
75
77
|
/**
|
|
76
78
|
* Renders `children` only once the workspace clears every blocking gate.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Tooltip } from "@heroui/react";
|
|
4
|
+
import { ArrowUpRight } from "lucide-react";
|
|
5
|
+
import { useActivePath } from "../use-active-path.js";
|
|
6
|
+
import { isNavItemActive, normalizeNav, resolveActivePath, } from "./nav.js";
|
|
7
|
+
/** A plain anchor - correct anywhere, and a full page load. See `ConnectLinkRenderer`. */
|
|
8
|
+
export const defaultRenderLink = ({ children, ...props }) => _jsx("a", { ...props, children: children });
|
|
9
|
+
const ROW = "group relative flex items-center rounded-lg text-sm transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus";
|
|
10
|
+
function rowClass(isActive, collapsed, depth) {
|
|
11
|
+
const shape = collapsed ? "size-10 justify-center" : `min-h-9 justify-start gap-3 px-3 py-2 ${depth > 0 ? "ml-4" : ""}`;
|
|
12
|
+
const tone = isActive
|
|
13
|
+
? "bg-accent-soft font-medium text-accent"
|
|
14
|
+
: "text-foreground hover:bg-surface-secondary";
|
|
15
|
+
return `${ROW} ${shape} ${tone}`;
|
|
16
|
+
}
|
|
17
|
+
function NavRow({ item, isActive, hasActiveChild, collapsed, depth, renderLink, onNavigate, }) {
|
|
18
|
+
const linkProps = {
|
|
19
|
+
href: item.href,
|
|
20
|
+
className: rowClass(isActive, collapsed, depth),
|
|
21
|
+
"aria-label": item.label,
|
|
22
|
+
"aria-current": isActive ? "page" : undefined,
|
|
23
|
+
onClick: onNavigate,
|
|
24
|
+
...(item.external ? { target: "_blank", rel: "noopener noreferrer" } : {}),
|
|
25
|
+
children: (_jsxs(_Fragment, { children: [item.icon && _jsx("span", { className: "relative flex size-5 shrink-0 items-center justify-center", children: item.icon }), !collapsed && _jsx("span", { className: "relative flex-1 truncate", children: item.label }), !collapsed && item.badge, !collapsed && item.external && (_jsx(ArrowUpRight, { className: "size-3.5 shrink-0 text-muted", "aria-hidden": "true" })), collapsed && hasActiveChild && !isActive && (_jsx("span", { "aria-hidden": "true", className: "absolute bottom-1 size-1 rounded-full bg-accent" }))] })),
|
|
26
|
+
};
|
|
27
|
+
// The tooltip stays mounted across expand/collapse and only toggles
|
|
28
|
+
// `isDisabled`. Branching into two trees instead re-creates every item's
|
|
29
|
+
// overlay and focus machinery on each toggle, which is what makes the width
|
|
30
|
+
// animation stutter.
|
|
31
|
+
return (_jsxs(Tooltip, { delay: 200, isDisabled: !collapsed, children: [_jsx(Tooltip.Trigger, { "aria-label": item.label, children: renderLink(linkProps) }), _jsx(Tooltip.Content, { placement: "right", children: item.label })] }));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The sidebar's nav column on its own - sections, items, one level of
|
|
35
|
+
* children, and the active highlight.
|
|
36
|
+
*
|
|
37
|
+
* `ConnectAppShell` renders this inside its rail and its mobile drawer, and it
|
|
38
|
+
* is exported for a host that wants the shell's navigation without its
|
|
39
|
+
* chrome: the same items, the same active resolution, dropped into a layout
|
|
40
|
+
* you already have.
|
|
41
|
+
*/
|
|
42
|
+
export function ConnectSideNav({ nav, activePath, renderLink = defaultRenderLink, collapsed = false, onNavigate, className, }) {
|
|
43
|
+
const pathname = useActivePath(activePath);
|
|
44
|
+
const active = resolveActivePath(pathname, nav);
|
|
45
|
+
const sections = normalizeNav(nav);
|
|
46
|
+
return (_jsx("div", { className: `flex flex-col gap-4 ${className ?? ""}`, children: sections.map((section, index) => (_jsxs("nav", { "aria-label": section.title ?? "Main", className: `flex flex-col gap-1 ${collapsed ? "items-center" : ""}`, children: [section.title && !collapsed && (_jsx("h2", { className: "px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-muted", children: section.title })), section.title && collapsed && index > 0 && _jsx("span", { className: "my-1 h-px w-6 bg-separator", "aria-hidden": "true" }), section.items.map((item) => {
|
|
47
|
+
const isActive = isNavItemActive(item, active);
|
|
48
|
+
const activeChild = (item.items ?? []).some((child) => isNavItemActive(child, active));
|
|
49
|
+
return (_jsxs("div", { className: `flex flex-col gap-1 ${collapsed ? "" : "w-full"}`, children: [_jsx(NavRow, { item: item, isActive: isActive, hasActiveChild: activeChild, collapsed: collapsed, depth: 0, renderLink: renderLink, onNavigate: onNavigate }), !collapsed &&
|
|
50
|
+
(isActive || activeChild) &&
|
|
51
|
+
item.items?.map((child) => (_jsx(NavRow, { item: child, isActive: isNavItemActive(child, active), hasActiveChild: false, collapsed: false, depth: 1, renderLink: renderLink, onNavigate: onNavigate }, child.href)))] }, item.href));
|
|
52
|
+
})] }, section.title ?? index))) }));
|
|
53
|
+
}
|
|
@@ -34,7 +34,7 @@ const HEADING = {
|
|
|
34
34
|
* The authoritative price is computed by Connect against the real cart:
|
|
35
35
|
* proration is priced against the actual cycle, a coupon is validated
|
|
36
36
|
* against the plan it is being applied to, trial eligibility depends on
|
|
37
|
-
* whether this
|
|
37
|
+
* whether this workspace has ever subscribed to this application before, and
|
|
38
38
|
* an accumulated credit is deducted. None of that is knowable from the
|
|
39
39
|
* partner's origin, so this component states the list price and says where
|
|
40
40
|
* the final figure comes from rather than quietly guessing at one.
|
|
@@ -47,7 +47,7 @@ export function ConnectSubscribeDialog({ plan, action, current, coupon, onClose,
|
|
|
47
47
|
// member and the one most in need of it: the two prices differ by an order
|
|
48
48
|
// of magnitude, and showing only the new one reads as a price rise.
|
|
49
49
|
const isChange = action === "upgrade" || action === "downgrade" || isIntervalSwitch;
|
|
50
|
-
return (_jsx(Modal.Backdrop, { isOpen: plan !== null, onOpenChange: (open) => !open && onClose(), children: _jsx(Modal.Container, { size: "sm", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, { onPress: onClose }), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: HEADING[action] }) }), plan && (_jsxs(_Fragment, { children: [_jsxs(Modal.Body, { className: "flex flex-col gap-4", children: [isChange && currentPlan ? (_jsxs("div", { className: "flex items-center gap-3 text-sm", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-muted", children: currentPlan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(currentPlan.price), "/", currentPlan.billingInterval === "annual" ? "yr" : "mo"] })] }), _jsx(ArrowRight, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate font-medium text-foreground", children: plan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(plan.price), "/", plan.billingInterval === "annual" ? "yr" : "mo"] })] })] })) : (_jsxs("div", { children: [_jsx("p", { className: "font-medium text-foreground", children: plan.name }), plan.description && _jsx("p", { className: "text-xs text-muted", children: plan.description })] })), _jsxs("div", { className: "flex flex-col gap-1 border-t border-separator pt-3 text-sm", children: [_jsxs("div", { className: "flex justify-between text-muted", children: [_jsx("span", { children: plan.name }), _jsx("span", { children: formatPrice(plan.price) })] }), _jsxs("div", { className: "flex justify-between text-muted", children: [_jsxs("span", { children: ["GST (", plan.gstRate, "%)"] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate) - plan.price) })] }), _jsxs("div", { className: "flex justify-between border-t border-separator pt-1 font-semibold text-foreground", children: [_jsxs("span", { children: ["Per ", cadenceOf(plan)] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate)) })] }), plan.billingInterval === "annual" && (_jsxs("p", { className: "text-xs text-muted", children: ["Works out to ", formatPrice(monthlyOf(withGst(plan.price, plan.gstRate), "annual")), " a month, billed yearly."] }))] }), plan.trialDays ? (_jsxs("p", { className: "text-xs text-accent", children: [plan.trialDays, "-day free trial, if this workspace has not subscribed to this
|
|
50
|
+
return (_jsx(Modal.Backdrop, { isOpen: plan !== null, onOpenChange: (open) => !open && onClose(), children: _jsx(Modal.Container, { size: "sm", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, { onPress: onClose }), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: HEADING[action] }) }), plan && (_jsxs(_Fragment, { children: [_jsxs(Modal.Body, { className: "flex flex-col gap-4", children: [isChange && currentPlan ? (_jsxs("div", { className: "flex items-center gap-3 text-sm", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-muted", children: currentPlan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(currentPlan.price), "/", currentPlan.billingInterval === "annual" ? "yr" : "mo"] })] }), _jsx(ArrowRight, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate font-medium text-foreground", children: plan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(plan.price), "/", plan.billingInterval === "annual" ? "yr" : "mo"] })] })] })) : (_jsxs("div", { children: [_jsx("p", { className: "font-medium text-foreground", children: plan.name }), plan.description && _jsx("p", { className: "text-xs text-muted", children: plan.description })] })), _jsxs("div", { className: "flex flex-col gap-1 border-t border-separator pt-3 text-sm", children: [_jsxs("div", { className: "flex justify-between text-muted", children: [_jsx("span", { children: plan.name }), _jsx("span", { children: formatPrice(plan.price) })] }), _jsxs("div", { className: "flex justify-between text-muted", children: [_jsxs("span", { children: ["GST (", plan.gstRate, "%)"] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate) - plan.price) })] }), _jsxs("div", { className: "flex justify-between border-t border-separator pt-1 font-semibold text-foreground", children: [_jsxs("span", { children: ["Per ", cadenceOf(plan)] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate)) })] }), plan.billingInterval === "annual" && (_jsxs("p", { className: "text-xs text-muted", children: ["Works out to ", formatPrice(monthlyOf(withGst(plan.price, plan.gstRate), "annual")), " a month, billed yearly."] }))] }), plan.trialDays ? (_jsxs("p", { className: "text-xs text-accent", children: [plan.trialDays, "-day free trial, if this workspace has not subscribed to this application before."] })) : null, coupon?.trim() ? (_jsxs("p", { className: "text-xs text-muted", children: ["Coupon ", _jsx("span", { className: "font-mono text-foreground", children: coupon.trim() }), " is carried over and validated on Connect."] })) : null, _jsxs("div", { className: "flex gap-2 rounded-lg bg-surface p-3 text-xs text-muted", children: [_jsx(Info, { className: "mt-0.5 size-4 shrink-0", "aria-hidden": "true" }), _jsxs("div", { className: "flex flex-col gap-1", children: [action === "subscribe" && (_jsx("p", { children: "Payment is completed on Connect, where your card or UPI mandate is set up and the tax invoice is issued. This page updates by itself once it is done." })), action === "upgrade" && (_jsxs("p", { children: ["Connect prices the difference for the days left in your current cycle and collects just that amount now. From the next cycle you are billed the ", plan.name, " rate."] })), action === "downgrade" && (_jsx("p", { children: "You keep what you have paid for. Connect works out the unused part of this cycle and applies it as credit or a refund, depending on how this application is configured." })), isIntervalSwitch && (
|
|
51
51
|
// Not a proration. The cycle you are on ends, so this is
|
|
52
52
|
// the one action where the amount collected can exceed a
|
|
53
53
|
// single cycle's list price - or be nothing at all, with
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { Button, Card, Chip, Skeleton } from "@heroui/react";
|
|
4
4
|
import { AlertTriangle, ArrowRight, Clock, CreditCard, ExternalLink, Users } from "lucide-react";
|
|
5
5
|
import { useConnect, useConnectEntitlements } from "../provider.js";
|
|
6
|
+
import { m, MotionProvider } from "./motion.js";
|
|
6
7
|
import { formatPrice } from "./plan-pricing.js";
|
|
7
8
|
import { ConnectLinkButton } from "./link-button.js";
|
|
8
9
|
/** Where the plan catalogue lives in the host app. Overridable, because not every app mounts it at `/plans`. */
|
|
@@ -54,7 +55,7 @@ function useSubscriptionState() {
|
|
|
54
55
|
* Rendered on its own it is just a card; `ConnectSubscriptionGate` is the usual
|
|
55
56
|
* way in, since it picks this only when there is genuinely nothing to show.
|
|
56
57
|
*/
|
|
57
|
-
export function ConnectNoSubscriptionCard({ plansHref = DEFAULT_PLANS_HREF, title = "You're not on a plan yet", description = "Pick a plan to unlock the full
|
|
58
|
+
export function ConnectNoSubscriptionCard({ plansHref = DEFAULT_PLANS_HREF, title = "You're not on a plan yet", description = "Pick a plan to unlock the full application. You can change or cancel it whenever you like.", className, }) {
|
|
58
59
|
return (_jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsx(Card.Title, { children: title }), _jsx(Card.Description, { children: description })] }), _jsx(Card.Footer, { children: _jsxs(ConnectLinkButton, { href: plansHref, variant: "primary", children: ["See plans", _jsx(ArrowRight, { className: "size-3.5" })] }) })] }));
|
|
59
60
|
}
|
|
60
61
|
/**
|
|
@@ -76,7 +77,7 @@ export function ConnectSubscriptionCard({ plansHref, className }) {
|
|
|
76
77
|
return _jsx(Skeleton, { className: `h-48 rounded-xl ${className ?? ""}` });
|
|
77
78
|
if (!detail || !plan)
|
|
78
79
|
return _jsx(ConnectNoSubscriptionCard, { plansHref: plansHref, className: className });
|
|
79
|
-
return (_jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: plan.name }), _jsx(SubscriptionChip, { state: state })] }), _jsxs(Card.Description, { children: [formatPrice(plan.price), " + ", plan.gstRate, "% GST per", " ", plan.billingInterval === "annual" ? "year" : "month"] })] }), _jsxs(Card.Content, { className: "flex flex-col gap-2 text-sm", children: [_jsx(NextEvent, { detail: detail, state: state }), _jsx(ConnectSeatUsage, { plan: plan, detail: detail })] }), _jsxs(Card.Footer, { className: "flex flex-wrap gap-2", children: [_jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }), _jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }));
|
|
80
|
+
return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 }, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: plan.name }), _jsx(SubscriptionChip, { state: state })] }), _jsxs(Card.Description, { children: [formatPrice(plan.price), " + ", plan.gstRate, "% GST per", " ", plan.billingInterval === "annual" ? "year" : "month"] })] }), _jsxs(Card.Content, { className: "flex flex-col gap-2 text-sm", children: [_jsx(NextEvent, { detail: detail, state: state }), _jsx(ConnectSeatUsage, { plan: plan, detail: detail })] }), _jsxs(Card.Footer, { className: "flex flex-wrap gap-2", children: [_jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }), _jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }) }) }));
|
|
80
81
|
}
|
|
81
82
|
function SubscriptionChip({ state }) {
|
|
82
83
|
switch (state) {
|