@cosmoops/connect-sdk 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -2
- package/dist/cjs/client/components/app-shell.js +1 -1
- package/dist/cjs/client/components/availability-form.js +75 -0
- package/dist/cjs/client/components/event-type-form.js +82 -0
- package/dist/cjs/client/components/plan-pricing.js +9 -4
- package/dist/cjs/client/components/plans.js +9 -1
- package/dist/cjs/client/components/user-menu.js +3 -24
- package/dist/cjs/client/components/workspace-switcher.js +23 -13
- package/dist/cjs/client/index.js +21 -3
- package/dist/cjs/client/portal-links.js +38 -0
- package/dist/cjs/client/use-scheduling.js +67 -8
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +78 -0
- package/dist/cjs/server/http.js +13 -1
- package/dist/cjs/server/resources/ai.js +6 -0
- package/dist/cjs/server/resources/scheduling.js +62 -0
- package/dist/cjs/server/resources/visitor.js +6 -0
- package/dist/esm/client/components/app-shell.js +1 -1
- package/dist/esm/client/components/availability-form.js +72 -0
- package/dist/esm/client/components/event-type-form.js +79 -0
- package/dist/esm/client/components/plan-pricing.js +9 -4
- package/dist/esm/client/components/plans.js +9 -1
- package/dist/esm/client/components/user-menu.js +3 -24
- package/dist/esm/client/components/workspace-switcher.js +26 -16
- package/dist/esm/client/index.js +12 -1
- package/dist/esm/client/portal-links.js +34 -0
- package/dist/esm/client/use-scheduling.js +64 -8
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +78 -0
- package/dist/esm/server/http.js +12 -1
- package/dist/esm/server/resources/ai.js +7 -1
- package/dist/esm/server/resources/scheduling.js +62 -0
- package/dist/esm/server/resources/visitor.js +7 -1
- package/dist/types/client/components/app-shell.d.ts +7 -1
- package/dist/types/client/components/availability-form.d.ts +22 -0
- package/dist/types/client/components/event-type-form.d.ts +22 -0
- package/dist/types/client/components/plan-pricing.d.ts +10 -0
- package/dist/types/client/components/workspace-switcher.d.ts +25 -6
- package/dist/types/client/index.d.ts +6 -1
- package/dist/types/client/portal-links.d.ts +22 -0
- package/dist/types/client/use-scheduling.d.ts +49 -1
- 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 +67 -1
- package/dist/types/types.d.ts +27 -0
- package/package.json +1 -1
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
exports.useBookableEventTypes = useBookableEventTypes;
|
|
5
|
+
exports.useAvailabilitySchedules = useAvailabilitySchedules;
|
|
6
|
+
exports.useCreateAvailabilitySchedule = useCreateAvailabilitySchedule;
|
|
7
|
+
exports.useCreateEventType = useCreateEventType;
|
|
5
8
|
exports.useAvailableSlots = useAvailableSlots;
|
|
6
9
|
exports.useBookings = useBookings;
|
|
7
10
|
exports.useBookingActions = useBookingActions;
|
|
@@ -21,6 +24,48 @@ function useBookableEventTypes() {
|
|
|
21
24
|
const { data, isLoading, error } = (0, provider_js_1.useConnectResource)("/scheduling/event-types");
|
|
22
25
|
return { eventTypes: data?.eventTypes ?? [], isLoading, error };
|
|
23
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The working-hours templates an appointment type can point at.
|
|
29
|
+
*
|
|
30
|
+
* Read this before offering a "set up availability" step - a workspace that
|
|
31
|
+
* already has one does not need a second "Working hours" from your UI.
|
|
32
|
+
*/
|
|
33
|
+
function useAvailabilitySchedules() {
|
|
34
|
+
const { data, isLoading, error } = (0, provider_js_1.useConnectResource)("/scheduling/schedules");
|
|
35
|
+
return { schedules: data?.schedules ?? [], isLoading, error };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Gives a workspace its first working hours.
|
|
39
|
+
*
|
|
40
|
+
* **Not optimistic** - same posture as `useBookingActions`: this is one of
|
|
41
|
+
* the two writes a host's first-run setup makes, and a modal that closes on
|
|
42
|
+
* a failed create is worse than one that stays open with the reason shown.
|
|
43
|
+
*/
|
|
44
|
+
function useCreateAvailabilitySchedule() {
|
|
45
|
+
const { run, isPending, error, clearError } = useResourceAction();
|
|
46
|
+
return {
|
|
47
|
+
isPending,
|
|
48
|
+
error,
|
|
49
|
+
clearError,
|
|
50
|
+
createSchedule: (input) => run("/scheduling/schedules", input),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Creates the first thing a workspace can be booked for.
|
|
55
|
+
*
|
|
56
|
+
* Host is a specific member, never a team - there is no groups read in this
|
|
57
|
+
* bridge, so a round-robin team host stays a Console-only capability
|
|
58
|
+
* reachable by following "Manage in Connect".
|
|
59
|
+
*/
|
|
60
|
+
function useCreateEventType() {
|
|
61
|
+
const { run, isPending, error, clearError } = useResourceAction();
|
|
62
|
+
return {
|
|
63
|
+
isPending,
|
|
64
|
+
error,
|
|
65
|
+
clearError,
|
|
66
|
+
createEventType: (input) => run("/scheduling/event-types", input),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
24
69
|
/**
|
|
25
70
|
* Free times for one appointment type.
|
|
26
71
|
*
|
|
@@ -74,15 +119,16 @@ function useBookings(query) {
|
|
|
74
119
|
};
|
|
75
120
|
}
|
|
76
121
|
/**
|
|
77
|
-
*
|
|
122
|
+
* The isPending/error bookkeeping every write in this file shares, factored
|
|
123
|
+
* out once `useCreateAvailabilitySchedule` and `useCreateEventType` needed
|
|
124
|
+
* the identical shape `useBookingActions` already had.
|
|
78
125
|
*
|
|
79
|
-
* **
|
|
80
|
-
* slot is
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* the two-phase hold exists to prevent.
|
|
126
|
+
* **Never optimistic**, and that is not an oversight for any caller of this:
|
|
127
|
+
* whether a slot is free, or a schedule name collides, is a question only the
|
|
128
|
+
* server can answer. Painting the result before it comes back is exactly the
|
|
129
|
+
* lie a two-phase hold (or a modal that closes early) would tell.
|
|
84
130
|
*/
|
|
85
|
-
function
|
|
131
|
+
function useResourceAction() {
|
|
86
132
|
const { mutateResource } = (0, provider_js_1.useConnect)();
|
|
87
133
|
const [isPending, setPending] = (0, react_1.useState)(false);
|
|
88
134
|
const [error, setError] = (0, react_1.useState)(null);
|
|
@@ -102,10 +148,23 @@ function useBookingActions() {
|
|
|
102
148
|
setPending(false);
|
|
103
149
|
}
|
|
104
150
|
}, [mutateResource]);
|
|
151
|
+
return { isPending, error, clearError: () => setError(null), run };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Book, move and cancel.
|
|
155
|
+
*
|
|
156
|
+
* **None of these is optimistic**, and that is not an oversight. Whether a
|
|
157
|
+
* slot is still free is a question only the server can answer, inside the
|
|
158
|
+
* transaction that decides it - two people can be holding the same minute
|
|
159
|
+
* right now. Painting a booking as made and withdrawing it is exactly the lie
|
|
160
|
+
* the two-phase hold exists to prevent.
|
|
161
|
+
*/
|
|
162
|
+
function useBookingActions() {
|
|
163
|
+
const { run, isPending, error, clearError } = useResourceAction();
|
|
105
164
|
return {
|
|
106
165
|
isPending,
|
|
107
166
|
error,
|
|
108
|
-
clearError
|
|
167
|
+
clearError,
|
|
109
168
|
createBooking: (input) => run("/scheduling/bookings", input),
|
|
110
169
|
rescheduleBooking: (input) => run("/scheduling/bookings/reschedule", input).then((result) => result?.booking ?? null),
|
|
111
170
|
cancelBooking: (input) => run("/scheduling/bookings/cancel", input),
|
|
@@ -853,6 +853,84 @@ function createConnectHandler(options) {
|
|
|
853
853
|
const asUser = await requireToken(request);
|
|
854
854
|
return json({ eventTypes: await client.scheduling.listEventTypes(workspaceId, { asUser }) });
|
|
855
855
|
},
|
|
856
|
+
/*
|
|
857
|
+
* Creates the first thing a workspace can be booked for - a browser-side
|
|
858
|
+
* counterpart to `POST /scheduling/event-types` above, for a host's own
|
|
859
|
+
* inline setup UI (`ConnectEventTypeFormModal`). Host is a specific
|
|
860
|
+
* member, never a team - there is no groups read in this bridge, so a
|
|
861
|
+
* round-robin team host stays a Console-only capability.
|
|
862
|
+
*/
|
|
863
|
+
"POST /scheduling/event-types": async (request) => {
|
|
864
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
865
|
+
const asUser = await requireToken(request);
|
|
866
|
+
const body = (await request.json().catch(() => null));
|
|
867
|
+
if (typeof body?.name !== "string" ||
|
|
868
|
+
typeof body.durationMinutes !== "number" ||
|
|
869
|
+
typeof body.hostMemberId !== "string" ||
|
|
870
|
+
typeof body.scheduleId !== "string" ||
|
|
871
|
+
typeof body.location?.kind !== "string") {
|
|
872
|
+
throw new errors_js_1.ConnectError({
|
|
873
|
+
status: 400,
|
|
874
|
+
code: "validation_error",
|
|
875
|
+
message: "name, durationMinutes, hostMemberId, scheduleId and location are required",
|
|
876
|
+
endpoint: "/scheduling/event-types",
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
return json(await client.scheduling.createEventType({
|
|
880
|
+
workspaceId,
|
|
881
|
+
name: body.name,
|
|
882
|
+
description: typeof body.description === "string" ? body.description : null,
|
|
883
|
+
durationMinutes: body.durationMinutes,
|
|
884
|
+
bufferBeforeMinutes: typeof body.bufferBeforeMinutes === "number" ? body.bufferBeforeMinutes : undefined,
|
|
885
|
+
bufferAfterMinutes: typeof body.bufferAfterMinutes === "number" ? body.bufferAfterMinutes : undefined,
|
|
886
|
+
hostMemberId: body.hostMemberId,
|
|
887
|
+
scheduleId: body.scheduleId,
|
|
888
|
+
location: {
|
|
889
|
+
kind: body.location.kind,
|
|
890
|
+
value: typeof body.location.value === "string" ? body.location.value : null,
|
|
891
|
+
},
|
|
892
|
+
publicBookingEnabled: typeof body.publicBookingEnabled === "boolean" ? body.publicBookingEnabled : undefined,
|
|
893
|
+
asUser,
|
|
894
|
+
}), 201);
|
|
895
|
+
},
|
|
896
|
+
/*
|
|
897
|
+
* The working-hours templates an appointment type can point at - read
|
|
898
|
+
* before a setup UI offers to create one, so it can tell "none yet" from
|
|
899
|
+
* "already set up".
|
|
900
|
+
*/
|
|
901
|
+
"GET /scheduling/schedules": async (request) => {
|
|
902
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
903
|
+
const asUser = await requireToken(request);
|
|
904
|
+
return json({ schedules: await client.scheduling.listSchedules(workspaceId, { asUser }) });
|
|
905
|
+
},
|
|
906
|
+
/**
|
|
907
|
+
* Gives a workspace its first working hours - the browser-side
|
|
908
|
+
* counterpart to `POST /scheduling/schedules` above, for
|
|
909
|
+
* `ConnectAvailabilityFormModal`.
|
|
910
|
+
*/
|
|
911
|
+
"POST /scheduling/schedules": async (request) => {
|
|
912
|
+
const { workspaceId } = await requireWorkspace(request);
|
|
913
|
+
const asUser = await requireToken(request);
|
|
914
|
+
const body = (await request.json().catch(() => null));
|
|
915
|
+
if (typeof body?.name !== "string" ||
|
|
916
|
+
typeof body.timezone !== "string" ||
|
|
917
|
+
typeof body.weeklyHours !== "object" ||
|
|
918
|
+
body.weeklyHours === null) {
|
|
919
|
+
throw new errors_js_1.ConnectError({
|
|
920
|
+
status: 400,
|
|
921
|
+
code: "validation_error",
|
|
922
|
+
message: "name, timezone and weeklyHours are required",
|
|
923
|
+
endpoint: "/scheduling/schedules",
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
return json(await client.scheduling.createSchedule({
|
|
927
|
+
workspaceId,
|
|
928
|
+
name: body.name,
|
|
929
|
+
timezone: body.timezone,
|
|
930
|
+
weeklyHours: body.weeklyHours,
|
|
931
|
+
asUser,
|
|
932
|
+
}), 201);
|
|
933
|
+
},
|
|
856
934
|
"GET /scheduling/slots": async (request) => {
|
|
857
935
|
const { workspaceId } = await requireWorkspace(request);
|
|
858
936
|
const asUser = await requireToken(request);
|
package/dist/cjs/server/http.js
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GENERATION_TIMEOUT_MS = void 0;
|
|
3
4
|
exports.pathSegment = pathSegment;
|
|
4
5
|
exports.request = request;
|
|
5
6
|
const errors_js_1 = require("../errors.js");
|
|
7
|
+
/**
|
|
8
|
+
* How long a *generating* endpoint may take to send its headers.
|
|
9
|
+
*
|
|
10
|
+
* One number, named once, and it is not a guess: it is what
|
|
11
|
+
* `POST /visitor/chat` and `POST /ai/chat` declare as their `maxDuration` on
|
|
12
|
+
* the far side. A client bound shorter than the route's own ceiling cannot be
|
|
13
|
+
* right - it can only abort work Connect is still allowed to be doing - and
|
|
14
|
+
* the two numbers drifting apart is exactly how a 20s client came to be
|
|
15
|
+
* fronting a 60s route.
|
|
16
|
+
*/
|
|
17
|
+
exports.GENERATION_TIMEOUT_MS = 60_000;
|
|
6
18
|
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
7
19
|
/**
|
|
8
20
|
* One id, safe to interpolate into a request path.
|
|
@@ -149,7 +161,7 @@ async function request(options, spec) {
|
|
|
149
161
|
// that has actually started to run as long as it needs. A caller that
|
|
150
162
|
// wants to bound the stream itself passes `spec.signal`.
|
|
151
163
|
const timeoutController = new AbortController();
|
|
152
|
-
const timer = setTimeout(() => timeoutController.abort(), options.timeoutMs);
|
|
164
|
+
const timer = setTimeout(() => timeoutController.abort(), spec.timeoutMs ?? options.timeoutMs);
|
|
153
165
|
const signal = spec.signal
|
|
154
166
|
? AbortSignal.any([spec.signal, timeoutController.signal])
|
|
155
167
|
: timeoutController.signal;
|
|
@@ -43,6 +43,12 @@ class AiResource {
|
|
|
43
43
|
...(input.screenContext ? { screenContext: input.screenContext } : {}),
|
|
44
44
|
},
|
|
45
45
|
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
46
|
+
/*
|
|
47
|
+
* Matches the route's own `maxDuration`. This endpoint does not send
|
|
48
|
+
* headers until the model's first token, so the default 20s bound was
|
|
49
|
+
* aborting any turn that opened with a tool call - see `RequestSpec.timeoutMs`.
|
|
50
|
+
*/
|
|
51
|
+
timeoutMs: http_js_1.GENERATION_TIMEOUT_MS,
|
|
46
52
|
raw: true,
|
|
47
53
|
signal: options.signal,
|
|
48
54
|
});
|
|
@@ -34,6 +34,68 @@ class SchedulingResource {
|
|
|
34
34
|
// `undefined` past every status check in between.
|
|
35
35
|
return result.eventTypes ?? [];
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The working-hours templates an appointment type can point at.
|
|
39
|
+
*
|
|
40
|
+
* Read this before offering to create one - a workspace that already has
|
|
41
|
+
* hours set does not need a second "Working hours" from your integration.
|
|
42
|
+
*/
|
|
43
|
+
async listSchedules(workspaceId, options = {}) {
|
|
44
|
+
const result = await (0, http_js_1.request)(this.transport, {
|
|
45
|
+
method: "GET",
|
|
46
|
+
path: "/scheduling/schedules",
|
|
47
|
+
query: { workspaceId },
|
|
48
|
+
headers: (0, acting_as_js_1.actingAs)(options.asUser),
|
|
49
|
+
});
|
|
50
|
+
return result.schedules ?? [];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Gives a workspace its first working hours.
|
|
54
|
+
*
|
|
55
|
+
* Narrower than what the workspace's own Calendar settings can do - no date
|
|
56
|
+
* overrides, and never owned by one person (`ownerMemberId` stays `null`
|
|
57
|
+
* server-side). Anything beyond that is a Console edit, not a second write
|
|
58
|
+
* path for your integration to maintain.
|
|
59
|
+
*/
|
|
60
|
+
createSchedule(input) {
|
|
61
|
+
return (0, http_js_1.request)(this.transport, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
path: "/scheduling/schedules",
|
|
64
|
+
json: {
|
|
65
|
+
workspaceId: input.workspaceId,
|
|
66
|
+
name: input.name,
|
|
67
|
+
timezone: input.timezone,
|
|
68
|
+
weeklyHours: input.weeklyHours,
|
|
69
|
+
},
|
|
70
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The first thing a workspace can be booked for.
|
|
75
|
+
*
|
|
76
|
+
* Needs a schedule - create or pick one with `listSchedules`/`createSchedule`
|
|
77
|
+
* first. The slug for a future public booking link is derived from `name`
|
|
78
|
+
* server-side; you cannot choose one.
|
|
79
|
+
*/
|
|
80
|
+
createEventType(input) {
|
|
81
|
+
return (0, http_js_1.request)(this.transport, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
path: "/scheduling/event-types",
|
|
84
|
+
json: {
|
|
85
|
+
workspaceId: input.workspaceId,
|
|
86
|
+
name: input.name,
|
|
87
|
+
description: input.description ?? null,
|
|
88
|
+
durationMinutes: input.durationMinutes,
|
|
89
|
+
bufferBeforeMinutes: input.bufferBeforeMinutes,
|
|
90
|
+
bufferAfterMinutes: input.bufferAfterMinutes,
|
|
91
|
+
hostMemberId: input.hostMemberId,
|
|
92
|
+
scheduleId: input.scheduleId,
|
|
93
|
+
location: input.location,
|
|
94
|
+
publicBookingEnabled: input.publicBookingEnabled ?? false,
|
|
95
|
+
},
|
|
96
|
+
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
37
99
|
/**
|
|
38
100
|
* Free times for one appointment type over one window.
|
|
39
101
|
*
|
|
@@ -100,6 +100,12 @@ class VisitorResource {
|
|
|
100
100
|
path: "/visitor/chat",
|
|
101
101
|
json: input,
|
|
102
102
|
headers: visitorHeaders(options),
|
|
103
|
+
/*
|
|
104
|
+
* Matches the route's own `maxDuration`. This endpoint does not send
|
|
105
|
+
* headers until the model's first token, so the default 20s bound was
|
|
106
|
+
* aborting any turn that opened with a tool call - see `RequestSpec.timeoutMs`.
|
|
107
|
+
*/
|
|
108
|
+
timeoutMs: http_js_1.GENERATION_TIMEOUT_MS,
|
|
103
109
|
raw: true,
|
|
104
110
|
signal: options.signal,
|
|
105
111
|
});
|
|
@@ -194,6 +194,6 @@ export function AppShell({ name, icon, homeHref = "/", nav, secondaryNav, active
|
|
|
194
194
|
onNavigate: closeMobile,
|
|
195
195
|
}), [renderLink, activePath, closeMobile]);
|
|
196
196
|
const accountRow = signedOut ? (showAuthButtons && (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(ConnectLoginButton, { size: "sm", fullWidth: true }), _jsx(ConnectRegisterButton, { size: "sm", fullWidth: true })] }))) : (_jsx(ConnectUserButton, { variant: "row", theme: theme, onThemeChange: onThemeChange }));
|
|
197
|
-
const railContent = (_jsxs(_Fragment, { children: [_jsx(ConnectSidebarHeader, { name: name, icon: icon, homeHref: homeHref }), workspaceSwitcher !== false && (_jsx("div", { className: "shrink-0 px-3 pb-1", children: _jsx(ConnectWorkspaceSwitcher, {}) })), _jsx(ConnectSidebarBody, { children: _jsx(ConnectSidebarNav, { nav: visibleNav }) }), (visibleSecondaryNav || sidebarFooter || showAppsGrid) && (_jsxs(ConnectSidebarFooter, { separated: false, children: [visibleSecondaryNav && _jsx(ConnectSidebarNav, { nav: visibleSecondaryNav }), sidebarFooter, showAppsGrid && _jsx(ConnectAppsGrid, { variant: "row" })] })), accountRow && _jsx(ConnectSidebarFooter, { children: accountRow })] }));
|
|
197
|
+
const railContent = (_jsxs(_Fragment, { children: [_jsx(ConnectSidebarHeader, { name: name, icon: icon, homeHref: homeHref }), workspaceSwitcher !== false && (_jsx("div", { className: "shrink-0 px-3 pb-1", children: _jsx(ConnectWorkspaceSwitcher, { alwaysShow: workspaceSwitcher?.alwaysShow ?? false }) })), _jsx(ConnectSidebarBody, { children: _jsx(ConnectSidebarNav, { nav: visibleNav }) }), (visibleSecondaryNav || sidebarFooter || showAppsGrid) && (_jsxs(ConnectSidebarFooter, { separated: false, children: [visibleSecondaryNav && _jsx(ConnectSidebarNav, { nav: visibleSecondaryNav }), sidebarFooter, showAppsGrid && _jsx(ConnectAppsGrid, { variant: "row" })] })), accountRow && _jsx(ConnectSidebarFooter, { children: accountRow })] }));
|
|
198
198
|
return (_jsx(MotionProvider, { children: _jsxs("div", { className: cn("flex h-dvh bg-background", className), children: [_jsx("a", { href: `#${CONTENT_ID}`, className: "sr-only focus:not-sr-only focus:absolute focus:start-3 focus:top-3 focus:z-50 focus:rounded-lg focus:bg-surface focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-foreground focus:shadow-lg focus:outline-2 focus:outline-offset-2 focus:outline-focus", children: "Skip to content" }), _jsx(ConnectShellProvider, { value: railShell, children: _jsx(ConnectSidebar, { children: railContent }) }), _jsx(Drawer.Backdrop, { isOpen: mobileOpen, onOpenChange: setMobileOpen, children: _jsx(Drawer.Content, { placement: "left", className: "w-[min(20rem,85vw)]", children: _jsxs(Drawer.Dialog, { "aria-label": "Navigation menu", className: "p-0 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]", children: [_jsx(Drawer.CloseTrigger, { className: "end-3 top-[calc(--spacing(3)+env(safe-area-inset-top))]" }), _jsx(ConnectShellProvider, { value: drawerShell, children: _jsx("div", { className: "flex h-full flex-col [&>:first-child]:pe-10", children: railContent }) })] }) }) }), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border bg-surface px-3 md:hidden", children: [_jsx(Button, { isIconOnly: true, variant: "tertiary", size: "sm", "aria-label": "Open navigation menu", onPress: () => setMobileOpen(true), children: _jsx(Menu, { className: "size-5" }) }), _jsx(ConnectLogo, { name: name, icon: icon })] }), _jsx("main", { id: CONTENT_ID, tabIndex: -1, className: "flex-1 overflow-y-auto outline-none", children: _jsxs("div", { className: contentClassName ?? "w-full px-4 py-5 md:px-6 md:py-6", children: [gated ? _jsx(ConnectAccessGate, { children: children }) : children, footer] }) })] }), askAi && _jsx(ConnectAskAi, { ...(askAi === true ? {} : askAi) })] }) }));
|
|
199
199
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Button, Input, Label, Modal, Switch, TextField } from "@heroui/react";
|
|
5
|
+
import { useCreateAvailabilitySchedule } from "../use-scheduling.js";
|
|
6
|
+
const WEEKDAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
7
|
+
const WEEKDAY_KEYS = [0, 1, 2, 3, 4, 5, 6];
|
|
8
|
+
/** Monday-Friday, 9am-5pm - the same starting point Connect's own Console form offers. */
|
|
9
|
+
function defaultWeeklyHours() {
|
|
10
|
+
const open = [{ startMinute: 9 * 60, endMinute: 17 * 60 }];
|
|
11
|
+
return { 0: [], 1: open, 2: open, 3: open, 4: open, 5: open, 6: [] };
|
|
12
|
+
}
|
|
13
|
+
function minutesToTimeInput(minutes) {
|
|
14
|
+
const hours = Math.floor(minutes / 60);
|
|
15
|
+
const mins = minutes % 60;
|
|
16
|
+
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
|
|
17
|
+
}
|
|
18
|
+
function timeInputToMinutes(value) {
|
|
19
|
+
const [hours, minutes] = value.split(":").map(Number);
|
|
20
|
+
return (hours ?? 0) * 60 + (minutes ?? 0);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Gives a workspace its first working hours, from inside a host application
|
|
24
|
+
* rather than a link-out to Connect.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately narrower than Connect's own Calendar settings form: one
|
|
27
|
+
* interval per day (no split shifts), no date overrides, and the timezone
|
|
28
|
+
* defaults to the browser's own rather than asking. This is a first-run
|
|
29
|
+
* setup step, not a second copy of the Console editor - "Manage in Connect"
|
|
30
|
+
* is still where the rest of what a schedule can do lives.
|
|
31
|
+
*
|
|
32
|
+
* **Not optimistic.** `useCreateAvailabilitySchedule` settles the write
|
|
33
|
+
* against the server before this closes, the same posture every write in
|
|
34
|
+
* this package takes - see `ConnectBookingDialog`.
|
|
35
|
+
*/
|
|
36
|
+
export function ConnectAvailabilityFormModal({ isOpen, onOpenChange, onCreated, }) {
|
|
37
|
+
const [name, setName] = useState("Working hours");
|
|
38
|
+
const [timezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
|
|
39
|
+
const [weeklyHours, setWeeklyHours] = useState(defaultWeeklyHours);
|
|
40
|
+
const { createSchedule, isPending, error, clearError } = useCreateAvailabilitySchedule();
|
|
41
|
+
const toggleDay = (day, open) => {
|
|
42
|
+
setWeeklyHours((prev) => ({
|
|
43
|
+
...prev,
|
|
44
|
+
[day]: open ? [{ startMinute: 9 * 60, endMinute: 17 * 60 }] : [],
|
|
45
|
+
}));
|
|
46
|
+
};
|
|
47
|
+
const setDayTime = (day, edge, value) => {
|
|
48
|
+
setWeeklyHours((prev) => {
|
|
49
|
+
const interval = prev[day][0];
|
|
50
|
+
if (!interval)
|
|
51
|
+
return prev;
|
|
52
|
+
return { ...prev, [day]: [{ ...interval, [edge]: timeInputToMinutes(value) }] };
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const isFormValid = name.trim().length > 0 && !isPending;
|
|
56
|
+
const handleSubmit = async (e) => {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
if (!isFormValid)
|
|
59
|
+
return;
|
|
60
|
+
clearError();
|
|
61
|
+
const schedule = await createSchedule({ name: name.trim(), timezone, weeklyHours });
|
|
62
|
+
if (schedule) {
|
|
63
|
+
onCreated?.(schedule);
|
|
64
|
+
onOpenChange(false);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return (_jsx(Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-120", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "Set your availability" }) }), _jsx(Modal.Body, { children: _jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs(TextField, { isRequired: true, variant: "secondary", children: [_jsx(Label, { children: "Name" }), _jsx(Input, { value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), _jsxs("p", { className: "text-xs text-muted", children: ["Timezone: ", timezone] }), _jsx("div", { className: "flex flex-col gap-2", children: WEEKDAY_KEYS.map((day) => {
|
|
68
|
+
const interval = weeklyHours[day][0];
|
|
69
|
+
const isOpenDay = interval !== undefined;
|
|
70
|
+
return (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Switch, { isSelected: isOpenDay, onChange: (open) => toggleDay(day, open), isDisabled: isPending, children: _jsxs(Switch.Content, { children: [_jsx(Switch.Control, { children: _jsx(Switch.Thumb, {}) }), _jsx("span", { className: "w-24 shrink-0 text-sm", children: WEEKDAY_LABELS[day] })] }) }), isOpenDay && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { type: "time", value: minutesToTimeInput(interval.startMinute), onChange: (e) => setDayTime(day, "startMinute", e.currentTarget.value), disabled: isPending }), _jsx("span", { className: "text-xs text-muted", children: "to" }), _jsx(Input, { type: "time", value: minutesToTimeInput(interval.endMinute), onChange: (e) => setDayTime(day, "endMinute", e.currentTarget.value), disabled: isPending })] }))] }, day));
|
|
71
|
+
}) }), error && _jsx("p", { className: "text-sm text-danger", children: error }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", onPress: () => onOpenChange(false), isDisabled: isPending, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Saving..." : "Save" })] })] }) })] }) }) }) }));
|
|
72
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { Button, Input, Label, ListBox, Modal, Select, TextArea, TextField } from "@heroui/react";
|
|
5
|
+
import { useConnect, useConnectWorkspaceMembers } from "../provider.js";
|
|
6
|
+
import { useAvailabilitySchedules, useCreateEventType } from "../use-scheduling.js";
|
|
7
|
+
const LOCATION_LABELS = {
|
|
8
|
+
phone: "Phone call",
|
|
9
|
+
address: "In person",
|
|
10
|
+
link: "Video link",
|
|
11
|
+
};
|
|
12
|
+
const LOCATION_KINDS = ["phone", "address", "link"];
|
|
13
|
+
/**
|
|
14
|
+
* Creates the first thing a workspace can be booked for, from inside a host
|
|
15
|
+
* application rather than a link-out to Connect.
|
|
16
|
+
*
|
|
17
|
+
* **Needs a schedule to exist first** - the picker reads `useAvailabilitySchedules`
|
|
18
|
+
* and renders "Set up availability first" with nothing to submit if none
|
|
19
|
+
* exists yet. Pair this with `ConnectAvailabilityFormModal` in a setup flow
|
|
20
|
+
* rather than assuming one.
|
|
21
|
+
*
|
|
22
|
+
* **Host is a specific member, never a team.** There is no groups read in
|
|
23
|
+
* this bridge, so a round-robin team host stays a Console-only capability -
|
|
24
|
+
* defaults to whoever is signed in, with an optional picker from the
|
|
25
|
+
* workspace's own roster.
|
|
26
|
+
*/
|
|
27
|
+
export function ConnectEventTypeFormModal({ isOpen, onOpenChange, onCreated, }) {
|
|
28
|
+
const { session } = useConnect();
|
|
29
|
+
const { data: membersData } = useConnectWorkspaceMembers();
|
|
30
|
+
const { schedules } = useAvailabilitySchedules();
|
|
31
|
+
const members = useMemo(() => membersData?.members ?? [], [membersData]);
|
|
32
|
+
const defaultHostId = useMemo(() => {
|
|
33
|
+
const self = members.find((member) => member.userId === session?.user?.userId);
|
|
34
|
+
return self?.memberId ?? members[0]?.memberId ?? "";
|
|
35
|
+
}, [members, session?.user?.userId]);
|
|
36
|
+
const [name, setName] = useState("");
|
|
37
|
+
const [description, setDescription] = useState("");
|
|
38
|
+
const [durationMinutes, setDurationMinutes] = useState(30);
|
|
39
|
+
const [bufferBefore, setBufferBefore] = useState(5);
|
|
40
|
+
const [bufferAfter, setBufferAfter] = useState(5);
|
|
41
|
+
const [scheduleId, setScheduleId] = useState("");
|
|
42
|
+
const [hostMemberId, setHostMemberId] = useState("");
|
|
43
|
+
const [locationKind, setLocationKind] = useState("phone");
|
|
44
|
+
const [locationValue, setLocationValue] = useState("");
|
|
45
|
+
const effectiveScheduleId = scheduleId || schedules[0]?.id || "";
|
|
46
|
+
const effectiveHostMemberId = hostMemberId || defaultHostId;
|
|
47
|
+
const { createEventType, isPending, error, clearError } = useCreateEventType();
|
|
48
|
+
const isFormValid = name.trim().length > 0 &&
|
|
49
|
+
durationMinutes >= 5 &&
|
|
50
|
+
durationMinutes <= 480 &&
|
|
51
|
+
effectiveScheduleId.length > 0 &&
|
|
52
|
+
effectiveHostMemberId.length > 0 &&
|
|
53
|
+
!isPending;
|
|
54
|
+
const handleSubmit = async (e) => {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
if (!isFormValid)
|
|
57
|
+
return;
|
|
58
|
+
clearError();
|
|
59
|
+
const trimmedDescription = description.trim();
|
|
60
|
+
const created = await createEventType({
|
|
61
|
+
name: name.trim(),
|
|
62
|
+
description: trimmedDescription.length > 0 ? trimmedDescription : null,
|
|
63
|
+
durationMinutes,
|
|
64
|
+
bufferBeforeMinutes: bufferBefore,
|
|
65
|
+
bufferAfterMinutes: bufferAfter,
|
|
66
|
+
hostMemberId: effectiveHostMemberId,
|
|
67
|
+
scheduleId: effectiveScheduleId,
|
|
68
|
+
location: {
|
|
69
|
+
kind: locationKind,
|
|
70
|
+
value: locationValue.trim().length > 0 ? locationValue.trim() : null,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
if (created) {
|
|
74
|
+
onCreated?.(created);
|
|
75
|
+
onOpenChange(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return (_jsx(Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-140", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: "New appointment type" }) }), _jsx(Modal.Body, { children: schedules.length === 0 ? (_jsx("p", { className: "text-sm text-muted", children: "Set up availability first - an appointment type needs working hours to book against." })) : (_jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs(TextField, { isRequired: true, variant: "secondary", children: [_jsx(Label, { children: "Name" }), _jsx(Input, { placeholder: "Consultation", value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), _jsxs(TextField, { variant: "secondary", children: [_jsx(Label, { children: "Description" }), _jsx(TextArea, { placeholder: "What this appointment is for", value: description, onChange: (e) => setDescription(e.currentTarget.value), disabled: isPending, rows: 2 })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs(TextField, { isRequired: true, variant: "secondary", children: [_jsx(Label, { children: "Length (min)" }), _jsx(Input, { type: "number", value: String(durationMinutes), onChange: (e) => setDurationMinutes(Number(e.currentTarget.value)), disabled: isPending })] }), _jsxs(TextField, { variant: "secondary", children: [_jsx(Label, { children: "Gap before" }), _jsx(Input, { type: "number", value: String(bufferBefore), onChange: (e) => setBufferBefore(Number(e.currentTarget.value)), disabled: isPending })] }), _jsxs(TextField, { variant: "secondary", children: [_jsx(Label, { children: "Gap after" }), _jsx(Input, { type: "number", value: String(bufferAfter), onChange: (e) => setBufferAfter(Number(e.currentTarget.value)), disabled: isPending })] })] }), schedules.length > 1 && (_jsxs(Select, { variant: "secondary", value: effectiveScheduleId, onChange: (value) => setScheduleId(String(value)), isDisabled: isPending, children: [_jsx(Label, { children: "Availability" }), _jsxs(Select.Trigger, { children: [_jsx(Select.Value, {}), _jsx(Select.Indicator, {})] }), _jsx(Select.Popover, { children: _jsx(ListBox, { children: schedules.map((schedule) => (_jsxs(ListBox.Item, { id: schedule.id, textValue: schedule.name, children: [schedule.name, _jsx(ListBox.ItemIndicator, {})] }, schedule.id))) }) })] })), members.length > 1 && (_jsxs(Select, { variant: "secondary", value: effectiveHostMemberId, onChange: (value) => setHostMemberId(String(value)), isDisabled: isPending, children: [_jsx(Label, { children: "Who hosts it" }), _jsxs(Select.Trigger, { children: [_jsx(Select.Value, {}), _jsx(Select.Indicator, {})] }), _jsx(Select.Popover, { children: _jsx(ListBox, { children: members.map((member) => (_jsxs(ListBox.Item, { id: member.memberId, textValue: member.displayName ?? member.email, children: [member.displayName ?? member.email, _jsx(ListBox.ItemIndicator, {})] }, member.memberId))) }) })] })), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs(Select, { variant: "secondary", value: locationKind, onChange: (value) => setLocationKind(value), isDisabled: isPending, children: [_jsx(Label, { children: "Where" }), _jsxs(Select.Trigger, { children: [_jsx(Select.Value, {}), _jsx(Select.Indicator, {})] }), _jsx(Select.Popover, { children: _jsx(ListBox, { children: LOCATION_KINDS.map((kind) => (_jsxs(ListBox.Item, { id: kind, textValue: LOCATION_LABELS[kind], children: [LOCATION_LABELS[kind], _jsx(ListBox.ItemIndicator, {})] }, kind))) }) })] }), _jsxs(TextField, { variant: "secondary", children: [_jsx(Label, { children: "Details" }), _jsx(TextArea, { placeholder: locationKind === "address" ? "Where to come" : "Left blank is fine", value: locationValue, onChange: (e) => setLocationValue(e.currentTarget.value), disabled: isPending, rows: 2 })] })] }), error && _jsx("p", { className: "text-sm text-danger", children: error }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", onPress: () => onOpenChange(false), isDisabled: isPending, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Saving..." : "Create" })] })] })) })] }) }) }) }));
|
|
79
|
+
}
|
|
@@ -126,20 +126,25 @@ export function groupPlansByTier(plans) {
|
|
|
126
126
|
description: plan.description,
|
|
127
127
|
monthly: null,
|
|
128
128
|
annual: null,
|
|
129
|
+
oneTime: null,
|
|
129
130
|
annualSavingPercent: null,
|
|
130
131
|
};
|
|
131
132
|
if (plan.billingInterval === "monthly") {
|
|
132
133
|
if (!tier.monthly || plan.price < tier.monthly.price)
|
|
133
134
|
tier.monthly = plan;
|
|
134
135
|
}
|
|
135
|
-
else if (
|
|
136
|
-
tier.annual
|
|
136
|
+
else if (plan.billingInterval === "annual") {
|
|
137
|
+
if (!tier.annual || plan.price < tier.annual.price)
|
|
138
|
+
tier.annual = plan;
|
|
139
|
+
}
|
|
140
|
+
else if (!tier.oneTime || plan.price < tier.oneTime.price) {
|
|
141
|
+
tier.oneTime = plan;
|
|
137
142
|
}
|
|
138
143
|
tiers.set(planGroup, tier);
|
|
139
144
|
}
|
|
140
145
|
return [...tiers.values()]
|
|
141
146
|
.map((tier) => {
|
|
142
|
-
const identity = tier.monthly ?? tier.annual;
|
|
147
|
+
const identity = tier.monthly ?? tier.annual ?? tier.oneTime;
|
|
143
148
|
return {
|
|
144
149
|
...tier,
|
|
145
150
|
name: identity?.name ?? tier.name,
|
|
@@ -150,7 +155,7 @@ export function groupPlansByTier(plans) {
|
|
|
150
155
|
.sort((a, b) => tierFloorPrice(a) - tierFloorPrice(b));
|
|
151
156
|
}
|
|
152
157
|
function tierFloorPrice(tier) {
|
|
153
|
-
const prices = [tier.monthly, tier.annual]
|
|
158
|
+
const prices = [tier.monthly, tier.annual, tier.oneTime]
|
|
154
159
|
.filter((plan) => plan !== null)
|
|
155
160
|
.map((plan) => monthlyOf(plan.price, plan.billingInterval));
|
|
156
161
|
return prices.length > 0 ? Math.min(...prices) : Number.MAX_SAFE_INTEGER;
|
|
@@ -126,7 +126,9 @@ export function ConnectPlans({ planIds, hideCoupon = false, className }) {
|
|
|
126
126
|
// Growth tier, and marking only the row it literally subscribes to
|
|
127
127
|
// would leave its own card unmarked half the time.
|
|
128
128
|
const isCurrentTier = subscription?.plan != null &&
|
|
129
|
-
(tier.monthly?.id === subscription.plan.id ||
|
|
129
|
+
(tier.monthly?.id === subscription.plan.id ||
|
|
130
|
+
tier.annual?.id === subscription.plan.id ||
|
|
131
|
+
tier.oneTime?.id === subscription.plan.id);
|
|
130
132
|
const isCurrent = action === "current";
|
|
131
133
|
const cadenceUnavailable = showAnnual ? tier.annual === null : tier.monthly === null;
|
|
132
134
|
return (_jsx(m.div, { variants: { hidden: { opacity: 0, y: 12 }, show: { opacity: 1, y: 0, transition: ENTER } }, children: _jsxs(Card, { className: isCurrentTier ? "h-full border-accent" : "h-full", children: [_jsxs(Card.Header, { children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx(Card.Title, { children: tier.name }), _jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [tier.annualSavingPercent !== null && showAnnual && (_jsxs(Chip, { className: "bg-accent-soft text-accent", children: ["Save ", tier.annualSavingPercent, "%"] })), isCurrentTier && _jsx(Chip, { className: "bg-accent-soft text-accent", children: "Current" })] })] }), tier.description && _jsx(Card.Description, { children: tier.description })] }), _jsxs(Card.Content, { className: "flex flex-col gap-4", children: [_jsxs("div", { children: [_jsx(AnimatePresence, { mode: "popLayout", initial: false, children: _jsx(m.div, { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -6 }, transition: { duration: 0.15 }, children: isFreePlan(plan.price) ? (_jsx("span", { className: "text-2xl font-semibold text-foreground", children: "Free" })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-2xl font-semibold text-foreground", children: formatPrice(plan.price) }), _jsx("span", { className: "text-sm text-muted", children: pricePerLabel(plan.billingInterval) })] })) }, plan.billingInterval) }), !isFreePlan(plan.price) && (_jsxs("p", { className: "mt-1 text-xs text-muted", children: ["+", plan.gstRate, "% GST"] })), cadenceUnavailable && (_jsxs("p", { className: "mt-1 text-xs text-muted", children: ["Only sold ", intervalLabel(plan.billingInterval).adverb, "."] }))] }), plan.trialDays && !isFreePlan(plan.price) ? (_jsxs("p", { className: "text-xs font-medium text-accent", children: [plan.trialDays, "-day free trial"] })) : null, plan.features.length > 0 && (_jsx("ul", { className: "flex flex-col gap-1.5", children: plan.features.map((feature) => (
|
|
@@ -211,7 +213,13 @@ function ConnectUnsubscribeRow({ subscription, appUrl, checkout, }) {
|
|
|
211
213
|
* monthly-only is a real thing (a free or entry plan usually is), and hiding
|
|
212
214
|
* it while a customer looks at annual pricing loses them an option that is
|
|
213
215
|
* still available to buy.
|
|
216
|
+
*
|
|
217
|
+
* A tier that is *only* a one-time plan ignores the toggle entirely - there
|
|
218
|
+
* is no cadence to switch between, so the price it shows must not depend on
|
|
219
|
+
* `showAnnual`.
|
|
214
220
|
*/
|
|
215
221
|
function pickTierPlan(tier, showAnnual) {
|
|
222
|
+
if (tier.oneTime && !tier.monthly && !tier.annual)
|
|
223
|
+
return tier.oneTime;
|
|
216
224
|
return showAnnual ? (tier.annual ?? tier.monthly) : (tier.monthly ?? tier.annual);
|
|
217
225
|
}
|
|
@@ -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:
|
|
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(
|
|
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 }) {
|