@cosmoops/connect-sdk 0.24.1 → 0.26.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 (75) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/dist/cjs/client/components/access-gate.js +31 -5
  3. package/dist/cjs/client/components/account-sessions.js +4 -3
  4. package/dist/cjs/client/components/app-shell.js +2 -1
  5. package/dist/cjs/client/components/booking-calendar.js +2 -1
  6. package/dist/cjs/client/components/cursor-pagination.js +3 -2
  7. package/dist/cjs/client/components/filter-tabs.js +23 -5
  8. package/dist/cjs/client/components/grievance-notice.js +2 -1
  9. package/dist/cjs/client/components/logo.js +3 -2
  10. package/dist/cjs/client/components/one-tap.js +2 -1
  11. package/dist/cjs/client/components/plans.js +3 -2
  12. package/dist/cjs/client/components/readiness.js +4 -3
  13. package/dist/cjs/client/components/shell-context.js +2 -1
  14. package/dist/cjs/client/components/side-nav.js +2 -1
  15. package/dist/cjs/client/components/sidebar.js +2 -1
  16. package/dist/cjs/client/components/subscription.js +5 -4
  17. package/dist/cjs/client/components/usage-meter.js +2 -1
  18. package/dist/cjs/client/components/user-menu.js +3 -2
  19. package/dist/cjs/client/provider.js +28 -7
  20. package/dist/cjs/client/use-active-path.js +12 -1
  21. package/dist/cjs/cn.js +53 -0
  22. package/dist/cjs/next/index.js +41 -3
  23. package/dist/cjs/server/client.js +1 -1
  24. package/dist/cjs/server/http.js +3 -1
  25. package/dist/cjs/server/resources/identity.js +12 -2
  26. package/dist/cjs/store.js +14 -1
  27. package/dist/cjs/ui/empty-state.js +2 -1
  28. package/dist/cjs/ui/index.js +8 -1
  29. package/dist/cjs/ui/page-header.js +2 -1
  30. package/dist/cjs/ui/page-shell.js +3 -2
  31. package/dist/cjs/ui/section.js +2 -1
  32. package/dist/cjs/ui/setting-row.js +2 -1
  33. package/dist/esm/client/components/access-gate.js +33 -7
  34. package/dist/esm/client/components/account-sessions.js +4 -3
  35. package/dist/esm/client/components/app-shell.js +2 -1
  36. package/dist/esm/client/components/booking-calendar.js +2 -1
  37. package/dist/esm/client/components/cursor-pagination.js +3 -2
  38. package/dist/esm/client/components/filter-tabs.js +23 -5
  39. package/dist/esm/client/components/grievance-notice.js +2 -1
  40. package/dist/esm/client/components/logo.js +3 -2
  41. package/dist/esm/client/components/one-tap.js +2 -1
  42. package/dist/esm/client/components/plans.js +3 -2
  43. package/dist/esm/client/components/readiness.js +4 -3
  44. package/dist/esm/client/components/shell-context.js +2 -1
  45. package/dist/esm/client/components/side-nav.js +2 -1
  46. package/dist/esm/client/components/sidebar.js +2 -1
  47. package/dist/esm/client/components/subscription.js +5 -4
  48. package/dist/esm/client/components/usage-meter.js +2 -1
  49. package/dist/esm/client/components/user-menu.js +3 -2
  50. package/dist/esm/client/provider.js +28 -7
  51. package/dist/esm/client/use-active-path.js +12 -1
  52. package/dist/esm/cn.js +48 -0
  53. package/dist/esm/next/index.js +41 -3
  54. package/dist/esm/server/client.js +1 -1
  55. package/dist/esm/server/http.js +3 -1
  56. package/dist/esm/server/resources/identity.js +12 -2
  57. package/dist/esm/store.js +14 -1
  58. package/dist/esm/ui/empty-state.js +2 -1
  59. package/dist/esm/ui/index.js +6 -0
  60. package/dist/esm/ui/page-header.js +2 -1
  61. package/dist/esm/ui/page-shell.js +3 -2
  62. package/dist/esm/ui/section.js +2 -1
  63. package/dist/esm/ui/setting-row.js +2 -1
  64. package/dist/theme.css +53 -0
  65. package/dist/types/client/components/access-gate.d.ts +35 -4
  66. package/dist/types/client/components/cursor-pagination.d.ts +6 -1
  67. package/dist/types/client/components/filter-tabs.d.ts +9 -3
  68. package/dist/types/cn.d.ts +48 -0
  69. package/dist/types/next/index.d.ts +12 -2
  70. package/dist/types/server/client.d.ts +1 -1
  71. package/dist/types/server/http.d.ts +22 -0
  72. package/dist/types/store.d.ts +6 -0
  73. package/dist/types/ui/index.d.ts +6 -0
  74. package/dist/types/ui/page-shell.d.ts +7 -1
  75. package/package.json +1 -1
@@ -7,6 +7,7 @@ import { ConnectEmptyState } from "../../ui/empty-state.js";
7
7
  import { useBookableEventTypes, useBookings, } from "../use-scheduling.js";
8
8
  import { dayColumns, dayStartInstant, hourMarks, layoutBookings, zonedInstant, } from "./booking-grid.js";
9
9
  import { ConnectBookingDetail, ConnectBookingDialog } from "./booking-dialogs.js";
10
+ import { cn } from "../../cn.js";
10
11
  /**
11
12
  * The visible band of the day. Outside it a booking is drawn at the edge and
12
13
  * flagged rather than hidden - see `layoutBookings`.
@@ -64,7 +65,7 @@ export function ConnectBookingCalendar({ timeZone, hostMemberId, window: visible
64
65
  const marks = useMemo(() => hourMarks(visibleWindow), [visibleWindow]);
65
66
  const shiftWeek = (weeks) => setWeekAnchor((anchor) => new Date(anchor.getTime() + weeks * 7 * 24 * 60 * 60_000));
66
67
  const eventTypeById = useMemo(() => new Map(eventTypes.map((eventType) => [eventType.id, eventType])), [eventTypes]);
67
- return (_jsxs("div", { className: `flex flex-col gap-4 ${className ?? ""}`, children: [_jsxs("header", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Previous week", onPress: () => shiftWeek(-1), children: _jsx(ChevronLeft, { className: "size-4", "aria-hidden": true }) }), _jsx(Button, { size: "sm", variant: "secondary", onPress: () => setWeekAnchor(startOfWeek(new Date(), zone)), children: "Today" }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Next week", onPress: () => shiftWeek(1), children: _jsx(ChevronRight, { className: "size-4", "aria-hidden": true }) }), _jsx("p", { className: "ml-2 text-sm font-medium", children: weekLabel(columns, zone) })] }), canBook && eventTypes.length > 0 && (_jsx(Button, { size: "sm", variant: "primary", onPress: () => setBooking({ startAt: nextHour(now) }), children: "New appointment" }))] }), error && _jsx("p", { className: "text-sm text-danger", children: error }), isLoading ? (_jsx(Skeleton, { className: "h-96 w-full rounded-lg" })) : (_jsxs(_Fragment, { children: [_jsx(Card, { className: "hidden overflow-hidden sm:block", children: _jsx(WeekGrid, { columns: columns, marks: marks, window: visibleWindow, positioned: positioned, eventTypeById: eventTypeById, zone: zone, onSelect: setSelected, onPickEmpty: canBook ? (startAt) => setBooking({ startAt }) : undefined }) }), _jsx("div", { className: "sm:hidden", children: _jsx(AgendaView, { columns: columns, bookings: bookings, eventTypeById: eventTypeById, zone: zone, onSelect: setSelected }) })] })), booking && (_jsx(ConnectBookingDialog, { isOpen: true, onOpenChange: (open) => !open && setBooking(null), eventTypes: eventTypes, initialStartAt: booking.startAt, timeZone: zone, onBooked: () => {
68
+ return (_jsxs("div", { className: cn("flex flex-col gap-4", className), children: [_jsxs("header", { className: "flex flex-wrap items-center justify-between gap-3", children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Previous week", onPress: () => shiftWeek(-1), children: _jsx(ChevronLeft, { className: "size-4", "aria-hidden": true }) }), _jsx(Button, { size: "sm", variant: "secondary", onPress: () => setWeekAnchor(startOfWeek(new Date(), zone)), children: "Today" }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "tertiary", "aria-label": "Next week", onPress: () => shiftWeek(1), children: _jsx(ChevronRight, { className: "size-4", "aria-hidden": true }) }), _jsx("p", { className: "ml-2 text-sm font-medium", children: weekLabel(columns, zone) })] }), canBook && eventTypes.length > 0 && (_jsx(Button, { size: "sm", variant: "primary", onPress: () => setBooking({ startAt: nextHour(now) }), children: "New appointment" }))] }), error && _jsx("p", { className: "text-sm text-danger", children: error }), isLoading ? (_jsx(Skeleton, { className: "h-96 w-full rounded-lg" })) : (_jsxs(_Fragment, { children: [_jsx(Card, { className: "hidden overflow-hidden sm:block", children: _jsx(WeekGrid, { columns: columns, marks: marks, window: visibleWindow, positioned: positioned, eventTypeById: eventTypeById, zone: zone, onSelect: setSelected, onPickEmpty: canBook ? (startAt) => setBooking({ startAt }) : undefined }) }), _jsx("div", { className: "sm:hidden", children: _jsx(AgendaView, { columns: columns, bookings: bookings, eventTypeById: eventTypeById, zone: zone, onSelect: setSelected }) })] })), booking && (_jsx(ConnectBookingDialog, { isOpen: true, onOpenChange: (open) => !open && setBooking(null), eventTypes: eventTypes, initialStartAt: booking.startAt, timeZone: zone, onBooked: () => {
68
69
  setBooking(null);
69
70
  refetch();
70
71
  } })), selected && (_jsx(ConnectBookingDetail, { booking: selected, eventType: eventTypeById.get(selected.eventTypeId) ?? null, timeZone: zone, onOpenChange: (open) => !open && setSelected(null), onChanged: () => {
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Button } from "@heroui/react";
4
+ import { cn } from "../../cn.js";
4
5
  /**
5
6
  * Next / previous for a cursor-paginated list.
6
7
  *
@@ -14,6 +15,6 @@ import { Button } from "@heroui/react";
14
15
  * filtering or paging changed the result set. Without it the table silently
15
16
  * swaps its contents.
16
17
  */
17
- export function ConnectCursorPagination({ shown, nextCursor, hasPrevious, onNext, onPrevious, isPending = false, noun = ["row", "rows"], }) {
18
- return (_jsxs("div", { className: "flex items-center justify-between gap-3 pt-3", children: [_jsxs("p", { "aria-live": "polite", className: "text-xs text-muted", children: [shown, " ", shown === 1 ? noun[0] : noun[1]] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { size: "sm", variant: "secondary", isDisabled: !hasPrevious || isPending, onPress: onPrevious, children: "Previous" }), _jsx(Button, { size: "sm", variant: "secondary", isDisabled: nextCursor === null || isPending, onPress: () => nextCursor && onNext(nextCursor), children: "Next" })] })] }));
18
+ export function ConnectCursorPagination({ shown, nextCursor, hasPrevious, onNext, onPrevious, isPending = false, noun = ["row", "rows"], className, }) {
19
+ return (_jsxs("div", { className: cn("flex items-center justify-between gap-3 pt-3", className), children: [_jsxs("p", { "aria-live": "polite", className: "text-xs text-muted", children: [shown, " ", shown === 1 ? noun[0] : noun[1]] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { size: "sm", variant: "secondary", isDisabled: !hasPrevious || isPending, onPress: onPrevious, children: "Previous" }), _jsx(Button, { size: "sm", variant: "secondary", isDisabled: nextCursor === null || isPending, onPress: () => nextCursor && onNext(nextCursor), children: "Next" })] })] }));
19
20
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Chip } from "@heroui/react";
4
+ import { cn } from "../../cn.js";
4
5
  /**
5
6
  * A single-select filter strip - the "All / Open / Won / Lost" row above a
6
7
  * list.
@@ -10,12 +11,29 @@ import { Chip } from "@heroui/react";
10
11
  * selected one should be announced as selected. A group of `<button>`s gets
11
12
  * neither, and that is the usual hand-rolled shape.
12
13
  *
13
- * Scrolls horizontally rather than wrapping on a narrow viewport - a filter
14
- * strip that wraps to three lines pushes the list itself off the screen.
14
+ * Wraps rather than scrolling on a narrow viewport. See the note on the
15
+ * container below: a horizontal scroller nested in a vertically scrolling
16
+ * page hides its overflow rather than deferring it.
15
17
  */
16
- export function ConnectFilterTabs({ options, value, onChange, label, }) {
17
- return (_jsx("div", { role: "radiogroup", "aria-label": label, className: "-mx-1 flex gap-2 overflow-x-auto px-1 pb-1", children: options.map((option) => {
18
+ export function ConnectFilterTabs({ options, value, onChange, label, className, }) {
19
+ return (
20
+ /*
21
+ * Wraps. It does not scroll.
22
+ *
23
+ * It used to scroll horizontally, on the reasoning that a filter wrapping
24
+ * to three lines pushes the list itself off the screen. That trade was
25
+ * the wrong way round for a mobile-first product: a horizontal scroller
26
+ * inside a vertically scrolling page is a gesture people do not think to
27
+ * try, so the options past the fold were not "one swipe away" - they were
28
+ * invisible. The strip also came to rest mid-option, showing "ll" where
29
+ * "All" should be.
30
+ *
31
+ * Wrapping costs one extra line on a phone and nothing at all above it,
32
+ * where the row has always fitted. Every option is now on screen and
33
+ * reachable without a gesture, which is what a filter is for.
34
+ */
35
+ _jsx("div", { role: "radiogroup", "aria-label": label, className: cn("-mx-1 flex flex-wrap gap-2 px-1", className), children: options.map((option) => {
18
36
  const isSelected = option.value === value;
19
- return (_jsx("button", { type: "button", role: "radio", "aria-checked": isSelected, onClick: () => onChange(option.value), className: "shrink-0 rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent", children: _jsxs(Chip, { size: "sm", variant: isSelected ? "primary" : "soft", color: isSelected ? "accent" : "default", children: [option.label, option.count !== undefined && _jsx("span", { className: "ml-1 opacity-70", children: option.count })] }) }, option.value ?? "__all"));
37
+ return (_jsx("button", { type: "button", role: "radio", "aria-checked": isSelected, onClick: () => onChange(option.value), className: "shrink-0 rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent", children: _jsxs(Chip, { variant: isSelected ? "primary" : "soft", color: isSelected ? "accent" : "default", className: "min-h-8 px-3.5 py-1.5 text-sm", children: [option.label, option.count !== undefined && _jsx("span", { className: "ml-1 opacity-70", children: option.count })] }) }, option.value ?? "__all"));
20
38
  }) }));
21
39
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useConnectCompanyProfile } from "../provider.js";
4
+ import { cn } from "../../cn.js";
4
5
  /**
5
6
  * The DPDP s.13 grievance channel, rendered.
6
7
  *
@@ -23,5 +24,5 @@ export function ConnectGrievanceNotice({ title = "Grievance redressal", applicat
23
24
  if (!profile)
24
25
  return null;
25
26
  const { grievance } = profile;
26
- return (_jsxs("section", { className: className ?? "space-y-3", children: [title !== null && _jsx("h3", { className: "text-base font-semibold", children: title }), _jsxs("p", { className: "text-sm text-muted", children: [applicationName ? `${applicationName} is operated on ` : "This service runs on ", "the CosmoOps Connect platform. ", _jsx("strong", { className: "font-medium text-foreground", children: profile.legalName }), " ", "is the Data Fiduciary for personal data processed here, under the Digital Personal Data Protection Act, 2023."] }), _jsxs("dl", { className: "grid grid-cols-1 gap-x-6 gap-y-3 text-sm sm:grid-cols-[auto_1fr]", children: [_jsx("dt", { className: "text-muted", children: "Grievance Officer" }), _jsx("dd", { className: "text-foreground", children: grievance.officerName }), _jsx("dt", { className: "text-muted", children: "Email" }), _jsx("dd", { children: _jsx("a", { className: "text-accent underline underline-offset-2", href: `mailto:${grievance.officerEmail}`, children: grievance.officerEmail }) }), _jsx("dt", { className: "text-muted", children: "Contactable" }), _jsx("dd", { className: "text-foreground", children: grievance.hours }), _jsx("dt", { className: "text-muted", children: "Registered address" }), _jsx("dd", { className: "text-foreground", children: profile.registeredAddress })] }), _jsxs("p", { className: "text-sm text-muted", children: ["Complaints are acknowledged and resolved within ", grievance.responseDays, " days of receipt."] })] }));
27
+ return (_jsxs("section", { className: cn("space-y-3", className), children: [title !== null && _jsx("h3", { className: "text-base font-semibold", children: title }), _jsxs("p", { className: "text-sm text-muted", children: [applicationName ? `${applicationName} is operated on ` : "This service runs on ", "the CosmoOps Connect platform. ", _jsx("strong", { className: "font-medium text-foreground", children: profile.legalName }), " ", "is the Data Fiduciary for personal data processed here, under the Digital Personal Data Protection Act, 2023."] }), _jsxs("dl", { className: "grid grid-cols-1 gap-x-6 gap-y-3 text-sm sm:grid-cols-[auto_1fr]", children: [_jsx("dt", { className: "text-muted", children: "Grievance Officer" }), _jsx("dd", { className: "text-foreground", children: grievance.officerName }), _jsx("dt", { className: "text-muted", children: "Email" }), _jsx("dd", { children: _jsx("a", { className: "text-accent underline underline-offset-2", href: `mailto:${grievance.officerEmail}`, children: grievance.officerEmail }) }), _jsx("dt", { className: "text-muted", children: "Contactable" }), _jsx("dd", { className: "text-foreground", children: grievance.hours }), _jsx("dt", { className: "text-muted", children: "Registered address" }), _jsx("dd", { className: "text-foreground", children: profile.registeredAddress })] }), _jsxs("p", { className: "text-sm text-muted", children: ["Complaints are acknowledged and resolved within ", grievance.responseDays, " days of receipt."] })] }));
27
28
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { ConnectMark } from "../mark.js";
4
+ import { cn } from "../../cn.js";
4
5
  /**
5
6
  * The shared platform lockup: an application's own glyph on the CosmoOps badge,
6
7
  * `cosmo·ops` beside it, and the application name underneath.
@@ -19,7 +20,7 @@ export function ConnectLogo({ name, icon, variant, collapsed = false, className
19
20
  const resolved = variant ?? (collapsed ? "mark" : "full");
20
21
  const badge = (_jsxs("span", { className: "relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-[0.28em] bg-accent text-accent-foreground", style: { width: "2em", height: "2em" }, "aria-hidden": "true", children: [icon ? _jsx("span", { className: "inline-flex items-center justify-center [&>*]:size-[1.25em]", children: icon }) : null, !icon && _jsx(ConnectMark, { className: "absolute inset-0 size-full" })] }));
21
22
  if (resolved === "mark") {
22
- return (_jsx("span", { className: `inline-flex text-sm leading-none ${className ?? ""}`, role: "img", "aria-label": name, children: badge }));
23
+ return (_jsx("span", { className: cn("inline-flex text-sm leading-none", className), role: "img", "aria-label": name, children: badge }));
23
24
  }
24
- return (_jsxs("span", { className: `inline-flex min-w-0 items-center gap-[0.6em] text-sm leading-none ${className ?? ""}`, role: "img", "aria-label": `cosmoops ${name}`, children: [resolved === "full" && badge, _jsxs("span", { className: "flex min-w-0 flex-col leading-none", children: [_jsxs("span", { className: "inline-flex items-center font-extrabold tracking-tight text-foreground", "aria-hidden": "true", children: [_jsx("span", { children: "cosmo" }), _jsx("span", { className: "mx-[0.12em] inline-block size-[0.19em] shrink-0 rounded-[0.04em] bg-accent" }), _jsx("span", { children: "ops" })] }), _jsx("span", { className: "mt-[0.42em] truncate text-[0.6em] font-semibold uppercase tracking-[0.26em] text-accent", "aria-hidden": "true", children: name })] })] }));
25
+ return (_jsxs("span", { className: cn("inline-flex min-w-0 items-center gap-[0.6em] text-sm leading-none", className), role: "img", "aria-label": `cosmoops ${name}`, children: [resolved === "full" && badge, _jsxs("span", { className: "flex min-w-0 flex-col leading-none", children: [_jsxs("span", { className: "inline-flex items-center font-extrabold tracking-tight text-foreground", "aria-hidden": "true", children: [_jsx("span", { children: "cosmo" }), _jsx("span", { className: "mx-[0.12em] inline-block size-[0.19em] shrink-0 rounded-[0.04em] bg-accent" }), _jsx("span", { children: "ops" })] }), _jsx("span", { className: "mt-[0.42em] truncate text-[0.6em] font-semibold uppercase tracking-[0.26em] text-accent", "aria-hidden": "true", children: name })] })] }));
25
26
  }
@@ -8,6 +8,7 @@ import { ConnectMark } from "../mark.js";
8
8
  import { initials } from "./identity.js";
9
9
  import { ENTER, m, MotionProvider } from "./motion.js";
10
10
  import { dismissOneTap, oneTapSuppressed } from "./one-tap-state.js";
11
+ import { cn } from "../../cn.js";
11
12
  export function ConnectOneTap({ onInteractiveSignIn, className = "" }) {
12
13
  const { session, isLoading, signIn, readOneTapHint, oneTapSignIn } = useConnect();
13
14
  const [hint, setHint] = useState(null);
@@ -92,7 +93,7 @@ export function ConnectOneTap({ onInteractiveSignIn, className = "" }) {
92
93
  // Fixed, above ordinary content but below a modal: this is an offer,
93
94
  // and it must never sit on top of something the visitor opened
94
95
  // 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
+ className: cn("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
97
  if (event.key === "Escape")
97
98
  dismiss();
98
99
  }, 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
@@ -8,6 +8,7 @@ import { buildCancelUrl, buildCheckoutUrl, useConnectCheckout, } from "../use-ch
8
8
  import { AnimatePresence, ENTER, m, MotionProvider } from "./motion.js";
9
9
  import { ConnectSubscribeDialog } from "./subscribe-dialog.js";
10
10
  import { ACTION_LABEL, checkoutSubscriptionId, discountLabel, formatPrice, groupPlansByTier, intervalLabel, isFreePlan, pricePerLabel, resolveAction, } from "./plan-pricing.js";
11
+ import { cn } from "../../cn.js";
11
12
  /**
12
13
  * The plan table: monthly/annual toggle, features, add-ons, the discount
13
14
  * actually in effect, and the subscribe / upgrade / downgrade flows.
@@ -102,7 +103,7 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
102
103
  setPending(null);
103
104
  }
104
105
  if (plansLoading || entitlementsLoading) {
105
- return (_jsx("div", { className: `grid gap-4 sm:grid-cols-2 lg:grid-cols-3 ${className ?? ""}`, children: Array.from({ length: 3 }, (_, index) => (_jsx(Skeleton, { className: "h-80 rounded-xl" }, index))) }));
106
+ return (_jsx("div", { className: cn("grid gap-4 sm:grid-cols-2 lg:grid-cols-3", className), children: Array.from({ length: 3 }, (_, index) => (_jsx(Skeleton, { className: "h-80 rounded-xl" }, index))) }));
106
107
  }
107
108
  if (!session?.user) {
108
109
  return _jsx("p", { className: "text-sm text-muted", children: "Sign in to see your plan options." });
@@ -110,7 +111,7 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
110
111
  if (error) {
111
112
  return _jsx("p", { className: "text-sm text-danger", children: "Could not load plans." });
112
113
  }
113
- return (_jsx(MotionProvider, { children: _jsxs("div", { className: `flex flex-col gap-6 ${className ?? ""}`, children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-4", children: [hasAnnual && hasMonthly && (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: showAnnual ? "text-sm text-muted" : "text-sm font-medium text-foreground", children: "Monthly" }), _jsx(Switch, { "aria-label": "Bill annually", isSelected: showAnnual, onChange: setAnnual, children: _jsx(Switch.Content, { children: _jsx(Switch.Control, { children: _jsx(Switch.Thumb, {}) }) }) }), _jsx("span", { className: showAnnual ? "text-sm font-medium text-foreground" : "text-sm text-muted", children: "Annual" }), bestSaving !== null && (_jsxs(Chip, { className: "bg-accent-soft text-accent", children: ["Save up to ", bestSaving, "%"] }))] })), !hideCoupon && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Tag, { className: "size-4 text-muted", "aria-hidden": "true" }), _jsx(Input, { "aria-label": "Coupon code", placeholder: "Coupon code", value: coupon, onChange: (event) => setCoupon(event.target.value.toUpperCase()), className: "w-40" })] }))] }), subscription?.subscription?.appliedDiscount && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", children: [_jsx(Sparkles, { className: "size-4 shrink-0", "aria-hidden": "true" }), _jsxs("span", { children: [discountLabel(subscription.subscription.appliedDiscount), subscription.subscription.appliedDiscount.code
114
+ return (_jsx(MotionProvider, { children: _jsxs("div", { className: cn("flex flex-col gap-6", className), children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-4", children: [hasAnnual && hasMonthly && (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("span", { className: showAnnual ? "text-sm text-muted" : "text-sm font-medium text-foreground", children: "Monthly" }), _jsx(Switch, { "aria-label": "Bill annually", isSelected: showAnnual, onChange: setAnnual, children: _jsx(Switch.Content, { children: _jsx(Switch.Control, { children: _jsx(Switch.Thumb, {}) }) }) }), _jsx("span", { className: showAnnual ? "text-sm font-medium text-foreground" : "text-sm text-muted", children: "Annual" }), bestSaving !== null && (_jsxs(Chip, { className: "bg-accent-soft text-accent", children: ["Save up to ", bestSaving, "%"] }))] })), !hideCoupon && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Tag, { className: "size-4 text-muted", "aria-hidden": "true" }), _jsx(Input, { "aria-label": "Coupon code", placeholder: "Coupon code", value: coupon, onChange: (event) => setCoupon(event.target.value.toUpperCase()), className: "w-40" })] }))] }), subscription?.subscription?.appliedDiscount && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", children: [_jsx(Sparkles, { className: "size-4 shrink-0", "aria-hidden": "true" }), _jsxs("span", { children: [discountLabel(subscription.subscription.appliedDiscount), subscription.subscription.appliedDiscount.code
114
115
  ? ` - code ${subscription.subscription.appliedDiscount.code}`
115
116
  : ""] })] })), _jsx(ConnectCheckoutStatusRow, { checkout: checkout }), _jsx(m.div, { className: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3", variants: { show: { transition: { staggerChildren: 0.06 } } }, initial: "hidden", animate: "show", children: tiers.map((tier) => {
116
117
  // The price point this card is showing. A tier missing the toggled
@@ -5,6 +5,7 @@ import { AlertTriangle, ExternalLink, Info, Loader2 } from "lucide-react";
5
5
  import { blockingBlockers } from "../../helpers.js";
6
6
  import { useConnect, useConnectEntitlements } from "../provider.js";
7
7
  import { ENTER, m, MotionProvider } from "./motion.js";
8
+ import { cn } from "../../cn.js";
8
9
  /**
9
10
  * Codes that mean "somebody at Connect is looking at it" - there is no action
10
11
  * for the customer to take, so these get an informational treatment and no
@@ -57,14 +58,14 @@ export function ConnectReadinessBanner({ includeAdvisory = false, hideWhenReady
57
58
  if (!session?.user)
58
59
  return null;
59
60
  if (isLoading) {
60
- return (_jsxs("div", { className: `flex items-center gap-2 text-sm text-muted ${className ?? ""}`, children: [_jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": "true" }), "Checking your workspace\u2026"] }));
61
+ return (_jsxs("div", { className: cn("flex items-center gap-2 text-sm text-muted", className), children: [_jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": "true" }), "Checking your workspace\u2026"] }));
61
62
  }
62
63
  if (!data)
63
64
  return null;
64
65
  const blockers = includeAdvisory ? data.readiness.readiness.blockers : blockingBlockers(data.readiness);
65
66
  if (blockers.length === 0)
66
67
  return hideWhenReady ? null : _jsx(ReadyRow, { className: className });
67
- return (_jsx(MotionProvider, { children: _jsx(m.div, { className: `flex flex-col gap-2 ${className ?? ""}`, role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: ENTER, children: blockers.map((blocker) => (_jsx(BlockerRow, { blocker: blocker }, `${blocker.code}-${blocker.message}`))) }) }));
68
+ return (_jsx(MotionProvider, { children: _jsx(m.div, { className: cn("flex flex-col gap-2", className), role: "status", initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 }, transition: ENTER, children: blockers.map((blocker) => (_jsx(BlockerRow, { blocker: blocker }, `${blocker.code}-${blocker.message}`))) }) }));
68
69
  }
69
70
  function BlockerRow({ blocker }) {
70
71
  const inProgress = IN_PROGRESS_CODES.has(blocker.code);
@@ -72,7 +73,7 @@ function BlockerRow({ blocker }) {
72
73
  return (_jsxs("div", { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg px-3 py-2 text-sm ${inProgress || !isBlocking ? "bg-surface text-muted" : "bg-danger-soft text-danger"}`, children: [_jsxs("span", { className: "flex items-center gap-2", children: [inProgress || !isBlocking ? (_jsx(Info, { className: "size-4 shrink-0", "aria-hidden": "true" })) : (_jsx(AlertTriangle, { className: "size-4 shrink-0", "aria-hidden": "true" })), blocker.message] }), blocker.resolveUrl && !inProgress && (_jsxs(Button, { variant: "secondary", onPress: () => window.open(blocker.resolveUrl, "_blank", "noopener,noreferrer"), children: [actionLabel(blocker), _jsx(ExternalLink, { className: "size-3.5" })] }))] }));
73
74
  }
74
75
  function ReadyRow({ className }) {
75
- return (_jsx("p", { className: `text-sm text-muted ${className ?? ""}`, children: "This workspace is cleared to use every part of the application." }));
76
+ return (_jsx("p", { className: cn("text-sm text-muted", className), children: "This workspace is cleared to use every part of the application." }));
76
77
  }
77
78
  /**
78
79
  * Renders `children` only once the workspace clears every blocking gate.
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, use } from "react";
4
4
  import { m } from "./motion.js";
5
+ import { cn } from "../../cn.js";
5
6
  /** A plain anchor - correct anywhere, and a full page load. See `ConnectLinkRenderer`. */
6
7
  export const defaultRenderLink = ({ children, ...props }) => _jsx("a", { ...props, children: children });
7
8
  /**
@@ -137,5 +138,5 @@ export function useConnectShell() {
137
138
  */
138
139
  export function SidebarLabel({ children, className, }) {
139
140
  const { collapsed } = useConnectShell();
140
- return (_jsx(m.span, { className: `min-w-0 flex-1 truncate text-start ${className ?? ""}`, animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": collapsed, children: children }));
141
+ return (_jsx(m.span, { className: cn("min-w-0 flex-1 truncate text-start", className), animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": collapsed, children: children }));
141
142
  }
@@ -5,6 +5,7 @@ import { ArrowUpRight } from "lucide-react";
5
5
  import { m } from "./motion.js";
6
6
  import { SidebarLabel, defaultRenderLink, sidebarRowClass, useConnectShell } from "./shell-context.js";
7
7
  import { useActivePath } from "../use-active-path.js";
8
+ import { cn } from "../../cn.js";
8
9
  import { isNavItemActive, normalizeNav, resolveActivePath, } from "./nav.js";
9
10
  export { defaultRenderLink };
10
11
  function rowClass(isActive, depth) {
@@ -64,7 +65,7 @@ export function ConnectSidebarNav({ nav, activePath, renderLink, collapsed, onNa
64
65
  const pathname = useActivePath(activePath ?? shell.activePath);
65
66
  const active = resolveActivePath(pathname, nav);
66
67
  const sections = normalizeNav(nav);
67
- return (_jsx("div", { className: `flex flex-col gap-2 ${className ?? ""}`, children: sections.map((section, index) => (_jsxs("nav", { "aria-label": section.title ?? "Main", className: "flex flex-col gap-1", children: [section.title && _jsx(SectionHeading, { title: section.title, collapsed: isCollapsed }), section.items.map((item) => {
68
+ return (_jsx("div", { className: cn("flex flex-col gap-2", className), children: sections.map((section, index) => (_jsxs("nav", { "aria-label": section.title ?? "Main", className: "flex flex-col gap-1", children: [section.title && _jsx(SectionHeading, { title: section.title, collapsed: isCollapsed }), section.items.map((item) => {
68
69
  const isActive = isNavItemActive(item, active);
69
70
  const activeChild = (item.items ?? []).some((child) => isNavItemActive(child, active));
70
71
  return (_jsxs("div", { className: "flex w-full flex-col gap-1", children: [_jsx(NavRow, { item: item, isActive: isActive, hasActiveChild: activeChild, collapsed: isCollapsed, depth: 0, renderLink: link, onNavigate: navigate }), !isCollapsed &&
@@ -5,6 +5,7 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
5
5
  import { ConnectLogo } from "./logo.js";
6
6
  import { EASE_OUT_QUART, MotionProvider, m } from "./motion.js";
7
7
  import { RAIL_COLLAPSED, RAIL_EXPANDED, SidebarLabel, sidebarRowClass, useConnectShell } from "./shell-context.js";
8
+ import { cn } from "../../cn.js";
8
9
  /**
9
10
  * The rail's own transition.
10
11
  *
@@ -43,7 +44,7 @@ export function ConnectSidebar({ children, label = "Sidebar", className }) {
43
44
  // from these parts by hand gets a sidebar that jumps between widths
44
45
  // instead of animating - a failure that looks like a styling bug and is
45
46
  // really a missing provider.
46
- _jsx(MotionProvider, { children: _jsx(m.aside, { "aria-label": label, className: `hidden shrink-0 flex-col overflow-hidden border-e border-border bg-surface md:flex ${className ?? ""}`,
47
+ _jsx(MotionProvider, { children: _jsx(m.aside, { "aria-label": label, className: cn("hidden shrink-0 flex-col overflow-hidden border-e border-border bg-surface md:flex", className),
47
48
  // `initial={false}` is what stops the rail sweeping open on first paint
48
49
  // for a returning user whose stored preference is "collapsed".
49
50
  initial: false, animate: { width: collapsed ? RAIL_COLLAPSED : RAIL_EXPANDED }, transition: RAIL_TRANSITION, children: children }) }));
@@ -6,6 +6,7 @@ import { useConnect, useConnectEntitlements } from "../provider.js";
6
6
  import { m, MotionProvider } from "./motion.js";
7
7
  import { formatPrice, intervalLabel } from "./plan-pricing.js";
8
8
  import { ConnectLinkButton } from "./link-button.js";
9
+ import { cn } from "../../cn.js";
9
10
  /** Where the plan catalogue lives in the host app. Overridable, because not every app mounts it at `/plans`. */
10
11
  const DEFAULT_PLANS_HREF = "/plans";
11
12
  function formatDate(iso) {
@@ -74,7 +75,7 @@ export function ConnectSubscriptionCard({ plansHref, className }) {
74
75
  const { appUrl } = useConnect();
75
76
  const { isLoading, detail, plan, state } = useSubscriptionState();
76
77
  if (isLoading)
77
- return _jsx(Skeleton, { className: `h-48 rounded-xl ${className ?? ""}` });
78
+ return _jsx(Skeleton, { className: cn("h-48 rounded-xl", className) });
78
79
  if (!detail || !plan)
79
80
  return _jsx(ConnectNoSubscriptionCard, { plansHref: plansHref, className: className });
80
81
  return (_jsx(MotionProvider, { children: _jsx(m.div, { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.2 }, children: _jsxs(Card, { className: className, children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: plan.name }), _jsx(SubscriptionChip, { state: state })] }), _jsxs(Card.Description, { children: [formatPrice(plan.price), " + ", plan.gstRate, "% GST per", " ", intervalLabel(plan.billingInterval).per] })] }), _jsxs(Card.Content, { className: "flex flex-col gap-2 text-sm", children: [_jsx(NextEvent, { detail: detail, state: state }), _jsx(ConnectSeatUsage, { plan: plan, detail: detail })] }), _jsxs(Card.Footer, { className: "flex flex-wrap gap-2", children: [_jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage on Connect", _jsx(ExternalLink, { className: "size-3.5" })] }), _jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Change plan" })] })] }) }) }));
@@ -124,7 +125,7 @@ export function ConnectSeatUsage({ plan, detail, className, }) {
124
125
  return null;
125
126
  const included = plan.seats.included ?? 0;
126
127
  const over = detail.seatCount - included;
127
- return (_jsxs("span", { className: `flex items-center gap-2 text-muted ${className ?? ""}`, children: [_jsx(Users, { className: "size-4 shrink-0", "aria-hidden": "true" }), detail.seatCount, " of ", included, " seat", included === 1 ? "" : "s", " used", over > 0 &&
128
+ return (_jsxs("span", { className: cn("flex items-center gap-2 text-muted", className), children: [_jsx(Users, { className: "size-4 shrink-0", "aria-hidden": "true" }), detail.seatCount, " of ", included, " seat", included === 1 ? "" : "s", " used", over > 0 &&
128
129
  (plan.seats.model === "per-seat"
129
130
  ? ` - ${over} extra at ${formatPrice(plan.seats.pricePerExtraSeat ?? 0)} each`
130
131
  : ` - ${over} over the plan's limit`)] }));
@@ -146,7 +147,7 @@ export function ConnectTrialBanner({ plansHref, className }) {
146
147
  const { detail, state } = useSubscriptionState();
147
148
  if (state !== "trialing" || !detail)
148
149
  return null;
149
- return (_jsxs("div", { className: `flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent ${className ?? ""}`, children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), "You're on a free trial", detail.nextBillingDate ? `. Your first charge is on ${formatDate(detail.nextBillingDate)}.` : "."] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), _jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage", _jsx(ExternalLink, { className: "size-3.5" })] })] })] }));
150
+ return (_jsxs("div", { className: cn("flex flex-wrap items-center justify-between gap-3 rounded-lg bg-accent-soft px-3 py-2 text-sm text-accent", className), children: [_jsxs("span", { className: "flex items-center gap-2", children: [_jsx(Clock, { className: "size-4 shrink-0", "aria-hidden": "true" }), "You're on a free trial", detail.nextBillingDate ? `. Your first charge is on ${formatDate(detail.nextBillingDate)}.` : "."] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ConnectLinkButton, { href: plansHref ?? DEFAULT_PLANS_HREF, variant: "tertiary", children: "Compare plans" }), _jsxs(Button, { variant: "secondary", onPress: () => window.open(`${appUrl}/billing/subscriptions/${detail.id}`, "_blank", "noopener,noreferrer"), children: ["Manage", _jsx(ExternalLink, { className: "size-3.5" })] })] })] }));
150
151
  }
151
152
  /**
152
153
  * Renders `children` for a subscribed workspace, and the "pick a plan" card for
@@ -166,7 +167,7 @@ export function ConnectSubscriptionGate({ children, plansHref, fallback, loading
166
167
  const { session, isLoading: sessionLoading } = useConnect();
167
168
  const { isLoading, state } = useSubscriptionState();
168
169
  if (sessionLoading || (session?.user && isLoading)) {
169
- return _jsx(_Fragment, { children: loading ?? _jsx(Skeleton, { className: `h-48 rounded-xl ${className ?? ""}` }) });
170
+ return _jsx(_Fragment, { children: loading ?? _jsx(Skeleton, { className: cn("h-48 rounded-xl", className) }) });
170
171
  }
171
172
  if (!session?.user)
172
173
  return _jsx(_Fragment, { children: fallback ?? null });
@@ -5,6 +5,7 @@ import { AlertTriangle } from "lucide-react";
5
5
  import { useConnect, useConnectAddonUsage } from "../provider.js";
6
6
  import { m, METER_SPRING, MotionProvider } from "./motion.js";
7
7
  import { formatPrice } from "./plan-pricing.js";
8
+ import { cn } from "../../cn.js";
8
9
  function formatUnits(value, unit) {
9
10
  return `${value.toLocaleString("en-IN")} ${unit}`;
10
11
  }
@@ -32,7 +33,7 @@ export function ConnectUsageMeter({ addonIds, hideWhenEmpty = true, title = "Usa
32
33
  if (!session?.user)
33
34
  return null;
34
35
  if (isLoading)
35
- return _jsx(Skeleton, { className: `h-40 rounded-xl ${className ?? ""}` });
36
+ return _jsx(Skeleton, { className: cn("h-40 rounded-xl", className) });
36
37
  if (error || !data)
37
38
  return null;
38
39
  // Defended here as well as in `billing.getAddonUsage`, and deliberately so:
@@ -9,6 +9,7 @@ import { ConnectAccountSessions } from "./account-sessions.js";
9
9
  import { m } from "./motion.js";
10
10
  import { SidebarLabel, sidebarRowClass, useConnectShell } from "./shell-context.js";
11
11
  import { ICON, MEDALLION, PANEL_FILL } from "./surfaces.js";
12
+ import { cn } from "../../cn.js";
12
13
  const THEME_OPTIONS = [
13
14
  { value: "light", label: "Light", icon: Sun },
14
15
  { value: "dark", label: "Dark", icon: Moon },
@@ -189,7 +190,7 @@ export function ConnectUserButton({ variant = "row", onSignOut, className, theme
189
190
  const { session, isLoading } = useConnect();
190
191
  const { collapsed } = useConnectShell();
191
192
  if (isLoading) {
192
- return (_jsxs("div", { className: `flex h-10 items-center gap-3 ${variant === "row" ? "ps-[4px]" : ""} ${className ?? ""}`, children: [_jsx(Skeleton, { className: "size-8 shrink-0 rounded-full" }), variant !== "compact" && !collapsed && _jsx(Skeleton, { className: "h-4 w-24" })] }));
193
+ return (_jsxs("div", { className: cn("flex h-10 items-center gap-3", variant === "row" ? "ps-[4px]" : "", className), children: [_jsx(Skeleton, { className: "size-8 shrink-0 rounded-full" }), variant !== "compact" && !collapsed && _jsx(Skeleton, { className: "h-4 w-24" })] }));
193
194
  }
194
195
  const user = session?.user;
195
196
  if (!user)
@@ -199,7 +200,7 @@ export function ConnectUserButton({ variant = "row", onSignOut, className, theme
199
200
  // `ps-1` against the default trailing padding is optical, not
200
201
  // sloppy: a circular avatar needs less inset than a text label to
201
202
  // look evenly seated in the button.
202
- className: `gap-2 ps-1 ${className ?? ""}`, children: [_jsx(UserAvatar, { user: user, size: "sm" }), variant === "inline" && _jsx("span", { className: "text-sm font-medium text-foreground", children: label })] })), _jsx(ProfileSurface, { onSignOut: onSignOut, theme: theme, onThemeChange: onThemeChange })] }));
203
+ className: cn("gap-2 ps-1", className), children: [_jsx(UserAvatar, { user: user, size: "sm" }), variant === "inline" && _jsx("span", { className: "text-sm font-medium text-foreground", children: label })] })), _jsx(ProfileSurface, { onSignOut: onSignOut, theme: theme, onThemeChange: onThemeChange })] }));
203
204
  }
204
205
  /**
205
206
  * @deprecated Renamed to `ConnectUserButton` in 0.12 - it is one button that
@@ -133,13 +133,34 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
133
133
  setResult({ session, error: null });
134
134
  }
135
135
  catch (cause) {
136
- // A bridge that cannot be reached is reported as signed out *with* an
137
- // error, so a Login button still renders rather than the UI hanging on
138
- // skeletons forever.
139
- setResult({
140
- session: { user: null, subscription: null, role: null, permissions: [], staff: false, audience: null },
141
- error: cause instanceof Error ? cause.message : "Could not reach Connect",
142
- });
136
+ const message = cause instanceof Error ? cause.message : "Could not reach Connect";
137
+ /*
138
+ * **A failed read never downgrades a session we already have.**
139
+ *
140
+ * This used to replace whatever was known with a signed-out session on
141
+ * every failure. On a first load that is right - something has to render
142
+ * rather than skeletons forever - but on a *refresh* it meant one
143
+ * throttled or dropped request logged the user out of the UI, and the
144
+ * host app's own guards then acted on that.
145
+ *
146
+ * Keeping the last known session is the honest reading: the bridge could
147
+ * not answer, so what we knew a moment ago is still the best answer we
148
+ * have. `error` is set either way, so an app that wants to surface
149
+ * "we've lost touch with Connect" still can.
150
+ */
151
+ setResult((previous) => previous?.session.user
152
+ ? { session: previous.session, error: message }
153
+ : {
154
+ session: {
155
+ user: null,
156
+ subscription: null,
157
+ role: null,
158
+ permissions: [],
159
+ staff: false,
160
+ audience: null,
161
+ },
162
+ error: message,
163
+ });
143
164
  }
144
165
  }, [fetchResource]);
145
166
  useEffect(() => {
@@ -17,6 +17,17 @@ let patched = false;
17
17
  * actually mounted without an `activePath`. Pass `activePath` (Next:
18
18
  * `usePathname()`) and none of this runs at all - which is the recommended
19
19
  * path for any app that has a router to ask.
20
+ *
21
+ * The notification is deferred to a microtask, and that is load-bearing
22
+ * rather than tidiness. Next's App Router commits its history entry from
23
+ * inside a `useInsertionEffect` (`HistoryUpdater` calls
24
+ * `history.pushState`/`replaceState` there), so a synchronous
25
+ * `dispatchEvent` reaches `useSyncExternalStore`'s listener *during* that
26
+ * phase and the re-render it forces is refused: React logs
27
+ * "useInsertionEffect must not schedule updates" on every navigation. A
28
+ * microtask runs once the commit's stack has unwound, by which point
29
+ * `location.pathname` already holds the new value, so the snapshot the store
30
+ * reads is the same one - only the scheduling moves.
20
31
  */
21
32
  function patchHistory() {
22
33
  if (patched || typeof window === "undefined" || typeof window.history === "undefined")
@@ -26,7 +37,7 @@ function patchHistory() {
26
37
  const original = window.history[method];
27
38
  window.history[method] = function patchedHistoryMethod(...args) {
28
39
  const result = original.apply(this, args);
29
- window.dispatchEvent(new Event(LOCATION_CHANGE));
40
+ queueMicrotask(() => window.dispatchEvent(new Event(LOCATION_CHANGE)));
30
41
  return result;
31
42
  };
32
43
  }
package/dist/esm/cn.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Merge class names so a caller's Tailwind utility actually wins.
3
+ *
4
+ * Every component in this package carries default utilities, and a partner
5
+ * styling one reaches for `className`. Concatenating the two does not do what
6
+ * it looks like it does:
7
+ *
8
+ * ```tsx
9
+ * // ConnectBookingCalendar with className="gap-8"
10
+ * <div className={`flex flex-col gap-4 ${className}`} />
11
+ * // -> class="flex flex-col gap-4 gap-8"
12
+ * ```
13
+ *
14
+ * Both rules are now in the stylesheet and both match, so the winner is
15
+ * decided by **source order in the generated CSS**, not by the order they
16
+ * appear in the attribute. Tailwind sorts its output by its own internal
17
+ * ordering, so whether `gap-8` beats `gap-4` depends on which utility Tailwind
18
+ * happens to emit later - `p-8` over `px-3 py-2` lands one way, `gap-8` over
19
+ * `gap-4` the other. An override that works on one prop and silently does
20
+ * nothing on the next is worse than one that never works, because nobody
21
+ * suspects the mechanism.
22
+ *
23
+ * `cn` resolves it by *removing* the conflicting default rather than adding to
24
+ * it - `cn("flex flex-col gap-4", "gap-8")` is `"flex flex-col gap-8"`, and
25
+ * `cn("px-3 py-2", "p-8")` is `"p-8"`. That is a real understanding of
26
+ * Tailwind's shorthand groups, which is why it is `tailwind-merge` and not a
27
+ * `join(" ")`.
28
+ *
29
+ * Re-exported rather than reimplemented. The implementation is
30
+ * `tailwind-variants`' `cn`, which `@heroui/react` re-exports and which every
31
+ * HeroUI component uses internally through `tv` - so a partner's override is
32
+ * resolved by exactly the same rules whether it lands on one of these
33
+ * components or on a HeroUI one. It is reached through `@heroui/react` rather
34
+ * than `tailwind-variants` directly because HeroUI is already a declared peer
35
+ * of both UI entry points, and importing a package this manifest does not name
36
+ * would be relying on someone else's transitive tree.
37
+ *
38
+ * Safe in the server-rendered `./ui` entry: the `@heroui/react` barrel carries
39
+ * no top-level `"use client"` (only its individual components do), so `cn`
40
+ * arrives as a real function rather than a client-reference proxy. That
41
+ * distinction matters for a *function* in a way it does not for a component -
42
+ * `ui/section.tsx` has always imported `Card` from the same barrel.
43
+ *
44
+ * Not exported from the root entry, which is isomorphic and React-free. It is
45
+ * public from `./ui`, because a partner composing on top of these primitives
46
+ * needs the same merge the primitives use.
47
+ */
48
+ export { cn } from "@heroui/react";
@@ -1,6 +1,7 @@
1
1
  import { cache } from "react";
2
2
  import { ConnectClient } from "../server/client.js";
3
3
  import { createConnectHandler } from "../server/handler.js";
4
+ import { ConnectError } from "../errors.js";
4
5
  /**
5
6
  * Plug-and-play wiring for Next.js.
6
7
  *
@@ -211,7 +212,34 @@ async function readSession(request, options) {
211
212
  try {
212
213
  return await (options.client ?? connectClient()).identity.getUser(token);
213
214
  }
214
- catch {
215
+ catch (cause) {
216
+ /*
217
+ * `null` means **not signed in**, and nothing else.
218
+ *
219
+ * This used to be a bare `catch { return null }`, so every failure - a
220
+ * throttled read, a five-hundred, a network blip - was reported to the
221
+ * caller as "there is no session here". That is the worst possible lie for
222
+ * this particular function, because `requireConnectSession` and every
223
+ * app guard built on it answer "not signed in" by redirecting to sign-in.
224
+ *
225
+ * The result was a login loop nobody could escape: a 429 on the session
226
+ * read bounced a signed-in user to `/signin`, the OAuth round trip spent
227
+ * *more* calls against the same exhausted bucket, and the app 429'd again
228
+ * on the way back. It could only clear when the rate-limit window rolled.
229
+ * Both of the screenshots that reported this show exactly that sequence.
230
+ *
231
+ * So only a genuine session failure answers `null`. `isSessionExpired`
232
+ * covers the whole family the API deliberately collapses into
233
+ * `invalid_token` - unknown, expired, revoked, or issued to another
234
+ * application - which are the cases where signing in again is the correct
235
+ * and only response.
236
+ *
237
+ * Everything else throws. A page that cannot reach Connect gets an error
238
+ * boundary, which is recoverable and truthful; being quietly signed out is
239
+ * neither.
240
+ */
241
+ if (cause instanceof ConnectError && !cause.isSessionExpired)
242
+ throw cause;
215
243
  return null;
216
244
  }
217
245
  }
@@ -254,8 +282,18 @@ const cachedAmbientSession = cache(async () => {
254
282
  * any other request-scoped read - a page that awaits it above every boundary
255
283
  * gives up its static shell.
256
284
  *
257
- * Returns `null` for every failure - an expired session and a missing one both
258
- * mean "not signed in", and a page guard should not need a try/catch.
285
+ * Returns `null` when there is **no session** - no cookie, or one Connect
286
+ * reports as expired, revoked or issued to another application. A page guard
287
+ * needs no try/catch for those, which are the cases where sending someone
288
+ * back through sign-in is the right answer.
289
+ *
290
+ * **It throws on anything else**, and that is deliberate. It previously
291
+ * swallowed every failure into `null`, so a rate-limited or unreachable
292
+ * Connect read as "signed out" - and `requireConnectSession` then redirected a
293
+ * signed-in user to sign-in, whose OAuth round trip spent more of the same
294
+ * exhausted budget and 429'd again on return. A login loop that could only end
295
+ * when the rate-limit window rolled. An error boundary is the honest outcome
296
+ * for "could not ask", and unlike a spurious sign-out it is recoverable.
259
297
  *
260
298
  * **Memoized per request for the plain, zero-argument call** - the shape
261
299
  * every page, layout and Server Action actually uses - via React's `cache()`.
@@ -157,4 +157,4 @@ export class ConnectClient {
157
157
  }
158
158
  }
159
159
  /** Kept in step with `package.json` by `sdk.test.ts`. */
160
- export const SDK_VERSION = "0.24.1";
160
+ export const SDK_VERSION = "0.26.0";
@@ -118,7 +118,9 @@ export async function request(options, spec) {
118
118
  // `PUT` and `DELETE` are idempotent by definition - replaying one lands the
119
119
  // same end state - so they retry like `GET`. `POST` still needs an
120
120
  // `Idempotency-Key` to earn it, because a replayed create is a second row.
121
- const idempotent = spec.method !== "POST" || headers["idempotency-key"] !== undefined;
121
+ const idempotent = spec.idempotent === true ||
122
+ spec.method !== "POST" ||
123
+ headers["idempotency-key"] !== undefined;
122
124
  const attempts = idempotent ? options.maxRetries + 1 : 1;
123
125
  let lastError;
124
126
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -174,7 +174,14 @@ export class IdentityResource {
174
174
  * `authorizeUrl()`.
175
175
  */
176
176
  async getUser(sessionToken) {
177
- const wire = await this.post("/sso/user", { session_token: sessionToken });
177
+ /*
178
+ * `idempotent` because it is: this asks who a session token belongs to and
179
+ * writes nothing. Without it a 429 here reached the caller on the first
180
+ * attempt, and the transport's `Retry-After` backoff - which exists
181
+ * exactly for this - never ran, because the rule keyed on the verb rather
182
+ * than on the effect.
183
+ */
184
+ const wire = await this.post("/sso/user", { session_token: sessionToken }, { idempotent: true });
178
185
  return {
179
186
  user: wire.user,
180
187
  subscription: wire.subscription,
@@ -204,8 +211,11 @@ export class IdentityResource {
204
211
  scope: options.scope,
205
212
  });
206
213
  }
207
- post(path, form) {
214
+ post(path, form,
215
+ /** Only `idempotent` today - for the `/sso/*` reads that are POSTs. */
216
+ extra) {
208
217
  const spec = {
218
+ ...extra,
209
219
  method: "POST",
210
220
  path,
211
221
  auth: "none",