@cosmoops/connect-sdk 0.37.0 → 0.38.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 (34) hide show
  1. package/dist/cjs/client/components/availability-form.js +75 -0
  2. package/dist/cjs/client/components/event-type-form.js +82 -0
  3. package/dist/cjs/client/components/user-menu.js +3 -24
  4. package/dist/cjs/client/index.js +21 -3
  5. package/dist/cjs/client/portal-links.js +38 -0
  6. package/dist/cjs/client/use-scheduling.js +67 -8
  7. package/dist/cjs/server/client.js +1 -1
  8. package/dist/cjs/server/handler.js +78 -0
  9. package/dist/cjs/server/http.js +13 -1
  10. package/dist/cjs/server/resources/ai.js +6 -0
  11. package/dist/cjs/server/resources/scheduling.js +62 -0
  12. package/dist/cjs/server/resources/visitor.js +6 -0
  13. package/dist/esm/client/components/availability-form.js +72 -0
  14. package/dist/esm/client/components/event-type-form.js +79 -0
  15. package/dist/esm/client/components/user-menu.js +3 -24
  16. package/dist/esm/client/index.js +12 -1
  17. package/dist/esm/client/portal-links.js +34 -0
  18. package/dist/esm/client/use-scheduling.js +64 -8
  19. package/dist/esm/server/client.js +1 -1
  20. package/dist/esm/server/handler.js +78 -0
  21. package/dist/esm/server/http.js +12 -1
  22. package/dist/esm/server/resources/ai.js +7 -1
  23. package/dist/esm/server/resources/scheduling.js +62 -0
  24. package/dist/esm/server/resources/visitor.js +7 -1
  25. package/dist/types/client/components/availability-form.d.ts +22 -0
  26. package/dist/types/client/components/event-type-form.d.ts +22 -0
  27. package/dist/types/client/index.d.ts +6 -1
  28. package/dist/types/client/portal-links.d.ts +22 -0
  29. package/dist/types/client/use-scheduling.d.ts +49 -1
  30. package/dist/types/server/client.d.ts +1 -1
  31. package/dist/types/server/http.d.ts +35 -0
  32. package/dist/types/server/resources/scheduling.d.ts +67 -1
  33. package/dist/types/types.d.ts +27 -0
  34. package/package.json +1 -1
@@ -34,6 +34,68 @@ class SchedulingResource {
34
34
  // `undefined` past every status check in between.
35
35
  return result.eventTypes ?? [];
36
36
  }
37
+ /**
38
+ * The working-hours templates an appointment type can point at.
39
+ *
40
+ * Read this before offering to create one - a workspace that already has
41
+ * hours set does not need a second "Working hours" from your integration.
42
+ */
43
+ async listSchedules(workspaceId, options = {}) {
44
+ const result = await (0, http_js_1.request)(this.transport, {
45
+ method: "GET",
46
+ path: "/scheduling/schedules",
47
+ query: { workspaceId },
48
+ headers: (0, acting_as_js_1.actingAs)(options.asUser),
49
+ });
50
+ return result.schedules ?? [];
51
+ }
52
+ /**
53
+ * Gives a workspace its first working hours.
54
+ *
55
+ * Narrower than what the workspace's own Calendar settings can do - no date
56
+ * overrides, and never owned by one person (`ownerMemberId` stays `null`
57
+ * server-side). Anything beyond that is a Console edit, not a second write
58
+ * path for your integration to maintain.
59
+ */
60
+ createSchedule(input) {
61
+ return (0, http_js_1.request)(this.transport, {
62
+ method: "POST",
63
+ path: "/scheduling/schedules",
64
+ json: {
65
+ workspaceId: input.workspaceId,
66
+ name: input.name,
67
+ timezone: input.timezone,
68
+ weeklyHours: input.weeklyHours,
69
+ },
70
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
71
+ });
72
+ }
73
+ /**
74
+ * The first thing a workspace can be booked for.
75
+ *
76
+ * Needs a schedule - create or pick one with `listSchedules`/`createSchedule`
77
+ * first. The slug for a future public booking link is derived from `name`
78
+ * server-side; you cannot choose one.
79
+ */
80
+ createEventType(input) {
81
+ return (0, http_js_1.request)(this.transport, {
82
+ method: "POST",
83
+ path: "/scheduling/event-types",
84
+ json: {
85
+ workspaceId: input.workspaceId,
86
+ name: input.name,
87
+ description: input.description ?? null,
88
+ durationMinutes: input.durationMinutes,
89
+ bufferBeforeMinutes: input.bufferBeforeMinutes,
90
+ bufferAfterMinutes: input.bufferAfterMinutes,
91
+ hostMemberId: input.hostMemberId,
92
+ scheduleId: input.scheduleId,
93
+ location: input.location,
94
+ publicBookingEnabled: input.publicBookingEnabled ?? false,
95
+ },
96
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
97
+ });
98
+ }
37
99
  /**
38
100
  * Free times for one appointment type over one window.
39
101
  *
@@ -100,6 +100,12 @@ class VisitorResource {
100
100
  path: "/visitor/chat",
101
101
  json: input,
102
102
  headers: visitorHeaders(options),
103
+ /*
104
+ * Matches the route's own `maxDuration`. This endpoint does not send
105
+ * headers until the model's first token, so the default 20s bound was
106
+ * aborting any turn that opened with a tool call - see `RequestSpec.timeoutMs`.
107
+ */
108
+ timeoutMs: http_js_1.GENERATION_TIMEOUT_MS,
103
109
  raw: true,
104
110
  signal: options.signal,
105
111
  });
@@ -0,0 +1,72 @@
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, Switch, TextField } from "@heroui/react";
5
+ import { useCreateAvailabilitySchedule } from "../use-scheduling.js";
6
+ const WEEKDAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
7
+ const WEEKDAY_KEYS = [0, 1, 2, 3, 4, 5, 6];
8
+ /** Monday-Friday, 9am-5pm - the same starting point Connect's own Console form offers. */
9
+ function defaultWeeklyHours() {
10
+ const open = [{ startMinute: 9 * 60, endMinute: 17 * 60 }];
11
+ return { 0: [], 1: open, 2: open, 3: open, 4: open, 5: open, 6: [] };
12
+ }
13
+ function minutesToTimeInput(minutes) {
14
+ const hours = Math.floor(minutes / 60);
15
+ const mins = minutes % 60;
16
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
17
+ }
18
+ function timeInputToMinutes(value) {
19
+ const [hours, minutes] = value.split(":").map(Number);
20
+ return (hours ?? 0) * 60 + (minutes ?? 0);
21
+ }
22
+ /**
23
+ * Gives a workspace its first working hours, from inside a host application
24
+ * rather than a link-out to Connect.
25
+ *
26
+ * Deliberately narrower than Connect's own Calendar settings form: one
27
+ * interval per day (no split shifts), no date overrides, and the timezone
28
+ * defaults to the browser's own rather than asking. This is a first-run
29
+ * setup step, not a second copy of the Console editor - "Manage in Connect"
30
+ * is still where the rest of what a schedule can do lives.
31
+ *
32
+ * **Not optimistic.** `useCreateAvailabilitySchedule` settles the write
33
+ * against the server before this closes, the same posture every write in
34
+ * this package takes - see `ConnectBookingDialog`.
35
+ */
36
+ export function ConnectAvailabilityFormModal({ isOpen, onOpenChange, onCreated, }) {
37
+ const [name, setName] = useState("Working hours");
38
+ const [timezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
39
+ const [weeklyHours, setWeeklyHours] = useState(defaultWeeklyHours);
40
+ const { createSchedule, isPending, error, clearError } = useCreateAvailabilitySchedule();
41
+ const toggleDay = (day, open) => {
42
+ setWeeklyHours((prev) => ({
43
+ ...prev,
44
+ [day]: open ? [{ startMinute: 9 * 60, endMinute: 17 * 60 }] : [],
45
+ }));
46
+ };
47
+ const setDayTime = (day, edge, value) => {
48
+ setWeeklyHours((prev) => {
49
+ const interval = prev[day][0];
50
+ if (!interval)
51
+ return prev;
52
+ return { ...prev, [day]: [{ ...interval, [edge]: timeInputToMinutes(value) }] };
53
+ });
54
+ };
55
+ const isFormValid = name.trim().length > 0 && !isPending;
56
+ const handleSubmit = async (e) => {
57
+ e.preventDefault();
58
+ if (!isFormValid)
59
+ return;
60
+ clearError();
61
+ const schedule = await createSchedule({ name: name.trim(), timezone, weeklyHours });
62
+ if (schedule) {
63
+ onCreated?.(schedule);
64
+ onOpenChange(false);
65
+ }
66
+ };
67
+ return (_jsx(Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-120", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Set your availability" }) }), _jsx(Modal.Body, { children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs(TextField, { isRequired: true, variant: "secondary", children: [_jsx(Label, { children: "Name" }), _jsx(Input, { value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), _jsxs("p", { className: "text-xs text-muted", children: ["Timezone: ", timezone] }), _jsx("div", { className: "flex flex-col gap-2", children: WEEKDAY_KEYS.map((day) => {
68
+ const interval = weeklyHours[day][0];
69
+ const isOpenDay = interval !== undefined;
70
+ return (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Switch, { isSelected: isOpenDay, onChange: (open) => toggleDay(day, open), isDisabled: isPending, children: _jsxs(Switch.Content, { children: [_jsx(Switch.Control, { children: _jsx(Switch.Thumb, {}) }), _jsx("span", { className: "w-24 shrink-0 text-sm", children: WEEKDAY_LABELS[day] })] }) }), isOpenDay && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { type: "time", value: minutesToTimeInput(interval.startMinute), onChange: (e) => setDayTime(day, "startMinute", e.currentTarget.value), disabled: isPending }), _jsx("span", { className: "text-xs text-muted", children: "to" }), _jsx(Input, { type: "time", value: minutesToTimeInput(interval.endMinute), onChange: (e) => setDayTime(day, "endMinute", e.currentTarget.value), disabled: isPending })] }))] }, day));
71
+ }) }), 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: () => onOpenChange(false), isDisabled: isPending, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Saving..." : "Save" })] })] }) })] }) }) }) }));
72
+ }
@@ -0,0 +1,79 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useMemo, useState } from "react";
4
+ import { Button, Input, Label, ListBox, Modal, Select, TextArea, TextField } from "@heroui/react";
5
+ import { useConnect, useConnectWorkspaceMembers } from "../provider.js";
6
+ import { useAvailabilitySchedules, useCreateEventType } from "../use-scheduling.js";
7
+ const LOCATION_LABELS = {
8
+ phone: "Phone call",
9
+ address: "In person",
10
+ link: "Video link",
11
+ };
12
+ const LOCATION_KINDS = ["phone", "address", "link"];
13
+ /**
14
+ * Creates the first thing a workspace can be booked for, from inside a host
15
+ * application rather than a link-out to Connect.
16
+ *
17
+ * **Needs a schedule to exist first** - the picker reads `useAvailabilitySchedules`
18
+ * and renders "Set up availability first" with nothing to submit if none
19
+ * exists yet. Pair this with `ConnectAvailabilityFormModal` in a setup flow
20
+ * rather than assuming one.
21
+ *
22
+ * **Host is a specific member, never a team.** There is no groups read in
23
+ * 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 the
25
+ * workspace's own roster.
26
+ */
27
+ export function ConnectEventTypeFormModal({ isOpen, onOpenChange, onCreated, }) {
28
+ const { session } = useConnect();
29
+ const { data: membersData } = useConnectWorkspaceMembers();
30
+ const { schedules } = useAvailabilitySchedules();
31
+ const members = useMemo(() => membersData?.members ?? [], [membersData]);
32
+ const defaultHostId = useMemo(() => {
33
+ const self = members.find((member) => member.userId === session?.user?.userId);
34
+ return self?.memberId ?? members[0]?.memberId ?? "";
35
+ }, [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("");
45
+ const effectiveScheduleId = scheduleId || schedules[0]?.id || "";
46
+ const effectiveHostMemberId = hostMemberId || defaultHostId;
47
+ const { createEventType, isPending, error, clearError } = useCreateEventType();
48
+ const isFormValid = name.trim().length > 0 &&
49
+ durationMinutes >= 5 &&
50
+ durationMinutes <= 480 &&
51
+ effectiveScheduleId.length > 0 &&
52
+ effectiveHostMemberId.length > 0 &&
53
+ !isPending;
54
+ const handleSubmit = async (e) => {
55
+ e.preventDefault();
56
+ if (!isFormValid)
57
+ return;
58
+ clearError();
59
+ const trimmedDescription = description.trim();
60
+ const created = await createEventType({
61
+ name: name.trim(),
62
+ description: trimmedDescription.length > 0 ? trimmedDescription : null,
63
+ durationMinutes,
64
+ bufferBeforeMinutes: bufferBefore,
65
+ bufferAfterMinutes: bufferAfter,
66
+ hostMemberId: effectiveHostMemberId,
67
+ scheduleId: effectiveScheduleId,
68
+ location: {
69
+ kind: locationKind,
70
+ value: locationValue.trim().length > 0 ? locationValue.trim() : null,
71
+ },
72
+ });
73
+ if (created) {
74
+ onCreated?.(created);
75
+ onOpenChange(false);
76
+ }
77
+ };
78
+ 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: "New appointment type" }) }), _jsx(Modal.Body, { children: schedules.length === 0 ? (_jsx("p", { className: "text-sm text-muted", children: "Set up availability first - an appointment type needs working hours to book against." })) : (_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: () => onOpenChange(false), isDisabled: isPending, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Saving..." : "Create" })] })] })) })] }) }) }) }));
79
+ }
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Avatar, Button, Chip, Modal, Skeleton, ToggleButton, ToggleButtonGroup, } from "@heroui/react";
4
4
  import { ChevronsUpDown, CreditCard, ExternalLink, Laptop, LogOut, Moon, ReceiptText, Sun, UserRound, } from "lucide-react";
5
5
  import { useConnect } from "../provider.js";
6
+ import { connectPortalUrl } from "../portal-links.js";
6
7
  import { ConnectMark } from "../mark.js";
7
8
  import { initials } from "./identity.js";
8
9
  import { ConnectAccountSessions } from "./account-sessions.js";
@@ -18,28 +19,6 @@ const THEME_OPTIONS = [
18
19
  function openExternal(url) {
19
20
  window.open(url, "_blank", "noopener,noreferrer");
20
21
  }
21
- /**
22
- * Connect's profile page, with the context it needs to offer a way back here.
23
- *
24
- * Connect answers `/profile` for any signed-in user, so the bare URL works on
25
- * its own - these two params only add the "Back to <your app>" banner. It
26
- * validates `return_to` against this `client_id`'s registered redirect
27
- * origins and silently drops the banner if they don't match, so a wrong or
28
- * stale `appUrl`/`clientId` pairing degrades to what it did before rather
29
- * than erroring.
30
- *
31
- * `window.location.href` rather than a configured home URL: the point is to
32
- * come back to the page they left, and only the browser knows which one that
33
- * is.
34
- */
35
- function profileUrl(appUrl, clientId) {
36
- const url = new URL("/profile", appUrl);
37
- url.searchParams.set("client_id", clientId);
38
- if (typeof window !== "undefined") {
39
- url.searchParams.set("return_to", window.location.href);
40
- }
41
- return url.toString();
42
- }
43
22
  /**
44
23
  * The member's photo, with their initials underneath it.
45
24
  *
@@ -140,7 +119,7 @@ function ProfileSurface({ onSignOut, theme, onThemeChange, }) {
140
119
  const label = user.displayName ?? user.email ?? "Account";
141
120
  const role = session?.role ?? null;
142
121
  const planChip = subscriptionChip(session?.subscription ?? null);
143
- return (_jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "md", scroll: "inside", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Account" }) }), _jsxs(Modal.Body, { className: "flex flex-col gap-5", children: [_jsxs("div", { className: `flex items-start gap-3 rounded-xl border border-border ${PANEL_FILL} p-4`, children: [_jsx(UserAvatar, { user: user, size: "lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && _jsx("p", { className: "truncate text-xs text-muted", children: user.email }), _jsx("p", { className: "truncate text-xs text-muted", children: user.workspaceName }), (role || planChip || !user.emailVerified) && (_jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && (_jsx(Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsx(DestinationTile, { icon: _jsx(UserRound, { className: ICON, "aria-hidden": "true" }), label: "Profile", url: profileUrl(appUrl, clientId) }), _jsx(DestinationTile, { icon: _jsx(CreditCard, { className: ICON, "aria-hidden": "true" }), label: "Billing", url: `${appUrl}/billing` }), _jsx(DestinationTile, { icon: _jsx(ConnectMark, { className: ICON }), label: "Connect", url: appUrl })] }), theme && onThemeChange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Appearance" }), _jsx(ToggleButtonGroup, { fullWidth: true, "aria-label": "Theme", selectionMode: "single", disallowEmptySelection: true, selectedKeys: [theme], onSelectionChange: (keys) => {
122
+ return (_jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { size: "md", scroll: "inside", children: _jsxs(Modal.Dialog, { children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Account" }) }), _jsxs(Modal.Body, { className: "flex flex-col gap-5", children: [_jsxs("div", { className: `flex items-start gap-3 rounded-xl border border-border ${PANEL_FILL} p-4`, children: [_jsx(UserAvatar, { user: user, size: "lg" }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [_jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && _jsx("p", { className: "truncate text-xs text-muted", children: user.email }), _jsx("p", { className: "truncate text-xs text-muted", children: user.workspaceName }), (role || planChip || !user.emailVerified) && (_jsxs("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && (_jsx(Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && (_jsx(Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsx(DestinationTile, { icon: _jsx(UserRound, { className: ICON, "aria-hidden": "true" }), label: "Profile", url: connectPortalUrl(appUrl, clientId, "/profile") }), _jsx(DestinationTile, { icon: _jsx(CreditCard, { className: ICON, "aria-hidden": "true" }), label: "Billing", url: `${appUrl}/billing` }), _jsx(DestinationTile, { icon: _jsx(ConnectMark, { className: ICON }), label: "Connect", url: appUrl })] }), theme && onThemeChange && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm font-medium text-foreground", children: "Appearance" }), _jsx(ToggleButtonGroup, { fullWidth: true, "aria-label": "Theme", selectionMode: "single", disallowEmptySelection: true, selectedKeys: [theme], onSelectionChange: (keys) => {
144
123
  const [next] = [...keys];
145
124
  if (typeof next === "string")
146
125
  onThemeChange(next);
@@ -250,7 +229,7 @@ export function ConnectProfileButton({ className }) {
250
229
  const { session, appUrl, clientId } = useConnect();
251
230
  if (!session?.user)
252
231
  return null;
253
- return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () => openExternal(profileUrl(appUrl, clientId)), children: [_jsx(UserRound, { className: "size-4 shrink-0" }), "Profile", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
232
+ return (_jsxs(Button, { variant: "tertiary", className: className, onPress: () => openExternal(connectPortalUrl(appUrl, clientId, "/profile")), children: [_jsx(UserRound, { className: "size-4 shrink-0" }), "Profile", _jsx(ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
254
233
  }
255
234
  /** A plain "Open Connect" button, for a footer or a help menu. */
256
235
  export function ConnectHomeButton({ className }) {
@@ -31,6 +31,11 @@ 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
35
+ // home - what `ConnectProfileButton` builds for `/profile` internally, made
36
+ // reusable for a host linking out to a different Connect page (e.g.
37
+ // `/calendar/settings`) on its own.
38
+ export { connectPortalUrl, useConnectPortalUrl } from "./portal-links.js";
34
39
  // The frame the rest of these components live inside. Exported next to the
35
40
  // pieces it composes rather than in a section of its own - an integration
36
41
  // reaching for a sidebar wants to find the launcher and the account menu in
@@ -144,5 +149,11 @@ export { ConnectWorkspaceSwitcher } from "./components/workspace-switcher.js";
144
149
  export { useConnectWorkspaces, useConnectWorkspaceMembers } from "./provider.js";
145
150
  export { ConnectBookingCalendar } from "./components/booking-calendar.js";
146
151
  export { ConnectBookingDialog, ConnectBookingDetail } from "./components/booking-dialogs.js";
147
- export { useBookableEventTypes, useAvailableSlots, useBookings, useBookingActions, } from "./use-scheduling.js";
152
+ // The write half of scheduling - a workspace's own first-time setup, not a
153
+ // partner integration's booking flow. `ConnectAvailabilityFormModal` and
154
+ // `ConnectEventTypeFormModal` give a host application a real inline setup
155
+ // step instead of a link-out to Connect's own Calendar settings.
156
+ export { ConnectAvailabilityFormModal } from "./components/availability-form.js";
157
+ export { ConnectEventTypeFormModal } from "./components/event-type-form.js";
158
+ export { useBookableEventTypes, useAvailabilitySchedules, useCreateAvailabilitySchedule, useCreateEventType, useAvailableSlots, useBookings, useBookingActions, } from "./use-scheduling.js";
148
159
  export { layoutBookings, dayColumns, zonedInstant, } from "./components/booking-grid.js";
@@ -0,0 +1,34 @@
1
+ "use client";
2
+ import { useConnect } from "./provider.js";
3
+ /**
4
+ * Any Connect portal page, with the context it needs to offer a way back
5
+ * here - `connectPortalUrl(appUrl, clientId, "/profile")`,
6
+ * `connectPortalUrl(appUrl, clientId, "/calendar/settings")`, and so on.
7
+ *
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
10
+ * (`ApplicationReturnBar` on Connect's side). It validates `return_to`
11
+ * against this `client_id`'s registered redirect origins and silently drops
12
+ * the banner if they don't match, so a wrong or stale `appUrl`/`clientId`
13
+ * pairing degrades to what linking there did before rather than erroring.
14
+ *
15
+ * `window.location.href` rather than a configured home URL: the point is to
16
+ * come back to the page they left, and only the browser knows which one that
17
+ * is.
18
+ */
19
+ export function connectPortalUrl(appUrl, clientId, path) {
20
+ const url = new URL(path, appUrl);
21
+ url.searchParams.set("client_id", clientId);
22
+ if (typeof window !== "undefined") {
23
+ url.searchParams.set("return_to", window.location.href);
24
+ }
25
+ return url.toString();
26
+ }
27
+ /**
28
+ * `connectPortalUrl`, with `appUrl`/`clientId` read from the provider instead
29
+ * of threaded through by every caller.
30
+ */
31
+ export function useConnectPortalUrl(path) {
32
+ const { appUrl, clientId } = useConnect();
33
+ return connectPortalUrl(appUrl, clientId, path);
34
+ }
@@ -15,6 +15,48 @@ export function useBookableEventTypes() {
15
15
  const { data, isLoading, error } = useConnectResource("/scheduling/event-types");
16
16
  return { eventTypes: data?.eventTypes ?? [], isLoading, error };
17
17
  }
18
+ /**
19
+ * The working-hours templates an appointment type can point at.
20
+ *
21
+ * Read this before offering a "set up availability" step - a workspace that
22
+ * already has one does not need a second "Working hours" from your UI.
23
+ */
24
+ export function useAvailabilitySchedules() {
25
+ const { data, isLoading, error } = useConnectResource("/scheduling/schedules");
26
+ return { schedules: data?.schedules ?? [], isLoading, error };
27
+ }
28
+ /**
29
+ * Gives a workspace its first working hours.
30
+ *
31
+ * **Not optimistic** - same posture as `useBookingActions`: this is one of
32
+ * the two writes a host's first-run setup makes, and a modal that closes on
33
+ * a failed create is worse than one that stays open with the reason shown.
34
+ */
35
+ export function useCreateAvailabilitySchedule() {
36
+ const { run, isPending, error, clearError } = useResourceAction();
37
+ return {
38
+ isPending,
39
+ error,
40
+ clearError,
41
+ createSchedule: (input) => run("/scheduling/schedules", input),
42
+ };
43
+ }
44
+ /**
45
+ * Creates the first thing a workspace can be booked for.
46
+ *
47
+ * Host is a specific member, never a team - there is no groups read in this
48
+ * bridge, so a round-robin team host stays a Console-only capability
49
+ * reachable by following "Manage in Connect".
50
+ */
51
+ export function useCreateEventType() {
52
+ const { run, isPending, error, clearError } = useResourceAction();
53
+ return {
54
+ isPending,
55
+ error,
56
+ clearError,
57
+ createEventType: (input) => run("/scheduling/event-types", input),
58
+ };
59
+ }
18
60
  /**
19
61
  * Free times for one appointment type.
20
62
  *
@@ -68,15 +110,16 @@ export function useBookings(query) {
68
110
  };
69
111
  }
70
112
  /**
71
- * Book, move and cancel.
113
+ * The isPending/error bookkeeping every write in this file shares, factored
114
+ * out once `useCreateAvailabilitySchedule` and `useCreateEventType` needed
115
+ * the identical shape `useBookingActions` already had.
72
116
  *
73
- * **None of these is optimistic**, and that is not an oversight. Whether a
74
- * slot is still free is a question only the server can answer, inside the
75
- * transaction that decides it - two people can be holding the same minute
76
- * right now. Painting a booking as made and withdrawing it is exactly the lie
77
- * the two-phase hold exists to prevent.
117
+ * **Never optimistic**, and that is not an oversight for any caller of this:
118
+ * whether a slot is free, or a schedule name collides, is a question only the
119
+ * server can answer. Painting the result before it comes back is exactly the
120
+ * lie a two-phase hold (or a modal that closes early) would tell.
78
121
  */
79
- export function useBookingActions() {
122
+ function useResourceAction() {
80
123
  const { mutateResource } = useConnect();
81
124
  const [isPending, setPending] = useState(false);
82
125
  const [error, setError] = useState(null);
@@ -96,10 +139,23 @@ export function useBookingActions() {
96
139
  setPending(false);
97
140
  }
98
141
  }, [mutateResource]);
142
+ return { isPending, error, clearError: () => setError(null), run };
143
+ }
144
+ /**
145
+ * Book, move and cancel.
146
+ *
147
+ * **None of these is optimistic**, and that is not an oversight. Whether a
148
+ * slot is still free is a question only the server can answer, inside the
149
+ * transaction that decides it - two people can be holding the same minute
150
+ * right now. Painting a booking as made and withdrawing it is exactly the lie
151
+ * the two-phase hold exists to prevent.
152
+ */
153
+ export function useBookingActions() {
154
+ const { run, isPending, error, clearError } = useResourceAction();
99
155
  return {
100
156
  isPending,
101
157
  error,
102
- clearError: () => setError(null),
158
+ clearError,
103
159
  createBooking: (input) => run("/scheduling/bookings", input),
104
160
  rescheduleBooking: (input) => run("/scheduling/bookings/reschedule", input).then((result) => result?.booking ?? null),
105
161
  cancelBooking: (input) => run("/scheduling/bookings/cancel", input),
@@ -175,4 +175,4 @@ export class ConnectClient {
175
175
  }
176
176
  }
177
177
  /** Kept in step with `package.json` by `sdk.test.ts`. */
178
- export const SDK_VERSION = "0.37.0";
178
+ export const SDK_VERSION = "0.38.0";
@@ -850,6 +850,84 @@ export function createConnectHandler(options) {
850
850
  const asUser = await requireToken(request);
851
851
  return json({ eventTypes: await client.scheduling.listEventTypes(workspaceId, { asUser }) });
852
852
  },
853
+ /*
854
+ * Creates the first thing a workspace can be booked for - a browser-side
855
+ * counterpart to `POST /scheduling/event-types` above, for a host's own
856
+ * inline setup UI (`ConnectEventTypeFormModal`). Host is a specific
857
+ * member, never a team - there is no groups read in this bridge, so a
858
+ * round-robin team host stays a Console-only capability.
859
+ */
860
+ "POST /scheduling/event-types": async (request) => {
861
+ const { workspaceId } = await requireWorkspace(request);
862
+ const asUser = await requireToken(request);
863
+ const body = (await request.json().catch(() => null));
864
+ if (typeof body?.name !== "string" ||
865
+ typeof body.durationMinutes !== "number" ||
866
+ typeof body.hostMemberId !== "string" ||
867
+ typeof body.scheduleId !== "string" ||
868
+ typeof body.location?.kind !== "string") {
869
+ throw new ConnectError({
870
+ status: 400,
871
+ code: "validation_error",
872
+ message: "name, durationMinutes, hostMemberId, scheduleId and location are required",
873
+ endpoint: "/scheduling/event-types",
874
+ });
875
+ }
876
+ return json(await client.scheduling.createEventType({
877
+ workspaceId,
878
+ name: body.name,
879
+ description: typeof body.description === "string" ? body.description : null,
880
+ durationMinutes: body.durationMinutes,
881
+ bufferBeforeMinutes: typeof body.bufferBeforeMinutes === "number" ? body.bufferBeforeMinutes : undefined,
882
+ bufferAfterMinutes: typeof body.bufferAfterMinutes === "number" ? body.bufferAfterMinutes : undefined,
883
+ hostMemberId: body.hostMemberId,
884
+ scheduleId: body.scheduleId,
885
+ location: {
886
+ kind: body.location.kind,
887
+ value: typeof body.location.value === "string" ? body.location.value : null,
888
+ },
889
+ publicBookingEnabled: typeof body.publicBookingEnabled === "boolean" ? body.publicBookingEnabled : undefined,
890
+ asUser,
891
+ }), 201);
892
+ },
893
+ /*
894
+ * The working-hours templates an appointment type can point at - read
895
+ * before a setup UI offers to create one, so it can tell "none yet" from
896
+ * "already set up".
897
+ */
898
+ "GET /scheduling/schedules": async (request) => {
899
+ const { workspaceId } = await requireWorkspace(request);
900
+ const asUser = await requireToken(request);
901
+ return json({ schedules: await client.scheduling.listSchedules(workspaceId, { asUser }) });
902
+ },
903
+ /**
904
+ * Gives a workspace its first working hours - the browser-side
905
+ * counterpart to `POST /scheduling/schedules` above, for
906
+ * `ConnectAvailabilityFormModal`.
907
+ */
908
+ "POST /scheduling/schedules": async (request) => {
909
+ const { workspaceId } = await requireWorkspace(request);
910
+ const asUser = await requireToken(request);
911
+ const body = (await request.json().catch(() => null));
912
+ if (typeof body?.name !== "string" ||
913
+ typeof body.timezone !== "string" ||
914
+ typeof body.weeklyHours !== "object" ||
915
+ body.weeklyHours === null) {
916
+ throw new ConnectError({
917
+ status: 400,
918
+ code: "validation_error",
919
+ message: "name, timezone and weeklyHours are required",
920
+ endpoint: "/scheduling/schedules",
921
+ });
922
+ }
923
+ return json(await client.scheduling.createSchedule({
924
+ workspaceId,
925
+ name: body.name,
926
+ timezone: body.timezone,
927
+ weeklyHours: body.weeklyHours,
928
+ asUser,
929
+ }), 201);
930
+ },
853
931
  "GET /scheduling/slots": async (request) => {
854
932
  const { workspaceId } = await requireWorkspace(request);
855
933
  const asUser = await requireToken(request);
@@ -1,4 +1,15 @@
1
1
  import { ConnectError } from "../errors.js";
2
+ /**
3
+ * How long a *generating* endpoint may take to send its headers.
4
+ *
5
+ * One number, named once, and it is not a guess: it is what
6
+ * `POST /visitor/chat` and `POST /ai/chat` declare as their `maxDuration` on
7
+ * the far side. A client bound shorter than the route's own ceiling cannot be
8
+ * right - it can only abort work Connect is still allowed to be doing - and
9
+ * the two numbers drifting apart is exactly how a 20s client came to be
10
+ * fronting a 60s route.
11
+ */
12
+ export const GENERATION_TIMEOUT_MS = 60_000;
2
13
  const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
3
14
  /**
4
15
  * One id, safe to interpolate into a request path.
@@ -145,7 +156,7 @@ export async function request(options, spec) {
145
156
  // that has actually started to run as long as it needs. A caller that
146
157
  // wants to bound the stream itself passes `spec.signal`.
147
158
  const timeoutController = new AbortController();
148
- const timer = setTimeout(() => timeoutController.abort(), options.timeoutMs);
159
+ const timer = setTimeout(() => timeoutController.abort(), spec.timeoutMs ?? options.timeoutMs);
149
160
  const signal = spec.signal
150
161
  ? AbortSignal.any([spec.signal, timeoutController.signal])
151
162
  : timeoutController.signal;
@@ -1,5 +1,5 @@
1
1
  import { actingAs } from "./acting-as.js";
2
- import { request } from "../http.js";
2
+ import { GENERATION_TIMEOUT_MS, request } from "../http.js";
3
3
  /**
4
4
  * RAG chat and structured extraction against a workspace's data.
5
5
  *
@@ -40,6 +40,12 @@ export class AiResource {
40
40
  ...(input.screenContext ? { screenContext: input.screenContext } : {}),
41
41
  },
42
42
  headers: actingAs(input.asUser),
43
+ /*
44
+ * Matches the route's own `maxDuration`. This endpoint does not send
45
+ * headers until the model's first token, so the default 20s bound was
46
+ * aborting any turn that opened with a tool call - see `RequestSpec.timeoutMs`.
47
+ */
48
+ timeoutMs: GENERATION_TIMEOUT_MS,
43
49
  raw: true,
44
50
  signal: options.signal,
45
51
  });