@cosmoops/connect-sdk 0.40.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.
- package/CHANGELOG.md +49 -0
- package/dist/cjs/client/components/call-button.js +398 -0
- package/dist/cjs/client/index.js +7 -2
- package/dist/cjs/client/portal-links.js +1 -1
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +73 -0
- package/dist/cjs/server/resources/calls.js +58 -0
- package/dist/esm/client/components/call-button.js +358 -0
- package/dist/esm/client/index.js +2 -1
- package/dist/esm/client/portal-links.js +1 -1
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +73 -0
- package/dist/esm/server/resources/calls.js +58 -0
- package/dist/types/client/components/call-button.d.ts +132 -0
- package/dist/types/client/index.d.ts +2 -0
- package/dist/types/client/portal-links.d.ts +1 -1
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/resources/calls.d.ts +32 -1
- package/dist/types/types.d.ts +110 -1
- package/package.json +4 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CallsResource = void 0;
|
|
4
4
|
const http_js_1 = require("../http.js");
|
|
5
|
+
const acting_as_js_1 = require("./acting-as.js");
|
|
5
6
|
/**
|
|
6
7
|
* Every call that arrived on a channel assigned to your application.
|
|
7
8
|
*
|
|
@@ -53,5 +54,62 @@ class CallsResource {
|
|
|
53
54
|
path: `/calls/${encodeURIComponent(callId)}`,
|
|
54
55
|
});
|
|
55
56
|
}
|
|
57
|
+
/*
|
|
58
|
+
* Browser calling - what `<ConnectCallButton>` runs on, through your bridge.
|
|
59
|
+
*
|
|
60
|
+
* All three need the `calls:place` scope **and** a signed-in member
|
|
61
|
+
* (`asUser`), whose own "Make calls" permission Connect checks. None works
|
|
62
|
+
* server-to-server: a call is placed by a person, from the workspace's own
|
|
63
|
+
* number, and is not recorded. Read how one went with `get(callId)`, which
|
|
64
|
+
* needs `calls:read` - a call your application placed is readable by it.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* Whether the member can call, and if not why - what `<ConnectCallButton>`
|
|
68
|
+
* renders from. Throws `ConnectError` 403 for a member without "Make
|
|
69
|
+
* calls"; the bridge turns that into `status: "no_permission"`.
|
|
70
|
+
*/
|
|
71
|
+
getCallingAvailability(input) {
|
|
72
|
+
return (0, http_js_1.request)(this.transport, {
|
|
73
|
+
method: "GET",
|
|
74
|
+
path: "/calls/availability",
|
|
75
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** A carrier token the member's browser signs in to the phone network with. */
|
|
79
|
+
getBrowserCallingToken(input) {
|
|
80
|
+
return (0, http_js_1.request)(this.transport, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
path: "/calls/browser-token",
|
|
83
|
+
json: {},
|
|
84
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Approves one call from the member to `phone` (E.164) and records it.
|
|
89
|
+
*
|
|
90
|
+
* Throws `ConnectError` with status `412` when the workspace has no active
|
|
91
|
+
* number, is not allowed to place calls, or the member is already on one -
|
|
92
|
+
* the message is written to be shown to them.
|
|
93
|
+
*/
|
|
94
|
+
startBrowserCall(input) {
|
|
95
|
+
return (0, http_js_1.request)(this.transport, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
path: "/calls/start",
|
|
98
|
+
json: { phone: input.phone },
|
|
99
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Closes a call the browser never managed to place - recorded as
|
|
104
|
+
* `unanswered`/`cancelled`. A no-op once the carrier has it.
|
|
105
|
+
*/
|
|
106
|
+
async cancelBrowserCall(input) {
|
|
107
|
+
await (0, http_js_1.request)(this.transport, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
path: "/calls/cancel",
|
|
110
|
+
json: { callId: input.callId },
|
|
111
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
56
114
|
}
|
|
57
115
|
exports.CallsResource = CallsResource;
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
4
|
+
import { Button } from "@heroui/react";
|
|
5
|
+
import { Mic, MicOff, Phone, PhoneOff, PhoneOutgoing } from "lucide-react";
|
|
6
|
+
import { cn } from "../../cn.js";
|
|
7
|
+
import { useConnect, useConnectResource } from "../provider.js";
|
|
8
|
+
import { useConnectPortalUrl } from "../portal-links.js";
|
|
9
|
+
const ConnectCallContext = createContext(null);
|
|
10
|
+
/** Start a call yourself. Must be inside `<ConnectCallProvider>`. */
|
|
11
|
+
export function useConnectCall() {
|
|
12
|
+
const value = useContext(ConnectCallContext);
|
|
13
|
+
if (!value) {
|
|
14
|
+
throw new Error("useConnectCall() needs a <ConnectCallProvider> above it.");
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
/** How long the SDK may take to sign in before the attempt is abandoned. */
|
|
19
|
+
const LOGIN_TIMEOUT_MS = 15_000;
|
|
20
|
+
/** How long the "Call ended" card stays before the bar goes away. */
|
|
21
|
+
const ENDED_CARD_MS = 4_000;
|
|
22
|
+
/** How long after a call to re-read the balance - the carrier's report settles it. */
|
|
23
|
+
const AVAILABILITY_SETTLE_MS = 3_000;
|
|
24
|
+
function messageFromCause(cause) {
|
|
25
|
+
if (typeof cause === "string" && cause.trim())
|
|
26
|
+
return cause.trim();
|
|
27
|
+
return "The call couldn't be connected.";
|
|
28
|
+
}
|
|
29
|
+
function errorMessage(cause) {
|
|
30
|
+
return cause instanceof Error && cause.message ? cause.message : "Something went wrong.";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Holds the call in progress and draws the in-call bar. See the module comment.
|
|
34
|
+
*/
|
|
35
|
+
export function ConnectCallProvider({ children, onCallEnded, onError, className, }) {
|
|
36
|
+
const { mutateResource } = useConnect();
|
|
37
|
+
const [call, setCall] = useState(null);
|
|
38
|
+
const { data: availability, refetch: refreshAvailability } = useConnectResource("/calls/availability");
|
|
39
|
+
// Read inside `finish`, which is wired to SDK events once.
|
|
40
|
+
const refreshRef = useRef(refreshAvailability);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
refreshRef.current = refreshAvailability;
|
|
43
|
+
}, [refreshAvailability]);
|
|
44
|
+
const clientRef = useRef(null);
|
|
45
|
+
const placedRef = useRef(false);
|
|
46
|
+
const targetRef = useRef(null);
|
|
47
|
+
const callIdRef = useRef(null);
|
|
48
|
+
const endedRef = useRef(false);
|
|
49
|
+
const dismissTimer = useRef(null);
|
|
50
|
+
// Read inside SDK event handlers wired once, so they always see the latest.
|
|
51
|
+
const onCallEndedRef = useRef(onCallEnded);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
onCallEndedRef.current = onCallEnded;
|
|
54
|
+
}, [onCallEnded]);
|
|
55
|
+
const cancelOnServer = useCallback(() => {
|
|
56
|
+
const callId = callIdRef.current;
|
|
57
|
+
if (!callId)
|
|
58
|
+
return;
|
|
59
|
+
// Best effort: Connect closes an unclaimed call on its own within minutes.
|
|
60
|
+
void mutateResource("/calls/cancel", { callId }).catch(() => undefined);
|
|
61
|
+
}, [mutateResource]);
|
|
62
|
+
const finish = useCallback((notice) => {
|
|
63
|
+
if (endedRef.current)
|
|
64
|
+
return;
|
|
65
|
+
endedRef.current = true;
|
|
66
|
+
const callId = callIdRef.current;
|
|
67
|
+
const target = targetRef.current;
|
|
68
|
+
placedRef.current = false;
|
|
69
|
+
setCall((current) => current
|
|
70
|
+
? {
|
|
71
|
+
...current,
|
|
72
|
+
phase: "ended",
|
|
73
|
+
notice,
|
|
74
|
+
endedAfterSeconds: current.connectedAt
|
|
75
|
+
? Math.round((Date.now() - current.connectedAt) / 1000)
|
|
76
|
+
: null,
|
|
77
|
+
}
|
|
78
|
+
: current);
|
|
79
|
+
if (dismissTimer.current)
|
|
80
|
+
clearTimeout(dismissTimer.current);
|
|
81
|
+
dismissTimer.current = setTimeout(() => setCall(null), ENDED_CARD_MS);
|
|
82
|
+
// The charge lands a moment after the line drops, so the minutes shown
|
|
83
|
+
// are re-read once it has had time to.
|
|
84
|
+
setTimeout(() => refreshRef.current(), AVAILABILITY_SETTLE_MS);
|
|
85
|
+
if (target) {
|
|
86
|
+
target.onEnded?.({ callId });
|
|
87
|
+
onCallEndedRef.current?.({ callId, phone: target.phone });
|
|
88
|
+
}
|
|
89
|
+
}, []);
|
|
90
|
+
const wire = useCallback((client) => {
|
|
91
|
+
const ringing = () => setCall((current) => (current ? { ...current, phase: "ringing" } : current));
|
|
92
|
+
client.on("onCalling", ringing);
|
|
93
|
+
client.on("onCallRemoteRinging", ringing);
|
|
94
|
+
client.on("onCallAnswered", () => setCall((current) => current ? { ...current, phase: "in-call", connectedAt: Date.now() } : current));
|
|
95
|
+
client.on("onCallTerminated", () => finish(null));
|
|
96
|
+
client.on("onCallFailed", (cause) => {
|
|
97
|
+
if (!placedRef.current)
|
|
98
|
+
cancelOnServer();
|
|
99
|
+
finish(messageFromCause(cause));
|
|
100
|
+
});
|
|
101
|
+
client.on("onMediaPermission", (event) => {
|
|
102
|
+
if (event?.status === "failure") {
|
|
103
|
+
cancelOnServer();
|
|
104
|
+
finish("Allow microphone access in your browser to make calls.");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}, [cancelOnServer, finish]);
|
|
108
|
+
const signIn = useCallback(async () => {
|
|
109
|
+
let client = clientRef.current;
|
|
110
|
+
if (!client) {
|
|
111
|
+
const { default: Plivo } = await import("plivo-browser-sdk");
|
|
112
|
+
client = new Plivo({
|
|
113
|
+
debug: "ERROR",
|
|
114
|
+
// Ask for the microphone when a call starts, not on page load.
|
|
115
|
+
permOnClick: true,
|
|
116
|
+
// Warn before a tab mid-call is closed.
|
|
117
|
+
closeProtection: true,
|
|
118
|
+
enableTracking: false,
|
|
119
|
+
}).client;
|
|
120
|
+
clientRef.current = client;
|
|
121
|
+
wire(client);
|
|
122
|
+
}
|
|
123
|
+
if (client.isLoggedIn)
|
|
124
|
+
return client;
|
|
125
|
+
const { token } = await mutateResource("/calls/browser-token", {});
|
|
126
|
+
const signedIn = client;
|
|
127
|
+
await new Promise((resolve, reject) => {
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
cleanup();
|
|
130
|
+
reject(new Error("Signing in to the phone service timed out."));
|
|
131
|
+
}, LOGIN_TIMEOUT_MS);
|
|
132
|
+
const onLogin = () => {
|
|
133
|
+
cleanup();
|
|
134
|
+
resolve();
|
|
135
|
+
};
|
|
136
|
+
const onLoginFailed = (cause) => {
|
|
137
|
+
cleanup();
|
|
138
|
+
reject(new Error(`Couldn't sign in to the phone service: ${messageFromCause(cause)}`));
|
|
139
|
+
};
|
|
140
|
+
function cleanup() {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
signedIn.off("onLogin", onLogin);
|
|
143
|
+
signedIn.off("onLoginFailed", onLoginFailed);
|
|
144
|
+
}
|
|
145
|
+
signedIn.on("onLogin", onLogin);
|
|
146
|
+
signedIn.on("onLoginFailed", onLoginFailed);
|
|
147
|
+
if (!signedIn.loginWithAccessToken(token)) {
|
|
148
|
+
cleanup();
|
|
149
|
+
reject(new Error("Couldn't sign in to the phone service."));
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return signedIn;
|
|
153
|
+
}, [mutateResource, wire]);
|
|
154
|
+
const isBusy = call !== null && call.phase !== "ended";
|
|
155
|
+
const startCall = useCallback(async (target) => {
|
|
156
|
+
if (isBusy) {
|
|
157
|
+
onError?.("You're already on a call.");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (dismissTimer.current)
|
|
161
|
+
clearTimeout(dismissTimer.current);
|
|
162
|
+
targetRef.current = target;
|
|
163
|
+
callIdRef.current = null;
|
|
164
|
+
placedRef.current = false;
|
|
165
|
+
endedRef.current = false;
|
|
166
|
+
setCall({
|
|
167
|
+
phase: "connecting",
|
|
168
|
+
label: target.name?.trim() || target.phone,
|
|
169
|
+
number: target.phone,
|
|
170
|
+
callId: null,
|
|
171
|
+
connectedAt: null,
|
|
172
|
+
endedAfterSeconds: null,
|
|
173
|
+
muted: false,
|
|
174
|
+
notice: null,
|
|
175
|
+
});
|
|
176
|
+
try {
|
|
177
|
+
const client = await signIn();
|
|
178
|
+
const started = await mutateResource("/calls/start", {
|
|
179
|
+
phone: target.phone,
|
|
180
|
+
});
|
|
181
|
+
callIdRef.current = started.callId;
|
|
182
|
+
setCall((current) => current ? { ...current, callId: started.callId, number: started.toNumber } : current);
|
|
183
|
+
// Connect dials the number on the record, not this one - it is passed
|
|
184
|
+
// because the SDK requires a destination.
|
|
185
|
+
if (!client.call(started.toNumber.replace(/^\+/, ""), started.headers)) {
|
|
186
|
+
throw new Error("The phone service didn't accept the call.");
|
|
187
|
+
}
|
|
188
|
+
placedRef.current = true;
|
|
189
|
+
}
|
|
190
|
+
catch (cause) {
|
|
191
|
+
cancelOnServer();
|
|
192
|
+
// A refusal is usually news about the workspace - an empty balance, a
|
|
193
|
+
// number that has gone - so the buttons should learn it too.
|
|
194
|
+
refreshRef.current();
|
|
195
|
+
const message = errorMessage(cause);
|
|
196
|
+
if (onError) {
|
|
197
|
+
onError(message);
|
|
198
|
+
endedRef.current = true;
|
|
199
|
+
setCall(null);
|
|
200
|
+
target.onEnded?.({ callId: callIdRef.current });
|
|
201
|
+
onCallEndedRef.current?.({ callId: callIdRef.current, phone: target.phone });
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
finish(message);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}, [cancelOnServer, finish, isBusy, mutateResource, onError, signIn]);
|
|
208
|
+
const hangUp = useCallback(() => {
|
|
209
|
+
const client = clientRef.current;
|
|
210
|
+
if (placedRef.current && client) {
|
|
211
|
+
client.hangup();
|
|
212
|
+
return; // `onCallTerminated` takes it from here.
|
|
213
|
+
}
|
|
214
|
+
cancelOnServer();
|
|
215
|
+
finish(null);
|
|
216
|
+
}, [cancelOnServer, finish]);
|
|
217
|
+
const toggleMute = useCallback(() => {
|
|
218
|
+
const client = clientRef.current;
|
|
219
|
+
if (!client)
|
|
220
|
+
return;
|
|
221
|
+
setCall((current) => {
|
|
222
|
+
if (!current)
|
|
223
|
+
return current;
|
|
224
|
+
if (current.muted)
|
|
225
|
+
client.unmute();
|
|
226
|
+
else
|
|
227
|
+
client.mute();
|
|
228
|
+
return { ...current, muted: !current.muted };
|
|
229
|
+
});
|
|
230
|
+
}, []);
|
|
231
|
+
// Sign out of the carrier when the provider unmounts.
|
|
232
|
+
useEffect(() => () => {
|
|
233
|
+
clientRef.current?.logout();
|
|
234
|
+
if (dismissTimer.current)
|
|
235
|
+
clearTimeout(dismissTimer.current);
|
|
236
|
+
}, []);
|
|
237
|
+
const value = useMemo(() => ({ isBusy, startCall, availability, refreshAvailability }), [isBusy, startCall, availability, refreshAvailability]);
|
|
238
|
+
return (_jsxs(ConnectCallContext.Provider, { value: value, children: [children, call && (_jsx(ConnectActiveCallBar, { call: call, onHangUp: hangUp, onToggleMute: toggleMute, className: className }))] }));
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Rings `phone` from the workspace's number. Needs `<ConnectCallProvider>`
|
|
242
|
+
* above it, and a member with "Make calls" - render it only for them, since a
|
|
243
|
+
* button the server refuses for everyone else helps nobody.
|
|
244
|
+
*/
|
|
245
|
+
export function ConnectCallButton({ phone, name, onEnded, variant = "default", children, isDisabled, className, }) {
|
|
246
|
+
const { isBusy, startCall, availability } = useConnectCall();
|
|
247
|
+
const press = () => void startCall({ phone, name, onEnded });
|
|
248
|
+
// Not offered at all to someone who may not call - a button that always
|
|
249
|
+
// refuses helps nobody.
|
|
250
|
+
if (availability?.status === "no_permission")
|
|
251
|
+
return null;
|
|
252
|
+
// Anything else short of `available` says why, where the button would be,
|
|
253
|
+
// with the next step for whoever can take it.
|
|
254
|
+
if (availability && availability.status !== "available") {
|
|
255
|
+
return variant === "compact" ? (_jsx(Button, { isIconOnly: true, size: "sm", variant: "ghost", "aria-label": unavailableReason(availability).text, isDisabled: true, className: className, children: _jsx(Phone, { className: "size-3.5" }) })) : (_jsx(ConnectCallUnavailable, { availability: availability, className: className }));
|
|
256
|
+
}
|
|
257
|
+
if (variant === "compact") {
|
|
258
|
+
return (_jsx(Button, { isIconOnly: true, size: "sm", variant: "ghost", "aria-label": `Call ${name?.trim() || phone}`, isDisabled: isBusy || isDisabled, onPress: press, className: className, children: _jsx(Phone, { className: "size-3.5" }) }));
|
|
259
|
+
}
|
|
260
|
+
return (_jsxs(Button, { size: "sm", variant: "primary", isDisabled: isBusy || isDisabled || availability === null, onPress: press, className: cn("shrink-0", className), children: [_jsx(Phone, { className: "size-4" }), children ?? "Call"] }));
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* What to say when a member cannot call, and the one link that fixes it -
|
|
264
|
+
* offered only to someone with the permission to follow it.
|
|
265
|
+
*/
|
|
266
|
+
function unavailableReason(availability) {
|
|
267
|
+
switch (availability.status) {
|
|
268
|
+
case "no_number":
|
|
269
|
+
return availability.canManageNumber && availability.offer
|
|
270
|
+
? {
|
|
271
|
+
text: "Calling needs a business number.",
|
|
272
|
+
action: {
|
|
273
|
+
label: `Add ${availability.offer.addonName}`,
|
|
274
|
+
path: `/billing/subscriptions/${availability.offer.subscriptionId}`,
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
: { text: "Calling needs a business number - ask a workspace admin.", action: null };
|
|
278
|
+
case "number_pending":
|
|
279
|
+
return availability.canManageNumber
|
|
280
|
+
? {
|
|
281
|
+
text: "Your business number is still being set up.",
|
|
282
|
+
action: { label: "Check calling", path: "/calling" },
|
|
283
|
+
}
|
|
284
|
+
: { text: "Your business number is still being set up.", action: null };
|
|
285
|
+
case "no_balance":
|
|
286
|
+
return availability.canTopUp
|
|
287
|
+
? { text: "Out of calling minutes.", action: { label: "Top up", path: "/calling" } }
|
|
288
|
+
: { text: "Out of calling minutes - ask a workspace admin to top up.", action: null };
|
|
289
|
+
default:
|
|
290
|
+
return { text: "Calling isn't available.", action: null };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** The inline "why not" a Call button becomes when the member cannot call. */
|
|
294
|
+
function ConnectCallUnavailable({ availability, className, }) {
|
|
295
|
+
const { text, action } = unavailableReason(availability);
|
|
296
|
+
const href = useConnectPortalUrl(action?.path ?? "/calling");
|
|
297
|
+
return (_jsxs("p", { className: cn("text-xs text-muted", className), children: [text, " ", action && (_jsx("a", { href: href, className: "font-medium text-accent hover:underline", children: action.label }))] }));
|
|
298
|
+
}
|
|
299
|
+
function formatRupees(paise) {
|
|
300
|
+
return `₹${(paise / 100).toLocaleString("en-IN", {
|
|
301
|
+
minimumFractionDigits: 2,
|
|
302
|
+
maximumFractionDigits: 2,
|
|
303
|
+
})}`;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The workspace's calling at a glance - its business number, the minutes
|
|
307
|
+
* left, and the next step - for a settings page. Reads the same availability
|
|
308
|
+
* the Call buttons do, so the two never disagree. Buying minutes and adding a
|
|
309
|
+
* number happen in Connect, and come back here when done.
|
|
310
|
+
*/
|
|
311
|
+
export function ConnectCallingStatus({ className }) {
|
|
312
|
+
const { availability } = useConnectCall();
|
|
313
|
+
const callingHref = useConnectPortalUrl("/calling");
|
|
314
|
+
const offerHref = useConnectPortalUrl(availability?.offer ? `/billing/subscriptions/${availability.offer.subscriptionId}` : "/calling");
|
|
315
|
+
if (!availability) {
|
|
316
|
+
return _jsx("div", { className: cn("h-24 animate-pulse rounded-lg bg-surface-secondary", className) });
|
|
317
|
+
}
|
|
318
|
+
const rate = formatRupees(availability.ratePaisePerMinute);
|
|
319
|
+
return (_jsxs("div", { className: cn("space-y-3 rounded-lg border border-separator bg-surface p-4", className), children: [_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-xs text-muted", children: "Business number" }), _jsx("p", { className: "text-sm font-medium tabular-nums", children: availability.number ?? (availability.status === "number_pending" ? "Being set up…" : "None") })] }), availability.number && (_jsxs("div", { className: "text-right", children: [_jsx("p", { className: "text-xs text-muted", children: "Calling minutes" }), _jsxs("p", { className: cn("text-sm font-medium tabular-nums", availability.lowBalance && "text-danger"), children: [formatRupees(availability.available), " \u00B7 about ", availability.availableMinutes, " min"] })] }))] }), availability.status === "no_permission" ? (_jsx("p", { className: "text-xs text-muted", children: "You don't have permission to make calls here." })) : !availability.number ? (_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"
|
|
320
|
+
? availability.canManageNumber && (_jsx("a", { href: callingHref, className: "font-medium text-accent hover:underline", children: "Check on your number" }))
|
|
321
|
+
: availability.canManageNumber && availability.offer
|
|
322
|
+
? (_jsxs("a", { href: offerHref, className: "font-medium text-accent hover:underline", children: ["Add ", availability.offer.addonName, " (", formatRupees(availability.offer.price), " + GST)"] }))
|
|
323
|
+
: "Ask a workspace admin to add a business number."] })) : (_jsxs("p", { className: "text-xs text-muted", children: [availability.lowBalance
|
|
324
|
+
? "Calling minutes are running low. "
|
|
325
|
+
: `Calls cost ${rate} + GST a minute, from prepaid minutes. `, availability.canTopUp ? (_jsx("a", { href: callingHref, className: "font-medium text-accent hover:underline", children: "Top up minutes" })) : ("A workspace admin can top up.")] }))] }));
|
|
326
|
+
}
|
|
327
|
+
const PHASE_LABEL = {
|
|
328
|
+
connecting: "Connecting…",
|
|
329
|
+
ringing: "Calling…",
|
|
330
|
+
ended: "Call ended",
|
|
331
|
+
};
|
|
332
|
+
function formatSeconds(seconds) {
|
|
333
|
+
const whole = Math.max(0, Math.round(seconds));
|
|
334
|
+
const minutes = Math.floor(whole / 60);
|
|
335
|
+
return `${minutes}:${String(whole % 60).padStart(2, "0")}`;
|
|
336
|
+
}
|
|
337
|
+
/** The floating in-call card. Exported for Storybook only - the provider draws it. */
|
|
338
|
+
export function ConnectActiveCallBar({ call, onHangUp, onToggleMute, className, }) {
|
|
339
|
+
const elapsed = useElapsedSeconds(call.phase === "in-call" ? call.connectedAt : null);
|
|
340
|
+
const live = call.phase !== "ended";
|
|
341
|
+
const status = call.phase === "in-call"
|
|
342
|
+
? formatSeconds(elapsed)
|
|
343
|
+
: call.phase === "ended" && call.endedAfterSeconds !== null
|
|
344
|
+
? `Call ended · ${formatSeconds(call.endedAfterSeconds)}`
|
|
345
|
+
: PHASE_LABEL[call.phase];
|
|
346
|
+
return (_jsx("div", { role: "status", "aria-live": "polite", className: cn("fixed inset-x-0 bottom-4 z-50 flex justify-center px-4", className), children: _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: [_jsx("span", { className: "flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-soft text-accent", children: _jsx(PhoneOutgoing, { "aria-hidden": true, className: "size-4" }) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "truncate text-sm font-medium", children: call.label }), _jsxs("p", { className: "truncate text-xs text-muted tabular-nums", children: [status, call.notice ? ` · ${call.notice}` : call.label === call.number ? "" : ` · ${call.number}`] })] }), live && (_jsxs(_Fragment, { children: [_jsx(Button, { isIconOnly: true, size: "sm", variant: "secondary", "aria-label": call.muted ? "Unmute" : "Mute", isDisabled: call.phase !== "in-call", onPress: onToggleMute, children: call.muted ? _jsx(MicOff, { className: "size-4" }) : _jsx(Mic, { className: "size-4" }) }), _jsx(Button, { isIconOnly: true, size: "sm", variant: "danger", "aria-label": "Hang up", onPress: onHangUp, children: _jsx(PhoneOff, { className: "size-4" }) })] }))] }) }));
|
|
347
|
+
}
|
|
348
|
+
/** Seconds since `since`, ticking once a second. `0` while there is nothing to time. */
|
|
349
|
+
function useElapsedSeconds(since) {
|
|
350
|
+
const [now, setNow] = useState(() => Date.now());
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (since === null)
|
|
353
|
+
return;
|
|
354
|
+
const timer = setInterval(() => setNow(Date.now()), 1_000);
|
|
355
|
+
return () => clearInterval(timer);
|
|
356
|
+
}, [since]);
|
|
357
|
+
return since === null ? 0 : Math.max(0, Math.round((now - since) / 1000));
|
|
358
|
+
}
|
package/dist/esm/client/index.js
CHANGED
|
@@ -31,7 +31,7 @@ ConnectUserMenu, ConnectManageSubscriptionButton, ConnectInvoicesButton, Connect
|
|
|
31
31
|
// Built, compiled into `dist/`, and reachable from no entry point until now -
|
|
32
32
|
// `ConnectUserMenu` was the only thing that could render it.
|
|
33
33
|
export { ConnectAccountSessions } from "./components/account-sessions.js";
|
|
34
|
-
// A link to *any* Connect portal page that offers a "Back to <your app>" way
|
|
34
|
+
// A link to *any* Connect portal page that offers a `"Back to <your app>"` way
|
|
35
35
|
// home - what `ConnectProfileButton` builds for `/profile` internally, made
|
|
36
36
|
// reusable for a host linking out to a different Connect page (e.g.
|
|
37
37
|
// `/calendar/settings`) on its own.
|
|
@@ -152,6 +152,7 @@ export { useConnectWorkspaces, useConnectWorkspaceMembers } from "./provider.js"
|
|
|
152
152
|
// than a link-out to Connect's own "Create workspace" button.
|
|
153
153
|
export { ConnectCreateWorkspaceModal } from "./components/create-workspace-form.js";
|
|
154
154
|
export { useCreateWorkspace } from "./use-workspaces.js";
|
|
155
|
+
export { ConnectCallProvider, ConnectCallButton, ConnectCallingStatus, useConnectCall, } from "./components/call-button.js";
|
|
155
156
|
export { ConnectBookingCalendar } from "./components/booking-calendar.js";
|
|
156
157
|
export { ConnectBookingDialog, ConnectBookingDetail } from "./components/booking-dialogs.js";
|
|
157
158
|
// The write half of scheduling - a workspace's own first-time setup, not a
|
|
@@ -6,7 +6,7 @@ import { useConnect } from "./provider.js";
|
|
|
6
6
|
* `connectPortalUrl(appUrl, clientId, "/calendar/settings")`, and so on.
|
|
7
7
|
*
|
|
8
8
|
* Connect answers any of these paths for a signed-in user on the bare URL -
|
|
9
|
-
* these two params only add the "Back to <your app>" banner
|
|
9
|
+
* these two params only add the `"Back to <your app>"` banner
|
|
10
10
|
* (`ApplicationReturnBar` on Connect's side). It validates `return_to`
|
|
11
11
|
* against this `client_id`'s registered redirect origins and silently drops
|
|
12
12
|
* the banner if they don't match, so a wrong or stale `appUrl`/`clientId`
|
|
@@ -1210,6 +1210,67 @@ export function createConnectHandler(options) {
|
|
|
1210
1210
|
const items = await client.sessions.list(await requireToken(request));
|
|
1211
1211
|
return json({ items });
|
|
1212
1212
|
},
|
|
1213
|
+
/*
|
|
1214
|
+
* Browser calling - `<ConnectCallButton>`. Three fixed writes and no read:
|
|
1215
|
+
* how a call went is your *server's* question (`client.calls.get`), never
|
|
1216
|
+
* the browser's, so a call log can't be written from what a page claims.
|
|
1217
|
+
*
|
|
1218
|
+
* The workspace and the member both come from the session, as everywhere
|
|
1219
|
+
* here; the browser names only the number.
|
|
1220
|
+
*/
|
|
1221
|
+
/*
|
|
1222
|
+
* The one calling *read*, and it is about the member, not about a call:
|
|
1223
|
+
* can they ring anybody from here. A refusal means they lack "Make calls"
|
|
1224
|
+
* - which is an answer, not a failure, so it is returned as one and the
|
|
1225
|
+
* button simply is not offered.
|
|
1226
|
+
*/
|
|
1227
|
+
"GET /calls/availability": async (request) => {
|
|
1228
|
+
await requireWorkspace(request);
|
|
1229
|
+
const asUser = await requireToken(request);
|
|
1230
|
+
try {
|
|
1231
|
+
return json(await client.calls.getCallingAvailability({ asUser }));
|
|
1232
|
+
}
|
|
1233
|
+
catch (error) {
|
|
1234
|
+
if (error instanceof ConnectError && error.status === 403) {
|
|
1235
|
+
return json(NO_CALLING_PERMISSION);
|
|
1236
|
+
}
|
|
1237
|
+
throw error;
|
|
1238
|
+
}
|
|
1239
|
+
},
|
|
1240
|
+
"POST /calls/browser-token": async (request) => {
|
|
1241
|
+
await requireWorkspace(request);
|
|
1242
|
+
const asUser = await requireToken(request);
|
|
1243
|
+
return json(await client.calls.getBrowserCallingToken({ asUser }));
|
|
1244
|
+
},
|
|
1245
|
+
"POST /calls/start": async (request) => {
|
|
1246
|
+
await requireWorkspace(request);
|
|
1247
|
+
const asUser = await requireToken(request);
|
|
1248
|
+
const body = (await request.json().catch(() => null));
|
|
1249
|
+
if (typeof body?.phone !== "string") {
|
|
1250
|
+
throw new ConnectError({
|
|
1251
|
+
status: 400,
|
|
1252
|
+
code: "validation_error",
|
|
1253
|
+
message: "phone is required",
|
|
1254
|
+
endpoint: "/calls/start",
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
return json(await client.calls.startBrowserCall({ phone: body.phone, asUser }), 201);
|
|
1258
|
+
},
|
|
1259
|
+
"POST /calls/cancel": async (request) => {
|
|
1260
|
+
await requireWorkspace(request);
|
|
1261
|
+
const asUser = await requireToken(request);
|
|
1262
|
+
const body = (await request.json().catch(() => null));
|
|
1263
|
+
if (typeof body?.callId !== "string") {
|
|
1264
|
+
throw new ConnectError({
|
|
1265
|
+
status: 400,
|
|
1266
|
+
code: "validation_error",
|
|
1267
|
+
message: "callId is required",
|
|
1268
|
+
endpoint: "/calls/cancel",
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
await client.calls.cancelBrowserCall({ callId: body.callId, asUser });
|
|
1272
|
+
return json({ cancelled: true });
|
|
1273
|
+
},
|
|
1213
1274
|
"POST /sessions/revoke": async (request) => {
|
|
1214
1275
|
const body = (await request.json().catch(() => null));
|
|
1215
1276
|
if (!body?.sessionId) {
|
|
@@ -1495,6 +1556,18 @@ export function createConnectHandler(options) {
|
|
|
1495
1556
|
}
|
|
1496
1557
|
};
|
|
1497
1558
|
}
|
|
1559
|
+
/** What a member without "Make calls" is told - see `GET /calls/availability`. */
|
|
1560
|
+
const NO_CALLING_PERMISSION = {
|
|
1561
|
+
status: "no_permission",
|
|
1562
|
+
number: null,
|
|
1563
|
+
available: 0,
|
|
1564
|
+
availableMinutes: 0,
|
|
1565
|
+
ratePaisePerMinute: 0,
|
|
1566
|
+
lowBalance: false,
|
|
1567
|
+
canTopUp: false,
|
|
1568
|
+
canManageNumber: false,
|
|
1569
|
+
offer: null,
|
|
1570
|
+
};
|
|
1498
1571
|
function json(body, status = 200) {
|
|
1499
1572
|
return new Response(JSON.stringify(body), {
|
|
1500
1573
|
status,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { request } from "../http.js";
|
|
2
|
+
import { actingAs } from "./acting-as.js";
|
|
2
3
|
/**
|
|
3
4
|
* Every call that arrived on a channel assigned to your application.
|
|
4
5
|
*
|
|
@@ -50,4 +51,61 @@ export class CallsResource {
|
|
|
50
51
|
path: `/calls/${encodeURIComponent(callId)}`,
|
|
51
52
|
});
|
|
52
53
|
}
|
|
54
|
+
/*
|
|
55
|
+
* Browser calling - what `<ConnectCallButton>` runs on, through your bridge.
|
|
56
|
+
*
|
|
57
|
+
* All three need the `calls:place` scope **and** a signed-in member
|
|
58
|
+
* (`asUser`), whose own "Make calls" permission Connect checks. None works
|
|
59
|
+
* server-to-server: a call is placed by a person, from the workspace's own
|
|
60
|
+
* number, and is not recorded. Read how one went with `get(callId)`, which
|
|
61
|
+
* needs `calls:read` - a call your application placed is readable by it.
|
|
62
|
+
*/
|
|
63
|
+
/**
|
|
64
|
+
* Whether the member can call, and if not why - what `<ConnectCallButton>`
|
|
65
|
+
* renders from. Throws `ConnectError` 403 for a member without "Make
|
|
66
|
+
* calls"; the bridge turns that into `status: "no_permission"`.
|
|
67
|
+
*/
|
|
68
|
+
getCallingAvailability(input) {
|
|
69
|
+
return request(this.transport, {
|
|
70
|
+
method: "GET",
|
|
71
|
+
path: "/calls/availability",
|
|
72
|
+
headers: actingAs(input.asUser),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** A carrier token the member's browser signs in to the phone network with. */
|
|
76
|
+
getBrowserCallingToken(input) {
|
|
77
|
+
return request(this.transport, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
path: "/calls/browser-token",
|
|
80
|
+
json: {},
|
|
81
|
+
headers: actingAs(input.asUser),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Approves one call from the member to `phone` (E.164) and records it.
|
|
86
|
+
*
|
|
87
|
+
* Throws `ConnectError` with status `412` when the workspace has no active
|
|
88
|
+
* number, is not allowed to place calls, or the member is already on one -
|
|
89
|
+
* the message is written to be shown to them.
|
|
90
|
+
*/
|
|
91
|
+
startBrowserCall(input) {
|
|
92
|
+
return request(this.transport, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
path: "/calls/start",
|
|
95
|
+
json: { phone: input.phone },
|
|
96
|
+
headers: actingAs(input.asUser),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Closes a call the browser never managed to place - recorded as
|
|
101
|
+
* `unanswered`/`cancelled`. A no-op once the carrier has it.
|
|
102
|
+
*/
|
|
103
|
+
async cancelBrowserCall(input) {
|
|
104
|
+
await request(this.transport, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
path: "/calls/cancel",
|
|
107
|
+
json: { callId: input.callId },
|
|
108
|
+
headers: actingAs(input.asUser),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
53
111
|
}
|