@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.
- package/CHANGELOG.md +86 -0
- package/dist/cjs/client/components/availability-form.js +52 -24
- package/dist/cjs/client/components/call-button.js +398 -0
- package/dist/cjs/client/components/create-workspace-form.js +66 -0
- package/dist/cjs/client/components/event-type-form.js +99 -25
- package/dist/cjs/client/components/workspace-switcher.js +42 -9
- package/dist/cjs/client/index.js +17 -2
- package/dist/cjs/client/portal-links.js +1 -1
- package/dist/cjs/client/use-scheduling.js +37 -0
- package/dist/cjs/client/use-workspaces.js +41 -0
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +176 -2
- package/dist/cjs/server/resources/calls.js +58 -0
- package/dist/cjs/server/resources/scheduling.js +67 -0
- package/dist/cjs/server/resources/workspaces.js +30 -11
- package/dist/esm/client/components/availability-form.js +53 -25
- package/dist/esm/client/components/call-button.js +358 -0
- package/dist/esm/client/components/create-workspace-form.js +63 -0
- package/dist/esm/client/components/event-type-form.js +100 -26
- package/dist/esm/client/components/workspace-switcher.js +42 -9
- package/dist/esm/client/index.js +8 -2
- package/dist/esm/client/portal-links.js +1 -1
- package/dist/esm/client/use-scheduling.js +34 -0
- package/dist/esm/client/use-workspaces.js +38 -0
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +176 -2
- package/dist/esm/server/resources/calls.js +58 -0
- package/dist/esm/server/resources/scheduling.js +67 -0
- package/dist/esm/server/resources/workspaces.js +30 -11
- package/dist/types/client/components/availability-form.d.ts +23 -12
- package/dist/types/client/components/call-button.d.ts +132 -0
- package/dist/types/client/components/create-workspace-form.d.ts +32 -0
- package/dist/types/client/components/event-type-form.d.ts +30 -8
- package/dist/types/client/components/workspace-switcher.d.ts +9 -9
- package/dist/types/client/index.d.ts +7 -1
- package/dist/types/client/portal-links.d.ts +1 -1
- package/dist/types/client/use-scheduling.d.ts +46 -2
- package/dist/types/client/use-workspaces.d.ts +27 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/resources/calls.d.ts +32 -1
- package/dist/types/server/resources/scheduling.d.ts +66 -2
- package/dist/types/server/resources/workspaces.d.ts +28 -0
- package/dist/types/types.d.ts +143 -1
- package/package.json +4 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button, Input, Label, Modal, Radio, RadioGroup, TextField } from "@heroui/react";
|
|
5
|
+
import { useCreateWorkspace } from "../use-workspaces.js";
|
|
6
|
+
const WORKSPACE_TYPES = [
|
|
7
|
+
{
|
|
8
|
+
id: "business",
|
|
9
|
+
label: "Business",
|
|
10
|
+
hint: "A company. Verified with company KYB, and can subscribe to anything sold to businesses.",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: "individual",
|
|
14
|
+
label: "Individual",
|
|
15
|
+
hint: "A person. Verified against your own identity rather than a company.",
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Opens another workspace under the signed-in user's own tenant - a branch
|
|
20
|
+
* office, a second business unit - from inside a host application rather
|
|
21
|
+
* than a link-out to Connect.
|
|
22
|
+
*
|
|
23
|
+
* **Only the tenant's owner can use this successfully.** `CreateWorkspaceUseCase`
|
|
24
|
+
* refuses anyone who merely belongs to a colleague's tenant, with a message
|
|
25
|
+
* naming that - someone who wants a workspace of their own uses a personal
|
|
26
|
+
* account instead.
|
|
27
|
+
*
|
|
28
|
+
* **The tenant's plan decides how many workspaces it may hold, and this does
|
|
29
|
+
* not pre-check it.** There is no capacity read to show ahead of time - the
|
|
30
|
+
* same posture Connect's own "Create workspace" button takes - so a refusal
|
|
31
|
+
* surfaces here exactly as the server wrote it ("Your current plan covers a
|
|
32
|
+
* single workspace - subscribe to a plan that includes additional workspaces
|
|
33
|
+
* to open another.", or "You've used all 3 of the workspaces your plans
|
|
34
|
+
* include. Upgrade a plan or delete a workspace to open another.").
|
|
35
|
+
*
|
|
36
|
+
* **Does not switch the session to the new workspace.** Creating one never
|
|
37
|
+
* does, on Connect's own screen either - see `useCreateWorkspace`'s doc
|
|
38
|
+
* comment for how to chain a switch once you have the result, and guard that
|
|
39
|
+
* chain on `inAudience` the same way `<ConnectWorkspaceSwitcher>` already
|
|
40
|
+
* does for every other row.
|
|
41
|
+
*/
|
|
42
|
+
export function ConnectCreateWorkspaceModal({ isOpen, onOpenChange, onCreated, }) {
|
|
43
|
+
return (_jsx(Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-110", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Create workspace" }) }), _jsx(Modal.Body, { children: _jsx(CreateWorkspaceFields, { onCancel: () => onOpenChange(false), onCreated: (workspace) => {
|
|
44
|
+
onCreated?.(workspace);
|
|
45
|
+
onOpenChange(false);
|
|
46
|
+
} }) })] }) }) }) }));
|
|
47
|
+
}
|
|
48
|
+
function CreateWorkspaceFields({ onCancel, onCreated, }) {
|
|
49
|
+
const [name, setName] = useState("");
|
|
50
|
+
const [type, setType] = useState("business");
|
|
51
|
+
const { createWorkspace, isPending, error, clearError } = useCreateWorkspace();
|
|
52
|
+
const isFormValid = name.trim().length > 0 && !isPending;
|
|
53
|
+
const handleSubmit = async (e) => {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
if (!isFormValid)
|
|
56
|
+
return;
|
|
57
|
+
clearError();
|
|
58
|
+
const created = await createWorkspace({ name: name.trim(), type });
|
|
59
|
+
if (created)
|
|
60
|
+
onCreated(created);
|
|
61
|
+
};
|
|
62
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs(TextField, { isRequired: true, variant: "secondary", children: [_jsx(Label, { children: "Name" }), _jsx(Input, { placeholder: "Acme Logistics", value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), _jsxs(RadioGroup, { variant: "secondary", value: type, onChange: (value) => setType(value), isDisabled: isPending, children: [_jsx(Label, { children: "Type" }), _jsx("div", { className: "mt-1.5 flex flex-col gap-2", children: WORKSPACE_TYPES.map((option) => (_jsx(Radio, { value: option.id, children: _jsxs(Radio.Content, { children: [_jsx(Radio.Control, { children: _jsx(Radio.Indicator, {}) }), _jsxs("div", { children: [_jsx("p", { className: "text-sm font-medium", children: option.label }), _jsx("p", { className: "text-xs text-muted", children: option.hint })] })] }) }, option.id))) })] }), error && _jsx("p", { className: "text-sm text-danger", children: error }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", onPress: onCancel, isDisabled: isPending, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Creating…" : "Create" })] })] }));
|
|
63
|
+
}
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useMemo, useState } from "react";
|
|
4
4
|
import { Button, Input, Label, ListBox, Modal, Select, TextArea, TextField } from "@heroui/react";
|
|
5
5
|
import { useConnect, useConnectWorkspaceMembers } from "../provider.js";
|
|
6
|
-
import { useAvailabilitySchedules, useCreateEventType } from "../use-scheduling.js";
|
|
6
|
+
import { useAvailabilitySchedules, useCreateEventType, useManageableEventTypes, useUpdateEventType, } from "../use-scheduling.js";
|
|
7
7
|
const LOCATION_LABELS = {
|
|
8
8
|
phone: "Phone call",
|
|
9
9
|
address: "In person",
|
|
@@ -11,40 +11,113 @@ const LOCATION_LABELS = {
|
|
|
11
11
|
};
|
|
12
12
|
const LOCATION_KINDS = ["phone", "address", "link"];
|
|
13
13
|
/**
|
|
14
|
-
* Creates
|
|
15
|
-
*
|
|
14
|
+
* Creates or edits an appointment type, from inside a host application
|
|
15
|
+
* rather than a link-out to Connect.
|
|
16
16
|
*
|
|
17
17
|
* **Needs a schedule to exist first** - the picker reads `useAvailabilitySchedules`
|
|
18
18
|
* and renders "Set up availability first" with nothing to submit if none
|
|
19
19
|
* exists yet. Pair this with `ConnectAvailabilityFormModal` in a setup flow
|
|
20
20
|
* rather than assuming one.
|
|
21
21
|
*
|
|
22
|
+
* **That gate waits for the read, and the order matters.** Both bridge reads
|
|
23
|
+
* answer `[]` while they are in flight, so testing `schedules.length === 0`
|
|
24
|
+
* first showed "Set up availability first" to everybody who opened this -
|
|
25
|
+
* including, for a second or two, somebody pressing Edit on an appointment
|
|
26
|
+
* type that provably *has* a schedule. A load state wearing an empty state's
|
|
27
|
+
* words is worse than a spinner: it reads as a verdict, and on a slow
|
|
28
|
+
* connection it is the only thing the modal ever says. A failed read is named
|
|
29
|
+
* as a failure for the same reason - it is not the same fact as "none exist".
|
|
30
|
+
*
|
|
22
31
|
* **Host is a specific member, never a team.** There is no groups read in
|
|
23
32
|
* this bridge, so a round-robin team host stays a Console-only capability -
|
|
24
|
-
* defaults to whoever is signed in, with an optional picker from
|
|
25
|
-
* workspace's own roster.
|
|
33
|
+
* defaults to whoever is signed in on create, with an optional picker from
|
|
34
|
+
* the workspace's own roster. **Editing a group-hosted row is refused
|
|
35
|
+
* inline** - the modal shows a notice instead of a form, because this
|
|
36
|
+
* surface cannot represent (and so cannot safely resend) a team host.
|
|
37
|
+
*
|
|
38
|
+
* This outer component only decides *which* message or form to show; the
|
|
39
|
+
* form itself is `EventTypeFields` below, mounted with `key={editing?.id}` -
|
|
40
|
+
* see that component's own doc comment for why.
|
|
26
41
|
*/
|
|
27
|
-
export function ConnectEventTypeFormModal({ isOpen, onOpenChange,
|
|
28
|
-
const
|
|
42
|
+
export function ConnectEventTypeFormModal({ isOpen, onOpenChange, eventTypeId = null, onSaved, }) {
|
|
43
|
+
const isEdit = eventTypeId !== null;
|
|
29
44
|
const { data: membersData } = useConnectWorkspaceMembers();
|
|
30
|
-
const { schedules } = useAvailabilitySchedules();
|
|
31
|
-
const
|
|
45
|
+
const { schedules, isLoading: isLoadingSchedules, error: schedulesError, } = useAvailabilitySchedules();
|
|
46
|
+
const { eventTypes: manageableEventTypes, isLoading: isLoadingEventTypes, error: eventTypesError, } = useManageableEventTypes();
|
|
47
|
+
const members = membersData?.members ?? [];
|
|
48
|
+
const editing = isEdit ? manageableEventTypes.find((row) => row.id === eventTypeId) ?? null : null;
|
|
49
|
+
/** A group host cannot be represented (or safely resent) by this form. */
|
|
50
|
+
const isUnrepresentableHost = isEdit && editing?.host.kind === "group";
|
|
51
|
+
/*
|
|
52
|
+
* Everything this modal decides from depends on a read, so the two
|
|
53
|
+
* not-yet-answered states come first - see this component's doc comment.
|
|
54
|
+
* An edit also waits for the row itself: `editing` is `null` both while the
|
|
55
|
+
* list is loading and when it has arrived without this id, and only the
|
|
56
|
+
* second of those is a real answer.
|
|
57
|
+
*/
|
|
58
|
+
const isLoading = isLoadingSchedules || (isEdit && (isLoadingEventTypes || (!editing && !eventTypesError)));
|
|
59
|
+
const readError = schedulesError ?? (isEdit ? eventTypesError : null);
|
|
60
|
+
let body;
|
|
61
|
+
if (isLoading) {
|
|
62
|
+
body = _jsx("p", { className: "text-sm text-muted", children: "Loading\u2026" });
|
|
63
|
+
}
|
|
64
|
+
else if (readError) {
|
|
65
|
+
body = (_jsx("p", { className: "text-sm text-muted", children: "That could not be loaded just now. Close this and try again." }));
|
|
66
|
+
}
|
|
67
|
+
else if (isUnrepresentableHost) {
|
|
68
|
+
body = (_jsx("p", { className: "text-sm text-muted", children: "This appointment type is hosted by a team, which this screen cannot show or change. Edit it in Connect." }));
|
|
69
|
+
}
|
|
70
|
+
else if (schedules.length === 0) {
|
|
71
|
+
body = (_jsx("p", { className: "text-sm text-muted", children: "Set up availability first - an appointment type needs working hours to book against." }));
|
|
72
|
+
}
|
|
73
|
+
else if (isEdit && !editing) {
|
|
74
|
+
/*
|
|
75
|
+
* The list arrived and does not hold this id - deleted, or never visible
|
|
76
|
+
* to this caller. Distinct from the load above, which is why that one
|
|
77
|
+
* cannot be allowed to borrow these words either.
|
|
78
|
+
*/
|
|
79
|
+
body = _jsx("p", { className: "text-sm text-muted", children: "That appointment type no longer exists." });
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
body = (_jsx(EventTypeFields, { editing: editing, schedules: schedules, members: members, onCancel: () => onOpenChange(false), onSaved: (saved) => {
|
|
83
|
+
onSaved?.(saved);
|
|
84
|
+
onOpenChange(false);
|
|
85
|
+
} }, editing?.id ?? "create"));
|
|
86
|
+
}
|
|
87
|
+
return (_jsx(Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-140", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: isEdit ? "Edit appointment type" : "New appointment type" }) }), _jsx(Modal.Body, { children: body })] }) }) }) }));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The form itself, mounted fresh (via the parent's `key`) for each distinct
|
|
91
|
+
* record it edits - or once for create. That remount is what initializes
|
|
92
|
+
* every field straight from `editing` with a plain `useState(initial)`
|
|
93
|
+
* rather than a `useEffect` copying it in after the fact: `editing` arrives
|
|
94
|
+
* asynchronously (`useManageableEventTypes` is a bridge read), and the
|
|
95
|
+
* parent already withholds this component until it has landed, so there is
|
|
96
|
+
* no later moment for a field to overwrite a half-typed edit.
|
|
97
|
+
*/
|
|
98
|
+
function EventTypeFields({ editing, schedules, members, onCancel, onSaved, }) {
|
|
99
|
+
const isEdit = editing !== null;
|
|
100
|
+
const { session } = useConnect();
|
|
32
101
|
const defaultHostId = useMemo(() => {
|
|
33
102
|
const self = members.find((member) => member.userId === session?.user?.userId);
|
|
34
103
|
return self?.memberId ?? members[0]?.memberId ?? "";
|
|
35
104
|
}, [members, session?.user?.userId]);
|
|
36
|
-
const [name, setName] = useState("");
|
|
37
|
-
const [description, setDescription] = useState("");
|
|
38
|
-
const [durationMinutes, setDurationMinutes] = useState(30);
|
|
39
|
-
const [bufferBefore, setBufferBefore] = useState(5);
|
|
40
|
-
const [bufferAfter, setBufferAfter] = useState(5);
|
|
41
|
-
const [scheduleId, setScheduleId] = useState("");
|
|
42
|
-
const [hostMemberId, setHostMemberId] = useState("");
|
|
43
|
-
const [locationKind, setLocationKind] = useState("phone");
|
|
44
|
-
const [locationValue, setLocationValue] = useState("");
|
|
105
|
+
const [name, setName] = useState(editing?.name ?? "");
|
|
106
|
+
const [description, setDescription] = useState(editing?.description ?? "");
|
|
107
|
+
const [durationMinutes, setDurationMinutes] = useState(editing?.durationMinutes ?? 30);
|
|
108
|
+
const [bufferBefore, setBufferBefore] = useState(editing?.bufferBeforeMinutes ?? 5);
|
|
109
|
+
const [bufferAfter, setBufferAfter] = useState(editing?.bufferAfterMinutes ?? 5);
|
|
110
|
+
const [scheduleId, setScheduleId] = useState(editing?.scheduleId ?? "");
|
|
111
|
+
const [hostMemberId, setHostMemberId] = useState(editing?.host.kind === "member" ? editing.host.memberId : "");
|
|
112
|
+
const [locationKind, setLocationKind] = useState(editing?.location.kind ?? "phone");
|
|
113
|
+
const [locationValue, setLocationValue] = useState(editing?.location.value ?? "");
|
|
45
114
|
const effectiveScheduleId = scheduleId || schedules[0]?.id || "";
|
|
46
115
|
const effectiveHostMemberId = hostMemberId || defaultHostId;
|
|
47
|
-
const { createEventType, isPending, error, clearError } = useCreateEventType();
|
|
116
|
+
const { createEventType, isPending: isCreating, error: createError, clearError: clearCreateError } = useCreateEventType();
|
|
117
|
+
const { updateEventType, isPending: isUpdating, error: updateError, clearError: clearUpdateError } = useUpdateEventType();
|
|
118
|
+
const isPending = isCreating || isUpdating;
|
|
119
|
+
const error = isEdit ? updateError : createError;
|
|
120
|
+
const clearError = isEdit ? clearUpdateError : clearCreateError;
|
|
48
121
|
const isFormValid = name.trim().length > 0 &&
|
|
49
122
|
durationMinutes >= 5 &&
|
|
50
123
|
durationMinutes <= 480 &&
|
|
@@ -57,7 +130,7 @@ export function ConnectEventTypeFormModal({ isOpen, onOpenChange, onCreated, })
|
|
|
57
130
|
return;
|
|
58
131
|
clearError();
|
|
59
132
|
const trimmedDescription = description.trim();
|
|
60
|
-
const
|
|
133
|
+
const shared = {
|
|
61
134
|
name: name.trim(),
|
|
62
135
|
description: trimmedDescription.length > 0 ? trimmedDescription : null,
|
|
63
136
|
durationMinutes,
|
|
@@ -69,11 +142,12 @@ export function ConnectEventTypeFormModal({ isOpen, onOpenChange, onCreated, })
|
|
|
69
142
|
kind: locationKind,
|
|
70
143
|
value: locationValue.trim().length > 0 ? locationValue.trim() : null,
|
|
71
144
|
},
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
145
|
+
};
|
|
146
|
+
const saved = isEdit
|
|
147
|
+
? await updateEventType({ eventTypeId: editing.id, ...shared })
|
|
148
|
+
: await createEventType(shared);
|
|
149
|
+
if (saved)
|
|
150
|
+
onSaved(saved);
|
|
77
151
|
};
|
|
78
|
-
return (
|
|
152
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs(TextField, { isRequired: true, variant: "secondary", children: [_jsx(Label, { children: "Name" }), _jsx(Input, { placeholder: "Consultation", value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), _jsxs(TextField, { variant: "secondary", children: [_jsx(Label, { children: "Description" }), _jsx(TextArea, { placeholder: "What this appointment is for", value: description, onChange: (e) => setDescription(e.currentTarget.value), disabled: isPending, rows: 2 })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs(TextField, { isRequired: true, variant: "secondary", children: [_jsx(Label, { children: "Length (min)" }), _jsx(Input, { type: "number", value: String(durationMinutes), onChange: (e) => setDurationMinutes(Number(e.currentTarget.value)), disabled: isPending })] }), _jsxs(TextField, { variant: "secondary", children: [_jsx(Label, { children: "Gap before" }), _jsx(Input, { type: "number", value: String(bufferBefore), onChange: (e) => setBufferBefore(Number(e.currentTarget.value)), disabled: isPending })] }), _jsxs(TextField, { variant: "secondary", children: [_jsx(Label, { children: "Gap after" }), _jsx(Input, { type: "number", value: String(bufferAfter), onChange: (e) => setBufferAfter(Number(e.currentTarget.value)), disabled: isPending })] })] }), schedules.length > 1 && (_jsxs(Select, { variant: "secondary", value: effectiveScheduleId, onChange: (value) => setScheduleId(String(value)), isDisabled: isPending, children: [_jsx(Label, { children: "Availability" }), _jsxs(Select.Trigger, { children: [_jsx(Select.Value, {}), _jsx(Select.Indicator, {})] }), _jsx(Select.Popover, { children: _jsx(ListBox, { children: schedules.map((schedule) => (_jsxs(ListBox.Item, { id: schedule.id, textValue: schedule.name, children: [schedule.name, _jsx(ListBox.ItemIndicator, {})] }, schedule.id))) }) })] })), members.length > 1 && (_jsxs(Select, { variant: "secondary", value: effectiveHostMemberId, onChange: (value) => setHostMemberId(String(value)), isDisabled: isPending, children: [_jsx(Label, { children: "Who hosts it" }), _jsxs(Select.Trigger, { children: [_jsx(Select.Value, {}), _jsx(Select.Indicator, {})] }), _jsx(Select.Popover, { children: _jsx(ListBox, { children: members.map((member) => (_jsxs(ListBox.Item, { id: member.memberId, textValue: member.displayName ?? member.email, children: [member.displayName ?? member.email, _jsx(ListBox.ItemIndicator, {})] }, member.memberId))) }) })] })), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs(Select, { variant: "secondary", value: locationKind, onChange: (value) => setLocationKind(value), isDisabled: isPending, children: [_jsx(Label, { children: "Where" }), _jsxs(Select.Trigger, { children: [_jsx(Select.Value, {}), _jsx(Select.Indicator, {})] }), _jsx(Select.Popover, { children: _jsx(ListBox, { children: LOCATION_KINDS.map((kind) => (_jsxs(ListBox.Item, { id: kind, textValue: LOCATION_LABELS[kind], children: [LOCATION_LABELS[kind], _jsx(ListBox.ItemIndicator, {})] }, kind))) }) })] }), _jsxs(TextField, { variant: "secondary", children: [_jsx(Label, { children: "Details" }), _jsx(TextArea, { placeholder: locationKind === "address" ? "Where to come" : "Left blank is fine", value: locationValue, onChange: (e) => setLocationValue(e.currentTarget.value), disabled: isPending, rows: 2 })] })] }), error && _jsx("p", { className: "text-sm text-danger", children: error }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", onPress: onCancel, isDisabled: isPending, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Saving..." : isEdit ? "Save changes" : "Create" })] })] }));
|
|
79
153
|
}
|