@cosmoops/connect-sdk 0.38.0 → 0.41.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 (44) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/cjs/client/components/availability-form.js +52 -24
  3. package/dist/cjs/client/components/call-button.js +398 -0
  4. package/dist/cjs/client/components/create-workspace-form.js +66 -0
  5. package/dist/cjs/client/components/event-type-form.js +99 -25
  6. package/dist/cjs/client/components/workspace-switcher.js +42 -9
  7. package/dist/cjs/client/index.js +17 -2
  8. package/dist/cjs/client/portal-links.js +1 -1
  9. package/dist/cjs/client/use-scheduling.js +37 -0
  10. package/dist/cjs/client/use-workspaces.js +41 -0
  11. package/dist/cjs/server/client.js +1 -1
  12. package/dist/cjs/server/handler.js +176 -2
  13. package/dist/cjs/server/resources/calls.js +58 -0
  14. package/dist/cjs/server/resources/scheduling.js +67 -0
  15. package/dist/cjs/server/resources/workspaces.js +30 -11
  16. package/dist/esm/client/components/availability-form.js +53 -25
  17. package/dist/esm/client/components/call-button.js +358 -0
  18. package/dist/esm/client/components/create-workspace-form.js +63 -0
  19. package/dist/esm/client/components/event-type-form.js +100 -26
  20. package/dist/esm/client/components/workspace-switcher.js +42 -9
  21. package/dist/esm/client/index.js +8 -2
  22. package/dist/esm/client/portal-links.js +1 -1
  23. package/dist/esm/client/use-scheduling.js +34 -0
  24. package/dist/esm/client/use-workspaces.js +38 -0
  25. package/dist/esm/server/client.js +1 -1
  26. package/dist/esm/server/handler.js +176 -2
  27. package/dist/esm/server/resources/calls.js +58 -0
  28. package/dist/esm/server/resources/scheduling.js +67 -0
  29. package/dist/esm/server/resources/workspaces.js +30 -11
  30. package/dist/types/client/components/availability-form.d.ts +23 -12
  31. package/dist/types/client/components/call-button.d.ts +132 -0
  32. package/dist/types/client/components/create-workspace-form.d.ts +32 -0
  33. package/dist/types/client/components/event-type-form.d.ts +30 -8
  34. package/dist/types/client/components/workspace-switcher.d.ts +9 -9
  35. package/dist/types/client/index.d.ts +7 -1
  36. package/dist/types/client/portal-links.d.ts +1 -1
  37. package/dist/types/client/use-scheduling.d.ts +46 -2
  38. package/dist/types/client/use-workspaces.d.ts +27 -0
  39. package/dist/types/server/client.d.ts +1 -1
  40. package/dist/types/server/resources/calls.d.ts +32 -1
  41. package/dist/types/server/resources/scheduling.d.ts +66 -2
  42. package/dist/types/server/resources/workspaces.d.ts +28 -0
  43. package/dist/types/types.d.ts +143 -1
  44. package/package.json +4 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,92 @@
3
3
  `@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
4
4
  versions may carry breaking changes** and patch versions never do.
5
5
 
6
+ ## 0.41.0
7
+
8
+ ### Calling from the browser: `<ConnectCallButton>`
9
+
10
+ A signed-in member can now ring a number from your app - through the
11
+ **workspace's own number**, so the person called sees the business, never the
12
+ member's phone. Calls are **not recorded**, and every one lands in Connect's
13
+ call log with who placed it, whether it was answered and how long they talked.
14
+
15
+ ```tsx
16
+ import { ConnectCallProvider, ConnectCallButton } from "@cosmoops/connect-sdk/client";
17
+
18
+ // once, inside <ConnectProvider>, beside your app shell - a call outlives the page
19
+ <ConnectCallProvider onCallEnded={({ callId }) => callId && logCall(callId)}>
20
+ {children}
21
+ </ConnectCallProvider>
22
+
23
+ // wherever a person can be rung
24
+ <ConnectCallButton phone="+919812345678" name="Asha Rao" />
25
+ ```
26
+
27
+ `<ConnectCallProvider>` holds the call and draws the in-call bar (mute, hang
28
+ up, a timer), so navigating mid-call does not hang up. `useConnectCall()` is
29
+ there for your own button. Plivo's browser SDK - now this package's one
30
+ runtime dependency - is loaded only when someone presses Call.
31
+
32
+ **Log the outcome on your server**, not from the browser: `onEnded` /
33
+ `onCallEnded` hand you the call id, and `connect.calls.get(callId)` reads
34
+ `status`, `talkSeconds`, `unansweredReason` and `placedByUserId`. A browser is
35
+ not a witness a call log should trust, and the carrier's report reaches
36
+ Connect a moment after the line drops - read again if `status` is still
37
+ `dialing` or `answered`.
38
+
39
+ **Two halves of permission, both required.** Your API key needs the new
40
+ `calls:place` scope (and `calls:read` to read outcomes); the member needs
41
+ "Make calls" - from their workspace role, or a role on your application
42
+ carrying `connect:call:place`. Nothing here works server-to-server: a call is
43
+ placed by a person. Re-run your seed with the new scopes.
44
+
45
+ New on `ConnectCall`: `handler` (`agent` | `member`), `placedByUserId`,
46
+ `talkSeconds` and `unansweredReason`. `CallStatus` gains `dialing` and
47
+ `unanswered`, which outbound calls already passed through - if you switch on
48
+ it exhaustively, add the two cases.
49
+
50
+ Server-side, `connect.calls` gains `getBrowserCallingToken`,
51
+ `startBrowserCall` and `cancelBrowserCall`; `createConnectHandler` serves
52
+ them as `POST /calls/{browser-token,start,cancel}`. You should not need to
53
+ call either directly.
54
+
55
+ ## 0.40.0
56
+
57
+ ### `workspaces.create()` exists now, and the switcher's "Create workspace" row is inline
58
+
59
+ 0.37.0 added `alwaysShow` and said, deliberately, that there was no
60
+ `workspaces.create()` - creating a workspace was Connect's job, tied to
61
+ KYC/KYB and a billing profile. That reasoning held for a tenant's *first*
62
+ workspace, which still mints a tenant, an Identity Platform account and a
63
+ password reset. It never applied to a tenant's *second* workspace: opening a
64
+ branch office under a tenant the caller already owns needs none of that -
65
+ `CreateWorkspaceUseCase` just checks ownership, the tenant's plan, and the
66
+ business-email-domain rule.
67
+
68
+ So `alwaysShow`'s "Create workspace" row now opens
69
+ `<ConnectCreateWorkspaceModal>` inline - name, individual-or-business - and
70
+ switches into the result automatically when it is possible
71
+ (`workspace.inAudience`), rather than opening Connect in a new tab and
72
+ leaving the member to find their way back. Needs the new `workspaces:create`
73
+ scope on your API key.
74
+
75
+ ```tsx
76
+ import { ConnectCreateWorkspaceModal, useCreateWorkspace } from "@cosmoops/connect-sdk/client";
77
+ ```
78
+
79
+ Both are usable standalone, not only through the switcher. `useCreateWorkspace()`
80
+ is not optimistic, same as every other scheduling/workspace write here: the
81
+ tenant's plan is what decides whether another workspace is allowed at all,
82
+ and that is a question only the server can answer. There is still no
83
+ capacity read to show ahead of time - submit and show the server's own
84
+ refusal, the same posture Connect's own "Create workspace" button takes.
85
+
86
+ `ConnectWorkspaceSwitcherProps.alwaysShow`'s doc comment, and
87
+ `WorkspacesResource`'s, are both updated; nothing about the prop's name or
88
+ default changed, so an existing host that set `alwaysShow: true` for the old
89
+ link-out gets the inline flow with no code change on its side beyond the
90
+ version bump and the new scope.
91
+
6
92
  ## 0.37.0
7
93
 
8
94
  ### The workspace switcher can stay visible at one workspace, and offer a second
@@ -23,24 +23,52 @@ function timeInputToMinutes(value) {
23
23
  return (hours ?? 0) * 60 + (minutes ?? 0);
24
24
  }
25
25
  /**
26
- * Gives a workspace its first working hours, from inside a host application
27
- * rather than a link-out to Connect.
26
+ * Creates or edits a workspace's working hours, from inside a host
27
+ * application rather than a link-out to Connect.
28
28
  *
29
29
  * Deliberately narrower than Connect's own Calendar settings form: one
30
- * interval per day (no split shifts), no date overrides, and the timezone
31
- * defaults to the browser's own rather than asking. This is a first-run
32
- * setup step, not a second copy of the Console editor - "Manage in Connect"
33
- * is still where the rest of what a schedule can do lives.
30
+ * interval per day (no split shifts), no date overrides, and the timezone is
31
+ * set once at creation (from the browser's own) and shown but not editable
32
+ * here afterwards. This is a first-party setup-and-edit surface, not a
33
+ * second copy of the Console editor - "Manage in Connect" is still where the
34
+ * rest of what a schedule can do lives.
34
35
  *
35
- * **Not optimistic.** `useCreateAvailabilitySchedule` settles the write
36
- * against the server before this closes, the same posture every write in
37
- * this package takes - see `ConnectBookingDialog`.
36
+ * **Not optimistic.** The write settles against the server before this
37
+ * closes, the same posture every write in this package takes - see
38
+ * `ConnectBookingDialog`.
39
+ *
40
+ * This outer component only decides whether the row to edit has loaded yet;
41
+ * the form itself is `ScheduleFields` below, mounted with
42
+ * `key={editing?.id}` - see that component's own doc comment for why.
43
+ */
44
+ function ConnectAvailabilityFormModal({ isOpen, onOpenChange, scheduleId = null, onSaved, }) {
45
+ const isEdit = scheduleId !== null;
46
+ const { schedules, isLoading, error } = (0, use_scheduling_js_1.useAvailabilitySchedules)();
47
+ const editing = isEdit ? schedules.find((row) => row.id === scheduleId) ?? null : null;
48
+ return ((0, jsx_runtime_1.jsx)(react_2.Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(react_2.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_2.Modal.Dialog, { className: "sm:max-w-120", children: [(0, jsx_runtime_1.jsx)(react_2.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_2.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Heading, { children: isEdit ? "Edit availability" : "Set your availability" }) }), (0, jsx_runtime_1.jsx)(react_2.Modal.Body, { children: isEdit && isLoading ? ((0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Loading\u2026" })) : isEdit && error ? ((0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "That could not be loaded just now. Close this and try again." })) : isEdit && !editing ? ((0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Those working hours no longer exist." })) : ((0, jsx_runtime_1.jsx)(ScheduleFields, { editing: editing, onCancel: () => onOpenChange(false), onSaved: (saved) => {
49
+ onSaved?.(saved);
50
+ onOpenChange(false);
51
+ } }, editing?.id ?? "create")) })] }) }) }) }));
52
+ }
53
+ /**
54
+ * The form itself, mounted fresh (via the parent's `key`) for each distinct
55
+ * record it edits - or once for create. That remount is what initializes
56
+ * every field straight from `editing` with a plain `useState(initial)`
57
+ * rather than a `useEffect` copying it in after the fact: `editing` arrives
58
+ * asynchronously (`useAvailabilitySchedules` is a bridge read), and the
59
+ * parent already withholds this component until it has landed, so there is
60
+ * no later moment for a field to overwrite a half-typed edit.
38
61
  */
39
- function ConnectAvailabilityFormModal({ isOpen, onOpenChange, onCreated, }) {
40
- const [name, setName] = (0, react_1.useState)("Working hours");
41
- const [timezone] = (0, react_1.useState)(() => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
42
- const [weeklyHours, setWeeklyHours] = (0, react_1.useState)(defaultWeeklyHours);
43
- const { createSchedule, isPending, error, clearError } = (0, use_scheduling_js_1.useCreateAvailabilitySchedule)();
62
+ function ScheduleFields({ editing, onCancel, onSaved, }) {
63
+ const isEdit = editing !== null;
64
+ const [name, setName] = (0, react_1.useState)(editing?.name ?? "Working hours");
65
+ const [timezone] = (0, react_1.useState)(() => editing?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
66
+ const [weeklyHours, setWeeklyHours] = (0, react_1.useState)(editing?.weeklyHours ?? defaultWeeklyHours);
67
+ const { createSchedule, isPending: isCreating, error: createError, clearError: clearCreateError } = (0, use_scheduling_js_1.useCreateAvailabilitySchedule)();
68
+ const { updateSchedule, isPending: isUpdating, error: updateError, clearError: clearUpdateError } = (0, use_scheduling_js_1.useUpdateAvailabilitySchedule)();
69
+ const isPending = isCreating || isUpdating;
70
+ const error = isEdit ? updateError : createError;
71
+ const clearError = isEdit ? clearUpdateError : clearCreateError;
44
72
  const toggleDay = (day, open) => {
45
73
  setWeeklyHours((prev) => ({
46
74
  ...prev,
@@ -61,15 +89,15 @@ function ConnectAvailabilityFormModal({ isOpen, onOpenChange, onCreated, }) {
61
89
  if (!isFormValid)
62
90
  return;
63
91
  clearError();
64
- const schedule = await createSchedule({ name: name.trim(), timezone, weeklyHours });
65
- if (schedule) {
66
- onCreated?.(schedule);
67
- onOpenChange(false);
68
- }
92
+ const saved = isEdit
93
+ ? await updateSchedule({ scheduleId: editing.id, name: name.trim(), weeklyHours })
94
+ : await createSchedule({ name: name.trim(), timezone, weeklyHours });
95
+ if (saved)
96
+ onSaved(saved);
69
97
  };
70
- return ((0, jsx_runtime_1.jsx)(react_2.Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(react_2.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_2.Modal.Dialog, { className: "sm:max-w-120", children: [(0, jsx_runtime_1.jsx)(react_2.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_2.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Heading, { children: "Set your availability" }) }), (0, jsx_runtime_1.jsx)(react_2.Modal.Body, { children: (0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { isRequired: true, variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Name" }), (0, jsx_runtime_1.jsx)(react_2.Input, { value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Timezone: ", timezone] }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col gap-2", children: WEEKDAY_KEYS.map((day) => {
71
- const interval = weeklyHours[day][0];
72
- const isOpenDay = interval !== undefined;
73
- return ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3", children: [(0, jsx_runtime_1.jsx)(react_2.Switch, { isSelected: isOpenDay, onChange: (open) => toggleDay(day, open), isDisabled: isPending, children: (0, jsx_runtime_1.jsxs)(react_2.Switch.Content, { children: [(0, jsx_runtime_1.jsx)(react_2.Switch.Control, { children: (0, jsx_runtime_1.jsx)(react_2.Switch.Thumb, {}) }), (0, jsx_runtime_1.jsx)("span", { className: "w-24 shrink-0 text-sm", children: WEEKDAY_LABELS[day] })] }) }), isOpenDay && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(react_2.Input, { type: "time", value: minutesToTimeInput(interval.startMinute), onChange: (e) => setDayTime(day, "startMinute", e.currentTarget.value), disabled: isPending }), (0, jsx_runtime_1.jsx)("span", { className: "text-xs text-muted", children: "to" }), (0, jsx_runtime_1.jsx)(react_2.Input, { type: "time", value: minutesToTimeInput(interval.endMinute), onChange: (e) => setDayTime(day, "endMinute", e.currentTarget.value), disabled: isPending })] }))] }, day));
74
- }) }), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-end gap-2", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", variant: "secondary", onPress: () => onOpenChange(false), isDisabled: isPending, children: "Cancel" }), (0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Saving..." : "Save" })] })] }) })] }) }) }) }));
98
+ return ((0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { isRequired: true, variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Name" }), (0, jsx_runtime_1.jsx)(react_2.Input, { value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Timezone: ", timezone] }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col gap-2", children: WEEKDAY_KEYS.map((day) => {
99
+ const interval = weeklyHours[day][0];
100
+ const isOpenDay = interval !== undefined;
101
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3", children: [(0, jsx_runtime_1.jsx)(react_2.Switch, { isSelected: isOpenDay, onChange: (open) => toggleDay(day, open), isDisabled: isPending, children: (0, jsx_runtime_1.jsxs)(react_2.Switch.Content, { children: [(0, jsx_runtime_1.jsx)(react_2.Switch.Control, { children: (0, jsx_runtime_1.jsx)(react_2.Switch.Thumb, {}) }), (0, jsx_runtime_1.jsx)("span", { className: "w-24 shrink-0 text-sm", children: WEEKDAY_LABELS[day] })] }) }), isOpenDay && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(react_2.Input, { type: "time", value: minutesToTimeInput(interval.startMinute), onChange: (e) => setDayTime(day, "startMinute", e.currentTarget.value), disabled: isPending }), (0, jsx_runtime_1.jsx)("span", { className: "text-xs text-muted", children: "to" }), (0, jsx_runtime_1.jsx)(react_2.Input, { type: "time", value: minutesToTimeInput(interval.endMinute), onChange: (e) => setDayTime(day, "endMinute", e.currentTarget.value), disabled: isPending })] }))] }, day));
102
+ }) }), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-end gap-2", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", variant: "secondary", onPress: onCancel, isDisabled: isPending, children: "Cancel" }), (0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Saving..." : isEdit ? "Save changes" : "Save" })] })] }));
75
103
  }
@@ -0,0 +1,398 @@
1
+ "use client";
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ exports.useConnectCall = useConnectCall;
38
+ exports.ConnectCallProvider = ConnectCallProvider;
39
+ exports.ConnectCallButton = ConnectCallButton;
40
+ exports.ConnectCallingStatus = ConnectCallingStatus;
41
+ exports.ConnectActiveCallBar = ConnectActiveCallBar;
42
+ const jsx_runtime_1 = require("react/jsx-runtime");
43
+ const react_1 = require("react");
44
+ const react_2 = require("@heroui/react");
45
+ const lucide_react_1 = require("lucide-react");
46
+ const cn_js_1 = require("../../cn.js");
47
+ const provider_js_1 = require("../provider.js");
48
+ const portal_links_js_1 = require("../portal-links.js");
49
+ const ConnectCallContext = (0, react_1.createContext)(null);
50
+ /** Start a call yourself. Must be inside `<ConnectCallProvider>`. */
51
+ function useConnectCall() {
52
+ const value = (0, react_1.useContext)(ConnectCallContext);
53
+ if (!value) {
54
+ throw new Error("useConnectCall() needs a <ConnectCallProvider> above it.");
55
+ }
56
+ return value;
57
+ }
58
+ /** How long the SDK may take to sign in before the attempt is abandoned. */
59
+ const LOGIN_TIMEOUT_MS = 15_000;
60
+ /** How long the "Call ended" card stays before the bar goes away. */
61
+ const ENDED_CARD_MS = 4_000;
62
+ /** How long after a call to re-read the balance - the carrier's report settles it. */
63
+ const AVAILABILITY_SETTLE_MS = 3_000;
64
+ function messageFromCause(cause) {
65
+ if (typeof cause === "string" && cause.trim())
66
+ return cause.trim();
67
+ return "The call couldn't be connected.";
68
+ }
69
+ function errorMessage(cause) {
70
+ return cause instanceof Error && cause.message ? cause.message : "Something went wrong.";
71
+ }
72
+ /**
73
+ * Holds the call in progress and draws the in-call bar. See the module comment.
74
+ */
75
+ function ConnectCallProvider({ children, onCallEnded, onError, className, }) {
76
+ const { mutateResource } = (0, provider_js_1.useConnect)();
77
+ const [call, setCall] = (0, react_1.useState)(null);
78
+ const { data: availability, refetch: refreshAvailability } = (0, provider_js_1.useConnectResource)("/calls/availability");
79
+ // Read inside `finish`, which is wired to SDK events once.
80
+ const refreshRef = (0, react_1.useRef)(refreshAvailability);
81
+ (0, react_1.useEffect)(() => {
82
+ refreshRef.current = refreshAvailability;
83
+ }, [refreshAvailability]);
84
+ const clientRef = (0, react_1.useRef)(null);
85
+ const placedRef = (0, react_1.useRef)(false);
86
+ const targetRef = (0, react_1.useRef)(null);
87
+ const callIdRef = (0, react_1.useRef)(null);
88
+ const endedRef = (0, react_1.useRef)(false);
89
+ const dismissTimer = (0, react_1.useRef)(null);
90
+ // Read inside SDK event handlers wired once, so they always see the latest.
91
+ const onCallEndedRef = (0, react_1.useRef)(onCallEnded);
92
+ (0, react_1.useEffect)(() => {
93
+ onCallEndedRef.current = onCallEnded;
94
+ }, [onCallEnded]);
95
+ const cancelOnServer = (0, react_1.useCallback)(() => {
96
+ const callId = callIdRef.current;
97
+ if (!callId)
98
+ return;
99
+ // Best effort: Connect closes an unclaimed call on its own within minutes.
100
+ void mutateResource("/calls/cancel", { callId }).catch(() => undefined);
101
+ }, [mutateResource]);
102
+ const finish = (0, react_1.useCallback)((notice) => {
103
+ if (endedRef.current)
104
+ return;
105
+ endedRef.current = true;
106
+ const callId = callIdRef.current;
107
+ const target = targetRef.current;
108
+ placedRef.current = false;
109
+ setCall((current) => current
110
+ ? {
111
+ ...current,
112
+ phase: "ended",
113
+ notice,
114
+ endedAfterSeconds: current.connectedAt
115
+ ? Math.round((Date.now() - current.connectedAt) / 1000)
116
+ : null,
117
+ }
118
+ : current);
119
+ if (dismissTimer.current)
120
+ clearTimeout(dismissTimer.current);
121
+ dismissTimer.current = setTimeout(() => setCall(null), ENDED_CARD_MS);
122
+ // The charge lands a moment after the line drops, so the minutes shown
123
+ // are re-read once it has had time to.
124
+ setTimeout(() => refreshRef.current(), AVAILABILITY_SETTLE_MS);
125
+ if (target) {
126
+ target.onEnded?.({ callId });
127
+ onCallEndedRef.current?.({ callId, phone: target.phone });
128
+ }
129
+ }, []);
130
+ const wire = (0, react_1.useCallback)((client) => {
131
+ const ringing = () => setCall((current) => (current ? { ...current, phase: "ringing" } : current));
132
+ client.on("onCalling", ringing);
133
+ client.on("onCallRemoteRinging", ringing);
134
+ client.on("onCallAnswered", () => setCall((current) => current ? { ...current, phase: "in-call", connectedAt: Date.now() } : current));
135
+ client.on("onCallTerminated", () => finish(null));
136
+ client.on("onCallFailed", (cause) => {
137
+ if (!placedRef.current)
138
+ cancelOnServer();
139
+ finish(messageFromCause(cause));
140
+ });
141
+ client.on("onMediaPermission", (event) => {
142
+ if (event?.status === "failure") {
143
+ cancelOnServer();
144
+ finish("Allow microphone access in your browser to make calls.");
145
+ }
146
+ });
147
+ }, [cancelOnServer, finish]);
148
+ const signIn = (0, react_1.useCallback)(async () => {
149
+ let client = clientRef.current;
150
+ if (!client) {
151
+ const { default: Plivo } = await Promise.resolve().then(() => __importStar(require("plivo-browser-sdk")));
152
+ client = new Plivo({
153
+ debug: "ERROR",
154
+ // Ask for the microphone when a call starts, not on page load.
155
+ permOnClick: true,
156
+ // Warn before a tab mid-call is closed.
157
+ closeProtection: true,
158
+ enableTracking: false,
159
+ }).client;
160
+ clientRef.current = client;
161
+ wire(client);
162
+ }
163
+ if (client.isLoggedIn)
164
+ return client;
165
+ const { token } = await mutateResource("/calls/browser-token", {});
166
+ const signedIn = client;
167
+ await new Promise((resolve, reject) => {
168
+ const timer = setTimeout(() => {
169
+ cleanup();
170
+ reject(new Error("Signing in to the phone service timed out."));
171
+ }, LOGIN_TIMEOUT_MS);
172
+ const onLogin = () => {
173
+ cleanup();
174
+ resolve();
175
+ };
176
+ const onLoginFailed = (cause) => {
177
+ cleanup();
178
+ reject(new Error(`Couldn't sign in to the phone service: ${messageFromCause(cause)}`));
179
+ };
180
+ function cleanup() {
181
+ clearTimeout(timer);
182
+ signedIn.off("onLogin", onLogin);
183
+ signedIn.off("onLoginFailed", onLoginFailed);
184
+ }
185
+ signedIn.on("onLogin", onLogin);
186
+ signedIn.on("onLoginFailed", onLoginFailed);
187
+ if (!signedIn.loginWithAccessToken(token)) {
188
+ cleanup();
189
+ reject(new Error("Couldn't sign in to the phone service."));
190
+ }
191
+ });
192
+ return signedIn;
193
+ }, [mutateResource, wire]);
194
+ const isBusy = call !== null && call.phase !== "ended";
195
+ const startCall = (0, react_1.useCallback)(async (target) => {
196
+ if (isBusy) {
197
+ onError?.("You're already on a call.");
198
+ return;
199
+ }
200
+ if (dismissTimer.current)
201
+ clearTimeout(dismissTimer.current);
202
+ targetRef.current = target;
203
+ callIdRef.current = null;
204
+ placedRef.current = false;
205
+ endedRef.current = false;
206
+ setCall({
207
+ phase: "connecting",
208
+ label: target.name?.trim() || target.phone,
209
+ number: target.phone,
210
+ callId: null,
211
+ connectedAt: null,
212
+ endedAfterSeconds: null,
213
+ muted: false,
214
+ notice: null,
215
+ });
216
+ try {
217
+ const client = await signIn();
218
+ const started = await mutateResource("/calls/start", {
219
+ phone: target.phone,
220
+ });
221
+ callIdRef.current = started.callId;
222
+ setCall((current) => current ? { ...current, callId: started.callId, number: started.toNumber } : current);
223
+ // Connect dials the number on the record, not this one - it is passed
224
+ // because the SDK requires a destination.
225
+ if (!client.call(started.toNumber.replace(/^\+/, ""), started.headers)) {
226
+ throw new Error("The phone service didn't accept the call.");
227
+ }
228
+ placedRef.current = true;
229
+ }
230
+ catch (cause) {
231
+ cancelOnServer();
232
+ // A refusal is usually news about the workspace - an empty balance, a
233
+ // number that has gone - so the buttons should learn it too.
234
+ refreshRef.current();
235
+ const message = errorMessage(cause);
236
+ if (onError) {
237
+ onError(message);
238
+ endedRef.current = true;
239
+ setCall(null);
240
+ target.onEnded?.({ callId: callIdRef.current });
241
+ onCallEndedRef.current?.({ callId: callIdRef.current, phone: target.phone });
242
+ }
243
+ else {
244
+ finish(message);
245
+ }
246
+ }
247
+ }, [cancelOnServer, finish, isBusy, mutateResource, onError, signIn]);
248
+ const hangUp = (0, react_1.useCallback)(() => {
249
+ const client = clientRef.current;
250
+ if (placedRef.current && client) {
251
+ client.hangup();
252
+ return; // `onCallTerminated` takes it from here.
253
+ }
254
+ cancelOnServer();
255
+ finish(null);
256
+ }, [cancelOnServer, finish]);
257
+ const toggleMute = (0, react_1.useCallback)(() => {
258
+ const client = clientRef.current;
259
+ if (!client)
260
+ return;
261
+ setCall((current) => {
262
+ if (!current)
263
+ return current;
264
+ if (current.muted)
265
+ client.unmute();
266
+ else
267
+ client.mute();
268
+ return { ...current, muted: !current.muted };
269
+ });
270
+ }, []);
271
+ // Sign out of the carrier when the provider unmounts.
272
+ (0, react_1.useEffect)(() => () => {
273
+ clientRef.current?.logout();
274
+ if (dismissTimer.current)
275
+ clearTimeout(dismissTimer.current);
276
+ }, []);
277
+ const value = (0, react_1.useMemo)(() => ({ isBusy, startCall, availability, refreshAvailability }), [isBusy, startCall, availability, refreshAvailability]);
278
+ return ((0, jsx_runtime_1.jsxs)(ConnectCallContext.Provider, { value: value, children: [children, call && ((0, jsx_runtime_1.jsx)(ConnectActiveCallBar, { call: call, onHangUp: hangUp, onToggleMute: toggleMute, className: className }))] }));
279
+ }
280
+ /**
281
+ * Rings `phone` from the workspace's number. Needs `<ConnectCallProvider>`
282
+ * above it, and a member with "Make calls" - render it only for them, since a
283
+ * button the server refuses for everyone else helps nobody.
284
+ */
285
+ function ConnectCallButton({ phone, name, onEnded, variant = "default", children, isDisabled, className, }) {
286
+ const { isBusy, startCall, availability } = useConnectCall();
287
+ const press = () => void startCall({ phone, name, onEnded });
288
+ // Not offered at all to someone who may not call - a button that always
289
+ // refuses helps nobody.
290
+ if (availability?.status === "no_permission")
291
+ return null;
292
+ // Anything else short of `available` says why, where the button would be,
293
+ // with the next step for whoever can take it.
294
+ if (availability && availability.status !== "available") {
295
+ return variant === "compact" ? ((0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "ghost", "aria-label": unavailableReason(availability).text, isDisabled: true, className: className, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Phone, { className: "size-3.5" }) })) : ((0, jsx_runtime_1.jsx)(ConnectCallUnavailable, { availability: availability, className: className }));
296
+ }
297
+ if (variant === "compact") {
298
+ return ((0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "ghost", "aria-label": `Call ${name?.trim() || phone}`, isDisabled: isBusy || isDisabled, onPress: press, className: className, children: (0, jsx_runtime_1.jsx)(lucide_react_1.Phone, { className: "size-3.5" }) }));
299
+ }
300
+ return ((0, jsx_runtime_1.jsxs)(react_2.Button, { size: "sm", variant: "primary", isDisabled: isBusy || isDisabled || availability === null, onPress: press, className: (0, cn_js_1.cn)("shrink-0", className), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Phone, { className: "size-4" }), children ?? "Call"] }));
301
+ }
302
+ /**
303
+ * What to say when a member cannot call, and the one link that fixes it -
304
+ * offered only to someone with the permission to follow it.
305
+ */
306
+ function unavailableReason(availability) {
307
+ switch (availability.status) {
308
+ case "no_number":
309
+ return availability.canManageNumber && availability.offer
310
+ ? {
311
+ text: "Calling needs a business number.",
312
+ action: {
313
+ label: `Add ${availability.offer.addonName}`,
314
+ path: `/billing/subscriptions/${availability.offer.subscriptionId}`,
315
+ },
316
+ }
317
+ : { text: "Calling needs a business number - ask a workspace admin.", action: null };
318
+ case "number_pending":
319
+ return availability.canManageNumber
320
+ ? {
321
+ text: "Your business number is still being set up.",
322
+ action: { label: "Check calling", path: "/calling" },
323
+ }
324
+ : { text: "Your business number is still being set up.", action: null };
325
+ case "no_balance":
326
+ return availability.canTopUp
327
+ ? { text: "Out of calling minutes.", action: { label: "Top up", path: "/calling" } }
328
+ : { text: "Out of calling minutes - ask a workspace admin to top up.", action: null };
329
+ default:
330
+ return { text: "Calling isn't available.", action: null };
331
+ }
332
+ }
333
+ /** The inline "why not" a Call button becomes when the member cannot call. */
334
+ function ConnectCallUnavailable({ availability, className, }) {
335
+ const { text, action } = unavailableReason(availability);
336
+ const href = (0, portal_links_js_1.useConnectPortalUrl)(action?.path ?? "/calling");
337
+ return ((0, jsx_runtime_1.jsxs)("p", { className: (0, cn_js_1.cn)("text-xs text-muted", className), children: [text, " ", action && ((0, jsx_runtime_1.jsx)("a", { href: href, className: "font-medium text-accent hover:underline", children: action.label }))] }));
338
+ }
339
+ function formatRupees(paise) {
340
+ return `₹${(paise / 100).toLocaleString("en-IN", {
341
+ minimumFractionDigits: 2,
342
+ maximumFractionDigits: 2,
343
+ })}`;
344
+ }
345
+ /**
346
+ * The workspace's calling at a glance - its business number, the minutes
347
+ * left, and the next step - for a settings page. Reads the same availability
348
+ * the Call buttons do, so the two never disagree. Buying minutes and adding a
349
+ * number happen in Connect, and come back here when done.
350
+ */
351
+ function ConnectCallingStatus({ className }) {
352
+ const { availability } = useConnectCall();
353
+ const callingHref = (0, portal_links_js_1.useConnectPortalUrl)("/calling");
354
+ const offerHref = (0, portal_links_js_1.useConnectPortalUrl)(availability?.offer ? `/billing/subscriptions/${availability.offer.subscriptionId}` : "/calling");
355
+ if (!availability) {
356
+ return (0, jsx_runtime_1.jsx)("div", { className: (0, cn_js_1.cn)("h-24 animate-pulse rounded-lg bg-surface-secondary", className) });
357
+ }
358
+ const rate = formatRupees(availability.ratePaisePerMinute);
359
+ return ((0, jsx_runtime_1.jsxs)("div", { className: (0, cn_js_1.cn)("space-y-3 rounded-lg border border-separator bg-surface p-4", className), children: [(0, jsx_runtime_1.jsxs)("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "min-w-0", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: "Business number" }), (0, jsx_runtime_1.jsx)("p", { className: "text-sm font-medium tabular-nums", children: availability.number ?? (availability.status === "number_pending" ? "Being set up…" : "None") })] }), availability.number && ((0, jsx_runtime_1.jsxs)("div", { className: "text-right", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: "Calling minutes" }), (0, jsx_runtime_1.jsxs)("p", { className: (0, cn_js_1.cn)("text-sm font-medium tabular-nums", availability.lowBalance && "text-danger"), children: [formatRupees(availability.available), " \u00B7 about ", availability.availableMinutes, " min"] })] }))] }), availability.status === "no_permission" ? ((0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: "You don't have permission to make calls here." })) : !availability.number ? ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Call customers from their record, from your own business number. Calls are not recorded, and minutes are prepaid at ", rate, " + GST a minute.", " ", availability.status === "number_pending"
360
+ ? availability.canManageNumber && ((0, jsx_runtime_1.jsx)("a", { href: callingHref, className: "font-medium text-accent hover:underline", children: "Check on your number" }))
361
+ : availability.canManageNumber && availability.offer
362
+ ? ((0, jsx_runtime_1.jsxs)("a", { href: offerHref, className: "font-medium text-accent hover:underline", children: ["Add ", availability.offer.addonName, " (", formatRupees(availability.offer.price), " + GST)"] }))
363
+ : "Ask a workspace admin to add a business number."] })) : ((0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: [availability.lowBalance
364
+ ? "Calling minutes are running low. "
365
+ : `Calls cost ${rate} + GST a minute, from prepaid minutes. `, availability.canTopUp ? ((0, jsx_runtime_1.jsx)("a", { href: callingHref, className: "font-medium text-accent hover:underline", children: "Top up minutes" })) : ("A workspace admin can top up.")] }))] }));
366
+ }
367
+ const PHASE_LABEL = {
368
+ connecting: "Connecting…",
369
+ ringing: "Calling…",
370
+ ended: "Call ended",
371
+ };
372
+ function formatSeconds(seconds) {
373
+ const whole = Math.max(0, Math.round(seconds));
374
+ const minutes = Math.floor(whole / 60);
375
+ return `${minutes}:${String(whole % 60).padStart(2, "0")}`;
376
+ }
377
+ /** The floating in-call card. Exported for Storybook only - the provider draws it. */
378
+ function ConnectActiveCallBar({ call, onHangUp, onToggleMute, className, }) {
379
+ const elapsed = useElapsedSeconds(call.phase === "in-call" ? call.connectedAt : null);
380
+ const live = call.phase !== "ended";
381
+ const status = call.phase === "in-call"
382
+ ? formatSeconds(elapsed)
383
+ : call.phase === "ended" && call.endedAfterSeconds !== null
384
+ ? `Call ended · ${formatSeconds(call.endedAfterSeconds)}`
385
+ : PHASE_LABEL[call.phase];
386
+ return ((0, jsx_runtime_1.jsx)("div", { role: "status", "aria-live": "polite", className: (0, cn_js_1.cn)("fixed inset-x-0 bottom-4 z-50 flex justify-center px-4", className), children: (0, jsx_runtime_1.jsxs)("div", { className: "flex w-full max-w-sm items-center gap-3 rounded-2xl border border-separator bg-surface p-3 shadow-lg", children: [(0, jsx_runtime_1.jsx)("span", { className: "flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-soft text-accent", children: (0, jsx_runtime_1.jsx)(lucide_react_1.PhoneOutgoing, { "aria-hidden": true, className: "size-4" }) }), (0, jsx_runtime_1.jsxs)("div", { className: "min-w-0 flex-1", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm font-medium", children: call.label }), (0, jsx_runtime_1.jsxs)("p", { className: "truncate text-xs text-muted tabular-nums", children: [status, call.notice ? ` · ${call.notice}` : call.label === call.number ? "" : ` · ${call.number}`] })] }), live && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "secondary", "aria-label": call.muted ? "Unmute" : "Mute", isDisabled: call.phase !== "in-call", onPress: onToggleMute, children: call.muted ? (0, jsx_runtime_1.jsx)(lucide_react_1.MicOff, { className: "size-4" }) : (0, jsx_runtime_1.jsx)(lucide_react_1.Mic, { className: "size-4" }) }), (0, jsx_runtime_1.jsx)(react_2.Button, { isIconOnly: true, size: "sm", variant: "danger", "aria-label": "Hang up", onPress: onHangUp, children: (0, jsx_runtime_1.jsx)(lucide_react_1.PhoneOff, { className: "size-4" }) })] }))] }) }));
387
+ }
388
+ /** Seconds since `since`, ticking once a second. `0` while there is nothing to time. */
389
+ function useElapsedSeconds(since) {
390
+ const [now, setNow] = (0, react_1.useState)(() => Date.now());
391
+ (0, react_1.useEffect)(() => {
392
+ if (since === null)
393
+ return;
394
+ const timer = setInterval(() => setNow(Date.now()), 1_000);
395
+ return () => clearInterval(timer);
396
+ }, [since]);
397
+ return since === null ? 0 : Math.max(0, Math.round((now - since) / 1000));
398
+ }