@cosmoops/connect-sdk 0.38.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/cjs/client/components/availability-form.js +52 -24
  3. package/dist/cjs/client/components/call-button.js +398 -0
  4. package/dist/cjs/client/components/create-workspace-form.js +66 -0
  5. package/dist/cjs/client/components/event-type-form.js +99 -25
  6. package/dist/cjs/client/components/workspace-switcher.js +42 -9
  7. package/dist/cjs/client/index.js +17 -2
  8. package/dist/cjs/client/portal-links.js +1 -1
  9. package/dist/cjs/client/use-scheduling.js +37 -0
  10. package/dist/cjs/client/use-workspaces.js +41 -0
  11. package/dist/cjs/server/client.js +1 -1
  12. package/dist/cjs/server/handler.js +176 -2
  13. package/dist/cjs/server/resources/calls.js +58 -0
  14. package/dist/cjs/server/resources/scheduling.js +67 -0
  15. package/dist/cjs/server/resources/workspaces.js +30 -11
  16. package/dist/esm/client/components/availability-form.js +53 -25
  17. package/dist/esm/client/components/call-button.js +358 -0
  18. package/dist/esm/client/components/create-workspace-form.js +63 -0
  19. package/dist/esm/client/components/event-type-form.js +100 -26
  20. package/dist/esm/client/components/workspace-switcher.js +42 -9
  21. package/dist/esm/client/index.js +8 -2
  22. package/dist/esm/client/portal-links.js +1 -1
  23. package/dist/esm/client/use-scheduling.js +34 -0
  24. package/dist/esm/client/use-workspaces.js +38 -0
  25. package/dist/esm/server/client.js +1 -1
  26. package/dist/esm/server/handler.js +176 -2
  27. package/dist/esm/server/resources/calls.js +58 -0
  28. package/dist/esm/server/resources/scheduling.js +67 -0
  29. package/dist/esm/server/resources/workspaces.js +30 -11
  30. package/dist/types/client/components/availability-form.d.ts +23 -12
  31. package/dist/types/client/components/call-button.d.ts +132 -0
  32. package/dist/types/client/components/create-workspace-form.d.ts +32 -0
  33. package/dist/types/client/components/event-type-form.d.ts +30 -8
  34. package/dist/types/client/components/workspace-switcher.d.ts +9 -9
  35. package/dist/types/client/index.d.ts +7 -1
  36. package/dist/types/client/portal-links.d.ts +1 -1
  37. package/dist/types/client/use-scheduling.d.ts +46 -2
  38. package/dist/types/client/use-workspaces.d.ts +27 -0
  39. package/dist/types/server/client.d.ts +1 -1
  40. package/dist/types/server/resources/calls.d.ts +32 -1
  41. package/dist/types/server/resources/scheduling.d.ts +66 -2
  42. package/dist/types/server/resources/workspaces.d.ts +28 -0
  43. package/dist/types/types.d.ts +143 -1
  44. package/package.json +4 -1
@@ -741,8 +741,8 @@ function createConnectHandler(options) {
741
741
  */
742
742
  "STORE": async (request) => storeRoute(request),
743
743
  /**
744
- * The workspace switcher's three reads, forwarded with the caller's own
745
- * session token.
744
+ * The workspace switcher's reads and writes, forwarded with the caller's
745
+ * own session token.
746
746
  *
747
747
  * `POST /workspaces/switch` is the only bridge route that *changes the
748
748
  * caller's session*, so it writes the new token back through the same
@@ -754,6 +754,14 @@ function createConnectHandler(options) {
754
754
  * A partner with no `session.write` cannot re-persist a rotated token, so
755
755
  * switching is simply absent for them rather than half-working - the same
756
756
  * treatment `/signin` and `/callback` already get.
757
+ *
758
+ * `POST /workspaces` opens another workspace under the caller's own
759
+ * tenant and does not rotate anything - it does not switch the session,
760
+ * same as Connect's own "Create workspace" button. Every guard
761
+ * (ownership, the plan's workspace allowance, the business-email-domain
762
+ * rule) lives server-side in `CreateWorkspaceUseCase`; this route is a
763
+ * plain forward with the caller's session token, exactly like the reads
764
+ * beside it.
757
765
  */
758
766
  /*
759
767
  * Push notifications - the browser half, and deliberately only that half.
@@ -810,6 +818,20 @@ function createConnectHandler(options) {
810
818
  return json(await client.push.revokeDevice(body.id, { asUser: token }));
811
819
  },
812
820
  "GET /workspaces": async (request) => json({ workspaces: await client.workspaces.list({ asUser: await requireToken(request) }) }),
821
+ "POST /workspaces": async (request) => {
822
+ const token = await requireToken(request);
823
+ const body = (await request.json().catch(() => null));
824
+ if (typeof body?.name !== "string" || (body.type !== "individual" && body.type !== "business")) {
825
+ throw new errors_js_1.ConnectError({
826
+ status: 400,
827
+ code: "validation_error",
828
+ message: "name and type are required",
829
+ endpoint: "POST /workspaces",
830
+ });
831
+ }
832
+ const workspace = await client.workspaces.create({ name: body.name, type: body.type }, { asUser: token });
833
+ return json({ workspace });
834
+ },
813
835
  "GET /workspaces/members": async (request) => json({ members: await client.workspaces.members({ asUser: await requireToken(request) }) }),
814
836
  "POST /workspaces/switch": async (request) => {
815
837
  const token = await requireToken(request);
@@ -893,6 +915,58 @@ function createConnectHandler(options) {
893
915
  asUser,
894
916
  }), 201);
895
917
  },
918
+ /*
919
+ * The richer read `ConnectEventTypeFormModal`'s edit mode needs - see
920
+ * `listManageableEventTypes`'s own doc comment for why this is a separate
921
+ * route from the catalogue above rather than a wider response on it.
922
+ */
923
+ "GET /scheduling/event-types/manage": async (request) => {
924
+ const { workspaceId } = await requireWorkspace(request);
925
+ const asUser = await requireToken(request);
926
+ return json({
927
+ eventTypes: await client.scheduling.listManageableEventTypes(workspaceId, { asUser }),
928
+ });
929
+ },
930
+ /*
931
+ * A fixed path with the id in the body, not `/scheduling/event-types/{id}` -
932
+ * this table is matched by exact string, the same reason the booking
933
+ * actions below take their id this way rather than as a path segment.
934
+ */
935
+ "POST /scheduling/event-types/update": async (request) => {
936
+ const { workspaceId } = await requireWorkspace(request);
937
+ const asUser = await requireToken(request);
938
+ const body = (await request.json().catch(() => null));
939
+ if (typeof body?.eventTypeId !== "string") {
940
+ throw new errors_js_1.ConnectError({
941
+ status: 400,
942
+ code: "validation_error",
943
+ message: "eventTypeId is required",
944
+ endpoint: "/scheduling/event-types/update",
945
+ });
946
+ }
947
+ return json(await client.scheduling.updateEventType({
948
+ workspaceId,
949
+ eventTypeId: body.eventTypeId,
950
+ name: typeof body.name === "string" ? body.name : undefined,
951
+ description: typeof body.description === "string" || body.description === null
952
+ ? body.description
953
+ : undefined,
954
+ durationMinutes: typeof body.durationMinutes === "number" ? body.durationMinutes : undefined,
955
+ bufferBeforeMinutes: typeof body.bufferBeforeMinutes === "number" ? body.bufferBeforeMinutes : undefined,
956
+ bufferAfterMinutes: typeof body.bufferAfterMinutes === "number" ? body.bufferAfterMinutes : undefined,
957
+ hostMemberId: typeof body.hostMemberId === "string" ? body.hostMemberId : undefined,
958
+ scheduleId: typeof body.scheduleId === "string" ? body.scheduleId : undefined,
959
+ location: body.location && typeof body.location.kind === "string"
960
+ ? {
961
+ kind: body.location.kind,
962
+ value: typeof body.location.value === "string" ? body.location.value : null,
963
+ }
964
+ : undefined,
965
+ publicBookingEnabled: typeof body.publicBookingEnabled === "boolean" ? body.publicBookingEnabled : undefined,
966
+ active: typeof body.active === "boolean" ? body.active : undefined,
967
+ asUser,
968
+ }));
969
+ },
896
970
  /*
897
971
  * The working-hours templates an appointment type can point at - read
898
972
  * before a setup UI offers to create one, so it can tell "none yet" from
@@ -931,6 +1005,33 @@ function createConnectHandler(options) {
931
1005
  asUser,
932
1006
  }), 201);
933
1007
  },
1008
+ /*
1009
+ * A fixed path with the id in the body, same reasoning as the event
1010
+ * type's own `update` route above.
1011
+ */
1012
+ "POST /scheduling/schedules/update": async (request) => {
1013
+ const { workspaceId } = await requireWorkspace(request);
1014
+ const asUser = await requireToken(request);
1015
+ const body = (await request.json().catch(() => null));
1016
+ if (typeof body?.scheduleId !== "string") {
1017
+ throw new errors_js_1.ConnectError({
1018
+ status: 400,
1019
+ code: "validation_error",
1020
+ message: "scheduleId is required",
1021
+ endpoint: "/scheduling/schedules/update",
1022
+ });
1023
+ }
1024
+ return json(await client.scheduling.updateSchedule({
1025
+ workspaceId,
1026
+ scheduleId: body.scheduleId,
1027
+ name: typeof body.name === "string" ? body.name : undefined,
1028
+ timezone: typeof body.timezone === "string" ? body.timezone : undefined,
1029
+ weeklyHours: typeof body.weeklyHours === "object" && body.weeklyHours !== null
1030
+ ? body.weeklyHours
1031
+ : undefined,
1032
+ asUser,
1033
+ }));
1034
+ },
934
1035
  "GET /scheduling/slots": async (request) => {
935
1036
  const { workspaceId } = await requireWorkspace(request);
936
1037
  const asUser = await requireToken(request);
@@ -1112,6 +1213,67 @@ function createConnectHandler(options) {
1112
1213
  const items = await client.sessions.list(await requireToken(request));
1113
1214
  return json({ items });
1114
1215
  },
1216
+ /*
1217
+ * Browser calling - `<ConnectCallButton>`. Three fixed writes and no read:
1218
+ * how a call went is your *server's* question (`client.calls.get`), never
1219
+ * the browser's, so a call log can't be written from what a page claims.
1220
+ *
1221
+ * The workspace and the member both come from the session, as everywhere
1222
+ * here; the browser names only the number.
1223
+ */
1224
+ /*
1225
+ * The one calling *read*, and it is about the member, not about a call:
1226
+ * can they ring anybody from here. A refusal means they lack "Make calls"
1227
+ * - which is an answer, not a failure, so it is returned as one and the
1228
+ * button simply is not offered.
1229
+ */
1230
+ "GET /calls/availability": async (request) => {
1231
+ await requireWorkspace(request);
1232
+ const asUser = await requireToken(request);
1233
+ try {
1234
+ return json(await client.calls.getCallingAvailability({ asUser }));
1235
+ }
1236
+ catch (error) {
1237
+ if (error instanceof errors_js_1.ConnectError && error.status === 403) {
1238
+ return json(NO_CALLING_PERMISSION);
1239
+ }
1240
+ throw error;
1241
+ }
1242
+ },
1243
+ "POST /calls/browser-token": async (request) => {
1244
+ await requireWorkspace(request);
1245
+ const asUser = await requireToken(request);
1246
+ return json(await client.calls.getBrowserCallingToken({ asUser }));
1247
+ },
1248
+ "POST /calls/start": async (request) => {
1249
+ await requireWorkspace(request);
1250
+ const asUser = await requireToken(request);
1251
+ const body = (await request.json().catch(() => null));
1252
+ if (typeof body?.phone !== "string") {
1253
+ throw new errors_js_1.ConnectError({
1254
+ status: 400,
1255
+ code: "validation_error",
1256
+ message: "phone is required",
1257
+ endpoint: "/calls/start",
1258
+ });
1259
+ }
1260
+ return json(await client.calls.startBrowserCall({ phone: body.phone, asUser }), 201);
1261
+ },
1262
+ "POST /calls/cancel": async (request) => {
1263
+ await requireWorkspace(request);
1264
+ const asUser = await requireToken(request);
1265
+ const body = (await request.json().catch(() => null));
1266
+ if (typeof body?.callId !== "string") {
1267
+ throw new errors_js_1.ConnectError({
1268
+ status: 400,
1269
+ code: "validation_error",
1270
+ message: "callId is required",
1271
+ endpoint: "/calls/cancel",
1272
+ });
1273
+ }
1274
+ await client.calls.cancelBrowserCall({ callId: body.callId, asUser });
1275
+ return json({ cancelled: true });
1276
+ },
1115
1277
  "POST /sessions/revoke": async (request) => {
1116
1278
  const body = (await request.json().catch(() => null));
1117
1279
  if (!body?.sessionId) {
@@ -1397,6 +1559,18 @@ function createConnectHandler(options) {
1397
1559
  }
1398
1560
  };
1399
1561
  }
1562
+ /** What a member without "Make calls" is told - see `GET /calls/availability`. */
1563
+ const NO_CALLING_PERMISSION = {
1564
+ status: "no_permission",
1565
+ number: null,
1566
+ available: 0,
1567
+ availableMinutes: 0,
1568
+ ratePaisePerMinute: 0,
1569
+ lowBalance: false,
1570
+ canTopUp: false,
1571
+ canManageNumber: false,
1572
+ offer: null,
1573
+ };
1400
1574
  function json(body, status = 200) {
1401
1575
  return new Response(JSON.stringify(body), {
1402
1576
  status,
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CallsResource = void 0;
4
4
  const http_js_1 = require("../http.js");
5
+ const acting_as_js_1 = require("./acting-as.js");
5
6
  /**
6
7
  * Every call that arrived on a channel assigned to your application.
7
8
  *
@@ -53,5 +54,62 @@ class CallsResource {
53
54
  path: `/calls/${encodeURIComponent(callId)}`,
54
55
  });
55
56
  }
57
+ /*
58
+ * Browser calling - what `<ConnectCallButton>` runs on, through your bridge.
59
+ *
60
+ * All three need the `calls:place` scope **and** a signed-in member
61
+ * (`asUser`), whose own "Make calls" permission Connect checks. None works
62
+ * server-to-server: a call is placed by a person, from the workspace's own
63
+ * number, and is not recorded. Read how one went with `get(callId)`, which
64
+ * needs `calls:read` - a call your application placed is readable by it.
65
+ */
66
+ /**
67
+ * Whether the member can call, and if not why - what `<ConnectCallButton>`
68
+ * renders from. Throws `ConnectError` 403 for a member without "Make
69
+ * calls"; the bridge turns that into `status: "no_permission"`.
70
+ */
71
+ getCallingAvailability(input) {
72
+ return (0, http_js_1.request)(this.transport, {
73
+ method: "GET",
74
+ path: "/calls/availability",
75
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
76
+ });
77
+ }
78
+ /** A carrier token the member's browser signs in to the phone network with. */
79
+ getBrowserCallingToken(input) {
80
+ return (0, http_js_1.request)(this.transport, {
81
+ method: "POST",
82
+ path: "/calls/browser-token",
83
+ json: {},
84
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
85
+ });
86
+ }
87
+ /**
88
+ * Approves one call from the member to `phone` (E.164) and records it.
89
+ *
90
+ * Throws `ConnectError` with status `412` when the workspace has no active
91
+ * number, is not allowed to place calls, or the member is already on one -
92
+ * the message is written to be shown to them.
93
+ */
94
+ startBrowserCall(input) {
95
+ return (0, http_js_1.request)(this.transport, {
96
+ method: "POST",
97
+ path: "/calls/start",
98
+ json: { phone: input.phone },
99
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
100
+ });
101
+ }
102
+ /**
103
+ * Closes a call the browser never managed to place - recorded as
104
+ * `unanswered`/`cancelled`. A no-op once the carrier has it.
105
+ */
106
+ async cancelBrowserCall(input) {
107
+ await (0, http_js_1.request)(this.transport, {
108
+ method: "POST",
109
+ path: "/calls/cancel",
110
+ json: { callId: input.callId },
111
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
112
+ });
113
+ }
56
114
  }
57
115
  exports.CallsResource = CallsResource;
@@ -76,6 +76,9 @@ class SchedulingResource {
76
76
  * Needs a schedule - create or pick one with `listSchedules`/`createSchedule`
77
77
  * first. The slug for a future public booking link is derived from `name`
78
78
  * server-side; you cannot choose one.
79
+ *
80
+ * Answers the richer *manageable* shape, not `BookableEventType` - you just
81
+ * created this row and are about to manage it, not book against it.
79
82
  */
80
83
  createEventType(input) {
81
84
  return (0, http_js_1.request)(this.transport, {
@@ -96,6 +99,70 @@ class SchedulingResource {
96
99
  headers: (0, acting_as_js_1.actingAs)(input.asUser),
97
100
  });
98
101
  }
102
+ /**
103
+ * The richer read `createEventType`/`updateEventType`'s edit mode needs -
104
+ * everything `listEventTypes`'s booking catalogue deliberately withholds,
105
+ * because this is the opposite audience: a member managing their own
106
+ * calendar, not a widget rendering someone else's. Includes inactive rows,
107
+ * since an edit screen is exactly where one gets reactivated.
108
+ */
109
+ async listManageableEventTypes(workspaceId, options = {}) {
110
+ const result = await (0, http_js_1.request)(this.transport, {
111
+ method: "GET",
112
+ path: "/scheduling/event-types/manage",
113
+ query: { workspaceId },
114
+ headers: (0, acting_as_js_1.actingAs)(options.asUser),
115
+ });
116
+ return result.eventTypes ?? [];
117
+ }
118
+ /**
119
+ * Edits an existing availability schedule.
120
+ *
121
+ * Narrower than what the workspace's own Calendar settings can do, the
122
+ * same way `createSchedule` is: `ownerMemberId` and date overrides stay
123
+ * untouched, since this surface never sets either.
124
+ */
125
+ updateSchedule(input) {
126
+ return (0, http_js_1.request)(this.transport, {
127
+ method: "PUT",
128
+ path: `/scheduling/schedules/${encodeURIComponent(input.scheduleId)}`,
129
+ json: {
130
+ workspaceId: input.workspaceId,
131
+ name: input.name,
132
+ timezone: input.timezone,
133
+ weeklyHours: input.weeklyHours,
134
+ },
135
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
136
+ });
137
+ }
138
+ /**
139
+ * Edits an existing appointment type.
140
+ *
141
+ * `hostMemberId` omitted leaves the host untouched, including a *group*
142
+ * host this surface cannot represent - so an edit that says nothing about
143
+ * the host can never accidentally overwrite a team assignment made in
144
+ * Connect.
145
+ */
146
+ updateEventType(input) {
147
+ return (0, http_js_1.request)(this.transport, {
148
+ method: "PUT",
149
+ path: `/scheduling/event-types/${encodeURIComponent(input.eventTypeId)}`,
150
+ json: {
151
+ workspaceId: input.workspaceId,
152
+ name: input.name,
153
+ description: input.description,
154
+ durationMinutes: input.durationMinutes,
155
+ bufferBeforeMinutes: input.bufferBeforeMinutes,
156
+ bufferAfterMinutes: input.bufferAfterMinutes,
157
+ hostMemberId: input.hostMemberId,
158
+ scheduleId: input.scheduleId,
159
+ location: input.location,
160
+ publicBookingEnabled: input.publicBookingEnabled,
161
+ active: input.active,
162
+ },
163
+ headers: (0, acting_as_js_1.actingAs)(input.asUser),
164
+ });
165
+ }
99
166
  /**
100
167
  * Free times for one appointment type over one window.
101
168
  *
@@ -3,17 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WorkspacesResource = void 0;
4
4
  const acting_as_js_1 = require("./acting-as.js");
5
5
  const http_js_1 = require("../http.js");
6
- /**
7
- * Which workspaces the signed-in user belongs to, moving between them, who
8
- * else is on the team, and inviting or granting access to your application.
9
- *
10
- * **Every call here needs `asUser`** - the signed-in user's opaque
11
- * `sessionToken`. There is no application-wide form of any of these: they take
12
- * no `userId` and no `workspaceId`, and resolve both from the token. That is
13
- * what makes the whole module safe to reach from a browser through
14
- * `createConnectHandler`, and it is why a partner cannot enumerate another
15
- * tenant's team with their own API key.
16
- */
17
6
  class WorkspacesResource {
18
7
  transport;
19
8
  constructor(transport) {
@@ -34,6 +23,36 @@ class WorkspacesResource {
34
23
  });
35
24
  return result.workspaces;
36
25
  }
26
+ /**
27
+ * Opens another workspace under the tenant the signed-in user already
28
+ * owns - a branch office, a second business unit.
29
+ *
30
+ * Every guard `CreateWorkspaceUseCase` enforces still applies: only the
31
+ * tenant's owner may call this, `type: "business"` still needs a non-free
32
+ * email domain, and the tenant's plan still decides whether it may hold
33
+ * another workspace at all - a `PreconditionError` surfaces as a thrown
34
+ * `ConnectError` with a message written to be shown ("Your current plan
35
+ * covers a single workspace...", "You've used all 3 of the workspaces
36
+ * your plans include..."). There is no `tenantId` parameter: the caller
37
+ * names no tenant, same as every other method on this resource names no
38
+ * workspace.
39
+ *
40
+ * `isActive`/`subscribed` on the result are always `false` - this does not
41
+ * switch the session, and the new workspace has no subscription yet. Chain
42
+ * `switch({workspaceId: result.workspaceId})` to move into it, but only
43
+ * when `result.inAudience` - a workspace outside this application's
44
+ * audience is refused by `switch` the same way `selectable()` in
45
+ * `<ConnectWorkspaceSwitcher>` already treats it.
46
+ */
47
+ async create(input, options) {
48
+ const result = await (0, http_js_1.request)(this.transport, {
49
+ method: "POST",
50
+ path: "/workspaces",
51
+ headers: (0, acting_as_js_1.actingAs)(options.asUser),
52
+ json: input,
53
+ });
54
+ return result.workspace;
55
+ }
37
56
  /**
38
57
  * Moves this session to another workspace, returning a **new session
39
58
  * token**. The one you passed is revoked.
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState } from "react";
4
4
  import { Button, Input, Label, Modal, Switch, TextField } from "@heroui/react";
5
- import { useCreateAvailabilitySchedule } from "../use-scheduling.js";
5
+ import { useAvailabilitySchedules, useCreateAvailabilitySchedule, useUpdateAvailabilitySchedule, } from "../use-scheduling.js";
6
6
  const WEEKDAY_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
7
7
  const WEEKDAY_KEYS = [0, 1, 2, 3, 4, 5, 6];
8
8
  /** Monday-Friday, 9am-5pm - the same starting point Connect's own Console form offers. */
@@ -20,24 +20,52 @@ function timeInputToMinutes(value) {
20
20
  return (hours ?? 0) * 60 + (minutes ?? 0);
21
21
  }
22
22
  /**
23
- * Gives a workspace its first working hours, from inside a host application
24
- * rather than a link-out to Connect.
23
+ * Creates or edits a workspace's working hours, from inside a host
24
+ * application rather than a link-out to Connect.
25
25
  *
26
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.
27
+ * interval per day (no split shifts), no date overrides, and the timezone is
28
+ * set once at creation (from the browser's own) and shown but not editable
29
+ * here afterwards. This is a first-party setup-and-edit surface, not a
30
+ * second copy of the Console editor - "Manage in Connect" is still where the
31
+ * rest of what a schedule can do lives.
31
32
  *
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`.
33
+ * **Not optimistic.** The write settles against the server before this
34
+ * closes, the same posture every write in this package takes - see
35
+ * `ConnectBookingDialog`.
36
+ *
37
+ * This outer component only decides whether the row to edit has loaded yet;
38
+ * the form itself is `ScheduleFields` below, mounted with
39
+ * `key={editing?.id}` - see that component's own doc comment for why.
40
+ */
41
+ export function ConnectAvailabilityFormModal({ isOpen, onOpenChange, scheduleId = null, onSaved, }) {
42
+ const isEdit = scheduleId !== null;
43
+ const { schedules, isLoading, error } = useAvailabilitySchedules();
44
+ const editing = isEdit ? schedules.find((row) => row.id === scheduleId) ?? null : null;
45
+ return (_jsx(Modal, { isOpen: isOpen, onOpenChange: onOpenChange, children: _jsx(Modal.Backdrop, { children: _jsx(Modal.Container, { children: _jsxs(Modal.Dialog, { className: "sm:max-w-120", children: [_jsx(Modal.CloseTrigger, {}), _jsx(Modal.Header, { children: _jsx(Modal.Heading, { children: isEdit ? "Edit availability" : "Set your availability" }) }), _jsx(Modal.Body, { children: isEdit && isLoading ? (_jsx("p", { className: "text-sm text-muted", children: "Loading\u2026" })) : isEdit && error ? (_jsx("p", { className: "text-sm text-muted", children: "That could not be loaded just now. Close this and try again." })) : isEdit && !editing ? (_jsx("p", { className: "text-sm text-muted", children: "Those working hours no longer exist." })) : (_jsx(ScheduleFields, { editing: editing, onCancel: () => onOpenChange(false), onSaved: (saved) => {
46
+ onSaved?.(saved);
47
+ onOpenChange(false);
48
+ } }, editing?.id ?? "create")) })] }) }) }) }));
49
+ }
50
+ /**
51
+ * The form itself, mounted fresh (via the parent's `key`) for each distinct
52
+ * record it edits - or once for create. That remount is what initializes
53
+ * every field straight from `editing` with a plain `useState(initial)`
54
+ * rather than a `useEffect` copying it in after the fact: `editing` arrives
55
+ * asynchronously (`useAvailabilitySchedules` is a bridge read), and the
56
+ * parent already withholds this component until it has landed, so there is
57
+ * no later moment for a field to overwrite a half-typed edit.
35
58
  */
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();
59
+ function ScheduleFields({ editing, onCancel, onSaved, }) {
60
+ const isEdit = editing !== null;
61
+ const [name, setName] = useState(editing?.name ?? "Working hours");
62
+ const [timezone] = useState(() => editing?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC");
63
+ const [weeklyHours, setWeeklyHours] = useState(editing?.weeklyHours ?? defaultWeeklyHours);
64
+ const { createSchedule, isPending: isCreating, error: createError, clearError: clearCreateError } = useCreateAvailabilitySchedule();
65
+ const { updateSchedule, isPending: isUpdating, error: updateError, clearError: clearUpdateError } = useUpdateAvailabilitySchedule();
66
+ const isPending = isCreating || isUpdating;
67
+ const error = isEdit ? updateError : createError;
68
+ const clearError = isEdit ? clearUpdateError : clearCreateError;
41
69
  const toggleDay = (day, open) => {
42
70
  setWeeklyHours((prev) => ({
43
71
  ...prev,
@@ -58,15 +86,15 @@ export function ConnectAvailabilityFormModal({ isOpen, onOpenChange, onCreated,
58
86
  if (!isFormValid)
59
87
  return;
60
88
  clearError();
61
- const schedule = await createSchedule({ name: name.trim(), timezone, weeklyHours });
62
- if (schedule) {
63
- onCreated?.(schedule);
64
- onOpenChange(false);
65
- }
89
+ const saved = isEdit
90
+ ? await updateSchedule({ scheduleId: editing.id, name: name.trim(), weeklyHours })
91
+ : await createSchedule({ name: name.trim(), timezone, weeklyHours });
92
+ if (saved)
93
+ onSaved(saved);
66
94
  };
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" })] })] }) })] }) }) }) }));
95
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [_jsxs(TextField, { isRequired: true, variant: "secondary", children: [_jsx(Label, { children: "Name" }), _jsx(Input, { value: name, onChange: (e) => setName(e.currentTarget.value), disabled: isPending, autoFocus: true })] }), _jsxs("p", { className: "text-xs text-muted", children: ["Timezone: ", timezone] }), _jsx("div", { className: "flex flex-col gap-2", children: WEEKDAY_KEYS.map((day) => {
96
+ const interval = weeklyHours[day][0];
97
+ const isOpenDay = interval !== undefined;
98
+ return (_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(Switch, { isSelected: isOpenDay, onChange: (open) => toggleDay(day, open), isDisabled: isPending, children: _jsxs(Switch.Content, { children: [_jsx(Switch.Control, { children: _jsx(Switch.Thumb, {}) }), _jsx("span", { className: "w-24 shrink-0 text-sm", children: WEEKDAY_LABELS[day] })] }) }), isOpenDay && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { type: "time", value: minutesToTimeInput(interval.startMinute), onChange: (e) => setDayTime(day, "startMinute", e.currentTarget.value), disabled: isPending }), _jsx("span", { className: "text-xs text-muted", children: "to" }), _jsx(Input, { type: "time", value: minutesToTimeInput(interval.endMinute), onChange: (e) => setDayTime(day, "endMinute", e.currentTarget.value), disabled: isPending })] }))] }, day));
99
+ }) }), error && _jsx("p", { className: "text-sm text-danger", children: error }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", onPress: onCancel, isDisabled: isPending, children: "Cancel" }), _jsx(Button, { type: "submit", variant: "primary", isDisabled: !isFormValid, children: isPending ? "Saving..." : isEdit ? "Save changes" : "Save" })] })] }));
72
100
  }