@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.
- package/CHANGELOG.md +37 -0
- package/dist/cjs/client/components/availability-form.js +103 -0
- package/dist/cjs/client/components/create-workspace-form.js +66 -0
- package/dist/cjs/client/components/event-type-form.js +156 -0
- package/dist/cjs/client/components/user-menu.js +3 -24
- package/dist/cjs/client/components/workspace-switcher.js +42 -9
- package/dist/cjs/client/index.js +31 -3
- package/dist/cjs/client/portal-links.js +38 -0
- package/dist/cjs/client/use-scheduling.js +104 -8
- package/dist/cjs/client/use-workspaces.js +41 -0
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +181 -2
- package/dist/cjs/server/http.js +13 -1
- package/dist/cjs/server/resources/ai.js +6 -0
- package/dist/cjs/server/resources/scheduling.js +129 -0
- package/dist/cjs/server/resources/visitor.js +6 -0
- package/dist/cjs/server/resources/workspaces.js +30 -11
- package/dist/esm/client/components/availability-form.js +100 -0
- package/dist/esm/client/components/create-workspace-form.js +63 -0
- package/dist/esm/client/components/event-type-form.js +153 -0
- package/dist/esm/client/components/user-menu.js +3 -24
- package/dist/esm/client/components/workspace-switcher.js +42 -9
- package/dist/esm/client/index.js +17 -1
- package/dist/esm/client/portal-links.js +34 -0
- package/dist/esm/client/use-scheduling.js +98 -8
- package/dist/esm/client/use-workspaces.js +38 -0
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +181 -2
- package/dist/esm/server/http.js +12 -1
- package/dist/esm/server/resources/ai.js +7 -1
- package/dist/esm/server/resources/scheduling.js +129 -0
- package/dist/esm/server/resources/visitor.js +7 -1
- package/dist/esm/server/resources/workspaces.js +30 -11
- package/dist/types/client/components/availability-form.d.ts +33 -0
- package/dist/types/client/components/create-workspace-form.d.ts +32 -0
- package/dist/types/client/components/event-type-form.d.ts +44 -0
- package/dist/types/client/components/workspace-switcher.d.ts +9 -9
- package/dist/types/client/index.d.ts +10 -1
- package/dist/types/client/portal-links.d.ts +22 -0
- package/dist/types/client/use-scheduling.d.ts +93 -1
- package/dist/types/client/use-workspaces.d.ts +27 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/http.d.ts +35 -0
- package/dist/types/server/resources/scheduling.d.ts +131 -1
- package/dist/types/server/resources/workspaces.d.ts +28 -0
- package/dist/types/types.d.ts +60 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,43 @@
|
|
|
3
3
|
`@cosmoops/connect-sdk` follows semver, with the 0.x convention that **minor
|
|
4
4
|
versions may carry breaking changes** and patch versions never do.
|
|
5
5
|
|
|
6
|
+
## 0.40.0
|
|
7
|
+
|
|
8
|
+
### `workspaces.create()` exists now, and the switcher's "Create workspace" row is inline
|
|
9
|
+
|
|
10
|
+
0.37.0 added `alwaysShow` and said, deliberately, that there was no
|
|
11
|
+
`workspaces.create()` - creating a workspace was Connect's job, tied to
|
|
12
|
+
KYC/KYB and a billing profile. That reasoning held for a tenant's *first*
|
|
13
|
+
workspace, which still mints a tenant, an Identity Platform account and a
|
|
14
|
+
password reset. It never applied to a tenant's *second* workspace: opening a
|
|
15
|
+
branch office under a tenant the caller already owns needs none of that -
|
|
16
|
+
`CreateWorkspaceUseCase` just checks ownership, the tenant's plan, and the
|
|
17
|
+
business-email-domain rule.
|
|
18
|
+
|
|
19
|
+
So `alwaysShow`'s "Create workspace" row now opens
|
|
20
|
+
`<ConnectCreateWorkspaceModal>` inline - name, individual-or-business - and
|
|
21
|
+
switches into the result automatically when it is possible
|
|
22
|
+
(`workspace.inAudience`), rather than opening Connect in a new tab and
|
|
23
|
+
leaving the member to find their way back. Needs the new `workspaces:create`
|
|
24
|
+
scope on your API key.
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { ConnectCreateWorkspaceModal, useCreateWorkspace } from "@cosmoops/connect-sdk/client";
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Both are usable standalone, not only through the switcher. `useCreateWorkspace()`
|
|
31
|
+
is not optimistic, same as every other scheduling/workspace write here: the
|
|
32
|
+
tenant's plan is what decides whether another workspace is allowed at all,
|
|
33
|
+
and that is a question only the server can answer. There is still no
|
|
34
|
+
capacity read to show ahead of time - submit and show the server's own
|
|
35
|
+
refusal, the same posture Connect's own "Create workspace" button takes.
|
|
36
|
+
|
|
37
|
+
`ConnectWorkspaceSwitcherProps.alwaysShow`'s doc comment, and
|
|
38
|
+
`WorkspacesResource`'s, are both updated; nothing about the prop's name or
|
|
39
|
+
default changed, so an existing host that set `alwaysShow: true` for the old
|
|
40
|
+
link-out gets the inline flow with no code change on its side beyond the
|
|
41
|
+
version bump and the new scope.
|
|
42
|
+
|
|
6
43
|
## 0.37.0
|
|
7
44
|
|
|
8
45
|
### The workspace switcher can stay visible at one workspace, and offer a second
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ConnectAvailabilityFormModal = ConnectAvailabilityFormModal;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const react_2 = require("@heroui/react");
|
|
8
|
+
const use_scheduling_js_1 = require("../use-scheduling.js");
|
|
9
|
+
const WEEKDAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
10
|
+
const WEEKDAY_KEYS = [0, 1, 2, 3, 4, 5, 6];
|
|
11
|
+
/** Monday-Friday, 9am-5pm - the same starting point Connect's own Console form offers. */
|
|
12
|
+
function defaultWeeklyHours() {
|
|
13
|
+
const open = [{ startMinute: 9 * 60, endMinute: 17 * 60 }];
|
|
14
|
+
return { 0: [], 1: open, 2: open, 3: open, 4: open, 5: open, 6: [] };
|
|
15
|
+
}
|
|
16
|
+
function minutesToTimeInput(minutes) {
|
|
17
|
+
const hours = Math.floor(minutes / 60);
|
|
18
|
+
const mins = minutes % 60;
|
|
19
|
+
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
|
|
20
|
+
}
|
|
21
|
+
function timeInputToMinutes(value) {
|
|
22
|
+
const [hours, minutes] = value.split(":").map(Number);
|
|
23
|
+
return (hours ?? 0) * 60 + (minutes ?? 0);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Creates or edits a workspace's working hours, from inside a host
|
|
27
|
+
* application rather than a link-out to Connect.
|
|
28
|
+
*
|
|
29
|
+
* Deliberately narrower than Connect's own Calendar settings form: one
|
|
30
|
+
* interval per day (no split shifts), no date overrides, and the timezone is
|
|
31
|
+
* set once at creation (from the browser's own) and shown but not editable
|
|
32
|
+
* here afterwards. This is a first-party setup-and-edit surface, not a
|
|
33
|
+
* second copy of the Console editor - "Manage in Connect" is still where the
|
|
34
|
+
* rest of what a schedule can do lives.
|
|
35
|
+
*
|
|
36
|
+
* **Not optimistic.** The write settles against the server before this
|
|
37
|
+
* closes, the same posture every write in this package takes - see
|
|
38
|
+
* `ConnectBookingDialog`.
|
|
39
|
+
*
|
|
40
|
+
* This outer component only decides whether the row to edit has loaded yet;
|
|
41
|
+
* the form itself is `ScheduleFields` below, mounted with
|
|
42
|
+
* `key={editing?.id}` - see that component's own doc comment for why.
|
|
43
|
+
*/
|
|
44
|
+
function ConnectAvailabilityFormModal({ isOpen, onOpenChange, scheduleId = null, onSaved, }) {
|
|
45
|
+
const isEdit = scheduleId !== null;
|
|
46
|
+
const { schedules, isLoading, error } = (0, use_scheduling_js_1.useAvailabilitySchedules)();
|
|
47
|
+
const editing = isEdit ? schedules.find((row) => row.id === scheduleId) ?? null : null;
|
|
48
|
+
return ((0, jsx_runtime_1.jsx)(react_2.Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(react_2.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_2.Modal.Dialog, { className: "sm:max-w-120", children: [(0, jsx_runtime_1.jsx)(react_2.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_2.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Heading, { children: isEdit ? "Edit availability" : "Set your availability" }) }), (0, jsx_runtime_1.jsx)(react_2.Modal.Body, { children: isEdit && isLoading ? ((0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Loading\u2026" })) : isEdit && error ? ((0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "That could not be loaded just now. Close this and try again." })) : isEdit && !editing ? ((0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Those working hours no longer exist." })) : ((0, jsx_runtime_1.jsx)(ScheduleFields, { editing: editing, onCancel: () => onOpenChange(false), onSaved: (saved) => {
|
|
49
|
+
onSaved?.(saved);
|
|
50
|
+
onOpenChange(false);
|
|
51
|
+
} }, editing?.id ?? "create")) })] }) }) }) }));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The form itself, mounted fresh (via the parent's `key`) for each distinct
|
|
55
|
+
* record it edits - or once for create. That remount is what initializes
|
|
56
|
+
* every field straight from `editing` with a plain `useState(initial)`
|
|
57
|
+
* rather than a `useEffect` copying it in after the fact: `editing` arrives
|
|
58
|
+
* asynchronously (`useAvailabilitySchedules` is a bridge read), and the
|
|
59
|
+
* parent already withholds this component until it has landed, so there is
|
|
60
|
+
* no later moment for a field to overwrite a half-typed edit.
|
|
61
|
+
*/
|
|
62
|
+
function ScheduleFields({ editing, onCancel, onSaved, }) {
|
|
63
|
+
const isEdit = editing !== null;
|
|
64
|
+
const [name, setName] = (0, react_1.useState)(editing?.name ?? "Working hours");
|
|
65
|
+
const [timezone] = (0, react_1.useState)(() => editing?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
|
|
66
|
+
const [weeklyHours, setWeeklyHours] = (0, react_1.useState)(editing?.weeklyHours ?? defaultWeeklyHours);
|
|
67
|
+
const { createSchedule, isPending: isCreating, error: createError, clearError: clearCreateError } = (0, use_scheduling_js_1.useCreateAvailabilitySchedule)();
|
|
68
|
+
const { updateSchedule, isPending: isUpdating, error: updateError, clearError: clearUpdateError } = (0, use_scheduling_js_1.useUpdateAvailabilitySchedule)();
|
|
69
|
+
const isPending = isCreating || isUpdating;
|
|
70
|
+
const error = isEdit ? updateError : createError;
|
|
71
|
+
const clearError = isEdit ? clearUpdateError : clearCreateError;
|
|
72
|
+
const toggleDay = (day, open) => {
|
|
73
|
+
setWeeklyHours((prev) => ({
|
|
74
|
+
...prev,
|
|
75
|
+
[day]: open ? [{ startMinute: 9 * 60, endMinute: 17 * 60 }] : [],
|
|
76
|
+
}));
|
|
77
|
+
};
|
|
78
|
+
const setDayTime = (day, edge, value) => {
|
|
79
|
+
setWeeklyHours((prev) => {
|
|
80
|
+
const interval = prev[day][0];
|
|
81
|
+
if (!interval)
|
|
82
|
+
return prev;
|
|
83
|
+
return { ...prev, [day]: [{ ...interval, [edge]: timeInputToMinutes(value) }] };
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
const isFormValid = name.trim().length > 0 && !isPending;
|
|
87
|
+
const handleSubmit = async (e) => {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
if (!isFormValid)
|
|
90
|
+
return;
|
|
91
|
+
clearError();
|
|
92
|
+
const saved = isEdit
|
|
93
|
+
? await updateSchedule({ scheduleId: editing.id, name: name.trim(), weeklyHours })
|
|
94
|
+
: await createSchedule({ name: name.trim(), timezone, weeklyHours });
|
|
95
|
+
if (saved)
|
|
96
|
+
onSaved(saved);
|
|
97
|
+
};
|
|
98
|
+
return ((0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { isRequired: true, variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Name" }), (0, jsx_runtime_1.jsx)(react_2.Input, { value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), (0, jsx_runtime_1.jsxs)("p", { className: "text-xs text-muted", children: ["Timezone: ", timezone] }), (0, jsx_runtime_1.jsx)("div", { className: "flex flex-col gap-2", children: WEEKDAY_KEYS.map((day) => {
|
|
99
|
+
const interval = weeklyHours[day][0];
|
|
100
|
+
const isOpenDay = interval !== undefined;
|
|
101
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-3", children: [(0, jsx_runtime_1.jsx)(react_2.Switch, { isSelected: isOpenDay, onChange: (open) => toggleDay(day, open), isDisabled: isPending, children: (0, jsx_runtime_1.jsxs)(react_2.Switch.Content, { children: [(0, jsx_runtime_1.jsx)(react_2.Switch.Control, { children: (0, jsx_runtime_1.jsx)(react_2.Switch.Thumb, {}) }), (0, jsx_runtime_1.jsx)("span", { className: "w-24 shrink-0 text-sm", children: WEEKDAY_LABELS[day] })] }) }), isOpenDay && ((0, jsx_runtime_1.jsxs)("div", { className: "flex items-center gap-2", children: [(0, jsx_runtime_1.jsx)(react_2.Input, { type: "time", value: minutesToTimeInput(interval.startMinute), onChange: (e) => setDayTime(day, "startMinute", e.currentTarget.value), disabled: isPending }), (0, jsx_runtime_1.jsx)("span", { className: "text-xs text-muted", children: "to" }), (0, jsx_runtime_1.jsx)(react_2.Input, { type: "time", value: minutesToTimeInput(interval.endMinute), onChange: (e) => setDayTime(day, "endMinute", e.currentTarget.value), disabled: isPending })] }))] }, day));
|
|
102
|
+
}) }), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-end gap-2", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", variant: "secondary", onPress: onCancel, isDisabled: isPending, children: "Cancel" }), (0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Saving..." : isEdit ? "Save changes" : "Save" })] })] }));
|
|
103
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ConnectCreateWorkspaceModal = ConnectCreateWorkspaceModal;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const react_2 = require("@heroui/react");
|
|
8
|
+
const use_workspaces_js_1 = require("../use-workspaces.js");
|
|
9
|
+
const WORKSPACE_TYPES = [
|
|
10
|
+
{
|
|
11
|
+
id: "business",
|
|
12
|
+
label: "Business",
|
|
13
|
+
hint: "A company. Verified with company KYB, and can subscribe to anything sold to businesses.",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "individual",
|
|
17
|
+
label: "Individual",
|
|
18
|
+
hint: "A person. Verified against your own identity rather than a company.",
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
/**
|
|
22
|
+
* Opens another workspace under the signed-in user's own tenant - a branch
|
|
23
|
+
* office, a second business unit - from inside a host application rather
|
|
24
|
+
* than a link-out to Connect.
|
|
25
|
+
*
|
|
26
|
+
* **Only the tenant's owner can use this successfully.** `CreateWorkspaceUseCase`
|
|
27
|
+
* refuses anyone who merely belongs to a colleague's tenant, with a message
|
|
28
|
+
* naming that - someone who wants a workspace of their own uses a personal
|
|
29
|
+
* account instead.
|
|
30
|
+
*
|
|
31
|
+
* **The tenant's plan decides how many workspaces it may hold, and this does
|
|
32
|
+
* not pre-check it.** There is no capacity read to show ahead of time - the
|
|
33
|
+
* same posture Connect's own "Create workspace" button takes - so a refusal
|
|
34
|
+
* surfaces here exactly as the server wrote it ("Your current plan covers a
|
|
35
|
+
* single workspace - subscribe to a plan that includes additional workspaces
|
|
36
|
+
* to open another.", or "You've used all 3 of the workspaces your plans
|
|
37
|
+
* include. Upgrade a plan or delete a workspace to open another.").
|
|
38
|
+
*
|
|
39
|
+
* **Does not switch the session to the new workspace.** Creating one never
|
|
40
|
+
* does, on Connect's own screen either - see `useCreateWorkspace`'s doc
|
|
41
|
+
* comment for how to chain a switch once you have the result, and guard that
|
|
42
|
+
* chain on `inAudience` the same way `<ConnectWorkspaceSwitcher>` already
|
|
43
|
+
* does for every other row.
|
|
44
|
+
*/
|
|
45
|
+
function ConnectCreateWorkspaceModal({ isOpen, onOpenChange, onCreated, }) {
|
|
46
|
+
return ((0, jsx_runtime_1.jsx)(react_2.Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(react_2.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_2.Modal.Dialog, { className: "sm:max-w-110", children: [(0, jsx_runtime_1.jsx)(react_2.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_2.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Heading, { children: "Create workspace" }) }), (0, jsx_runtime_1.jsx)(react_2.Modal.Body, { children: (0, jsx_runtime_1.jsx)(CreateWorkspaceFields, { onCancel: () => onOpenChange(false), onCreated: (workspace) => {
|
|
47
|
+
onCreated?.(workspace);
|
|
48
|
+
onOpenChange(false);
|
|
49
|
+
} }) })] }) }) }) }));
|
|
50
|
+
}
|
|
51
|
+
function CreateWorkspaceFields({ onCancel, onCreated, }) {
|
|
52
|
+
const [name, setName] = (0, react_1.useState)("");
|
|
53
|
+
const [type, setType] = (0, react_1.useState)("business");
|
|
54
|
+
const { createWorkspace, isPending, error, clearError } = (0, use_workspaces_js_1.useCreateWorkspace)();
|
|
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 created = await createWorkspace({ name: name.trim(), type });
|
|
62
|
+
if (created)
|
|
63
|
+
onCreated(created);
|
|
64
|
+
};
|
|
65
|
+
return ((0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { isRequired: true, variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Name" }), (0, jsx_runtime_1.jsx)(react_2.Input, { placeholder: "Acme Logistics", value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), (0, jsx_runtime_1.jsxs)(react_2.RadioGroup, { variant: "secondary", value: type, onChange: (value) => setType(value), isDisabled: isPending, children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Type" }), (0, jsx_runtime_1.jsx)("div", { className: "mt-1.5 flex flex-col gap-2", children: WORKSPACE_TYPES.map((option) => ((0, jsx_runtime_1.jsx)(react_2.Radio, { value: option.id, children: (0, jsx_runtime_1.jsxs)(react_2.Radio.Content, { children: [(0, jsx_runtime_1.jsx)(react_2.Radio.Control, { children: (0, jsx_runtime_1.jsx)(react_2.Radio.Indicator, {}) }), (0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm font-medium", children: option.label }), (0, jsx_runtime_1.jsx)("p", { className: "text-xs text-muted", children: option.hint })] })] }) }, option.id))) })] }), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-end gap-2", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", variant: "secondary", onPress: onCancel, isDisabled: isPending, children: "Cancel" }), (0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Creating…" : "Create" })] })] }));
|
|
66
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ConnectEventTypeFormModal = ConnectEventTypeFormModal;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const react_2 = require("@heroui/react");
|
|
8
|
+
const provider_js_1 = require("../provider.js");
|
|
9
|
+
const use_scheduling_js_1 = require("../use-scheduling.js");
|
|
10
|
+
const LOCATION_LABELS = {
|
|
11
|
+
phone: "Phone call",
|
|
12
|
+
address: "In person",
|
|
13
|
+
link: "Video link",
|
|
14
|
+
};
|
|
15
|
+
const LOCATION_KINDS = ["phone", "address", "link"];
|
|
16
|
+
/**
|
|
17
|
+
* Creates or edits an appointment type, from inside a host application
|
|
18
|
+
* rather than a link-out to Connect.
|
|
19
|
+
*
|
|
20
|
+
* **Needs a schedule to exist first** - the picker reads `useAvailabilitySchedules`
|
|
21
|
+
* and renders "Set up availability first" with nothing to submit if none
|
|
22
|
+
* exists yet. Pair this with `ConnectAvailabilityFormModal` in a setup flow
|
|
23
|
+
* rather than assuming one.
|
|
24
|
+
*
|
|
25
|
+
* **That gate waits for the read, and the order matters.** Both bridge reads
|
|
26
|
+
* answer `[]` while they are in flight, so testing `schedules.length === 0`
|
|
27
|
+
* first showed "Set up availability first" to everybody who opened this -
|
|
28
|
+
* including, for a second or two, somebody pressing Edit on an appointment
|
|
29
|
+
* type that provably *has* a schedule. A load state wearing an empty state's
|
|
30
|
+
* words is worse than a spinner: it reads as a verdict, and on a slow
|
|
31
|
+
* connection it is the only thing the modal ever says. A failed read is named
|
|
32
|
+
* as a failure for the same reason - it is not the same fact as "none exist".
|
|
33
|
+
*
|
|
34
|
+
* **Host is a specific member, never a team.** There is no groups read in
|
|
35
|
+
* this bridge, so a round-robin team host stays a Console-only capability -
|
|
36
|
+
* defaults to whoever is signed in on create, with an optional picker from
|
|
37
|
+
* the workspace's own roster. **Editing a group-hosted row is refused
|
|
38
|
+
* inline** - the modal shows a notice instead of a form, because this
|
|
39
|
+
* surface cannot represent (and so cannot safely resend) a team host.
|
|
40
|
+
*
|
|
41
|
+
* This outer component only decides *which* message or form to show; the
|
|
42
|
+
* form itself is `EventTypeFields` below, mounted with `key={editing?.id}` -
|
|
43
|
+
* see that component's own doc comment for why.
|
|
44
|
+
*/
|
|
45
|
+
function ConnectEventTypeFormModal({ isOpen, onOpenChange, eventTypeId = null, onSaved, }) {
|
|
46
|
+
const isEdit = eventTypeId !== null;
|
|
47
|
+
const { data: membersData } = (0, provider_js_1.useConnectWorkspaceMembers)();
|
|
48
|
+
const { schedules, isLoading: isLoadingSchedules, error: schedulesError, } = (0, use_scheduling_js_1.useAvailabilitySchedules)();
|
|
49
|
+
const { eventTypes: manageableEventTypes, isLoading: isLoadingEventTypes, error: eventTypesError, } = (0, use_scheduling_js_1.useManageableEventTypes)();
|
|
50
|
+
const members = membersData?.members ?? [];
|
|
51
|
+
const editing = isEdit ? manageableEventTypes.find((row) => row.id === eventTypeId) ?? null : null;
|
|
52
|
+
/** A group host cannot be represented (or safely resent) by this form. */
|
|
53
|
+
const isUnrepresentableHost = isEdit && editing?.host.kind === "group";
|
|
54
|
+
/*
|
|
55
|
+
* Everything this modal decides from depends on a read, so the two
|
|
56
|
+
* not-yet-answered states come first - see this component's doc comment.
|
|
57
|
+
* An edit also waits for the row itself: `editing` is `null` both while the
|
|
58
|
+
* list is loading and when it has arrived without this id, and only the
|
|
59
|
+
* second of those is a real answer.
|
|
60
|
+
*/
|
|
61
|
+
const isLoading = isLoadingSchedules || (isEdit && (isLoadingEventTypes || (!editing && !eventTypesError)));
|
|
62
|
+
const readError = schedulesError ?? (isEdit ? eventTypesError : null);
|
|
63
|
+
let body;
|
|
64
|
+
if (isLoading) {
|
|
65
|
+
body = (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Loading\u2026" });
|
|
66
|
+
}
|
|
67
|
+
else if (readError) {
|
|
68
|
+
body = ((0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "That could not be loaded just now. Close this and try again." }));
|
|
69
|
+
}
|
|
70
|
+
else if (isUnrepresentableHost) {
|
|
71
|
+
body = ((0, jsx_runtime_1.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." }));
|
|
72
|
+
}
|
|
73
|
+
else if (schedules.length === 0) {
|
|
74
|
+
body = ((0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "Set up availability first - an appointment type needs working hours to book against." }));
|
|
75
|
+
}
|
|
76
|
+
else if (isEdit && !editing) {
|
|
77
|
+
/*
|
|
78
|
+
* The list arrived and does not hold this id - deleted, or never visible
|
|
79
|
+
* to this caller. Distinct from the load above, which is why that one
|
|
80
|
+
* cannot be allowed to borrow these words either.
|
|
81
|
+
*/
|
|
82
|
+
body = (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-muted", children: "That appointment type no longer exists." });
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
body = ((0, jsx_runtime_1.jsx)(EventTypeFields, { editing: editing, schedules: schedules, members: members, onCancel: () => onOpenChange(false), onSaved: (saved) => {
|
|
86
|
+
onSaved?.(saved);
|
|
87
|
+
onOpenChange(false);
|
|
88
|
+
} }, editing?.id ?? "create"));
|
|
89
|
+
}
|
|
90
|
+
return ((0, jsx_runtime_1.jsx)(react_2.Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: (0, jsx_runtime_1.jsx)(react_2.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Container, { children: (0, jsx_runtime_1.jsxs)(react_2.Modal.Dialog, { className: "sm:max-w-140", children: [(0, jsx_runtime_1.jsx)(react_2.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_2.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_2.Modal.Heading, { children: isEdit ? "Edit appointment type" : "New appointment type" }) }), (0, jsx_runtime_1.jsx)(react_2.Modal.Body, { children: body })] }) }) }) }));
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The form itself, mounted fresh (via the parent's `key`) for each distinct
|
|
94
|
+
* record it edits - or once for create. That remount is what initializes
|
|
95
|
+
* every field straight from `editing` with a plain `useState(initial)`
|
|
96
|
+
* rather than a `useEffect` copying it in after the fact: `editing` arrives
|
|
97
|
+
* asynchronously (`useManageableEventTypes` is a bridge read), and the
|
|
98
|
+
* parent already withholds this component until it has landed, so there is
|
|
99
|
+
* no later moment for a field to overwrite a half-typed edit.
|
|
100
|
+
*/
|
|
101
|
+
function EventTypeFields({ editing, schedules, members, onCancel, onSaved, }) {
|
|
102
|
+
const isEdit = editing !== null;
|
|
103
|
+
const { session } = (0, provider_js_1.useConnect)();
|
|
104
|
+
const defaultHostId = (0, react_1.useMemo)(() => {
|
|
105
|
+
const self = members.find((member) => member.userId === session?.user?.userId);
|
|
106
|
+
return self?.memberId ?? members[0]?.memberId ?? "";
|
|
107
|
+
}, [members, session?.user?.userId]);
|
|
108
|
+
const [name, setName] = (0, react_1.useState)(editing?.name ?? "");
|
|
109
|
+
const [description, setDescription] = (0, react_1.useState)(editing?.description ?? "");
|
|
110
|
+
const [durationMinutes, setDurationMinutes] = (0, react_1.useState)(editing?.durationMinutes ?? 30);
|
|
111
|
+
const [bufferBefore, setBufferBefore] = (0, react_1.useState)(editing?.bufferBeforeMinutes ?? 5);
|
|
112
|
+
const [bufferAfter, setBufferAfter] = (0, react_1.useState)(editing?.bufferAfterMinutes ?? 5);
|
|
113
|
+
const [scheduleId, setScheduleId] = (0, react_1.useState)(editing?.scheduleId ?? "");
|
|
114
|
+
const [hostMemberId, setHostMemberId] = (0, react_1.useState)(editing?.host.kind === "member" ? editing.host.memberId : "");
|
|
115
|
+
const [locationKind, setLocationKind] = (0, react_1.useState)(editing?.location.kind ?? "phone");
|
|
116
|
+
const [locationValue, setLocationValue] = (0, react_1.useState)(editing?.location.value ?? "");
|
|
117
|
+
const effectiveScheduleId = scheduleId || schedules[0]?.id || "";
|
|
118
|
+
const effectiveHostMemberId = hostMemberId || defaultHostId;
|
|
119
|
+
const { createEventType, isPending: isCreating, error: createError, clearError: clearCreateError } = (0, use_scheduling_js_1.useCreateEventType)();
|
|
120
|
+
const { updateEventType, isPending: isUpdating, error: updateError, clearError: clearUpdateError } = (0, use_scheduling_js_1.useUpdateEventType)();
|
|
121
|
+
const isPending = isCreating || isUpdating;
|
|
122
|
+
const error = isEdit ? updateError : createError;
|
|
123
|
+
const clearError = isEdit ? clearUpdateError : clearCreateError;
|
|
124
|
+
const isFormValid = name.trim().length > 0 &&
|
|
125
|
+
durationMinutes >= 5 &&
|
|
126
|
+
durationMinutes <= 480 &&
|
|
127
|
+
effectiveScheduleId.length > 0 &&
|
|
128
|
+
effectiveHostMemberId.length > 0 &&
|
|
129
|
+
!isPending;
|
|
130
|
+
const handleSubmit = async (e) => {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
if (!isFormValid)
|
|
133
|
+
return;
|
|
134
|
+
clearError();
|
|
135
|
+
const trimmedDescription = description.trim();
|
|
136
|
+
const shared = {
|
|
137
|
+
name: name.trim(),
|
|
138
|
+
description: trimmedDescription.length > 0 ? trimmedDescription : null,
|
|
139
|
+
durationMinutes,
|
|
140
|
+
bufferBeforeMinutes: bufferBefore,
|
|
141
|
+
bufferAfterMinutes: bufferAfter,
|
|
142
|
+
hostMemberId: effectiveHostMemberId,
|
|
143
|
+
scheduleId: effectiveScheduleId,
|
|
144
|
+
location: {
|
|
145
|
+
kind: locationKind,
|
|
146
|
+
value: locationValue.trim().length > 0 ? locationValue.trim() : null,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
const saved = isEdit
|
|
150
|
+
? await updateEventType({ eventTypeId: editing.id, ...shared })
|
|
151
|
+
: await createEventType(shared);
|
|
152
|
+
if (saved)
|
|
153
|
+
onSaved(saved);
|
|
154
|
+
};
|
|
155
|
+
return ((0, jsx_runtime_1.jsxs)("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { isRequired: true, variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Name" }), (0, jsx_runtime_1.jsx)(react_2.Input, { placeholder: "Consultation", value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), (0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Description" }), (0, jsx_runtime_1.jsx)(react_2.TextArea, { placeholder: "What this appointment is for", value: description, onChange: (e) => setDescription(e.currentTarget.value), disabled: isPending, rows: 2 })] }), (0, jsx_runtime_1.jsxs)("div", { className: "grid gap-4 sm:grid-cols-3", children: [(0, jsx_runtime_1.jsxs)(react_2.TextField, { isRequired: true, variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Length (min)" }), (0, jsx_runtime_1.jsx)(react_2.Input, { type: "number", value: String(durationMinutes), onChange: (e) => setDurationMinutes(Number(e.currentTarget.value)), disabled: isPending })] }), (0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Gap before" }), (0, jsx_runtime_1.jsx)(react_2.Input, { type: "number", value: String(bufferBefore), onChange: (e) => setBufferBefore(Number(e.currentTarget.value)), disabled: isPending })] }), (0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Gap after" }), (0, jsx_runtime_1.jsx)(react_2.Input, { type: "number", value: String(bufferAfter), onChange: (e) => setBufferAfter(Number(e.currentTarget.value)), disabled: isPending })] })] }), schedules.length > 1 && ((0, jsx_runtime_1.jsxs)(react_2.Select, { variant: "secondary", value: effectiveScheduleId, onChange: (value) => setScheduleId(String(value)), isDisabled: isPending, children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Availability" }), (0, jsx_runtime_1.jsxs)(react_2.Select.Trigger, { children: [(0, jsx_runtime_1.jsx)(react_2.Select.Value, {}), (0, jsx_runtime_1.jsx)(react_2.Select.Indicator, {})] }), (0, jsx_runtime_1.jsx)(react_2.Select.Popover, { children: (0, jsx_runtime_1.jsx)(react_2.ListBox, { children: schedules.map((schedule) => ((0, jsx_runtime_1.jsxs)(react_2.ListBox.Item, { id: schedule.id, textValue: schedule.name, children: [schedule.name, (0, jsx_runtime_1.jsx)(react_2.ListBox.ItemIndicator, {})] }, schedule.id))) }) })] })), members.length > 1 && ((0, jsx_runtime_1.jsxs)(react_2.Select, { variant: "secondary", value: effectiveHostMemberId, onChange: (value) => setHostMemberId(String(value)), isDisabled: isPending, children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Who hosts it" }), (0, jsx_runtime_1.jsxs)(react_2.Select.Trigger, { children: [(0, jsx_runtime_1.jsx)(react_2.Select.Value, {}), (0, jsx_runtime_1.jsx)(react_2.Select.Indicator, {})] }), (0, jsx_runtime_1.jsx)(react_2.Select.Popover, { children: (0, jsx_runtime_1.jsx)(react_2.ListBox, { children: members.map((member) => ((0, jsx_runtime_1.jsxs)(react_2.ListBox.Item, { id: member.memberId, textValue: member.displayName ?? member.email, children: [member.displayName ?? member.email, (0, jsx_runtime_1.jsx)(react_2.ListBox.ItemIndicator, {})] }, member.memberId))) }) })] })), (0, jsx_runtime_1.jsxs)("div", { className: "grid gap-4 sm:grid-cols-2", children: [(0, jsx_runtime_1.jsxs)(react_2.Select, { variant: "secondary", value: locationKind, onChange: (value) => setLocationKind(value), isDisabled: isPending, children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Where" }), (0, jsx_runtime_1.jsxs)(react_2.Select.Trigger, { children: [(0, jsx_runtime_1.jsx)(react_2.Select.Value, {}), (0, jsx_runtime_1.jsx)(react_2.Select.Indicator, {})] }), (0, jsx_runtime_1.jsx)(react_2.Select.Popover, { children: (0, jsx_runtime_1.jsx)(react_2.ListBox, { children: LOCATION_KINDS.map((kind) => ((0, jsx_runtime_1.jsxs)(react_2.ListBox.Item, { id: kind, textValue: LOCATION_LABELS[kind], children: [LOCATION_LABELS[kind], (0, jsx_runtime_1.jsx)(react_2.ListBox.ItemIndicator, {})] }, kind))) }) })] }), (0, jsx_runtime_1.jsxs)(react_2.TextField, { variant: "secondary", children: [(0, jsx_runtime_1.jsx)(react_2.Label, { children: "Details" }), (0, jsx_runtime_1.jsx)(react_2.TextArea, { placeholder: locationKind === "address" ? "Where to come" : "Left blank is fine", value: locationValue, onChange: (e) => setLocationValue(e.currentTarget.value), disabled: isPending, rows: 2 })] })] }), error && (0, jsx_runtime_1.jsx)("p", { className: "text-sm text-danger", children: error }), (0, jsx_runtime_1.jsxs)("div", { className: "flex justify-end gap-2", children: [(0, jsx_runtime_1.jsx)(react_2.Button, { type: "button", variant: "secondary", onPress: onCancel, isDisabled: isPending, children: "Cancel" }), (0, jsx_runtime_1.jsx)(react_2.Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Saving..." : isEdit ? "Save changes" : "Create" })] })] }));
|
|
156
|
+
}
|
|
@@ -13,6 +13,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
13
13
|
const react_1 = require("@heroui/react");
|
|
14
14
|
const lucide_react_1 = require("lucide-react");
|
|
15
15
|
const provider_js_1 = require("../provider.js");
|
|
16
|
+
const portal_links_js_1 = require("../portal-links.js");
|
|
16
17
|
const mark_js_1 = require("../mark.js");
|
|
17
18
|
const identity_js_1 = require("./identity.js");
|
|
18
19
|
const account_sessions_js_1 = require("./account-sessions.js");
|
|
@@ -28,28 +29,6 @@ const THEME_OPTIONS = [
|
|
|
28
29
|
function openExternal(url) {
|
|
29
30
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
30
31
|
}
|
|
31
|
-
/**
|
|
32
|
-
* Connect's profile page, with the context it needs to offer a way back here.
|
|
33
|
-
*
|
|
34
|
-
* Connect answers `/profile` for any signed-in user, so the bare URL works on
|
|
35
|
-
* its own - these two params only add the "Back to <your app>" banner. It
|
|
36
|
-
* validates `return_to` against this `client_id`'s registered redirect
|
|
37
|
-
* origins and silently drops the banner if they don't match, so a wrong or
|
|
38
|
-
* stale `appUrl`/`clientId` pairing degrades to what it did before rather
|
|
39
|
-
* than erroring.
|
|
40
|
-
*
|
|
41
|
-
* `window.location.href` rather than a configured home URL: the point is to
|
|
42
|
-
* come back to the page they left, and only the browser knows which one that
|
|
43
|
-
* is.
|
|
44
|
-
*/
|
|
45
|
-
function profileUrl(appUrl, clientId) {
|
|
46
|
-
const url = new URL("/profile", appUrl);
|
|
47
|
-
url.searchParams.set("client_id", clientId);
|
|
48
|
-
if (typeof window !== "undefined") {
|
|
49
|
-
url.searchParams.set("return_to", window.location.href);
|
|
50
|
-
}
|
|
51
|
-
return url.toString();
|
|
52
|
-
}
|
|
53
32
|
/**
|
|
54
33
|
* The member's photo, with their initials underneath it.
|
|
55
34
|
*
|
|
@@ -150,7 +129,7 @@ function ProfileSurface({ onSignOut, theme, onThemeChange, }) {
|
|
|
150
129
|
const label = user.displayName ?? user.email ?? "Account";
|
|
151
130
|
const role = session?.role ?? null;
|
|
152
131
|
const planChip = subscriptionChip(session?.subscription ?? null);
|
|
153
|
-
return ((0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { size: "md", scroll: "inside", children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { children: [(0, jsx_runtime_1.jsx)(react_1.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Heading, { children: "Account" }) }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { className: "flex flex-col gap-5", children: [(0, jsx_runtime_1.jsxs)("div", { className: `flex items-start gap-3 rounded-xl border border-border ${surfaces_js_1.PANEL_FILL} p-4`, children: [(0, jsx_runtime_1.jsx)(UserAvatar, { user: user, size: "lg" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && (0, jsx_runtime_1.jsx)("p", { className: "truncate text-xs text-muted", children: user.email }), (0, jsx_runtime_1.jsx)("p", { className: "truncate text-xs text-muted", children: user.workspaceName }), (role || planChip || !user.emailVerified) && ((0, jsx_runtime_1.jsxs)("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && ((0, jsx_runtime_1.jsx)(react_1.Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && ((0, jsx_runtime_1.jsx)(react_1.Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-3 gap-2", children: [(0, jsx_runtime_1.jsx)(DestinationTile, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.UserRound, { className: surfaces_js_1.ICON, "aria-hidden": "true" }), label: "Profile", url:
|
|
132
|
+
return ((0, jsx_runtime_1.jsx)(react_1.Modal.Backdrop, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Container, { size: "md", scroll: "inside", children: (0, jsx_runtime_1.jsxs)(react_1.Modal.Dialog, { children: [(0, jsx_runtime_1.jsx)(react_1.Modal.CloseTrigger, {}), (0, jsx_runtime_1.jsx)(react_1.Modal.Header, { children: (0, jsx_runtime_1.jsx)(react_1.Modal.Heading, { children: "Account" }) }), (0, jsx_runtime_1.jsxs)(react_1.Modal.Body, { className: "flex flex-col gap-5", children: [(0, jsx_runtime_1.jsxs)("div", { className: `flex items-start gap-3 rounded-xl border border-border ${surfaces_js_1.PANEL_FILL} p-4`, children: [(0, jsx_runtime_1.jsx)(UserAvatar, { user: user, size: "lg" }), (0, jsx_runtime_1.jsxs)("div", { className: "flex min-w-0 flex-1 flex-col gap-0.5", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm font-semibold text-foreground", children: label }), user.email && (0, jsx_runtime_1.jsx)("p", { className: "truncate text-xs text-muted", children: user.email }), (0, jsx_runtime_1.jsx)("p", { className: "truncate text-xs text-muted", children: user.workspaceName }), (role || planChip || !user.emailVerified) && ((0, jsx_runtime_1.jsxs)("div", { className: "mt-1.5 flex flex-wrap items-center gap-1.5", children: [role && ((0, jsx_runtime_1.jsx)(react_1.Chip, { size: "sm", variant: "soft", color: "accent", children: humanizeRole(role) })), planChip, !user.emailVerified && ((0, jsx_runtime_1.jsx)(react_1.Chip, { size: "sm", variant: "soft", color: "warning", children: "Email unverified" }))] }))] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "grid grid-cols-3 gap-2", children: [(0, jsx_runtime_1.jsx)(DestinationTile, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.UserRound, { className: surfaces_js_1.ICON, "aria-hidden": "true" }), label: "Profile", url: (0, portal_links_js_1.connectPortalUrl)(appUrl, clientId, "/profile") }), (0, jsx_runtime_1.jsx)(DestinationTile, { icon: (0, jsx_runtime_1.jsx)(lucide_react_1.CreditCard, { className: surfaces_js_1.ICON, "aria-hidden": "true" }), label: "Billing", url: `${appUrl}/billing` }), (0, jsx_runtime_1.jsx)(DestinationTile, { icon: (0, jsx_runtime_1.jsx)(mark_js_1.ConnectMark, { className: surfaces_js_1.ICON }), label: "Connect", url: appUrl })] }), theme && onThemeChange && ((0, jsx_runtime_1.jsxs)("div", { className: "flex flex-col gap-2", children: [(0, jsx_runtime_1.jsx)("p", { className: "text-sm font-medium text-foreground", children: "Appearance" }), (0, jsx_runtime_1.jsx)(react_1.ToggleButtonGroup, { fullWidth: true, "aria-label": "Theme", selectionMode: "single", disallowEmptySelection: true, selectedKeys: [theme], onSelectionChange: (keys) => {
|
|
154
133
|
const [next] = [...keys];
|
|
155
134
|
if (typeof next === "string")
|
|
156
135
|
onThemeChange(next);
|
|
@@ -260,7 +239,7 @@ function ConnectProfileButton({ className }) {
|
|
|
260
239
|
const { session, appUrl, clientId } = (0, provider_js_1.useConnect)();
|
|
261
240
|
if (!session?.user)
|
|
262
241
|
return null;
|
|
263
|
-
return ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", className: className, onPress: () => openExternal(
|
|
242
|
+
return ((0, jsx_runtime_1.jsxs)(react_1.Button, { variant: "tertiary", className: className, onPress: () => openExternal((0, portal_links_js_1.connectPortalUrl)(appUrl, clientId, "/profile")), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.UserRound, { className: "size-4 shrink-0" }), "Profile", (0, jsx_runtime_1.jsx)(lucide_react_1.ExternalLink, { className: "size-3.5 shrink-0 text-muted" })] }));
|
|
264
243
|
}
|
|
265
244
|
/** A plain "Open Connect" button, for a footer or a help menu. */
|
|
266
245
|
function ConnectHomeButton({ className }) {
|
|
@@ -7,6 +7,7 @@ const react_1 = require("react");
|
|
|
7
7
|
const react_2 = require("@heroui/react");
|
|
8
8
|
const lucide_react_1 = require("lucide-react");
|
|
9
9
|
const provider_js_1 = require("../provider.js");
|
|
10
|
+
const create_workspace_form_js_1 = require("./create-workspace-form.js");
|
|
10
11
|
const motion_js_1 = require("./motion.js");
|
|
11
12
|
const shell_context_js_1 = require("./shell-context.js");
|
|
12
13
|
/** `Acme Logistics` -> `AL`. The collapsed rail's whole label for a workspace. */
|
|
@@ -14,9 +15,6 @@ function workspaceInitials(name) {
|
|
|
14
15
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
15
16
|
return (parts.slice(0, 2).map((part) => part[0]).join("") || "?").toUpperCase();
|
|
16
17
|
}
|
|
17
|
-
function openExternal(url) {
|
|
18
|
-
window.open(url, "_blank", "noopener,noreferrer");
|
|
19
|
-
}
|
|
20
18
|
/**
|
|
21
19
|
* Switches which workspace this application is looking at.
|
|
22
20
|
*
|
|
@@ -31,9 +29,9 @@ function openExternal(url) {
|
|
|
31
29
|
* a workspace is *created*, so hiding it at one workspace would guarantee
|
|
32
30
|
* nobody ever reached two. `alwaysShow` is the escape hatch for a partner
|
|
33
31
|
* application that wants that same discoverability without becoming Connect:
|
|
34
|
-
* it keeps the dropdown open below two workspaces and adds a row that
|
|
35
|
-
*
|
|
36
|
-
* that
|
|
32
|
+
* it keeps the dropdown open below two workspaces and adds a row that opens
|
|
33
|
+
* `<ConnectCreateWorkspaceModal>` inline, auto-switching into the result when
|
|
34
|
+
* that is possible - see `handleCreated` below.
|
|
37
35
|
*
|
|
38
36
|
* **Authorization is the data, not a prop.** The list comes from the caller's
|
|
39
37
|
* own session (`GET /workspaces` takes no user id), so a workspace the user
|
|
@@ -53,12 +51,13 @@ function openExternal(url) {
|
|
|
53
51
|
* `onSwitched` defaults to.
|
|
54
52
|
*/
|
|
55
53
|
function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, alwaysShow = false, }) {
|
|
56
|
-
const { mutateResource
|
|
54
|
+
const { mutateResource } = (0, provider_js_1.useConnect)();
|
|
57
55
|
const { collapsed } = (0, shell_context_js_1.useConnectShell)();
|
|
58
56
|
const user = (0, provider_js_1.useConnectUser)();
|
|
59
|
-
const { data, isLoading } = (0, provider_js_1.useConnectWorkspaces)();
|
|
57
|
+
const { data, isLoading, refetch } = (0, provider_js_1.useConnectWorkspaces)();
|
|
60
58
|
const [switching, setSwitching] = (0, react_1.useState)(null);
|
|
61
59
|
const [error, setError] = (0, react_1.useState)(null);
|
|
60
|
+
const [isCreateOpen, setIsCreateOpen] = (0, react_1.useState)(false);
|
|
62
61
|
const workspaces = data?.workspaces ?? [];
|
|
63
62
|
// Nothing loaded yet. Deliberately renders `null` rather than a skeleton:
|
|
64
63
|
// this sits in persistent chrome, and a placeholder that resolves to
|
|
@@ -93,11 +92,45 @@ function ConnectWorkspaceSwitcher({ onSwitched, row = true, className, alwaysSho
|
|
|
93
92
|
setSwitching(null);
|
|
94
93
|
}
|
|
95
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* After a successful create: bring the new row into this list (it is
|
|
97
|
+
* created, not switched to - see `ConnectCreateWorkspaceModal`'s doc
|
|
98
|
+
* comment), then move into it when that is actually possible.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately **not** `handleSwitch` - that function's `selectable()`
|
|
101
|
+
* guard requires `subscribed`, which a workspace this young never is, so
|
|
102
|
+
* calling it here would silently do nothing. `SwitchWorkspaceUseCase`
|
|
103
|
+
* itself has no subscription gate, only membership/MFA/audience, so this
|
|
104
|
+
* calls the bridge directly and only withholds on `inAudience` - a
|
|
105
|
+
* workspace of a type this application is not sold to, which `switch`
|
|
106
|
+
* really does refuse.
|
|
107
|
+
*/
|
|
108
|
+
const handleCreated = async (workspace) => {
|
|
109
|
+
setIsCreateOpen(false);
|
|
110
|
+
refetch();
|
|
111
|
+
if (!workspace.inAudience)
|
|
112
|
+
return;
|
|
113
|
+
setSwitching(workspace.workspaceId);
|
|
114
|
+
setError(null);
|
|
115
|
+
try {
|
|
116
|
+
await mutateResource("/workspaces/switch", { workspaceId: workspace.workspaceId });
|
|
117
|
+
if (onSwitched) {
|
|
118
|
+
onSwitched(workspace);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
window.location.reload();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (cause) {
|
|
125
|
+
setError(cause instanceof Error ? cause.message : "Workspace created, but could not switch to it");
|
|
126
|
+
setSwitching(null);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
96
129
|
return ((0, jsx_runtime_1.jsxs)("div", { className: row ? "" : className, children: [(0, jsx_runtime_1.jsxs)(react_2.Dropdown, { children: [row ? (
|
|
97
130
|
// A 32px tile rather than a 20px glyph, so the initials land on the
|
|
98
131
|
// same optical centre the avatar below them uses - see
|
|
99
132
|
// `SIDEBAR_ROW_TILE`. Collapsed, this tile *is* the switcher.
|
|
100
|
-
(0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "tertiary", "aria-label": `Workspace: ${label}. Switch workspace`, className: (0, shell_context_js_1.sidebarRowClass)({ tile: true, className }), children: [(0, jsx_runtime_1.jsx)("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-xs font-semibold text-accent", children: workspaceInitials(label) }), (0, jsx_runtime_1.jsx)(shell_context_js_1.SidebarLabel, { className: "text-sm font-medium text-foreground", children: label }), (0, jsx_runtime_1.jsx)(motion_js_1.m.span, { className: "shrink-0 text-muted", animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "size-4" }) })] })) : ((0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "secondary", size: "sm", className: "w-full justify-between", children: [(0, jsx_runtime_1.jsx)("span", { className: "truncate", children: label }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" })] })), (0, jsx_runtime_1.jsx)(react_2.Dropdown.Popover, { children: (0, jsx_runtime_1.jsxs)(react_2.ListBox, { children: [workspaces.map((workspace) => ((0, jsx_runtime_1.jsxs)(react_2.ListBox.Item, { id: workspace.workspaceId, textValue: workspace.name, isDisabled: !selectable(workspace) || switching !== null, onAction: () => void handleSwitch(workspace), children: [(0, jsx_runtime_1.jsxs)("div", { className: "min-w-0", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm", children: workspace.name }), selectable(workspace) ? null : ((0, jsx_runtime_1.jsx)("p", { className: "truncate text-xs text-muted", children: reasonUnavailable(workspace) }))] }), (0, jsx_runtime_1.jsx)(react_2.ListBox.ItemIndicator, {})] }, workspace.workspaceId))), alwaysShow ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [workspaces.length > 0 && (0, jsx_runtime_1.jsx)(react_2.Separator, {}), (0, jsx_runtime_1.jsxs)(react_2.ListBox.Item, { id: "__create_workspace", textValue: "Create workspace", onAction: () =>
|
|
133
|
+
(0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "tertiary", "aria-label": `Workspace: ${label}. Switch workspace`, className: (0, shell_context_js_1.sidebarRowClass)({ tile: true, className }), children: [(0, jsx_runtime_1.jsx)("span", { className: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-xs font-semibold text-accent", children: workspaceInitials(label) }), (0, jsx_runtime_1.jsx)(shell_context_js_1.SidebarLabel, { className: "text-sm font-medium text-foreground", children: label }), (0, jsx_runtime_1.jsx)(motion_js_1.m.span, { className: "shrink-0 text-muted", animate: { opacity: collapsed ? 0 : 1 }, transition: collapsed ? { duration: 0.1 } : { duration: 0.18, delay: 0.1 }, "aria-hidden": true, children: (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "size-4" }) })] })) : ((0, jsx_runtime_1.jsxs)(react_2.Button, { variant: "secondary", size: "sm", className: "w-full justify-between", children: [(0, jsx_runtime_1.jsx)("span", { className: "truncate", children: label }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronsUpDown, { className: "size-4 shrink-0 text-muted", "aria-hidden": "true" })] })), (0, jsx_runtime_1.jsx)(react_2.Dropdown.Popover, { children: (0, jsx_runtime_1.jsxs)(react_2.ListBox, { children: [workspaces.map((workspace) => ((0, jsx_runtime_1.jsxs)(react_2.ListBox.Item, { id: workspace.workspaceId, textValue: workspace.name, isDisabled: !selectable(workspace) || switching !== null, onAction: () => void handleSwitch(workspace), children: [(0, jsx_runtime_1.jsxs)("div", { className: "min-w-0", children: [(0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm", children: workspace.name }), selectable(workspace) ? null : ((0, jsx_runtime_1.jsx)("p", { className: "truncate text-xs text-muted", children: reasonUnavailable(workspace) }))] }), (0, jsx_runtime_1.jsx)(react_2.ListBox.ItemIndicator, {})] }, workspace.workspaceId))), alwaysShow ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [workspaces.length > 0 && (0, jsx_runtime_1.jsx)(react_2.Separator, {}), (0, jsx_runtime_1.jsxs)(react_2.ListBox.Item, { id: "__create_workspace", textValue: "Create workspace", onAction: () => setIsCreateOpen(true), children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Plus, { className: "size-4 shrink-0 text-muted", "aria-hidden": true }), (0, jsx_runtime_1.jsx)("p", { className: "truncate text-sm", children: "Create workspace" })] })] })) : null] }) })] }), error && !collapsed ? (0, jsx_runtime_1.jsx)("p", { className: "mt-1 px-2 text-xs text-danger", children: error }) : null, alwaysShow ? ((0, jsx_runtime_1.jsx)(create_workspace_form_js_1.ConnectCreateWorkspaceModal, { isOpen: isCreateOpen, onOpenChange: setIsCreateOpen, onCreated: (workspace) => void handleCreated(workspace) })) : null] }));
|
|
101
134
|
}
|
|
102
135
|
/** Belongs to the user, and usable here. The same two facts `switch` enforces. */
|
|
103
136
|
function selectable(workspace) {
|