@cosmoops/connect-sdk 0.3.0 → 0.6.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 (81) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +215 -6
  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 +162 -0
  6. package/dist/cjs/client/components/account-sessions.js +21 -19
  7. package/dist/cjs/client/components/app-shell.js +157 -0
  8. package/dist/cjs/client/components/apps-grid.js +2 -1
  9. package/dist/cjs/client/components/integrations-modal.js +95 -0
  10. package/dist/cjs/client/components/motion.js +27 -0
  11. package/dist/cjs/client/components/nav.js +97 -0
  12. package/dist/cjs/client/components/plans.js +39 -37
  13. package/dist/cjs/client/components/readiness.js +3 -1
  14. package/dist/cjs/client/components/side-nav.js +58 -0
  15. package/dist/cjs/client/components/subscription.js +2 -1
  16. package/dist/cjs/client/components/surfaces.js +54 -0
  17. package/dist/cjs/client/components/usage-meter.js +3 -2
  18. package/dist/cjs/client/components/user-menu.js +30 -3
  19. package/dist/cjs/client/index.js +29 -1
  20. package/dist/cjs/client/provider.js +54 -2
  21. package/dist/cjs/client/use-active-path.js +77 -0
  22. package/dist/cjs/errors.js +1 -1
  23. package/dist/cjs/next/index.js +96 -15
  24. package/dist/cjs/server/client.js +10 -2
  25. package/dist/cjs/server/handler.js +89 -6
  26. package/dist/cjs/server/index.js +8 -1
  27. package/dist/cjs/server/resources/analytics.js +66 -0
  28. package/dist/cjs/server/resources/identity.js +1 -1
  29. package/dist/cjs/server/resources/integrations.js +66 -0
  30. package/dist/cjs/server/resources/sessions.js +1 -1
  31. package/dist/cjs/server/webhooks.js +127 -0
  32. package/dist/esm/cli/doctor.js +3 -3
  33. package/dist/esm/cli/init.js +3 -3
  34. package/dist/esm/client/components/access-gate.js +158 -0
  35. package/dist/esm/client/components/account-sessions.js +21 -19
  36. package/dist/esm/client/components/app-shell.js +154 -0
  37. package/dist/esm/client/components/apps-grid.js +2 -1
  38. package/dist/esm/client/components/integrations-modal.js +92 -0
  39. package/dist/esm/client/components/motion.js +21 -0
  40. package/dist/esm/client/components/nav.js +89 -0
  41. package/dist/esm/client/components/plans.js +39 -37
  42. package/dist/esm/client/components/readiness.js +3 -1
  43. package/dist/esm/client/components/side-nav.js +53 -0
  44. package/dist/esm/client/components/subscription.js +2 -1
  45. package/dist/esm/client/components/surfaces.js +51 -0
  46. package/dist/esm/client/components/usage-meter.js +3 -2
  47. package/dist/esm/client/components/user-menu.js +31 -4
  48. package/dist/esm/client/index.js +15 -1
  49. package/dist/esm/client/provider.js +53 -2
  50. package/dist/esm/client/use-active-path.js +74 -0
  51. package/dist/esm/errors.js +1 -1
  52. package/dist/esm/next/index.js +95 -15
  53. package/dist/esm/server/client.js +10 -2
  54. package/dist/esm/server/handler.js +89 -6
  55. package/dist/esm/server/index.js +5 -0
  56. package/dist/esm/server/resources/analytics.js +62 -0
  57. package/dist/esm/server/resources/identity.js +1 -1
  58. package/dist/esm/server/resources/integrations.js +62 -0
  59. package/dist/esm/server/resources/sessions.js +1 -1
  60. package/dist/esm/server/webhooks.js +123 -0
  61. package/dist/theme.css +28 -0
  62. package/dist/types/client/components/access-gate.d.ts +79 -0
  63. package/dist/types/client/components/app-shell.d.ts +115 -0
  64. package/dist/types/client/components/integrations-modal.d.ts +21 -0
  65. package/dist/types/client/components/motion.d.ts +22 -0
  66. package/dist/types/client/components/nav.d.ts +110 -0
  67. package/dist/types/client/components/side-nav.d.ts +31 -0
  68. package/dist/types/client/components/surfaces.d.ts +51 -0
  69. package/dist/types/client/index.d.ts +12 -1
  70. package/dist/types/client/provider.d.ts +38 -3
  71. package/dist/types/client/use-active-path.d.ts +8 -0
  72. package/dist/types/errors.d.ts +1 -1
  73. package/dist/types/next/index.d.ts +40 -0
  74. package/dist/types/server/client.d.ts +8 -2
  75. package/dist/types/server/index.d.ts +7 -0
  76. package/dist/types/server/resources/analytics.d.ts +31 -0
  77. package/dist/types/server/resources/identity.d.ts +12 -2
  78. package/dist/types/server/resources/integrations.d.ts +40 -0
  79. package/dist/types/server/webhooks.d.ts +67 -0
  80. package/dist/types/types.d.ts +182 -1
  81. package/package.json +10 -4
@@ -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 organization 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
+ }
@@ -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
+ }
@@ -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
  /**
@@ -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
- ? ` - code ${subscription.subscription.appliedDiscount.code}`
109
- : ""] })] })), _jsx(ConnectCheckoutStatusRow, { checkout: checkout }), _jsx("div", { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3", children: tiers.map((tier) => {
110
- // The price point this card is showing. A tier missing the toggled
111
- // cadence falls back to the one it does sell rather than dropping
112
- // out of the grid, and says so under the price.
113
- const plan = pickTierPlan(tier, showAnnual);
114
- if (!plan)
115
- return null;
116
- const action = resolveAction(plan, subscription);
117
- // "Current" belongs to the tier, not just the exact price point: an
118
- // org on Growth annual looking at monthly pricing is still on the
119
- // Growth tier, and marking only the row it literally subscribes to
120
- // would leave its own card unmarked half the time.
121
- const isCurrentTier = subscription?.plan != null &&
122
- (tier.monthly?.id === subscription.plan.id || tier.annual?.id === subscription.plan.id);
123
- const isCurrent = action === "current";
124
- const cadenceUnavailable = showAnnual ? tier.annual === null : tier.monthly === null;
125
- return (_jsxs(Card, { className: isCurrentTier ? "border-accent" : undefined, 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("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"] }), _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
- // `name`, not `key` - the key is an identifier
127
- // ("campaign-module"), and printing it here is what this
128
- // card used to do. The description rides along as a
129
- // title so a feature that needs explaining can have one
130
- // without a second line in every card.
131
- _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" &&
132
- `${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"
133
- ? "primary"
134
- : "secondary",
135
- // Disabled while a hand-off is in flight, not just on the
136
- // card that started it: the org has one subscription per
137
- // service, so a second checkout opened against a different
138
- // plan would race the first and one of them would be
139
- // rejected on arrival.
140
- isDisabled: isCurrent || checkout.status === "waiting", onPress: () => setPending({ plan, action }), children: [ACTION_LABEL[action], !isCurrent && _jsx(ExternalLink, { className: "size-3.5" })] }) })] }, tier.planGroup));
141
- }) }), 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) })] }));
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
+ // org 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 org has one subscription per
138
+ // service, 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
- if (checkout.status === "idle")
154
- return null;
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,6 +23,7 @@ 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";
@@ -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("div", { className: `flex flex-col gap-2 ${className ?? ""}`, role: "status", children: blockers.map((blocker) => (_jsx(BlockerRow, { blocker: blocker }, `${blocker.code}-${blocker.message}`))) }));
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);
@@ -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
+ }
@@ -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`. */
@@ -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) {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The one elevation model every panel inside a Connect modal follows.
3
+ *
4
+ * This exists because "just use `bg-surface`" is wrong here, and wrong in a
5
+ * way that is invisible until you look at the tokens. `Modal.Dialog` paints
6
+ * itself `bg-overlay`, and in the shared theme (`theme.css`) `--overlay` and
7
+ * `--surface` sit at the *same lightness* in both schemes - 100% against 100%
8
+ * in light, 21% against 21% in dark. A `bg-surface` panel dropped on a modal
9
+ * therefore has no fill contrast at all; only its border separates it from
10
+ * the dialog behind it.
11
+ *
12
+ * That is how the account modal ended up looking half-finished: the identity
13
+ * card used `bg-surface-secondary` and read as a solid block, while the
14
+ * destination tiles and the session list used `bg-surface` and read as
15
+ * hairline outlines floating on nothing. Same nesting depth, two different
16
+ * physical materials, for no reason a user could infer.
17
+ *
18
+ * Three levels, and nesting one inside another always steps the fill:
19
+ *
20
+ * - **L0 - the dialog itself.** `bg-overlay`, owned by HeroUI. Not ours.
21
+ * - **L1 - a panel on the dialog.** `PANEL_FILL` + `border-border`. The
22
+ * identity card, each destination tile, the session list.
23
+ * - **L2 - a medallion inside a panel.** `bg-surface`, which is L0's value
24
+ * and therefore reads as *inset* against L1 rather than raised. Light:
25
+ * white on grey. Dark: near-black on charcoal. Both directions legible.
26
+ *
27
+ * The inversion at L2 is deliberate and is why this is written down. Reaching
28
+ * for a "lighter" fill one step down works in light mode and quietly fails in
29
+ * dark; stepping *back to the dialog's own value* is the trick that reads in
30
+ * both, because the contrast comes from the gap between L1 and L0 rather than
31
+ * from a direction of travel.
32
+ */
33
+ export const PANEL_FILL = "bg-surface-secondary";
34
+ /**
35
+ * The icon well shared by destination tiles and session rows: a 40px box
36
+ * holding a 20px glyph.
37
+ *
38
+ * Half the well, which is the point. Both surfaces used to run a `size-4`
39
+ * (16px) icon inside a `size-9` (36px) well - a 44% fill that reads as an
40
+ * icon someone forgot to finish, and the specific thing that made the
41
+ * Profile/Billing/Connect tiles look under-drawn. They also disagreed on
42
+ * shape (a circle in the tiles, a rounded square in the session rows) for
43
+ * what is the same component role, so the two surfaces never looked related.
44
+ *
45
+ * Pair with `ICON` on the glyph inside. Session rows override the well to
46
+ * `size-9` for density but keep the same `ICON`, so glyph size stays constant
47
+ * across the whole modal even where the well does not.
48
+ */
49
+ export const MEDALLION = "size-10";
50
+ /** The glyph inside a `MEDALLION`. Constant across every well in the modal - see `MEDALLION`. */
51
+ export const ICON = "size-5";
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Card, Skeleton } from "@heroui/react";
4
4
  import { AlertTriangle } from "lucide-react";
5
5
  import { useConnect, useConnectAddonUsage } from "../provider.js";
6
+ import { m, METER_SPRING, MotionProvider } from "./motion.js";
6
7
  import { formatPrice } from "./plan-pricing.js";
7
8
  function formatUnits(value, unit) {
8
9
  return `${value.toLocaleString("en-IN")} ${unit}`;
@@ -50,7 +51,7 @@ export function ConnectUsageMeter({ addonIds, hideWhenEmpty = true, title = "Usa
50
51
  return null;
51
52
  return (_jsx(Card, { className: className, children: _jsxs(Card.Header, { children: [_jsx(Card.Title, { children: title }), _jsx(Card.Description, { children: "No metered add-ons on this plan." })] }) }));
52
53
  }
53
- return (_jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsx(Card.Title, { children: title }), data.resetsAt && _jsxs(Card.Description, { children: ["Resets on ", formatDate(data.resetsAt), "."] })] }), _jsx(Card.Content, { className: "flex flex-col gap-4", children: addons.map((addon) => (_jsx(AddonRow, { addon: addon }, addon.addonId))) })] }));
54
+ return (_jsx(MotionProvider, { children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsx(Card.Title, { children: title }), data.resetsAt && _jsxs(Card.Description, { children: ["Resets on ", formatDate(data.resetsAt), "."] })] }), _jsx(Card.Content, { className: "flex flex-col gap-4", children: addons.map((addon) => (_jsx(AddonRow, { addon: addon }, addon.addonId))) })] }) }));
54
55
  }
55
56
  function AddonRow({ addon }) {
56
57
  const overdrawn = addon.balance < 0;
@@ -61,5 +62,5 @@ function AddonRow({ addon }) {
61
62
  const carried = addon.balance > addon.includedPerCycle ? addon.balance - addon.includedPerCycle : 0;
62
63
  return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { className: "font-medium text-foreground", children: addon.name }), _jsxs("span", { className: overdrawn ? "text-danger" : "text-muted", children: [overdrawn
63
64
  ? `${formatUnits(Math.abs(addon.balance), addon.unit)} over`
64
- : `${formatUnits(addon.balance, addon.unit)} left`, _jsxs("span", { className: "text-muted", children: [" of ", formatUnits(addon.includedPerCycle, addon.unit)] })] })] }), _jsx("div", { className: "h-1.5 w-full overflow-hidden rounded-full bg-surface", role: "meter", "aria-label": `${addon.name} remaining`, "aria-valuenow": addon.balance, "aria-valuemin": 0, "aria-valuemax": addon.includedPerCycle, children: _jsx("div", { className: `h-full rounded-full ${overdrawn ? "bg-danger" : "bg-accent"}`, style: { width: `${overdrawn ? 100 : fraction * 100}%` } }) }), carried > 0 && (_jsxs("p", { className: "text-xs text-muted", children: ["Includes ", formatUnits(carried, addon.unit), " carried over from last cycle."] })), overdrawn && addon.overagePricePerUnit !== null && (_jsxs("p", { className: "flex items-center gap-1.5 text-xs text-danger", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0", "aria-hidden": "true" }), "Billed at ", formatPrice(addon.overagePricePerUnit), " per ", addon.unit, " beyond your allowance."] })), !overdrawn && addon.overagePolicy === "reject" && addon.balance === 0 && (_jsxs("p", { className: "flex items-center gap-1.5 text-xs text-danger", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0", "aria-hidden": "true" }), "Nothing left this cycle - further use is blocked until it resets."] }))] }));
65
+ : `${formatUnits(addon.balance, addon.unit)} left`, _jsxs("span", { className: "text-muted", children: [" of ", formatUnits(addon.includedPerCycle, addon.unit)] })] })] }), _jsx("div", { className: "h-1.5 w-full overflow-hidden rounded-full bg-surface", role: "meter", "aria-label": `${addon.name} remaining`, "aria-valuenow": addon.balance, "aria-valuemin": 0, "aria-valuemax": addon.includedPerCycle, children: _jsx(m.div, { className: `h-full rounded-full ${overdrawn ? "bg-danger" : "bg-accent"}`, initial: { width: 0 }, animate: { width: `${overdrawn ? 100 : fraction * 100}%` }, transition: METER_SPRING }) }), carried > 0 && (_jsxs("p", { className: "text-xs text-muted", children: ["Includes ", formatUnits(carried, addon.unit), " carried over from last cycle."] })), overdrawn && addon.overagePricePerUnit !== null && (_jsxs("p", { className: "flex items-center gap-1.5 text-xs text-danger", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0", "aria-hidden": "true" }), "Billed at ", formatPrice(addon.overagePricePerUnit), " per ", addon.unit, " beyond your allowance."] })), !overdrawn && addon.overagePolicy === "reject" && addon.balance === 0 && (_jsxs("p", { className: "flex items-center gap-1.5 text-xs text-danger", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0", "aria-hidden": "true" }), "Nothing left this cycle - further use is blocked until it resets."] }))] }));
65
66
  }
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Avatar, Button, Chip, Modal, Separator, Skeleton, ToggleButton, ToggleButtonGroup, } from "@heroui/react";
3
+ import { Avatar, Button, Chip, Modal, Skeleton, ToggleButton, ToggleButtonGroup, } from "@heroui/react";
4
4
  import { CreditCard, ExternalLink, Laptop, LogOut, Moon, ReceiptText, Sun, UserRound, } from "lucide-react";
5
5
  import { useConnect } from "../provider.js";
6
6
  import { ConnectMark } from "../mark.js";
7
7
  import { ConnectAccountSessions } from "./account-sessions.js";
8
+ import { ICON, MEDALLION, PANEL_FILL } from "./surfaces.js";
8
9
  const THEME_OPTIONS = [
9
10
  { value: "light", label: "Light", icon: Sun },
10
11
  { value: "dark", label: "Dark", icon: Moon },
@@ -18,6 +19,20 @@ function initials(name, email) {
18
19
  function openExternal(url) {
19
20
  window.open(url, "_blank", "noopener,noreferrer");
20
21
  }
22
+ /**
23
+ * The member's photo, with their initials underneath it.
24
+ *
25
+ * Both halves always render, which is the whole trick: `<Avatar.Fallback>` is
26
+ * what shows while `src` is still loading and what stays if it 404s or the
27
+ * user has no photo at all, so there is no `photoUrl &&` branch anywhere and
28
+ * no flash of an empty circle on a cold cache. This component previously
29
+ * rendered *only* the fallback - `ConnectUser` carried no photo field at all,
30
+ * so every Connect account showed initials no matter what picture the member
31
+ * had set on their profile.
32
+ */
33
+ function UserAvatar({ user, size, }) {
34
+ return (_jsxs(Avatar, { size: size, color: "accent", variant: "soft", children: [user.photoUrl && _jsx(Avatar.Image, { alt: "", src: user.photoUrl }), _jsx(Avatar.Fallback, { children: initials(user.displayName, user.email) })] }));
35
+ }
21
36
  /** `dispatch_admin` / `billing-viewer` -> `Dispatch admin`. */
22
37
  function humanizeRole(role) {
23
38
  const words = role.replace(/[_-]+/g, " ").trim();
@@ -58,9 +73,17 @@ function subscriptionChip(subscription) {
58
73
  * `w-full` is load-bearing, not decoration: HeroUI's `.button` base is
59
74
  * `w-fit`, so a tile dropped into a grid cell shrinks to its own label and
60
75
  * the three of them come out ragged and unequal.
76
+ *
77
+ * On the box: `p-3` all round rather than the `px-2 py-4` this used to carry.
78
+ * That old pair was both lopsided (16px of vertical air against 8px of
79
+ * horizontal) and too tall for what it held, so a 16px glyph sat marooned in
80
+ * a ~92px tile. Even padding plus the shared `size-10`/`size-5` medallion
81
+ * (see `MEDALLION`) puts the icon at half the well instead of a stranded
82
+ * 44%, and takes the tile down to ~88px while making its contents read
83
+ * bigger, not smaller.
61
84
  */
62
85
  function DestinationTile({ icon, label, url, }) {
63
- return (_jsxs(Button, { variant: "tertiary", "aria-label": `${label} (opens in a new tab)`, className: "relative h-auto w-full flex-col items-center justify-center gap-2 rounded-xl border border-border bg-surface px-2 py-4 hover:bg-surface-secondary", onPress: () => openExternal(url), children: [_jsx(ExternalLink, { className: "absolute end-2 top-2 size-3 text-muted", "aria-hidden": "true" }), _jsx("span", { className: "flex size-9 items-center justify-center rounded-full bg-surface-secondary text-muted", children: icon }), _jsx("span", { className: "text-xs font-medium text-foreground", children: label })] }));
86
+ return (_jsxs(Button, { variant: "tertiary", "aria-label": `${label} (opens in a new tab)`, className: `relative h-auto w-full flex-col items-center justify-center gap-2 rounded-xl border border-border ${PANEL_FILL} p-3 hover:border-accent hover:bg-surface-secondary`, onPress: () => openExternal(url), children: [_jsx(ExternalLink, { className: "absolute end-2 top-2 size-3.5 text-muted", "aria-hidden": "true" }), _jsx("span", { className: `flex ${MEDALLION} items-center justify-center rounded-xl bg-surface text-muted`, children: icon }), _jsx("span", { className: "text-xs font-medium text-foreground", children: label })] }));
64
87
  }
65
88
  /**
66
89
  * Avatar and name in the trigger; an identity card, the three Connect
@@ -101,11 +124,15 @@ export function ConnectUserMenu({ compact = false, onSignOut, className, theme,
101
124
  const label = user.displayName ?? user.email ?? "Account";
102
125
  const role = session?.serviceRole?.role;
103
126
  const planChip = subscriptionChip(session?.subscription ?? null);
104
- return (_jsxs(Modal, { children: [_jsxs(Button, { variant: "tertiary", "aria-label": `Account: ${label}`, className: `gap-1 pl-1 ${className ?? ""}`, children: [_jsx(Avatar, { size: "sm", color: "accent", variant: "soft", children: _jsx(Avatar.Fallback, { children: initials(user.displayName, user.email) }) }), !compact && _jsx("span", { className: "text-sm font-medium text-foreground", children: label })] }), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "md", scroll: "inside", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Account" }) }), _jsxs(Modal.Body, { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start gap-3 rounded-xl border border-border bg-surface-secondary p-4", children: [_jsx(Avatar, { size: "lg", color: "accent", variant: "soft", children: _jsx(Avatar.Fallback, { children: initials(user.displayName, user.email) }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && _jsx("p", { className: "truncate text-xs text-muted", children: user.email }), _jsxs("div", { className: "mt-2 flex flex-wrap items-center gap-1.5", children: [_jsx(Chip, { size: "sm", variant: "soft", children: user.organizationName }), role && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && (_jsx(Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] })] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsx(DestinationTile, { icon: _jsx(UserRound, { className: "size-4", "aria-hidden": "true" }), label: "Profile", url: `${appUrl}/profile` }), _jsx(DestinationTile, { icon: _jsx(CreditCard, { className: "size-4", "aria-hidden": "true" }), label: "Billing", url: `${appUrl}/billing` }), _jsx(DestinationTile, { icon: _jsx(ConnectMark, { className: "size-4" }), label: "Connect", url: appUrl })] }), theme && onThemeChange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Appearance" }), _jsx(ToggleButtonGroup, { fullWidth: true, "aria-label": "Theme", selectionMode: "single", disallowEmptySelection: true, selectedKeys: [theme], onSelectionChange: (keys) => {
127
+ return (_jsxs(Modal, { children: [_jsxs(Button, { variant: "tertiary", "aria-label": `Account: ${label}`,
128
+ // `pl-1` against the default right padding is optical, not sloppy: a
129
+ // circular avatar needs less inset than a text label to look evenly
130
+ // seated in the button.
131
+ className: `gap-2 pl-1 ${className ?? ""}`, children: [_jsx(UserAvatar, { user: user, size: "sm" }), !compact && _jsx("span", { className: "text-sm font-medium text-foreground", children: label })] }), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "md", scroll: "inside", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Account" }) }), _jsxs(Modal.Body, { className: "flex flex-col gap-5", children: [_jsxs("div", { className: `flex items-start gap-3 rounded-xl border border-border ${PANEL_FILL} p-4`, children: [_jsx(UserAvatar, { user: user, size: "lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && _jsx("p", { className: "truncate text-xs text-muted", children: user.email }), _jsx("p", { className: "truncate text-xs text-muted", children: user.organizationName }), (role || planChip || !user.emailVerified) && (_jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && (_jsx(Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsx(DestinationTile, { icon: _jsx(UserRound, { className: ICON, "aria-hidden": "true" }), label: "Profile", url: `${appUrl}/profile` }), _jsx(DestinationTile, { icon: _jsx(CreditCard, { className: ICON, "aria-hidden": "true" }), label: "Billing", url: `${appUrl}/billing` }), _jsx(DestinationTile, { icon: _jsx(ConnectMark, { className: ICON }), label: "Connect", url: appUrl })] }), theme && onThemeChange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Appearance" }), _jsx(ToggleButtonGroup, { fullWidth: true, "aria-label": "Theme", selectionMode: "single", disallowEmptySelection: true, selectedKeys: [theme], onSelectionChange: (keys) => {
105
132
  const [next] = [...keys];
106
133
  if (typeof next === "string")
107
134
  onThemeChange(next);
108
- }, children: THEME_OPTIONS.map(({ value, label: optionLabel, icon: Icon }, index) => (_jsxs(ToggleButton, { id: value, children: [index > 0 && _jsx(ToggleButtonGroup.Separator, {}), _jsx(Icon, { className: "size-4", "aria-hidden": "true" }), optionLabel] }, value))) })] })), _jsx(Separator, {}), _jsx(ConnectAccountSessions, {})] }), _jsx(Modal.Footer, { children: _jsxs(Button, { slot: "close", fullWidth: true, variant: "tertiary", className: "text-danger", onPress: () => {
135
+ }, children: THEME_OPTIONS.map(({ value, label: optionLabel, icon: Icon }, index) => (_jsxs(ToggleButton, { id: value, children: [index > 0 && _jsx(ToggleButtonGroup.Separator, {}), _jsx(Icon, { className: "size-4", "aria-hidden": "true" }), optionLabel] }, value))) })] })), _jsx(ConnectAccountSessions, {})] }), _jsx(Modal.Footer, { children: _jsxs(Button, { slot: "close", fullWidth: true, variant: "tertiary", className: "text-danger", onPress: () => {
109
136
  void signOut().then(onSignOut);
110
137
  }, children: [_jsx(LogOut, { className: "size-4 shrink-0" }), "Sign out"] }) })] }) }) })] }));
111
138
  }