@cosmoops/connect-sdk 0.19.0 → 0.21.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 (46) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +16 -2
  3. package/dist/cjs/client/components/apps-grid.js +112 -23
  4. package/dist/cjs/client/components/identity.js +24 -0
  5. package/dist/cjs/client/components/one-tap-state.js +96 -0
  6. package/dist/cjs/client/components/one-tap.js +106 -0
  7. package/dist/cjs/client/components/plan-pricing.js +16 -0
  8. package/dist/cjs/client/components/plans.js +1 -1
  9. package/dist/cjs/client/components/subscribe-dialog.js +2 -1
  10. package/dist/cjs/client/components/user-menu.js +2 -6
  11. package/dist/cjs/client/index.js +11 -3
  12. package/dist/cjs/client/provider.js +88 -0
  13. package/dist/cjs/server/client.js +9 -1
  14. package/dist/cjs/server/handler.js +126 -2
  15. package/dist/cjs/server/resources/calls.js +57 -0
  16. package/dist/cjs/server/resources/channels.js +115 -0
  17. package/dist/cjs/server/resources/identity.js +40 -0
  18. package/dist/esm/client/components/apps-grid.js +115 -26
  19. package/dist/esm/client/components/identity.js +21 -0
  20. package/dist/esm/client/components/one-tap-state.js +90 -0
  21. package/dist/esm/client/components/one-tap.js +103 -0
  22. package/dist/esm/client/components/plan-pricing.js +15 -0
  23. package/dist/esm/client/components/plans.js +3 -3
  24. package/dist/esm/client/components/subscribe-dialog.js +3 -2
  25. package/dist/esm/client/components/user-menu.js +1 -5
  26. package/dist/esm/client/index.js +7 -0
  27. package/dist/esm/client/provider.js +88 -0
  28. package/dist/esm/server/client.js +9 -1
  29. package/dist/esm/server/handler.js +127 -3
  30. package/dist/esm/server/resources/calls.js +53 -0
  31. package/dist/esm/server/resources/channels.js +111 -0
  32. package/dist/esm/server/resources/identity.js +39 -0
  33. package/dist/types/client/components/apps-grid.d.ts +14 -7
  34. package/dist/types/client/components/identity.d.ts +17 -0
  35. package/dist/types/client/components/one-tap-state.d.ts +39 -0
  36. package/dist/types/client/components/one-tap.d.ts +50 -0
  37. package/dist/types/client/components/plan-pricing.d.ts +13 -0
  38. package/dist/types/client/index.d.ts +4 -2
  39. package/dist/types/client/provider.d.ts +81 -0
  40. package/dist/types/server/client.d.ts +7 -1
  41. package/dist/types/server/index.d.ts +2 -0
  42. package/dist/types/server/resources/calls.d.ts +45 -0
  43. package/dist/types/server/resources/channels.d.ts +91 -0
  44. package/dist/types/server/resources/identity.d.ts +35 -0
  45. package/dist/types/types.d.ts +186 -1
  46. package/package.json +1 -1
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.IdentityResource = void 0;
4
4
  exports.createState = createState;
5
+ exports.createPkcePair = createPkcePair;
5
6
  const errors_js_1 = require("../../errors.js");
6
7
  const http_js_1 = require("../http.js");
7
8
  /** A URL-safe random string, suitable for `state` or a PKCE verifier. */
@@ -16,6 +17,23 @@ function base64UrlEncode(bytes) {
16
17
  binary += String.fromCharCode(byte);
17
18
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
18
19
  }
20
+ /**
21
+ * Mints a PKCE pair for the One Tap flow.
22
+ *
23
+ * The redirect flow can take PKCE or leave it, because the code is delivered
24
+ * to the application's server on a redirect the browser only forwards. One Tap
25
+ * hands the code *to the page*, so the verifier is the thing that makes a
26
+ * stolen code worthless - it stays in an `HttpOnly` cookie on the application's
27
+ * own origin and is never sent to the browser.
28
+ *
29
+ * `crypto.subtle` rather than a Node hash, so this stays isomorphic and the
30
+ * `./next` entry keeps loading on the edge runtime as well as Node.
31
+ */
32
+ async function createPkcePair() {
33
+ const verifier = createState(32);
34
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
35
+ return { verifier, challenge: base64UrlEncode(new Uint8Array(digest)) };
36
+ }
19
37
  function base64UrlDecode(value) {
20
38
  const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "=");
21
39
  const binary = atob(padded);
@@ -54,6 +72,28 @@ class IdentityResource {
54
72
  }
55
73
  return url.toString();
56
74
  }
75
+ /**
76
+ * The two Connect endpoints One Tap talks to **from the browser**, or `null`
77
+ * when this client has no `clientId` configured and the flow cannot run.
78
+ *
79
+ * Handed out by the bridge rather than built in the page, so One Tap needs no
80
+ * client-side configuration at all - a `./next` application sets
81
+ * `CONNECT_CLIENT_ID` server-side and nothing else. Both URLs carry only the
82
+ * public `client_id`; neither is a credential.
83
+ *
84
+ * Returning `null` rather than throwing (as `authorizeUrl` does) because this
85
+ * is read on the `/session` route, which must keep answering for an
86
+ * application that has no OAuth client configured at all.
87
+ */
88
+ oneTapEndpoints() {
89
+ if (!this.clientId)
90
+ return null;
91
+ const hint = new URL("/api/sso/session-hint", this.appBaseUrl);
92
+ hint.searchParams.set("client_id", this.clientId);
93
+ const authorize = new URL("/api/sso/authorize/one-tap", this.appBaseUrl);
94
+ authorize.searchParams.set("client_id", this.clientId);
95
+ return { hintUrl: hint.toString(), authorizeUrl: authorize.toString() };
96
+ }
57
97
  /** Step 2 - trade the one-time `code` for a session. Server-side only. */
58
98
  async exchangeCode(input) {
59
99
  const wire = await this.post("/sso/token", {
@@ -1,45 +1,134 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { Button, Modal, Skeleton } from "@heroui/react";
4
- import { AlertCircle, ExternalLink, LayoutGrid } from "lucide-react";
3
+ import { Button, Input, Label, Modal, Skeleton, TextField } from "@heroui/react";
4
+ import { AlertCircle, ArrowUpRight, BarChart3, Calendar, Cloud, Cpu, CreditCard, Database, HardDrive, LayoutGrid, LineChart, ListChecks, Lock, Mail, MessageSquare, Package, Plus, Receipt, Search, Server, ShieldCheck, } from "lucide-react";
5
+ import { useMemo, useState } from "react";
5
6
  import { useConnectApps, useConnect } from "../provider.js";
6
- import { ENTER, m, MotionProvider } from "./motion.js";
7
+ import { m, MotionProvider } from "./motion.js";
7
8
  import { SidebarLabel, sidebarRowClass } from "./shell-context.js";
8
- function AppTile({ app }) {
9
- const disabled = app.launchUrl === null;
10
- const body = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "relative flex size-12 items-center justify-center overflow-hidden rounded-xl bg-surface-secondary", children: [app.iconUrl ? (
11
- // Deliberately a plain `<img>`: this package ships into any React app
12
- // and cannot assume `next/image` or any other framework loader.
13
- // eslint-disable-next-line @next/next/no-img-element
14
- _jsx("img", { src: app.iconUrl, alt: "", className: "size-full object-cover" })) : (_jsx(LayoutGrid, { className: "size-6 text-muted", "aria-hidden": "true" })), app.needsAttention && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-warning text-warning-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: app.name }), app.isCurrent && _jsx("span", { className: "text-[0.625rem] uppercase tracking-wide text-accent", children: "Current" })] }));
15
- const shared = "flex flex-col items-center gap-2 rounded-xl p-3 transition-colors focus-visible:outline-2 focus-visible:outline-focus";
9
+ /**
10
+ * Connect's curated `Application.icon` set, as components.
11
+ *
12
+ * A closed map rather than a dynamic lookup on `lucide-react`: the icon name
13
+ * arrives over the wire, and resolving an arbitrary string against a module's
14
+ * exports is how a catalog typo becomes a render crash. Anything unrecognised
15
+ * - including the older, lowercased names some applications were registered
16
+ * with - falls through to the neutral tile glyph.
17
+ */
18
+ const APP_ICONS = {
19
+ Mail,
20
+ MessageSquare,
21
+ Database,
22
+ HardDrive,
23
+ BarChart3,
24
+ LineChart,
25
+ ShieldCheck,
26
+ Lock,
27
+ CreditCard,
28
+ Receipt,
29
+ ListChecks,
30
+ Calendar,
31
+ Server,
32
+ Cloud,
33
+ Cpu,
34
+ Package,
35
+ };
36
+ /** Below this many tiles a filter field is one more thing to look past. */
37
+ const SEARCH_THRESHOLD = 8;
38
+ /**
39
+ * The tile's own glyph: the uploaded brand icon when there is one, the
40
+ * curated lucide icon when there is not, and the neutral grid mark when the
41
+ * name is one this version of the SDK does not know.
42
+ */
43
+ function AppGlyph({ app }) {
44
+ if (app.iconUrl) {
45
+ // Deliberately a plain `<img>`: this package ships into any React app
46
+ // and cannot assume `next/image` or any other framework loader.
47
+ // eslint-disable-next-line @next/next/no-img-element
48
+ return _jsx("img", { src: app.iconUrl, alt: "", className: "size-full object-cover" });
49
+ }
50
+ const Icon = APP_ICONS[app.icon] ?? LayoutGrid;
51
+ return _jsx(Icon, { className: "size-6", "aria-hidden": "true" });
52
+ }
53
+ function AppTile({ app, onOpen }) {
54
+ // A subscribed app with no registered site is the one genuinely dead tile:
55
+ // there is nothing to open and nothing to buy.
56
+ const disabled = app.subscribed && app.launchUrl === null;
57
+ const shared = "group/tile flex w-full flex-col items-center gap-2 rounded-2xl p-3 text-center outline-offset-2 transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-focus";
58
+ const body = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "relative flex size-14 items-center justify-center overflow-hidden rounded-[0.8125rem] bg-surface-secondary text-muted transition-colors duration-200 group-hover/tile:bg-default group-hover/tile:text-foreground", children: [_jsx(AppGlyph, { app: app }), app.needsAttention && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-warning text-warning-foreground", children: _jsx(AlertCircle, { className: "size-3", "aria-hidden": "true" }) })), !app.subscribed && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-default text-muted opacity-0 transition-opacity duration-200 group-hover/tile:opacity-100", children: _jsx(Plus, { className: "size-3", "aria-hidden": "true" }) }))] }), _jsx("span", { className: "line-clamp-2 text-xs font-medium leading-tight text-foreground", children: app.name }), app.isCurrent ? (_jsx("span", { className: "text-[0.625rem] uppercase tracking-wide text-accent", children: "Current" })) : (!app.subscribed && (_jsxs("span", { className: "flex items-center gap-0.5 text-[0.625rem] uppercase tracking-wide text-muted opacity-0 transition-opacity duration-200 group-hover/tile:opacity-100", children: ["Subscribe", _jsx(ArrowUpRight, { className: "size-2.5", "aria-hidden": "true" })] })))] }));
16
59
  if (disabled) {
17
60
  return (_jsx("div", { className: `${shared} cursor-default opacity-60`, title: `${app.name} has no launch URL configured`, children: body }));
18
61
  }
19
- return (_jsx("a", { href: app.launchUrl, target: "_blank", rel: "noopener noreferrer", className: `${shared} hover:bg-surface-secondary`, title: app.description || app.name, children: body }));
62
+ return (
63
+ // A `<button>` rather than an `<a>`: where the tile goes depends on
64
+ // `subscribed` *and* on Connect's own origin, which only the provider
65
+ // knows, so one handler resolves it rather than every caller rebuilding
66
+ // the URL. Both destinations still open in a new tab - a launcher must
67
+ // not throw away the page you launched it from.
68
+ _jsx(m.button, { type: "button", onClick: () => onOpen(app), className: `${shared} hover:bg-surface-secondary`, title: app.description || app.name, whileHover: { y: -2 }, whileTap: { scale: 0.94 }, transition: { type: "spring", stiffness: 420, damping: 28 }, children: body }));
69
+ }
70
+ function AppsSection({ title, hint, apps, offset, onOpen, }) {
71
+ if (apps.length === 0)
72
+ return null;
73
+ return (_jsxs("section", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 px-1", children: [_jsx("h3", { className: "text-[0.6875rem] font-semibold uppercase tracking-[0.06em] text-muted", children: title }), _jsx("p", { className: "text-[0.6875rem] text-muted", children: hint })] }), _jsx("div", { className: "grid grid-cols-3 gap-1 sm:grid-cols-4", children: apps.map((app, index) => (_jsx(m.div, { initial: { opacity: 0, scale: 0.9, y: 8 }, animate: { opacity: 1, scale: 1, y: 0 }, transition: {
74
+ type: "spring",
75
+ stiffness: 420,
76
+ damping: 30,
77
+ // Capped so a large catalog does not make the last tile wait.
78
+ delay: Math.min(offset + index, 14) * 0.018,
79
+ }, children: _jsx(AppTile, { app: app, onOpen: onOpen }) }, app.applicationId))) })] }));
80
+ }
81
+ function GridSkeleton() {
82
+ return (_jsx("div", { className: "grid grid-cols-3 gap-1 sm:grid-cols-4", "aria-hidden": "true", children: Array.from({ length: 8 }, (_, index) => (_jsxs("div", { className: "flex flex-col items-center gap-2 p-3", children: [_jsx(Skeleton, { className: "size-14 rounded-[0.8125rem]" }), _jsx(Skeleton, { className: "h-3 w-14" })] }, index))) }));
20
83
  }
21
84
  /**
22
- * The Google-style app launcher: a grid icon in your top bar that opens a
23
- * modal of every Connect application this workspace can actually open.
85
+ * The Google-style app launcher: a grid icon that opens every Connect
86
+ * application this workspace has, and every one it could have.
87
+ *
88
+ * Two sections, because they are two different actions. **Your apps** is what
89
+ * the workspace can open right now - the current application pinned first and
90
+ * marked, so the grid never has a hole where "you are here" should be, and a
91
+ * paused subscription still listed but badged, because a dunning cycle should
92
+ * not make a product look deleted. **Explore** is the rest of the active
93
+ * catalog, minus anything this workspace's type could never subscribe to, and
94
+ * a tile there opens that application's page on Connect.
24
95
  *
25
- * Only launchable apps appear, so no tile is a dead end - a paused
26
- * subscription still shows, badged, because a dunning cycle should not make a
27
- * product look deleted. The current app is pinned first and marked, rather
28
- * than filtered out, so the grid never has a hole where "you are here" should
29
- * be.
96
+ * It used to show subscriptions only, with a "Browse all applications" link
97
+ * under the grid pointing at a Connect route that does not exist - so a
98
+ * workspace with one subscription saw one tile and had no working way to find
99
+ * anything else. Discovery belongs in the grid.
30
100
  *
31
101
  * Renders nothing when signed out: a launcher with nothing to launch is just a
32
102
  * button that opens an empty box.
33
103
  */
34
104
  export function ConnectAppsGrid({ className, label = "Switch app", variant = "row" }) {
35
- const { session, isLoading: sessionLoading } = useConnect();
105
+ const { session, isLoading: sessionLoading, appUrl } = useConnect();
36
106
  const { data, isLoading, error } = useConnectApps();
107
+ const [query, setQuery] = useState("");
108
+ const apps = useMemo(() => data?.apps ?? [], [data]);
109
+ const { mine, explore } = useMemo(() => {
110
+ const needle = query.trim().toLowerCase();
111
+ const matches = needle
112
+ ? apps.filter((app) => app.name.toLowerCase().includes(needle) || app.description.toLowerCase().includes(needle))
113
+ : apps;
114
+ return {
115
+ mine: matches.filter((app) => app.subscribed),
116
+ explore: matches.filter((app) => !app.subscribed),
117
+ };
118
+ }, [apps, query]);
37
119
  if (sessionLoading || !session?.user)
38
120
  return null;
39
- const apps = data?.apps ?? [];
40
- return (_jsx(MotionProvider, { children: _jsxs(Modal, { children: [variant === "row" ? (_jsxs(Button, { variant: "tertiary", "aria-label": label, className: sidebarRowClass({ className }), children: [_jsx("span", { className: "flex size-5 shrink-0 items-center justify-center", children: _jsx(LayoutGrid, { className: "size-5" }) }), _jsx(SidebarLabel, { children: label })] })) : (_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: _jsx(LayoutGrid, { className: "size-5" }) })), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-md", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Your apps" }) }), _jsxs(Modal.Body, { children: [isLoading && (_jsx("div", { className: "grid grid-cols-3 gap-2", children: Array.from({ length: 6 }, (_, index) => (_jsxs("div", { className: "flex flex-col items-center gap-2 p-3", children: [_jsx(Skeleton, { className: "size-12 rounded-xl" }), _jsx(Skeleton, { className: "h-3 w-14" })] }, index))) })), !isLoading && error && (_jsx("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), !isLoading && !error && apps.length === 0 && (_jsx("p", { className: "py-6 text-center text-sm text-muted", children: "No other apps yet. Anything this workspace subscribes to shows up here." })), !isLoading && !error && apps.length > 0 && (_jsx(m.div, { className: "grid grid-cols-3 gap-2", variants: { show: { transition: { staggerChildren: 0.04 } } }, initial: "hidden", animate: "show", children: apps.map((app) => (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 8 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsx(AppTile, { app: app }) }, app.applicationId))) }))] }), _jsx(Modal.Footer, { children: _jsx(ConnectBrowseAllLink, {}) })] }) }) })] }) }));
41
- }
42
- function ConnectBrowseAllLink() {
43
- const { appUrl } = useConnect();
44
- return (_jsxs(Button, { variant: "tertiary", className: "w-full", onPress: () => window.open(`${appUrl}/applications`, "_blank", "noopener,noreferrer"), children: ["Browse all applications", _jsx(ExternalLink, { className: "size-3.5 text-muted" })] }));
121
+ const openApp = (app) => {
122
+ // Subscribed goes to the partner's own site; everything else goes to that
123
+ // application's page on Connect, which is where a plan is actually
124
+ // chosen. The SDK owns this mapping so no integration has to know either
125
+ // URL shape.
126
+ const target = app.subscribed && app.launchUrl ? app.launchUrl : `${appUrl}/apps/${app.applicationId}`;
127
+ window.open(target, "_blank", "noopener,noreferrer");
128
+ };
129
+ const showSearch = apps.length > SEARCH_THRESHOLD;
130
+ const isEmpty = !isLoading && !error && mine.length === 0 && explore.length === 0;
131
+ return (_jsx(MotionProvider, { children: _jsxs(Modal, { children: [variant === "row" ? (_jsxs(Button, { variant: "tertiary", "aria-label": label, className: sidebarRowClass({ className }), children: [_jsx("span", { className: "flex size-5 shrink-0 items-center justify-center", children: _jsx(LayoutGrid, { className: "size-5" }) }), _jsx(SidebarLabel, { children: label })] })) : (_jsx(Button, { isIconOnly: true, variant: "primary", "aria-label": label, className: className, children: _jsx(LayoutGrid, { className: "size-5" }) })), _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-lg", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Your apps" }) }), _jsxs(Modal.Body, { className: "space-y-4", children: [showSearch && (_jsxs(TextField, { className: "relative", variant: "secondary", value: query, onChange: setQuery, children: [_jsx(Label, { className: "sr-only", children: "Filter apps" }), _jsx(Input, { className: "pr-9", placeholder: "Filter apps" }), _jsx(Search, { className: "pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted", "aria-hidden": "true" })] })), isLoading && _jsx(GridSkeleton, {}), !isLoading && error && (_jsx("p", { className: "py-6 text-center text-sm text-danger", children: "Could not load your apps." })), isEmpty && (_jsx("p", { className: "py-6 text-center text-sm text-muted", children: query
132
+ ? `Nothing matches “${query}”.`
133
+ : "No apps yet. Anything this workspace can open shows up here." })), !isLoading && !error && (_jsxs(_Fragment, { children: [_jsx(AppsSection, { title: "Your apps", hint: "Open", apps: mine, offset: 0, onOpen: openApp }), _jsx(AppsSection, { title: "Explore", hint: "Subscribe on Connect", apps: explore, offset: mine.length, onOpen: openApp })] }))] })] }) }) })] }) }));
45
134
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * How a person is rendered when there is no photo to render.
3
+ *
4
+ * Shared rather than copied because two surfaces showing *different* initials
5
+ * for the same account reads as a bug in the account, not in the component -
6
+ * and the One Tap prompt and the user menu are frequently on screen within
7
+ * seconds of each other.
8
+ */
9
+ /**
10
+ * Up to two letters standing for a person.
11
+ *
12
+ * Splits on the punctuation an email address uses as well as whitespace, so
13
+ * `priya.nair@acme.test` yields `PN` rather than `PR` - an account with no
14
+ * display name is exactly the case the fallback exists for, and taking the
15
+ * first two characters of a local part would make it useless there.
16
+ */
17
+ export function initials(name, email = null) {
18
+ const source = name?.trim() || email?.trim() || "";
19
+ const parts = source.split(/[\s@._-]+/).filter(Boolean);
20
+ return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
21
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * How long One Tap stays quiet, and why.
3
+ *
4
+ * A prompt that reappears on every page load after being closed is not a
5
+ * convenience, it is an interstitial. Two things silence it, both stored in the
6
+ * application's own `localStorage` - nothing here leaves the browser, and
7
+ * nothing here is a security control:
8
+ *
9
+ * - **Dismissal, escalating.** Closing it once is "not now"; closing it three
10
+ * times is "stop asking". The backoff says that back.
11
+ * - **Signing out.** Someone who has just signed out of this application does
12
+ * not want to be offered the same account back on the next render. That is a
13
+ * different fact from a dismissal, so it does not consume a dismissal, but it
14
+ * buys a day of quiet.
15
+ *
16
+ * Every access is wrapped: `localStorage` throws outright in some embedded and
17
+ * privacy-restricted contexts, and a prompt is never worth breaking a page for.
18
+ * A read that fails reads as "not suppressed", which is the same state a fresh
19
+ * browser is in.
20
+ */
21
+ const STORAGE_KEY = "connect_one_tap";
22
+ /** Escalating quiet after 1, 2, and 3-or-more dismissals. */
23
+ const DISMISS_BACKOFF_MS = [2 * 60 * 60 * 1000, 24 * 60 * 60 * 1000, 7 * 24 * 60 * 60 * 1000];
24
+ const SIGN_OUT_QUIET_MS = 24 * 60 * 60 * 1000;
25
+ function read() {
26
+ const empty = { dismissals: 0, until: 0 };
27
+ if (typeof window === "undefined")
28
+ return empty;
29
+ try {
30
+ const raw = window.localStorage.getItem(STORAGE_KEY);
31
+ if (!raw)
32
+ return empty;
33
+ const parsed = JSON.parse(raw);
34
+ return {
35
+ dismissals: typeof parsed.dismissals === "number" ? parsed.dismissals : 0,
36
+ until: typeof parsed.until === "number" ? parsed.until : 0,
37
+ };
38
+ }
39
+ catch {
40
+ return empty;
41
+ }
42
+ }
43
+ function write(state) {
44
+ if (typeof window === "undefined")
45
+ return;
46
+ try {
47
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
48
+ }
49
+ catch {
50
+ // Storage is full, blocked, or unavailable. The prompt simply keeps its
51
+ // default behaviour rather than the page failing.
52
+ }
53
+ }
54
+ /**
55
+ * Whether the prompt may be shown at all.
56
+ *
57
+ * Read once when the component mounts, never during render: `localStorage` is
58
+ * external mutable state, and reading it in a render body is impure - with the
59
+ * React Compiler on, exactly the kind of thing that can be hoisted into the
60
+ * wrong answer. Same reasoning as `ConnectProvider`'s `silentAttemptSpent`.
61
+ */
62
+ export function oneTapSuppressed() {
63
+ return Date.now() < read().until;
64
+ }
65
+ /** Records a close, and escalates how long the next quiet period lasts. */
66
+ export function dismissOneTap() {
67
+ const { dismissals } = read();
68
+ const next = dismissals + 1;
69
+ const backoff = DISMISS_BACKOFF_MS[Math.min(next, DISMISS_BACKOFF_MS.length) - 1];
70
+ write({ dismissals: next, until: Date.now() + backoff });
71
+ }
72
+ /**
73
+ * Silences the prompt after an explicit sign-out, without spending a dismissal.
74
+ * Signing out is not a complaint about the prompt, so it must not push someone
75
+ * closer to the seven-day backoff.
76
+ */
77
+ export function suppressOneTapAfterSignOut() {
78
+ write({ dismissals: read().dismissals, until: Date.now() + SIGN_OUT_QUIET_MS });
79
+ }
80
+ /** Clears the record once a sign-in actually happens - the prompt did its job. */
81
+ export function clearOneTapSuppression() {
82
+ if (typeof window === "undefined")
83
+ return;
84
+ try {
85
+ window.localStorage.removeItem(STORAGE_KEY);
86
+ }
87
+ catch {
88
+ // See `write`.
89
+ }
90
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { X } from "lucide-react";
5
+ import { Avatar, Button, Card } from "@heroui/react";
6
+ import { useConnect } from "../provider.js";
7
+ import { ConnectMark } from "../mark.js";
8
+ import { initials } from "./identity.js";
9
+ import { ENTER, m, MotionProvider } from "./motion.js";
10
+ import { dismissOneTap, oneTapSuppressed } from "./one-tap-state.js";
11
+ export function ConnectOneTap({ onInteractiveSignIn, className = "" }) {
12
+ const { session, isLoading, signIn, readOneTapHint, oneTapSignIn } = useConnect();
13
+ const [hint, setHint] = useState(null);
14
+ const [phase, setPhase] = useState("hidden");
15
+ /**
16
+ * Sampled once at mount rather than read during render, for the same reason
17
+ * `ConnectProvider` samples its silent-attempt marker that way: `localStorage`
18
+ * is external mutable state and reading it in a render body is impure.
19
+ * `false` on the server, which is correct - nothing renders until the hint
20
+ * has resolved on the client anyway.
21
+ */
22
+ const [suppressed] = useState(() => typeof window !== "undefined" && oneTapSuppressed());
23
+ const signedOut = !isLoading && !session?.user;
24
+ const askable = signedOut && !suppressed && Boolean(session?.oneTap);
25
+ useEffect(() => {
26
+ if (!askable)
27
+ return;
28
+ // Nothing here is a synchronous `setState` in an effect: every write
29
+ // happens after awaiting the network. The flag is what stops a late answer
30
+ // landing on a component that has already unmounted.
31
+ let cancelled = false;
32
+ void readOneTapHint().then((answer) => {
33
+ if (cancelled)
34
+ return;
35
+ // A visitor Connect does not recognise is the common case and gets
36
+ // nothing - no prompt, no flicker, no trace that a question was asked.
37
+ if (!answer || answer.status !== "signed_in")
38
+ return;
39
+ setHint(answer);
40
+ setPhase("offered");
41
+ });
42
+ return () => {
43
+ cancelled = true;
44
+ };
45
+ }, [askable, readOneTapHint]);
46
+ const handoff = useCallback(() => {
47
+ setPhase("hidden");
48
+ if (onInteractiveSignIn)
49
+ onInteractiveSignIn();
50
+ else
51
+ signIn();
52
+ }, [onInteractiveSignIn, signIn]);
53
+ const accept = useCallback(async () => {
54
+ // A visitor who has never authorised this application cannot be signed in
55
+ // silently and Connect would refuse anyway, so the tap goes straight to the
56
+ // screen that can ask them properly.
57
+ if (!hint?.consented) {
58
+ handoff();
59
+ return;
60
+ }
61
+ setPhase("working");
62
+ const result = await oneTapSignIn();
63
+ if (result.ok) {
64
+ // The provider has already refreshed the session; this component
65
+ // disappears because `signedOut` is now false.
66
+ setPhase("hidden");
67
+ return;
68
+ }
69
+ // `consent_required` and `interaction_required` both mean a screen is
70
+ // genuinely needed. `login_required` means the Connect session went away
71
+ // between the hint and the tap. All three are handed to the interactive
72
+ // flow, which can explain itself; a rate limit or a transport failure just
73
+ // closes the prompt rather than blaming the visitor for tapping.
74
+ if (result.reason === "consent_required" ||
75
+ result.reason === "interaction_required" ||
76
+ result.reason === "login_required") {
77
+ handoff();
78
+ return;
79
+ }
80
+ setPhase("hidden");
81
+ }, [handoff, hint, oneTapSignIn]);
82
+ const dismiss = useCallback(() => {
83
+ dismissOneTap();
84
+ setPhase("hidden");
85
+ }, []);
86
+ if (phase === "hidden" || !hint)
87
+ return null;
88
+ const displayName = hint.user?.displayName ?? hint.user?.email ?? null;
89
+ const applicationName = hint.application?.name;
90
+ const busy = phase === "working";
91
+ return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0, y: 12 }, animate: { opacity: 1, y: 0 }, transition: ENTER, role: "dialog", "aria-label": applicationName ? `Continue to ${applicationName} with CosmoOps` : "Continue with CosmoOps",
92
+ // Fixed, above ordinary content but below a modal: this is an offer,
93
+ // and it must never sit on top of something the visitor opened
94
+ // themselves.
95
+ className: `fixed inset-x-3 bottom-3 z-40 sm:inset-x-auto sm:right-4 sm:bottom-4 sm:w-80 ${className}`, onKeyDown: (event) => {
96
+ if (event.key === "Escape")
97
+ dismiss();
98
+ }, children: _jsxs(Card, { className: "p-4 shadow-lg", children: [_jsxs("div", { className: "flex items-start gap-3", children: [hint.consented ? (_jsxs(Avatar, { size: "md", color: "accent", variant: "soft", className: "shrink-0 rounded-full", children: [hint.user?.photoUrl && (_jsx(Avatar.Image, { alt: displayName ?? "Your account", src: hint.user.photoUrl })), _jsx(Avatar.Fallback, { children: initials(hint.user?.displayName ?? null, hint.user?.email ?? null) })] })) : (_jsx(ConnectMark, { className: "mt-0.5 size-8 shrink-0" })), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium text-foreground", children: hint.consented ? `Continue as ${displayName ?? "your account"}` : "You're signed in to CosmoOps" }), _jsx("p", { className: "truncate text-xs text-muted", children: hint.consented
99
+ ? [hint.user?.email, hint.workspace?.name].filter(Boolean).join(" · ")
100
+ : applicationName
101
+ ? `Continue to ${applicationName}`
102
+ : "Continue with your account" })] }), _jsx(Button, { variant: "tertiary", size: "sm", isIconOnly: true, "aria-label": "Not now", onPress: dismiss, isDisabled: busy, children: _jsx(X, { className: "size-4" }) })] }), _jsxs(Button, { variant: "secondary", className: "mt-3 w-full", onPress: () => void accept(), isPending: busy, children: [_jsx(ConnectMark, { className: "size-4" }), busy ? "Signing you in…" : "Continue"] })] }) }) }));
103
+ }
@@ -15,6 +15,21 @@
15
15
  * No React here - it is imported by both a client component and its dialog,
16
16
  * and nothing in it needs to be.
17
17
  */
18
+ /**
19
+ * Whether a plan costs nothing - the SDK's copy of Connect's `isFreeSelection`
20
+ * (`packages/data/src/payments/free-plan.ts`), which is what the server
21
+ * actually decides the free-activation path with.
22
+ *
23
+ * A copy rather than an import for the reason `types.ts` gives: the published
24
+ * package carries none of Connect's internals. The rule is one line and does
25
+ * not drift, and `conformance.test.ts` pins the shape either side of it.
26
+ *
27
+ * @param addonsTotal Paise of the add-ons *selected*, not of the plan's whole
28
+ * catalog. A free plan with a paid add-on ticked is not free.
29
+ */
30
+ export function isFreePlan(price, addonsTotal = 0) {
31
+ return price === 0 && addonsTotal === 0;
32
+ }
18
33
  /** Paise are the wire unit everywhere in Connect; rupees are the display unit. */
19
34
  export function formatPrice(paise) {
20
35
  return new Intl.NumberFormat("en-IN", {
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { Button, Card, Chip, Input, Skeleton, Switch } from "@heroui/react";
4
4
  import { Check, CheckCircle2, ExternalLink, Loader2, Sparkles, Tag } from "lucide-react";
5
5
  import { useMemo, useState } from "react";
@@ -7,7 +7,7 @@ import { useConnect, useConnectEntitlements, useConnectPlans } from "../provider
7
7
  import { buildCancelUrl, buildCheckoutUrl, useConnectCheckout, } from "../use-checkout.js";
8
8
  import { AnimatePresence, ENTER, m, MotionProvider } from "./motion.js";
9
9
  import { ConnectSubscribeDialog } from "./subscribe-dialog.js";
10
- import { ACTION_LABEL, discountLabel, formatPrice, groupPlansByTier, intervalLabel, pricePerLabel, resolveAction, } from "./plan-pricing.js";
10
+ import { ACTION_LABEL, discountLabel, formatPrice, groupPlansByTier, intervalLabel, isFreePlan, pricePerLabel, resolveAction, } from "./plan-pricing.js";
11
11
  /**
12
12
  * The plan table: monthly/annual toggle, features, add-ons, the discount
13
13
  * actually in effect, and the subscribe / upgrade / downgrade flows.
@@ -123,7 +123,7 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
123
123
  (tier.monthly?.id === subscription.plan.id || tier.annual?.id === subscription.plan.id);
124
124
  const isCurrent = action === "current";
125
125
  const cadenceUnavailable = showAnnual ? tier.annual === null : tier.monthly === null;
126
- return (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsxs(Card, { className: isCurrentTier ? "h-full border-accent" : "h-full", children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: tier.name }), _jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [tier.annualSavingPercent !== null && showAnnual && (_jsxs(Chip, { className: "bg-accent-soft text-accent", children: ["Save ", tier.annualSavingPercent, "%"] })), isCurrentTier && _jsx(Chip, { className: "bg-accent-soft text-accent", children: "Current" })] })] }), tier.description && _jsx(Card.Description, { children: tier.description })] }), _jsxs(Card.Content, { className: "flex flex-col gap-4", children: [_jsxs("div", { children: [_jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsxs(m.div, { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 }, children: [_jsx("span", { className: "text-2xl font-semibold text-foreground", children: formatPrice(plan.price) }), _jsx("span", { className: "text-sm text-muted", children: pricePerLabel(plan.billingInterval) })] }, plan.billingInterval) }), _jsxs("p", { className: "mt-1 text-xs text-muted", children: ["+", plan.gstRate, "% GST"] }), cadenceUnavailable && (_jsxs("p", { className: "mt-1 text-xs text-muted", children: ["Only sold ", intervalLabel(plan.billingInterval).adverb, "."] }))] }), plan.trialDays ? (_jsxs("p", { className: "text-xs font-medium text-accent", children: [plan.trialDays, "-day free trial"] })) : null, plan.features.length > 0 && (_jsx("ul", { className: "flex flex-col gap-1.5", children: plan.features.map((feature) => (
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: _jsx(m.div, { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 }, children: isFreePlan(plan.price) ? (_jsx("span", { className: "text-2xl font-semibold text-foreground", children: "Free" })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-2xl font-semibold text-foreground", children: formatPrice(plan.price) }), _jsx("span", { className: "text-sm text-muted", children: pricePerLabel(plan.billingInterval) })] })) }, plan.billingInterval) }), !isFreePlan(plan.price) && (_jsxs("p", { className: "mt-1 text-xs text-muted", children: ["+", plan.gstRate, "% GST"] })), cadenceUnavailable && (_jsxs("p", { className: "mt-1 text-xs text-muted", children: ["Only sold ", intervalLabel(plan.billingInterval).adverb, "."] }))] }), plan.trialDays && !isFreePlan(plan.price) ? (_jsxs("p", { className: "text-xs font-medium text-accent", children: [plan.trialDays, "-day free trial"] })) : null, plan.features.length > 0 && (_jsx("ul", { className: "flex flex-col gap-1.5", children: plan.features.map((feature) => (
127
127
  // `name`, not `key` - the key is an identifier
128
128
  // ("campaign-module"), and printing it here is what this
129
129
  // card used to do. The description rides along as a
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { Button, Modal } from "@heroui/react";
4
4
  import { ArrowRight, ExternalLink, Info } from "lucide-react";
5
- import { formatPrice, intervalLabel, monthlyOf } from "./plan-pricing.js";
5
+ import { formatPrice, intervalLabel, isFreePlan, monthlyOf } from "./plan-pricing.js";
6
6
  /** GST is quoted exclusive everywhere in Connect; this is the same arithmetic `calculateGST` does server-side. */
7
7
  function withGst(price, gstRate) {
8
8
  return price + Math.round(price * (gstRate / 100));
@@ -47,7 +47,8 @@ export function ConnectSubscribeDialog({ plan, action, current, coupon, onClose,
47
47
  // member and the one most in need of it: the two prices differ by an order
48
48
  // of magnitude, and showing only the new one reads as a price rise.
49
49
  const isChange = action === "upgrade" || action === "downgrade" || isIntervalSwitch;
50
- return (_jsx(Modal.Backdrop, { isOpen: plan !== null, onOpenChange: (open) => !open && onClose(), children: _jsx(Modal.Container, { size: "sm", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, { onPress: onClose }), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: HEADING[action] }) }), plan && (_jsxs(_Fragment, { children: [_jsxs(Modal.Body, { className: "flex flex-col gap-4", children: [isChange && currentPlan ? (_jsxs("div", { className: "flex items-center gap-3 text-sm", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-muted", children: currentPlan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(currentPlan.price), "/", intervalLabel(currentPlan.billingInterval).short] })] }), _jsx(ArrowRight, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate font-medium text-foreground", children: plan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(plan.price), "/", intervalLabel(plan.billingInterval).short] })] })] })) : (_jsxs("div", { children: [_jsx("p", { className: "font-medium text-foreground", children: plan.name }), plan.description && _jsx("p", { className: "text-xs text-muted", children: plan.description })] })), _jsxs("div", { className: "flex flex-col gap-1 border-t border-separator pt-3 text-sm", children: [_jsxs("div", { className: "flex justify-between text-muted", children: [_jsx("span", { children: plan.name }), _jsx("span", { children: formatPrice(plan.price) })] }), _jsxs("div", { className: "flex justify-between text-muted", children: [_jsxs("span", { children: ["GST (", plan.gstRate, "%)"] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate) - plan.price) })] }), _jsxs("div", { className: "flex justify-between border-t border-separator pt-1 font-semibold text-foreground", children: [_jsxs("span", { children: ["Per ", cadenceOf(plan)] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate)) })] }), plan.billingInterval === "annual" && (_jsxs("p", { className: "text-xs text-muted", children: ["Works out to ", formatPrice(monthlyOf(withGst(plan.price, plan.gstRate), "annual")), " a month, billed yearly."] }))] }), plan.trialDays ? (_jsxs("p", { className: "text-xs text-accent", children: [plan.trialDays, "-day free trial, if this workspace has not subscribed to this application before."] })) : null, coupon?.trim() ? (_jsxs("p", { className: "text-xs text-muted", children: ["Coupon ", _jsx("span", { className: "font-mono text-foreground", children: coupon.trim() }), " is carried over and validated on Connect."] })) : null, _jsxs("div", { className: "flex gap-2 rounded-lg bg-surface p-3 text-xs text-muted", children: [_jsx(Info, { className: "mt-0.5 size-4 shrink-0", "aria-hidden": "true" }), _jsxs("div", { className: "flex flex-col gap-1", children: [action === "subscribe" && (_jsx("p", { children: "Payment is completed on Connect, where your card or UPI mandate is set up and the tax invoice is issued. This page updates by itself once it is done." })), action === "upgrade" && (_jsxs("p", { children: ["Connect prices the difference for the days left in your current cycle and collects just that amount now. From the next cycle you are billed the ", plan.name, " rate."] })), action === "downgrade" && (_jsx("p", { children: "You keep what you have paid for. Connect works out the unused part of this cycle and applies it as credit or a refund, depending on how this application is configured." })), isIntervalSwitch && (
50
+ return (_jsx(Modal.Backdrop, { isOpen: plan !== null, onOpenChange: (open) => !open && onClose(), children: _jsx(Modal.Container, { size: "sm", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, { onPress: onClose }), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: HEADING[action] }) }), plan && (_jsxs(_Fragment, { children: [_jsxs(Modal.Body, { className: "flex flex-col gap-4", children: [isChange && currentPlan ? (_jsxs("div", { className: "flex items-center gap-3 text-sm", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate text-muted", children: currentPlan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(currentPlan.price), "/", intervalLabel(currentPlan.billingInterval).short] })] }), _jsx(ArrowRight, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "truncate font-medium text-foreground", children: plan.name }), _jsxs("p", { className: "text-xs text-muted", children: [formatPrice(plan.price), "/", intervalLabel(plan.billingInterval).short] })] })] })) : (_jsxs("div", { children: [_jsx("p", { className: "font-medium text-foreground", children: plan.name }), plan.description && _jsx("p", { className: "text-xs text-muted", children: plan.description })] })), isFreePlan(plan.price) ? (_jsxs("div", { className: "flex justify-between border-t border-separator pt-3 text-sm font-semibold text-foreground", children: [_jsx("span", { children: plan.name }), _jsx("span", { children: "Free" })] })) : (_jsxs("div", { className: "flex flex-col gap-1 border-t border-separator pt-3 text-sm", children: [_jsxs("div", { className: "flex justify-between text-muted", children: [_jsx("span", { children: plan.name }), _jsx("span", { children: formatPrice(plan.price) })] }), _jsxs("div", { className: "flex justify-between text-muted", children: [_jsxs("span", { children: ["GST (", plan.gstRate, "%)"] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate) - plan.price) })] }), _jsxs("div", { className: "flex justify-between border-t border-separator pt-1 font-semibold text-foreground", children: [_jsxs("span", { children: ["Per ", cadenceOf(plan)] }), _jsx("span", { children: formatPrice(withGst(plan.price, plan.gstRate)) })] }), plan.billingInterval === "annual" && (_jsxs("p", { className: "text-xs text-muted", children: ["Works out to ", formatPrice(monthlyOf(withGst(plan.price, plan.gstRate), "annual")), " a month, billed yearly."] }))] })), plan.trialDays && !isFreePlan(plan.price) ? (_jsxs("p", { className: "text-xs text-accent", children: [plan.trialDays, "-day free trial, if this workspace has not subscribed to this application before."] })) : null, coupon?.trim() ? (_jsxs("p", { className: "text-xs text-muted", children: ["Coupon ", _jsx("span", { className: "font-mono text-foreground", children: coupon.trim() }), " is carried over and validated on Connect."] })) : null, _jsxs("div", { className: "flex gap-2 rounded-lg bg-surface p-3 text-xs text-muted", children: [_jsx(Info, { className: "mt-0.5 size-4 shrink-0", "aria-hidden": "true" }), _jsxs("div", { className: "flex flex-col gap-1", children: [action === "subscribe" &&
51
+ (isFreePlan(plan.price) ? (_jsx("p", { children: "This plan is free. Connect activates it straight away - there is no payment to make, no card or UPI mandate to set up, and no invoice to expect. This page updates by itself once it is done." })) : (_jsx("p", { children: "Payment is completed on Connect, where your card or UPI mandate is set up and the tax invoice is issued. This page updates by itself once it is done." }))), action === "upgrade" && (_jsxs("p", { children: ["Connect prices the difference for the days left in your current cycle and collects just that amount now. From the next cycle you are billed the ", plan.name, " rate."] })), action === "downgrade" && (_jsx("p", { children: "You keep what you have paid for. Connect works out the unused part of this cycle and applies it as credit or a refund, depending on how this application is configured." })), isIntervalSwitch && (
51
52
  // Not a proration. The cycle you are on ends, so this is
52
53
  // the one action where the amount collected can exceed a
53
54
  // single cycle's list price - or be nothing at all, with
@@ -4,6 +4,7 @@ import { Avatar, Button, Chip, Modal, Skeleton, ToggleButton, ToggleButtonGroup,
4
4
  import { ChevronsUpDown, 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
+ import { initials } from "./identity.js";
7
8
  import { ConnectAccountSessions } from "./account-sessions.js";
8
9
  import { m } from "./motion.js";
9
10
  import { SidebarLabel, sidebarRowClass, useConnectShell } from "./shell-context.js";
@@ -13,11 +14,6 @@ const THEME_OPTIONS = [
13
14
  { value: "dark", label: "Dark", icon: Moon },
14
15
  { value: "system", label: "System", icon: Laptop },
15
16
  ];
16
- function initials(name, email) {
17
- const source = name?.trim() || email?.trim() || "";
18
- const parts = source.split(/[\s@._-]+/).filter(Boolean);
19
- return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
20
- }
21
17
  function openExternal(url) {
22
18
  window.open(url, "_blank", "noopener,noreferrer");
23
19
  }
@@ -21,6 +21,7 @@ export { ConnectGrievanceNotice } from "./components/grievance-notice.js";
21
21
  export { ConnectMark } from "./mark.js";
22
22
  export { ConnectLogo } from "./components/logo.js";
23
23
  export { ConnectLoginButton, ConnectRegisterButton } from "./components/auth-buttons.js";
24
+ export { ConnectOneTap } from "./components/one-tap.js";
24
25
  export { ConnectUserButton, ConnectProfileModal,
25
26
  /** @deprecated Renamed to `ConnectUserButton`. */
26
27
  ConnectUserMenu, ConnectManageSubscriptionButton, ConnectInvoicesButton, ConnectProfileButton, ConnectHomeButton, ConnectSignOutButton, } from "./components/user-menu.js";
@@ -105,6 +106,12 @@ export { ConnectCursorPagination } from "./components/cursor-pagination.js";
105
106
  export { ConnectFilterTabs } from "./components/filter-tabs.js";
106
107
  export { useConnectCheckout, buildCheckoutUrl } from "./use-checkout.js";
107
108
  export { formatPrice, monthlyOf, resolveAction, annualSaving, discountLabel, ACTION_LABEL,
109
+ // Exported for the same reason `groupPlansByTier` is: a partner writing its
110
+ // own pricing grid has to draw a ₹0 plan as "Free" rather than as a price
111
+ // with a cadence and a GST rate attached, and re-deriving "is this free"
112
+ // from a raw `price === 0` misses the add-on half of the rule Connect
113
+ // actually applies.
114
+ isFreePlan,
108
115
  // The tier model, exported because a partner writing its own pricing grid
109
116
  // needs the same pairing `ConnectPlans` uses. Grouping a flat plan list by
110
117
  // hand means matching on `name`, which Connect neither enforces unique nor