@cosmoops/connect-sdk 0.37.0 → 0.40.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 (47) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/cjs/client/components/availability-form.js +103 -0
  3. package/dist/cjs/client/components/create-workspace-form.js +66 -0
  4. package/dist/cjs/client/components/event-type-form.js +156 -0
  5. package/dist/cjs/client/components/user-menu.js +3 -24
  6. package/dist/cjs/client/components/workspace-switcher.js +42 -9
  7. package/dist/cjs/client/index.js +31 -3
  8. package/dist/cjs/client/portal-links.js +38 -0
  9. package/dist/cjs/client/use-scheduling.js +104 -8
  10. package/dist/cjs/client/use-workspaces.js +41 -0
  11. package/dist/cjs/server/client.js +1 -1
  12. package/dist/cjs/server/handler.js +181 -2
  13. package/dist/cjs/server/http.js +13 -1
  14. package/dist/cjs/server/resources/ai.js +6 -0
  15. package/dist/cjs/server/resources/scheduling.js +129 -0
  16. package/dist/cjs/server/resources/visitor.js +6 -0
  17. package/dist/cjs/server/resources/workspaces.js +30 -11
  18. package/dist/esm/client/components/availability-form.js +100 -0
  19. package/dist/esm/client/components/create-workspace-form.js +63 -0
  20. package/dist/esm/client/components/event-type-form.js +153 -0
  21. package/dist/esm/client/components/user-menu.js +3 -24
  22. package/dist/esm/client/components/workspace-switcher.js +42 -9
  23. package/dist/esm/client/index.js +17 -1
  24. package/dist/esm/client/portal-links.js +34 -0
  25. package/dist/esm/client/use-scheduling.js +98 -8
  26. package/dist/esm/client/use-workspaces.js +38 -0
  27. package/dist/esm/server/client.js +1 -1
  28. package/dist/esm/server/handler.js +181 -2
  29. package/dist/esm/server/http.js +12 -1
  30. package/dist/esm/server/resources/ai.js +7 -1
  31. package/dist/esm/server/resources/scheduling.js +129 -0
  32. package/dist/esm/server/resources/visitor.js +7 -1
  33. package/dist/esm/server/resources/workspaces.js +30 -11
  34. package/dist/types/client/components/availability-form.d.ts +33 -0
  35. package/dist/types/client/components/create-workspace-form.d.ts +32 -0
  36. package/dist/types/client/components/event-type-form.d.ts +44 -0
  37. package/dist/types/client/components/workspace-switcher.d.ts +9 -9
  38. package/dist/types/client/index.d.ts +10 -1
  39. package/dist/types/client/portal-links.d.ts +22 -0
  40. package/dist/types/client/use-scheduling.d.ts +93 -1
  41. package/dist/types/client/use-workspaces.d.ts +27 -0
  42. package/dist/types/server/client.d.ts +1 -1
  43. package/dist/types/server/http.d.ts +35 -0
  44. package/dist/types/server/resources/scheduling.d.ts +131 -1
  45. package/dist/types/server/resources/workspaces.d.ts +28 -0
  46. package/dist/types/types.d.ts +60 -0
  47. package/package.json +1 -1
@@ -34,6 +34,135 @@ 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
+ * Answers the richer *manageable* shape, not `BookableEventType` - you just
81
+ * created this row and are about to manage it, not book against it.
82
+ */
83
+ createEventType(input) {
84
+ return (0, http_js_1.request)(this.transport, {
85
+ method: "POST",
86
+ path: "/scheduling/event-types",
87
+ json: {
88
+ workspaceId: input.workspaceId,
89
+ name: input.name,
90
+ description: input.description ?? null,
91
+ durationMinutes: input.durationMinutes,
92
+ bufferBeforeMinutes: input.bufferBeforeMinutes,
93
+ bufferAfterMinutes: input.bufferAfterMinutes,
94
+ hostMemberId: input.hostMemberId,
95
+ scheduleId: input.scheduleId,
96
+ location: input.location,
97
+ publicBookingEnabled: input.publicBookingEnabled ?? false,
98
+ },
99
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
100
+ });
101
+ }
102
+ /**
103
+ * The richer read `createEventType`/`updateEventType`'s edit mode needs -
104
+ * everything `listEventTypes`'s booking catalogue deliberately withholds,
105
+ * because this is the opposite audience: a member managing their own
106
+ * calendar, not a widget rendering someone else's. Includes inactive rows,
107
+ * since an edit screen is exactly where one gets reactivated.
108
+ */
109
+ async listManageableEventTypes(workspaceId, options = {}) {
110
+ const result = await (0, http_js_1.request)(this.transport, {
111
+ method: "GET",
112
+ path: "/scheduling/event-types/manage",
113
+ query: { workspaceId },
114
+ headers: (0, acting_as_js_1.actingAs)(options.asUser),
115
+ });
116
+ return result.eventTypes ?? [];
117
+ }
118
+ /**
119
+ * Edits an existing availability schedule.
120
+ *
121
+ * Narrower than what the workspace's own Calendar settings can do, the
122
+ * same way `createSchedule` is: `ownerMemberId` and date overrides stay
123
+ * untouched, since this surface never sets either.
124
+ */
125
+ updateSchedule(input) {
126
+ return (0, http_js_1.request)(this.transport, {
127
+ method: "PUT",
128
+ path: `/scheduling/schedules/${encodeURIComponent(input.scheduleId)}`,
129
+ json: {
130
+ workspaceId: input.workspaceId,
131
+ name: input.name,
132
+ timezone: input.timezone,
133
+ weeklyHours: input.weeklyHours,
134
+ },
135
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
136
+ });
137
+ }
138
+ /**
139
+ * Edits an existing appointment type.
140
+ *
141
+ * `hostMemberId` omitted leaves the host untouched, including a *group*
142
+ * host this surface cannot represent - so an edit that says nothing about
143
+ * the host can never accidentally overwrite a team assignment made in
144
+ * Connect.
145
+ */
146
+ updateEventType(input) {
147
+ return (0, http_js_1.request)(this.transport, {
148
+ method: "PUT",
149
+ path: `/scheduling/event-types/${encodeURIComponent(input.eventTypeId)}`,
150
+ json: {
151
+ workspaceId: input.workspaceId,
152
+ name: input.name,
153
+ description: input.description,
154
+ durationMinutes: input.durationMinutes,
155
+ bufferBeforeMinutes: input.bufferBeforeMinutes,
156
+ bufferAfterMinutes: input.bufferAfterMinutes,
157
+ hostMemberId: input.hostMemberId,
158
+ scheduleId: input.scheduleId,
159
+ location: input.location,
160
+ publicBookingEnabled: input.publicBookingEnabled,
161
+ active: input.active,
162
+ },
163
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
164
+ });
165
+ }
37
166
  /**
38
167
  * Free times for one appointment type over one window.
39
168
  *
@@ -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
  });
@@ -3,17 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WorkspacesResource = void 0;
4
4
  const acting_as_js_1 = require("./acting-as.js");
5
5
  const http_js_1 = require("../http.js");
6
- /**
7
- * Which workspaces the signed-in user belongs to, moving between them, who
8
- * else is on the team, and inviting or granting access to your application.
9
- *
10
- * **Every call here needs `asUser`** - the signed-in user's opaque
11
- * `sessionToken`. There is no application-wide form of any of these: they take
12
- * no `userId` and no `workspaceId`, and resolve both from the token. That is
13
- * what makes the whole module safe to reach from a browser through
14
- * `createConnectHandler`, and it is why a partner cannot enumerate another
15
- * tenant's team with their own API key.
16
- */
17
6
  class WorkspacesResource {
18
7
  transport;
19
8
  constructor(transport) {
@@ -34,6 +23,36 @@ class WorkspacesResource {
34
23
  });
35
24
  return result.workspaces;
36
25
  }
26
+ /**
27
+ * Opens another workspace under the tenant the signed-in user already
28
+ * owns - a branch office, a second business unit.
29
+ *
30
+ * Every guard `CreateWorkspaceUseCase` enforces still applies: only the
31
+ * tenant's owner may call this, `type: "business"` still needs a non-free
32
+ * email domain, and the tenant's plan still decides whether it may hold
33
+ * another workspace at all - a `PreconditionError` surfaces as a thrown
34
+ * `ConnectError` with a message written to be shown ("Your current plan
35
+ * covers a single workspace...", "You've used all 3 of the workspaces
36
+ * your plans include..."). There is no `tenantId` parameter: the caller
37
+ * names no tenant, same as every other method on this resource names no
38
+ * workspace.
39
+ *
40
+ * `isActive`/`subscribed` on the result are always `false` - this does not
41
+ * switch the session, and the new workspace has no subscription yet. Chain
42
+ * `switch({workspaceId: result.workspaceId})` to move into it, but only
43
+ * when `result.inAudience` - a workspace outside this application's
44
+ * audience is refused by `switch` the same way `selectable()` in
45
+ * `<ConnectWorkspaceSwitcher>` already treats it.
46
+ */
47
+ async create(input, options) {
48
+ const result = await (0, http_js_1.request)(this.transport, {
49
+ method: "POST",
50
+ path: "/workspaces",
51
+ headers: (0, acting_as_js_1.actingAs)(options.asUser),
52
+ json: input,
53
+ });
54
+ return result.workspace;
55
+ }
37
56
  /**
38
57
  * Moves this session to another workspace, returning a **new session
39
58
  * token**. The one you passed is revoked.
@@ -0,0 +1,100 @@
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 { useAvailabilitySchedules, useCreateAvailabilitySchedule, useUpdateAvailabilitySchedule, } 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
+ * Creates or edits a workspace's working hours, from inside a host
24
+ * application 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 is
28
+ * set once at creation (from the browser's own) and shown but not editable
29
+ * here afterwards. This is a first-party setup-and-edit surface, not a
30
+ * second copy of the Console editor - "Manage in Connect" is still where the
31
+ * rest of what a schedule can do lives.
32
+ *
33
+ * **Not optimistic.** The write settles against the server before this
34
+ * closes, the same posture every write in this package takes - see
35
+ * `ConnectBookingDialog`.
36
+ *
37
+ * This outer component only decides whether the row to edit has loaded yet;
38
+ * the form itself is `ScheduleFields` below, mounted with
39
+ * `key={editing?.id}` - see that component's own doc comment for why.
40
+ */
41
+ export function ConnectAvailabilityFormModal({ isOpen, onOpenChange, scheduleId = null, onSaved, }) {
42
+ const isEdit = scheduleId !== null;
43
+ const { schedules, isLoading, error } = useAvailabilitySchedules();
44
+ const editing = isEdit ? schedules.find((row) => row.id === scheduleId) ?? null : null;
45
+ 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: isEdit ? "Edit availability" : "Set your availability" }) }), _jsx(Modal.Body, { children: isEdit && isLoading ? (_jsx("p", { className: "text-sm text-muted", children: "Loading\u2026" })) : isEdit && error ? (_jsx("p", { className: "text-sm text-muted", children: "That could not be loaded just now. Close this and try again." })) : isEdit && !editing ? (_jsx("p", { className: "text-sm text-muted", children: "Those working hours no longer exist." })) : (_jsx(ScheduleFields, { editing: editing, onCancel: () => onOpenChange(false), onSaved: (saved) => {
46
+ onSaved?.(saved);
47
+ onOpenChange(false);
48
+ } }, editing?.id ?? "create")) })] }) }) }) }));
49
+ }
50
+ /**
51
+ * The form itself, mounted fresh (via the parent's `key`) for each distinct
52
+ * record it edits - or once for create. That remount is what initializes
53
+ * every field straight from `editing` with a plain `useState(initial)`
54
+ * rather than a `useEffect` copying it in after the fact: `editing` arrives
55
+ * asynchronously (`useAvailabilitySchedules` is a bridge read), and the
56
+ * parent already withholds this component until it has landed, so there is
57
+ * no later moment for a field to overwrite a half-typed edit.
58
+ */
59
+ function ScheduleFields({ editing, onCancel, onSaved, }) {
60
+ const isEdit = editing !== null;
61
+ const [name, setName] = useState(editing?.name ?? "Working hours");
62
+ const [timezone] = useState(() => editing?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
63
+ const [weeklyHours, setWeeklyHours] = useState(editing?.weeklyHours ?? defaultWeeklyHours);
64
+ const { createSchedule, isPending: isCreating, error: createError, clearError: clearCreateError } = useCreateAvailabilitySchedule();
65
+ const { updateSchedule, isPending: isUpdating, error: updateError, clearError: clearUpdateError } = useUpdateAvailabilitySchedule();
66
+ const isPending = isCreating || isUpdating;
67
+ const error = isEdit ? updateError : createError;
68
+ const clearError = isEdit ? clearUpdateError : clearCreateError;
69
+ const toggleDay = (day, open) => {
70
+ setWeeklyHours((prev) => ({
71
+ ...prev,
72
+ [day]: open ? [{ startMinute: 9 * 60, endMinute: 17 * 60 }] : [],
73
+ }));
74
+ };
75
+ const setDayTime = (day, edge, value) => {
76
+ setWeeklyHours((prev) => {
77
+ const interval = prev[day][0];
78
+ if (!interval)
79
+ return prev;
80
+ return { ...prev, [day]: [{ ...interval, [edge]: timeInputToMinutes(value) }] };
81
+ });
82
+ };
83
+ const isFormValid = name.trim().length > 0 && !isPending;
84
+ const handleSubmit = async (e) => {
85
+ e.preventDefault();
86
+ if (!isFormValid)
87
+ return;
88
+ clearError();
89
+ const saved = isEdit
90
+ ? await updateSchedule({ scheduleId: editing.id, name: name.trim(), weeklyHours })
91
+ : await createSchedule({ name: name.trim(), timezone, weeklyHours });
92
+ if (saved)
93
+ onSaved(saved);
94
+ };
95
+ 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, { 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) => {
96
+ const interval = weeklyHours[day][0];
97
+ const isOpenDay = interval !== undefined;
98
+ 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));
99
+ }) }), 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" : "Save" })] })] }));
100
+ }
@@ -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
+ }
@@ -0,0 +1,153 @@
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, useManageableEventTypes, useUpdateEventType, } 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 or edits an appointment type, from inside a host application
15
+ * 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
+ * **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
+ *
31
+ * **Host is a specific member, never a team.** There is no groups read in
32
+ * this bridge, so a round-robin team host stays a Console-only capability -
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.
41
+ */
42
+ export function ConnectEventTypeFormModal({ isOpen, onOpenChange, eventTypeId = null, onSaved, }) {
43
+ const isEdit = eventTypeId !== null;
44
+ const { data: membersData } = useConnectWorkspaceMembers();
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();
101
+ const defaultHostId = useMemo(() => {
102
+ const self = members.find((member) => member.userId === session?.user?.userId);
103
+ return self?.memberId ?? members[0]?.memberId ?? "";
104
+ }, [members, session?.user?.userId]);
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 ?? "");
114
+ const effectiveScheduleId = scheduleId || schedules[0]?.id || "";
115
+ const effectiveHostMemberId = hostMemberId || defaultHostId;
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;
121
+ const isFormValid = name.trim().length > 0 &&
122
+ durationMinutes >= 5 &&
123
+ durationMinutes <= 480 &&
124
+ effectiveScheduleId.length > 0 &&
125
+ effectiveHostMemberId.length > 0 &&
126
+ !isPending;
127
+ const handleSubmit = async (e) => {
128
+ e.preventDefault();
129
+ if (!isFormValid)
130
+ return;
131
+ clearError();
132
+ const trimmedDescription = description.trim();
133
+ const shared = {
134
+ name: name.trim(),
135
+ description: trimmedDescription.length > 0 ? trimmedDescription : null,
136
+ durationMinutes,
137
+ bufferBeforeMinutes: bufferBefore,
138
+ bufferAfterMinutes: bufferAfter,
139
+ hostMemberId: effectiveHostMemberId,
140
+ scheduleId: effectiveScheduleId,
141
+ location: {
142
+ kind: locationKind,
143
+ value: locationValue.trim().length > 0 ? locationValue.trim() : null,
144
+ },
145
+ };
146
+ const saved = isEdit
147
+ ? await updateEventType({ eventTypeId: editing.id, ...shared })
148
+ : await createEventType(shared);
149
+ if (saved)
150
+ onSaved(saved);
151
+ };
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" })] })] }));
153
+ }
@@ -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 }) {