@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.
- package/CHANGELOG.md +158 -0
- package/README.md +16 -2
- package/dist/cjs/client/components/apps-grid.js +112 -23
- package/dist/cjs/client/components/identity.js +24 -0
- package/dist/cjs/client/components/one-tap-state.js +96 -0
- package/dist/cjs/client/components/one-tap.js +106 -0
- package/dist/cjs/client/components/plan-pricing.js +16 -0
- package/dist/cjs/client/components/plans.js +1 -1
- package/dist/cjs/client/components/subscribe-dialog.js +2 -1
- package/dist/cjs/client/components/user-menu.js +2 -6
- package/dist/cjs/client/index.js +11 -3
- package/dist/cjs/client/provider.js +88 -0
- package/dist/cjs/server/client.js +9 -1
- package/dist/cjs/server/handler.js +126 -2
- package/dist/cjs/server/resources/calls.js +57 -0
- package/dist/cjs/server/resources/channels.js +115 -0
- package/dist/cjs/server/resources/identity.js +40 -0
- package/dist/esm/client/components/apps-grid.js +115 -26
- package/dist/esm/client/components/identity.js +21 -0
- package/dist/esm/client/components/one-tap-state.js +90 -0
- package/dist/esm/client/components/one-tap.js +103 -0
- package/dist/esm/client/components/plan-pricing.js +15 -0
- package/dist/esm/client/components/plans.js +3 -3
- package/dist/esm/client/components/subscribe-dialog.js +3 -2
- package/dist/esm/client/components/user-menu.js +1 -5
- package/dist/esm/client/index.js +7 -0
- package/dist/esm/client/provider.js +88 -0
- package/dist/esm/server/client.js +9 -1
- package/dist/esm/server/handler.js +127 -3
- package/dist/esm/server/resources/calls.js +53 -0
- package/dist/esm/server/resources/channels.js +111 -0
- package/dist/esm/server/resources/identity.js +39 -0
- package/dist/types/client/components/apps-grid.d.ts +14 -7
- package/dist/types/client/components/identity.d.ts +17 -0
- package/dist/types/client/components/one-tap-state.d.ts +39 -0
- package/dist/types/client/components/one-tap.d.ts +50 -0
- package/dist/types/client/components/plan-pricing.d.ts +13 -0
- package/dist/types/client/index.d.ts +4 -2
- package/dist/types/client/provider.d.ts +81 -0
- package/dist/types/server/client.d.ts +7 -1
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/server/resources/calls.d.ts +45 -0
- package/dist/types/server/resources/channels.d.ts +91 -0
- package/dist/types/server/resources/identity.d.ts +35 -0
- package/dist/types/types.d.ts +186 -1
- 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,
|
|
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 {
|
|
7
|
+
import { m, MotionProvider } from "./motion.js";
|
|
7
8
|
import { SidebarLabel, sidebarRowClass } from "./shell-context.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 (
|
|
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
|
|
23
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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:
|
|
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" &&
|
|
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
|
}
|
package/dist/esm/client/index.js
CHANGED
|
@@ -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
|