@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,127 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verifyConnectWebhook = verifyConnectWebhook;
4
+ exports.createConnectWebhookHandler = createConnectWebhookHandler;
5
+ const node_crypto_1 = require("node:crypto");
6
+ /**
7
+ * How far out of date a timestamp may be. Five minutes, the same value
8
+ * Connect signs against and the same one Stripe uses - so a backend that
9
+ * already verifies Stripe webhooks needs no new assumptions.
10
+ */
11
+ const TIMESTAMP_TOLERANCE_SECONDS = 300;
12
+ const SIGNATURE_HEADER = "connect-signature";
13
+ /**
14
+ * Verifies a `Connect-Signature` header against the raw request body.
15
+ *
16
+ * Exported on its own for anyone wiring this into a framework
17
+ * `createConnectWebhookHandler` does not cover. The **raw** body matters:
18
+ * verify before parsing, and never re-serialise - `JSON.stringify(await
19
+ * req.json())` produces a different string whenever key order or number
20
+ * formatting differs, and the signature will not match.
21
+ *
22
+ * Returns a boolean and never explains which check failed. Reporting "bad
23
+ * timestamp" separately from "bad signature" tells whoever is probing which
24
+ * half of a forgery to keep working on.
25
+ */
26
+ function verifyConnectWebhook(rawBody, signatureHeader, secret, now = new Date()) {
27
+ if (!signatureHeader)
28
+ return false;
29
+ const parts = new Map(signatureHeader
30
+ .split(",")
31
+ .map((part) => part.trim().split("="))
32
+ .filter((pair) => pair.length === 2)
33
+ .map(([key, value]) => [key, value]));
34
+ const timestamp = Number(parts.get("t"));
35
+ const presented = parts.get("v1");
36
+ if (!presented || !Number.isFinite(timestamp))
37
+ return false;
38
+ // Absolute difference: a timestamp from the future is as much a sign of a
39
+ // forgery as a stale one, and accepting it would let somebody mint a
40
+ // signature that stays valid indefinitely.
41
+ const nowSeconds = Math.floor(now.getTime() / 1000);
42
+ if (Math.abs(nowSeconds - timestamp) > TIMESTAMP_TOLERANCE_SECONDS)
43
+ return false;
44
+ const expected = (0, node_crypto_1.createHmac)("sha256", secret)
45
+ .update(`${timestamp}.${rawBody}`)
46
+ .digest("hex");
47
+ // `timingSafeEqual` throws on a length mismatch, and a wrong-length hex
48
+ // string is exactly what a probe looks like - so lengths are compared
49
+ // first and only equal-length buffers reach it.
50
+ if (presented.length !== expected.length)
51
+ return false;
52
+ return (0, node_crypto_1.timingSafeEqual)(Buffer.from(presented, "utf8"), Buffer.from(expected, "utf8"));
53
+ }
54
+ /**
55
+ * A ready-made `POST` route for Connect's webhooks.
56
+ *
57
+ * ```ts
58
+ * export const POST = createConnectWebhookHandler({
59
+ * secret: process.env.CONNECT_WEBHOOK_SECRET!,
60
+ * on: {
61
+ * "organization.deletion_scheduled": ({ organizationId, purgeAfter }) =>
62
+ * freeze(organizationId, purgeAfter),
63
+ * "organization.deletion_cancelled": ({ organizationId }) => unfreeze(organizationId),
64
+ * "organization.deleted": ({ organizationId }) => purge(organizationId),
65
+ * },
66
+ * })
67
+ * ```
68
+ *
69
+ * Three behaviours worth knowing, because each of them is a decision rather
70
+ * than an accident:
71
+ *
72
+ * **An unverified request gets a 401 and nothing else happens.** No parsing,
73
+ * no logging of the body, no handler. The endpoint is a public URL, so
74
+ * anything reachable before the signature check is reachable by anyone.
75
+ *
76
+ * **An unhandled event type is a 200.** Connect adds event types over time,
77
+ * and answering 4xx to one you have not written code for would dead-letter
78
+ * it immediately and, after enough of them, disable your endpoint. Silence
79
+ * is the correct answer to "we do not care about this one".
80
+ *
81
+ * **A throwing handler produces a 500, on purpose.** That is what makes
82
+ * Connect retry. Swallowing the error would acknowledge an event you failed
83
+ * to process, and the retry ladder is the only thing standing between a
84
+ * transient failure and a permanently missed deletion.
85
+ *
86
+ * Delivery is at-least-once and unordered. Dedupe on `envelope.id`, and
87
+ * reconcile against the readiness endpoint rather than trusting arrival
88
+ * order - the pull channel is authoritative, this one is fast.
89
+ */
90
+ function createConnectWebhookHandler(options) {
91
+ return async function POST(request) {
92
+ // Read as text and verify before parsing. `JSON.stringify(await
93
+ // request.json())` is not the same string that was signed - key order and
94
+ // number formatting both differ - so a handler that parses first can
95
+ // never verify.
96
+ const rawBody = await request.text();
97
+ const signature = request.headers.get(SIGNATURE_HEADER);
98
+ if (!verifyConnectWebhook(rawBody, signature, options.secret)) {
99
+ return new Response("Invalid signature", { status: 401 });
100
+ }
101
+ let envelope;
102
+ try {
103
+ envelope = JSON.parse(rawBody);
104
+ }
105
+ catch {
106
+ // Signed by us and still unparseable is not something a retry fixes.
107
+ return new Response("Malformed payload", { status: 400 });
108
+ }
109
+ const handler = options.on[envelope.type];
110
+ if (!handler)
111
+ return new Response(null, { status: 200 });
112
+ try {
113
+ await handler(envelope.data, envelope);
114
+ }
115
+ catch (error) {
116
+ if (options.onError) {
117
+ await options.onError(error, envelope);
118
+ }
119
+ else {
120
+ console.error(`Connect webhook ${envelope.type} (${envelope.id}) failed:`, error);
121
+ }
122
+ // Deliberately a 500: it is what makes Connect retry. See the doc above.
123
+ return new Response("Handler failed", { status: 500 });
124
+ }
125
+ return new Response(null, { status: 200 });
126
+ };
127
+ }
@@ -159,8 +159,8 @@ function checkEnv(root) {
159
159
  status: "fail",
160
160
  title: `${name} set`,
161
161
  detail: declaredEmpty
162
- ? `Declared but empty in .env.local. The value is in your service's Setup tab in Connect.`
163
- : `Not found in .env.local or the environment. Both values are in your service's Setup tab in Connect.`,
162
+ ? `Declared but empty in .env.local. The value is in your service's Integration tab in Connect.`
163
+ : `Not found in .env.local or the environment. Both values are in your service's Integration tab in Connect.`,
164
164
  };
165
165
  });
166
166
  }
@@ -173,7 +173,7 @@ function checkRedirectUri() {
173
173
  return {
174
174
  status: "warn",
175
175
  title: "Redirect URI registered in Connect",
176
- detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your service's Setup tab - including the deployed origin, not just http://localhost:3000.",
176
+ detail: "Not checkable from here. Confirm {your origin}/api/connect/callback is listed as a redirect URI in your service's Integration tab - including the deployed origin, not just http://localhost:3000.",
177
177
  };
178
178
  }
179
179
  const MARKER = { pass: " ok ", warn: " note ", fail: " FAIL " };
@@ -39,7 +39,7 @@ const PLANS_PAGE = `"use client";
39
39
  // with the other exports) whenever you want your own copy or layout.
40
40
  export { ConnectPlansPage as default } from "@cosmoops/connect-sdk/client";
41
41
  `;
42
- const ENV_TEMPLATE = `# CosmoOps Connect - values come from your service's Setup tab.
42
+ const ENV_TEMPLATE = `# CosmoOps Connect - values come from your service's Integration tab.
43
43
  CONNECT_API_KEY=
44
44
  CONNECT_CLIENT_ID=
45
45
  `;
@@ -106,7 +106,7 @@ export function runInit(argv = []) {
106
106
  console.log(` ${marker} ${relative(root, target)} - ${scaffold.description}`);
107
107
  }
108
108
  const env = ensureEnv(root);
109
- console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your service's Setup tab`);
109
+ console.log(` ${env === "skipped" ? "exists " : env === "appended" ? "updated" : "created"} .env.local - fill in the two values from your service's Integration tab`);
110
110
  // The two steps a scaffolder must not do for you: editing an existing layout
111
111
  // means parsing and rewriting JSX someone else wrote, and the redirect URI
112
112
  // lives in Connect, not in this repo.
@@ -132,7 +132,7 @@ Three things left, all one-time:
132
132
  Tailwind roots its content scan inside node_modules and skips your app.
133
133
 
134
134
  3. Register {your origin}/api/connect/callback as a redirect URI in your
135
- service's Setup tab in Connect.
135
+ service's Integration tab in Connect.
136
136
 
137
137
  No next.config.ts changes needed - not even transpilePackages: dist/ is
138
138
  already ES2022 with "use client" hoisted, so Turbopack and webpack consume it
@@ -0,0 +1,158 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { Ban, AlertTriangle, Clock, CreditCard, ExternalLink, X } from "lucide-react";
5
+ import { Button, Card, Skeleton } from "@heroui/react";
6
+ import { blockingBlockers } from "../../helpers.js";
7
+ import { useConnect, useConnectEntitlements } from "../provider.js";
8
+ import { ENTER, m, MotionProvider } from "./motion.js";
9
+ import { ConnectReadinessBanner } from "./readiness.js";
10
+ function formatDate(iso) {
11
+ return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
12
+ }
13
+ /**
14
+ * The single composed answer to "can this workspace use my app right now,
15
+ * and if not, which of the four things is it": choose a plan, wait on
16
+ * approval, resolve an overdue payment, or - a suspended workspace - none of
17
+ * the above, because nothing short of a platform admin fixes that one.
18
+ *
19
+ * Reuses the existing single sources of truth rather than re-deriving them:
20
+ * `blockingBlockers()` (`helpers.ts`) for severity, the same readiness read
21
+ * `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
22
+ * is a priority ordering over the same data, not a second opinion about it.
23
+ *
24
+ * Priority, first match wins: `suspended` > `no_subscription` >
25
+ * `onboarding_pending` > any other blocking code (`blocked`) >
26
+ * `payment_retry` (advisory - `children` still render) > `clear`.
27
+ */
28
+ export function useConnectAccessGate() {
29
+ const { session, isLoading: sessionLoading } = useConnect();
30
+ const { data, isLoading: entitlementsLoading } = useConnectEntitlements();
31
+ if (sessionLoading || (session?.user && entitlementsLoading)) {
32
+ return { state: "loading", blocker: null, paymentRetry: null };
33
+ }
34
+ if (!session?.user) {
35
+ return { state: "signed_out", blocker: null, paymentRetry: null };
36
+ }
37
+ if (!data) {
38
+ // Signed in, not loading, yet nothing came back - a failed read (see
39
+ // `error` on `useConnectEntitlements`). Fail open rather than hard-block
40
+ // the whole app on a network hiccup; `ConnectReadinessBanner` makes the
41
+ // same call for its own "no data" branch.
42
+ return { state: "clear", blocker: null, paymentRetry: null };
43
+ }
44
+ const readiness = data.readiness;
45
+ const blocking = blockingBlockers(readiness);
46
+ const suspended = blocking.find((b) => b.code === "organization_suspended");
47
+ if (suspended)
48
+ return { state: "suspended", blocker: suspended, paymentRetry: null };
49
+ const noSubscription = blocking.find((b) => b.code === "subscription_missing");
50
+ if (noSubscription)
51
+ return { state: "no_subscription", blocker: noSubscription, paymentRetry: null };
52
+ const onboardingPending = blocking.find((b) => b.code === "onboarding_approval_pending");
53
+ if (onboardingPending) {
54
+ return { state: "onboarding_pending", blocker: onboardingPending, paymentRetry: null };
55
+ }
56
+ if (blocking.length > 0) {
57
+ return { state: "blocked", blocker: blocking[0] ?? null, paymentRetry: null };
58
+ }
59
+ const paymentRetry = readiness.readiness.blockers.find((b) => b.code === "payment_retry_pending");
60
+ if (paymentRetry)
61
+ return { state: "payment_retry", blocker: null, paymentRetry };
62
+ return { state: "clear", blocker: null, paymentRetry: null };
63
+ }
64
+ /**
65
+ * Wrap your app's content once - typically in your root layout, alongside
66
+ * (not instead of) your own header/nav - and it renders exactly one of: the
67
+ * hard-block card for a suspended workspace, one with no plan, or one
68
+ * waiting on approval; your `children` plus a dismissible payment-due banner
69
+ * during an automatic dunning retry; or just `children` once everything is
70
+ * clear.
71
+ *
72
+ * **Do not wrap your own plans/pricing page with this.** The `no_subscription`
73
+ * card's button sends someone to Connect's subscribe page directly (see
74
+ * `GateCard`'s doc comment on why - it used to link to your in-app `/plans`
75
+ * instead, which is a dead end once this gate hides that very page too), but
76
+ * a customer can still navigate to your plans page on their own (a nav link,
77
+ * a bookmark) and must actually see it there. Since the gate is mounted once
78
+ * around every route, carving out that exception is your router's job, not
79
+ * a prop on this component: check the current path in your layout/shell and
80
+ * skip wrapping `children` in `<ConnectAccessGate>` for that one route.
81
+ *
82
+ * **Signed-out renders `children`, not a fallback** - deliberately different
83
+ * from `ConnectReadinessGate`/`ConnectSubscriptionGate`, which show nothing
84
+ * until signed in. None of the four states this component composes is a
85
+ * property of a signed-out visitor, and a component mounted around your
86
+ * entire app must not hide a public landing page behind an empty gate. Your
87
+ * own sign-in flow decides what a signed-out visitor sees.
88
+ *
89
+ * **This is a UX gate, not a security boundary** - same caveat as every
90
+ * other gate in this package. The authoritative check is
91
+ * `client.entitlements.getReadiness` on your server, before you do the
92
+ * thing.
93
+ */
94
+ export function ConnectAccessGate({ children, fallback, loading, className }) {
95
+ const gate = useConnectAccessGate();
96
+ if (gate.state === "loading") {
97
+ return _jsx(_Fragment, { children: loading ?? _jsx(Skeleton, { className: `h-32 rounded-xl ${className ?? ""}` }) });
98
+ }
99
+ if (gate.state === "signed_out" || gate.state === "clear") {
100
+ return _jsx(_Fragment, { children: children });
101
+ }
102
+ if (gate.state === "payment_retry") {
103
+ return (_jsxs(_Fragment, { children: [gate.paymentRetry && _jsx(PaymentRetryBanner, { blocker: gate.paymentRetry, className: className }), children] }));
104
+ }
105
+ return (_jsx(_Fragment, { children: fallback ?? _jsx(AccessGateFallback, { state: gate.state, blocker: gate.blocker, className: className }) }));
106
+ }
107
+ function AccessGateFallback({ state, blocker, className, }) {
108
+ if (state === "no_subscription") {
109
+ return (_jsx(GateCard, { icon: CreditCard, iconClassName: "text-accent", title: "Choose a plan to continue", message: blocker?.message ?? "This workspace isn't subscribed to this service yet.", actionLabel: "See plans", resolveUrl: blocker?.resolveUrl ?? null, className: className }));
110
+ }
111
+ if (state === "suspended") {
112
+ return (_jsx(GateCard, { icon: Ban, iconClassName: "text-danger", title: "Workspace suspended", message: blocker?.message ?? "This workspace has been suspended.", resolveUrl: null, className: className }));
113
+ }
114
+ if (state === "onboarding_pending") {
115
+ return (_jsx(GateCard, { icon: Clock, iconClassName: "text-muted", title: "Waiting on approval", message: blocker?.message ?? "This workspace is waiting on approval before it can use this service.", resolveUrl: null, className: className }));
116
+ }
117
+ // "blocked" - any other blocking code (verification, audience, seats, ...).
118
+ // Full fidelity for a code this component doesn't special-case: the same
119
+ // banner-in-a-card `ConnectReadinessGate` falls back to, rather than
120
+ // `GateCard`'s single-blocker shape - there can be more than one of these
121
+ // at once, and each already carries its own action.
122
+ return (_jsx(Card, { className: className, children: _jsx(Card.Content, { className: "py-6", children: _jsx(ConnectReadinessBanner, {}) }) }));
123
+ }
124
+ /**
125
+ * One card shape for every hard-block state: an icon, what's wrong, and -
126
+ * when the server sent a `resolveUrl` - the one button that fixes it,
127
+ * opening Connect directly in a new tab. The same pattern every other
128
+ * resolvable blocker in this package uses (`BlockerRow`, `PaymentRetryBanner`
129
+ * below) - `no_subscription` used to be the one exception, sending someone
130
+ * to the host app's own `/plans` page instead. That was a deliberate choice
131
+ * on `ConnectNoSubscriptionCard` (browsing plans belongs in your product),
132
+ * but inside a gate mounted around your *entire* app it is a dead end: the
133
+ * card replaces whatever page it is rendered on, including your own plans
134
+ * page if you did not carve it out (see `ConnectAccessGate`'s doc comment),
135
+ * so "see plans" landing back on the same blocked screen never gets anyone
136
+ * to a choice they can act on. Sending them to Connect's own subscribe page
137
+ * always resolves.
138
+ */
139
+ function GateCard({ icon: Icon, iconClassName, title, message, actionLabel, resolveUrl, className, }) {
140
+ return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: ENTER, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Icon, { className: `size-5 shrink-0 ${iconClassName}`, "aria-hidden": "true" }), _jsx(Card.Title, { children: title })] }), _jsx(Card.Description, { children: message })] }), resolveUrl && (_jsx(Card.Footer, { children: _jsxs(Button, { variant: "primary", onPress: () => window.open(resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel ?? "Resolve on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }) }))] }) }) }));
141
+ }
142
+ /**
143
+ * The skippable payment-due warning. Dismissal is scoped to this specific
144
+ * dunning cycle - keyed by `graceEndsAt`, so a dismissed banner reappears on
145
+ * its own if a *new* retry cycle starts (a different `graceEndsAt`) even
146
+ * though an old dismissal is still on record.
147
+ */
148
+ function PaymentRetryBanner({ blocker, className, }) {
149
+ const dismissKey = blocker.graceEndsAt ? `connect_dismissed_payment_retry_pending_${blocker.graceEndsAt}` : null;
150
+ const [dismissed, setDismissed] = useState(() => dismissKey !== null && typeof window !== "undefined" && window.localStorage.getItem(dismissKey) === "1");
151
+ if (dismissed)
152
+ return null;
153
+ return (_jsx(MotionProvider, { children: _jsxs(m.div, { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent ${className ?? ""}`, role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: ENTER, children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" }), blocker.message, blocker.graceEndsAt && ` Resolve by ${formatDate(blocker.graceEndsAt)} to avoid losing access.`] }), _jsxs("div", { className: "flex items-center gap-2", children: [blocker.resolveUrl && (_jsxs(Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: ["Resolve payment", _jsx(ExternalLink, { className: "size-3.5" })] })), _jsx(Button, { variant: "tertiary", isIconOnly: true, "aria-label": "Dismiss", onPress: () => {
154
+ setDismissed(true);
155
+ if (dismissKey && typeof window !== "undefined")
156
+ window.localStorage.setItem(dismissKey, "1");
157
+ }, children: _jsx(X, { className: "size-4" }) })] })] }) }));
158
+ }
@@ -4,6 +4,8 @@ import { useState } from "react";
4
4
  import { AlertDialog, Button, Chip, Skeleton } from "@heroui/react";
5
5
  import { LogOut, Monitor, MonitorSmartphone, Smartphone, Tablet } from "lucide-react";
6
6
  import { useConnect, useConnectSessions } from "../provider.js";
7
+ import { AnimatePresence, EASE_OUT_QUART, m, MotionProvider } from "./motion.js";
8
+ import { ICON, PANEL_FILL } from "./surfaces.js";
7
9
  import { formatRelativeTime } from "../../format-relative-time.js";
8
10
  const DEVICE_ICONS = {
9
11
  desktop: Monitor,
@@ -75,13 +77,13 @@ export function ConnectAccountSessions({ className, maxVisible = 4 }) {
75
77
  const [isRevokingOthers, setIsRevokingOthers] = useState(false);
76
78
  const [showAll, setShowAll] = useState(false);
77
79
  if (isLoading) {
78
- return (_jsxs("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [_jsx(Skeleton, { className: "h-4 w-28" }), _jsx("div", { className: "flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface", children: Array.from({ length: 2 }, (_, index) => (_jsxs("div", { className: "flex items-center gap-3 px-3 py-3", children: [_jsx(Skeleton, { className: "size-9 shrink-0 rounded-lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-32" }), _jsx(Skeleton, { className: "h-3 w-24" })] })] }, index))) })] }));
80
+ return (_jsxs("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [_jsx(Skeleton, { className: "h-4 w-28" }), _jsx("div", { className: `flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border ${PANEL_FILL}`, children: Array.from({ length: 2 }, (_, index) => (_jsxs("div", { className: "flex items-center gap-3 px-3 py-3", children: [_jsx(Skeleton, { className: "size-9 shrink-0 rounded-xl" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-32" }), _jsx(Skeleton, { className: "h-3 w-24" })] })] }, index))) })] }));
79
81
  }
80
82
  // A failed read used to render as *nothing*, which is indistinguishable
81
83
  // from "you have no other devices" - the one reading of this list that
82
84
  // should never be guessed at.
83
85
  if (error) {
84
- return (_jsxs("section", { className: `flex flex-col gap-2 ${className ?? ""}`, children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Active sessions" }), _jsx("p", { className: "rounded-xl border border-border bg-surface px-3 py-4 text-xs text-muted", children: "Could not load your sessions. Try again in a moment." })] }));
86
+ return (_jsxs("section", { className: `flex flex-col gap-2 ${className ?? ""}`, children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Active sessions" }), _jsx("p", { className: `rounded-xl border border-border ${PANEL_FILL} px-3 py-4 text-xs text-muted`, children: "Could not load your sessions. Try again in a moment." })] }));
85
87
  }
86
88
  const sessions = ordered(data?.items ?? []);
87
89
  if (sessions.length === 0)
@@ -89,21 +91,21 @@ export function ConnectAccountSessions({ className, maxVisible = 4 }) {
89
91
  const hasOtherSessions = sessions.some((session) => !session.isCurrent);
90
92
  const isCollapsible = maxVisible > 0 && sessions.length > maxVisible;
91
93
  const visible = isCollapsible && !showAll ? sessions.slice(0, maxVisible) : sessions;
92
- return (_jsxs("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("p", { className: "flex items-center gap-2 text-sm font-medium text-foreground", children: ["Active sessions", _jsx(Chip, { size: "sm", variant: "soft", children: sessions.length })] }), _jsx("p", { className: "text-xs text-muted", children: "Devices signed in to your Connect account." })] }), hasOtherSessions && (_jsx(Button, { size: "sm", variant: "tertiary", className: "shrink-0 text-xs text-danger", isPending: isRevokingOthers, isDisabled: isRevokingOthers, onPress: () => {
93
- setIsRevokingOthers(true);
94
- void mutateResource("/sessions/revoke-others")
95
- .then(() => {
96
- invalidateResource("/sessions");
97
- refetch();
98
- })
99
- .finally(() => setIsRevokingOthers(false));
100
- }, children: "Sign out others" }))] }), _jsx("div", { className: "flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border bg-surface", children: visible.map((session) => {
101
- const Icon = DEVICE_ICONS[session.device.deviceType];
102
- return (_jsxs("div", { className: "flex items-center gap-3 px-3 py-2.5", children: [_jsx("span", { className: `flex size-9 shrink-0 items-center justify-center rounded-lg ${session.isCurrent ? "bg-accent-soft text-accent-soft-foreground" : "bg-surface-secondary text-muted"}`, children: _jsx(Icon, { className: "size-4", "aria-hidden": "true" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx("span", { className: "truncate text-sm font-medium text-foreground", children: deviceLabel(session) }), session.isCurrent && (_jsx(Chip, { color: "accent", size: "sm", variant: "soft", className: "shrink-0", children: "This device" }))] }), _jsx("p", { className: "truncate text-xs text-muted", children: metaLine(session) })] }), !session.isCurrent && (_jsx(RevokeRow, { session: session, onRevoke: () => {
103
- void mutateResource("/sessions/revoke", { sessionId: session.id }).then(() => {
104
- invalidateResource("/sessions");
105
- refetch();
106
- });
107
- } }))] }, session.id));
108
- }) }), isCollapsible && (_jsx(Button, { size: "sm", variant: "tertiary", className: "self-start text-xs", onPress: () => setShowAll((open) => !open), children: showAll ? "Show fewer" : `Show all ${sessions.length} devices` }))] }));
94
+ return (_jsx(MotionProvider, { children: _jsxs("section", { className: `flex flex-col gap-3 ${className ?? ""}`, children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsxs("p", { className: "flex items-center gap-2 text-sm font-medium text-foreground", children: ["Active sessions", _jsx(Chip, { size: "sm", variant: "soft", children: sessions.length })] }), _jsx("p", { className: "text-xs text-muted", children: "Devices signed in to your Connect account." })] }), hasOtherSessions && (_jsx(Button, { size: "sm", variant: "tertiary", className: "shrink-0 text-xs text-danger", isPending: isRevokingOthers, isDisabled: isRevokingOthers, onPress: () => {
95
+ setIsRevokingOthers(true);
96
+ void mutateResource("/sessions/revoke-others")
97
+ .then(() => {
98
+ invalidateResource("/sessions");
99
+ refetch();
100
+ })
101
+ .finally(() => setIsRevokingOthers(false));
102
+ }, children: "Sign out others" }))] }), _jsx("div", { className: `flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border ${PANEL_FILL}`, children: _jsx(AnimatePresence, { initial: false, children: visible.map((session) => {
103
+ const Icon = DEVICE_ICONS[session.device.deviceType];
104
+ return (_jsxs(m.div, { layout: true, exit: { opacity: 0, height: 0, overflow: "hidden" }, transition: { duration: 0.25, ease: EASE_OUT_QUART }, className: "flex items-center gap-3 px-3 py-2.5", children: [_jsx("span", { className: `flex size-9 shrink-0 items-center justify-center rounded-xl ${session.isCurrent ? "bg-accent-soft text-accent-soft-foreground" : "bg-surface text-muted"}`, children: _jsx(Icon, { className: ICON, "aria-hidden": "true" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx("span", { className: "truncate text-sm font-medium text-foreground", children: deviceLabel(session) }), session.isCurrent && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", className: "shrink-0", children: "This device" }))] }), _jsx("p", { className: "truncate text-xs text-muted", children: metaLine(session) })] }), !session.isCurrent && (_jsx(RevokeRow, { session: session, onRevoke: () => {
105
+ void mutateResource("/sessions/revoke", { sessionId: session.id }).then(() => {
106
+ invalidateResource("/sessions");
107
+ refetch();
108
+ });
109
+ } }))] }, session.id));
110
+ }) }) }), isCollapsible && (_jsx(Button, { size: "sm", variant: "tertiary", className: "self-start text-xs", onPress: () => setShowAll((open) => !open), children: showAll ? "Show fewer" : `Show all ${sessions.length} devices` }))] }) }));
109
111
  }
@@ -0,0 +1,154 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useCallback, useState, useSyncExternalStore } from "react";
4
+ import { Button, Drawer, Tooltip } from "@heroui/react";
5
+ import { Menu, PanelLeftClose, PanelLeftOpen } from "lucide-react";
6
+ import { useConnect } from "../provider.js";
7
+ import { ConnectAccessGate } from "./access-gate.js";
8
+ import { ConnectAppsGrid } from "./apps-grid.js";
9
+ import { ConnectLoginButton, ConnectRegisterButton } from "./auth-buttons.js";
10
+ import { ConnectLogo } from "./logo.js";
11
+ import { ConnectSideNav, defaultRenderLink } from "./side-nav.js";
12
+ import { ConnectUserMenu } from "./user-menu.js";
13
+ import { gateExemptPaths, normalizePath, } from "./nav.js";
14
+ import { useActivePath } from "../use-active-path.js";
15
+ const COLLAPSED_KEY = "connect_sidenav_collapsed";
16
+ const COLLAPSED_EVENT = "connect:sidenav-collapsed";
17
+ /**
18
+ * The stored rail preference, or `null` when there isn't one (or when reading
19
+ * it is not allowed).
20
+ *
21
+ * `localStorage` throws outright in a partitioned iframe or with site data
22
+ * blocked, and an application frame is not worth losing over a preference.
23
+ */
24
+ function collapsedSnapshot() {
25
+ try {
26
+ const stored = window.localStorage.getItem(COLLAPSED_KEY);
27
+ return stored === null ? null : stored === "1";
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ /**
34
+ * No preference on the server, which is the point: `useSyncExternalStore` uses
35
+ * this for the hydrating render and only then swaps in the real reading, so a
36
+ * returning user's collapsed rail can never make the server's HTML and the
37
+ * client's first pass disagree. Reading storage during render instead is a
38
+ * hydration mismatch waiting for its first user.
39
+ */
40
+ function serverCollapsedSnapshot() {
41
+ return null;
42
+ }
43
+ function subscribeCollapsed(onStoreChange) {
44
+ // `storage` covers the app's other tabs; the custom event covers this one,
45
+ // which `storage` deliberately never fires in.
46
+ window.addEventListener("storage", onStoreChange);
47
+ window.addEventListener(COLLAPSED_EVENT, onStoreChange);
48
+ return () => {
49
+ window.removeEventListener("storage", onStoreChange);
50
+ window.removeEventListener(COLLAPSED_EVENT, onStoreChange);
51
+ };
52
+ }
53
+ /**
54
+ * The sidebar's brand row: the lockup when expanded, the badge alone when not.
55
+ */
56
+ function BrandLink({ name, icon, homeHref, collapsed, renderLink, }) {
57
+ return renderLink({
58
+ href: homeHref,
59
+ className: `flex items-center rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus ${collapsed ? "justify-center" : ""}`,
60
+ // `ConnectLogo` names itself, but it does it with `role="img"` on a
61
+ // `<span>`, which the `<a>` wrapping it does not inherit as its own name.
62
+ "aria-label": `${name} home`,
63
+ children: _jsx(ConnectLogo, { name: name, icon: icon, collapsed: collapsed }),
64
+ });
65
+ }
66
+ /**
67
+ * The application frame every CosmoOps service platform runs inside: a
68
+ * collapsible sidebar, a top bar carrying the app launcher and the account
69
+ * menu, and your pages in the middle.
70
+ *
71
+ * This is the piece each partner was otherwise writing by hand, and writing
72
+ * slightly differently - which is how a family of services stops looking like
73
+ * one product. Everything that should be identical across platforms (the
74
+ * lockup, the rail geometry, the active-route treatment, the launcher, the
75
+ * account menu, the access gate) lives here; everything that genuinely
76
+ * differs is a prop:
77
+ *
78
+ * ```tsx
79
+ * "use client";
80
+ * import Link from "next/link";
81
+ * import { usePathname } from "next/navigation";
82
+ * import { ConnectAppShell } from "@cosmoops/connect-sdk/client";
83
+ * import { LayoutDashboard, Truck } from "lucide-react";
84
+ *
85
+ * export function Shell({ children }: { children: React.ReactNode }) {
86
+ * return (
87
+ * <ConnectAppShell
88
+ * name="Dispatch"
89
+ * icon={<Truck />}
90
+ * activePath={usePathname()}
91
+ * renderLink={(props) => <Link {...props} />}
92
+ * nav={[
93
+ * { label: "Overview", href: "/", icon: <LayoutDashboard className="size-5" /> },
94
+ * { label: "Plans", href: "/plans", icon: <Truck className="size-5" />, gateExempt: true },
95
+ * ]}
96
+ * >
97
+ * {children}
98
+ * </ConnectAppShell>
99
+ * );
100
+ * }
101
+ * ```
102
+ *
103
+ * There is no `isSignedIn` branch anywhere in here, and there should be none
104
+ * in yours: the auth buttons render nothing once a session exists, the user
105
+ * menu renders nothing without one, and the launcher renders nothing when
106
+ * signed out. The swap happens inside the components.
107
+ *
108
+ * It renders no provider of its own - mount `ConnectProvider` above it, once,
109
+ * so a page with a shell, a plan table and a usage meter still makes one
110
+ * session read.
111
+ */
112
+ export function ConnectAppShell({ name, icon, homeHref = "/", nav, activePath, renderLink = defaultRenderLink, title, headerActions, sidebarFooter, footer, defaultCollapsed = false, collapsed: controlledCollapsed, onCollapsedChange, collapsible = true, showAppsGrid = true, showAuthButtons = true, theme, onThemeChange, gate = true, gateExemptRoutes, className, contentClassName, children, }) {
113
+ const { session, isLoading } = useConnect();
114
+ const pathname = useActivePath(activePath);
115
+ const [mobileOpen, setMobileOpen] = useState(false);
116
+ // The rail's own state lives in `localStorage`, read as an external store
117
+ // rather than copied into React state: it is shared with this app's other
118
+ // tabs, and the reading is not available during the render that hydrates.
119
+ const stored = useSyncExternalStore(subscribeCollapsed, collapsedSnapshot, serverCollapsedSnapshot);
120
+ const isControlled = controlledCollapsed !== undefined;
121
+ const collapsed = collapsible ? (isControlled ? controlledCollapsed : (stored ?? defaultCollapsed)) : false;
122
+ const toggleCollapsed = useCallback(() => {
123
+ const next = !collapsed;
124
+ onCollapsedChange?.(next);
125
+ if (isControlled)
126
+ return;
127
+ try {
128
+ window.localStorage.setItem(COLLAPSED_KEY, next ? "1" : "0");
129
+ }
130
+ catch {
131
+ // Not persisted - but the event below still moves the rail for this session.
132
+ }
133
+ window.dispatchEvent(new Event(COLLAPSED_EVENT));
134
+ }, [collapsed, isControlled, onCollapsedChange]);
135
+ const closeMobile = useCallback(() => setMobileOpen(false), []);
136
+ const currentPath = normalizePath(pathname);
137
+ const exempt = [...gateExemptPaths(nav), ...(gateExemptRoutes ?? []).map(normalizePath)];
138
+ const isExempt = exempt.some((path) => currentPath === path || currentPath.startsWith(`${path}/`));
139
+ // A signed-out visitor has no readiness to check and nothing to be blocked
140
+ // by; `ConnectAccessGate` passes them straight through, but mounting it at
141
+ // all costs an entitlements read that cannot succeed.
142
+ const signedOut = !isLoading && !session?.user;
143
+ const gated = gate && !isExempt && !signedOut;
144
+ const sideNav = (mobile) => (_jsx(ConnectSideNav, { nav: nav, activePath: pathname, renderLink: renderLink, collapsed: mobile ? false : collapsed, onNavigate: mobile ? closeMobile : undefined }));
145
+ return (_jsxs("div", { className: `flex h-screen bg-background ${className ?? ""}`, children: [_jsxs("aside", { "aria-label": "Sidebar", className: `hidden shrink-0 flex-col border-r border-border bg-surface transition-[width] duration-200 ease-in-out will-change-[width] md:flex ${collapsed ? "w-16" : "w-64"}`, children: [_jsx("div", { className: `flex h-16 shrink-0 items-center border-b border-border ${collapsed ? "justify-center px-2" : "px-4"}`, children: _jsx(BrandLink, { name: name, icon: icon, homeHref: homeHref, collapsed: collapsed, renderLink: renderLink }) }), _jsx("div", { className: `flex-1 overflow-y-auto py-4 ${collapsed ? "px-2" : "px-3"}`, children: sideNav(false) }), _jsxs("div", { className: `shrink-0 border-t border-border ${collapsed ? "flex flex-col items-center gap-2 p-2" : "flex flex-col gap-2 p-3"}`, children: [!collapsed && sidebarFooter, collapsible && (
146
+ // The button goes in as a *direct* child, the way `Modal` and
147
+ // `Dropdown` take theirs. `Tooltip.Trigger` renders a wrapper
148
+ // `<div>` and puts the props it is given on that div - so an
149
+ // `aria-label` passed there names a div nobody can reach and
150
+ // leaves this icon-only button with no accessible name at all.
151
+ // The wrapper is for trigger elements React Aria cannot drive
152
+ // directly; a HeroUI `Button` is not one.
153
+ _jsxs(Tooltip, { delay: 200, children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": collapsed ? "Expand sidebar" : "Collapse sidebar", className: collapsed ? "" : "self-start", onPress: toggleCollapsed, children: collapsed ? _jsx(PanelLeftOpen, { className: "size-4" }) : _jsx(PanelLeftClose, { className: "size-4" }) }), _jsx(Tooltip.Content, { placement: "right", children: collapsed ? "Expand sidebar" : "Collapse sidebar" })] }))] })] }), _jsx(Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: _jsx(Drawer.Content, { placement: "left", className: "w-72 max-w-[80vw]", children: _jsxs(Drawer.Dialog, { "aria-label": "Navigation menu", children: [_jsx(Drawer.CloseTrigger, {}), _jsx(Drawer.Header, { children: _jsx(Drawer.Heading, { "aria-label": name, children: _jsx(ConnectLogo, { name: name, icon: icon }) }) }), _jsx(Drawer.Body, { children: sideNav(true) }), sidebarFooter && _jsx(Drawer.Footer, { className: "flex flex-col items-stretch gap-3", children: sidebarFooter })] }) }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex h-16 shrink-0 items-center gap-3 border-b border-border bg-surface px-4 md:px-6", children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", className: "md:hidden", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: _jsx(Menu, { className: "size-5" }) }), typeof title === "string" ? (_jsx("h1", { className: "truncate text-lg font-semibold text-foreground", children: title })) : (title), _jsxs("div", { className: "ml-auto flex items-center gap-1", children: [headerActions, showAppsGrid && _jsx(ConnectAppsGrid, {}), _jsx(ConnectUserMenu, { compact: true, theme: theme, onThemeChange: onThemeChange }), showAuthButtons && (_jsxs(_Fragment, { children: [_jsx(ConnectLoginButton, { size: "sm" }), _jsx(ConnectRegisterButton, { size: "sm" })] }))] })] }), _jsx("main", { className: "flex-1 overflow-y-auto", children: _jsxs("div", { className: contentClassName ?? "mx-auto w-full max-w-6xl px-4 py-6 md:px-6 md:py-8", children: [gated ? _jsx(ConnectAccessGate, { children: children }) : children, footer] }) })] })] }));
154
+ }
@@ -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 ? (
@@ -35,7 +36,7 @@ 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("div", { className: "grid grid-cols-3 gap-2", children: apps.map((app) => (_jsx(AppTile, { app: app }, app.serviceId))) }))] }), _jsx(Modal.Footer, { children: _jsx(ConnectBrowseAllLink, {}) })] }) }) })] }));
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.serviceId))) }))] }), _jsx(Modal.Footer, { children: _jsx(ConnectBrowseAllLink, {}) })] }) }) })] }) }));
39
40
  }
40
41
  function ConnectBrowseAllLink() {
41
42
  const { appUrl } = useConnect();